From 26b4c5bfd7e0d2bc079a8fae69567cf2eade2c05 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 16:11:31 +0300 Subject: [PATCH 01/13] Fix: Move Stripe Payment Intent to always allow redirect --- src/Payment/Drivers/StripePaymentDriver.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Payment/Drivers/StripePaymentDriver.php b/src/Payment/Drivers/StripePaymentDriver.php index 9a8a8f8..fdfd58a 100644 --- a/src/Payment/Drivers/StripePaymentDriver.php +++ b/src/Payment/Drivers/StripePaymentDriver.php @@ -100,12 +100,18 @@ class StripePaymentDriver implements 'currency' => $amount->currency->code, 'capture_method' => $captureMethod, 'confirm' => true, + // 'never' rather than the client-side paymentMethodTypes: ['card'] + // restriction alone — the storefront's Payment Element already + // excludes every redirect-based method, but without this Stripe + // still falls back to whatever's enabled in the Dashboard and + // demands a return_url on confirm. Setting this unconditionally + // (not only when no payment_method is given) matches the actual + // flow: a payment_method is always supplied here. + 'automatic_payment_methods' => ['enabled' => true, 'allow_redirects' => 'never'], ]; if (isset($data['payment_method'])) { $params['payment_method'] = $data['payment_method']; - } else { - $params['automatic_payment_methods'] = ['enabled' => true]; } try { From d9fb3bbde661bc8f49b3032462af029c5983b03a Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 16:12:03 +0300 Subject: [PATCH 02/13] Bump version to 0.17.1 --- CHANGELOG.md | 11 +++++++++++ composer.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 021ff5b..6987027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.17.1] - 2026-09-15 + +### Fixed +- `Modules\Core\Payment\Drivers\StripePaymentDriver::createAndConfirm()` only set + `automatic_payment_methods` when no `payment_method` was given — the actual checkout flow always + sends one, so it was omitted, and Stripe fell back to whatever payment methods are enabled in the + Dashboard and demanded a `return_url` on confirm. Fixed by setting `automatic_payment_methods` + unconditionally with `allow_redirects: never` — the storefront's Payment Element already restricts + itself to `paymentMethodTypes: ['card']`, so this just tells Stripe the same thing server-side, + which drops the `return_url` requirement. + ## [0.17.0] - 2026-09-14 ### Added diff --git a/composer.json b/composer.json index eb1881e..b9f92f0 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.17.0", + "version": "0.17.1", "autoload": { "psr-4": { "Modules\\Core\\": "src/" From a5f3008ce289d1934a75d13a398335dea8d4056e Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 21:17:56 +0300 Subject: [PATCH 03/13] Fix: Update Order status to Processing when payment has been recieved --- src/CorePlugin.php | 4 +- ...xtension.php => OrderActionsExtension.php} | 89 +++++++++++-------- .../Extensions/OrderItemsTableExtension.php | 2 +- .../Listeners/ApplyResolvedPaymentStatus.php | 37 ++++++-- .../Drivers/BankTransferPaymentDriver.php | 2 +- src/Payment/Drivers/StripePaymentDriver.php | 1 - .../Support/TransactionDriverAdapter.php | 4 +- 7 files changed, 86 insertions(+), 53 deletions(-) rename src/Order/Filament/Extensions/{OrderRefundActionsExtension.php => OrderActionsExtension.php} (74%) diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 31efae4..33a0b55 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -28,7 +28,7 @@ use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension; use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension; -use Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension; +use Modules\Core\Order\Filament\Extensions\OrderActionsExtension; use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; @@ -70,7 +70,7 @@ class CorePlugin implements Plugin ValuesRelationManager::class => ValuesRelationManagerExtension::class, ShippingMethodResource::class => ShippingMethodResourceExtension::class, ListShippingMethod::class => ShippingMethodListExtension::class, - ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class], + ManageOrder::class => [OrderViewExtension::class, OrderActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class], OrderItemsTable::class => OrderItemsTableExtension::class, ]); diff --git a/src/Order/Filament/Extensions/OrderRefundActionsExtension.php b/src/Order/Filament/Extensions/OrderActionsExtension.php similarity index 74% rename from src/Order/Filament/Extensions/OrderRefundActionsExtension.php rename to src/Order/Filament/Extensions/OrderActionsExtension.php index 39639d2..d90a11c 100644 --- a/src/Order/Filament/Extensions/OrderRefundActionsExtension.php +++ b/src/Order/Filament/Extensions/OrderActionsExtension.php @@ -32,10 +32,6 @@ use ReflectionProperty; * could return a real, honest failure — see Payment\Support\ * TransactionDriverAdapter's own docblock for that history. * - * Fix, for capture: wrap the action's own action() closure so that, on - * Halt, we call $action->sendFailureNotification() ourselves before letting - * the Halt continue propagating — everything else is untouched. - * * Fix, for refund: same notification fix, but the action() closure is * replaced outright (not wrapped) rather than reused, because refund also * needs a "Refund via" driver Select added to the modal (see @@ -43,15 +39,28 @@ use ReflectionProperty; * Payment\Support\TransactionDriverAdapter::refundVia() instead of * Lunar\Models\Transaction::refund() — see fixRefundAction()'s own * docblock. + * + * Fix, for capture: same notification fix, but the action() closure is + * also replaced outright — the actual call is routed through + * Payment\Support\TransactionDriverAdapter::capture() instead of + * Lunar\Models\Transaction::capture() (see fixCaptureAction()), so a + * manual backoffice capture goes through the app's own payment driver + * registry and dispatches Payment\Events\PaymentCaptured exactly like a + * checkout-time capture does — the vendor path resolved + * Lunar\Facades\Payments (an entirely separate, unused driver registry) + * and never dispatched that event, which is why Order::status used to + * stay stuck on 'awaiting_payment' after a manual capture even though + * Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus now advances it + * on PaymentCaptured. */ -class OrderRefundActionsExtension extends ViewPageExtension +class OrderActionsExtension extends ViewPageExtension { public function headerActions(array $actions): array { return array_map( fn (Action $action) => match ($action->getName()) { 'refund' => $this->fixRefundAction($action), - 'capture' => $this->fixFailureNotification($action), + 'capture' => $this->fixCaptureAction($action), default => $action, }, $actions, @@ -123,6 +132,41 @@ class OrderRefundActionsExtension extends ViewPageExtension }); } + /** + * Mirrors fixRefundAction()'s notification fix, but for the "amount" + * field already on the vendor schema — no extra field needed, since + * capture always goes back through the transaction's own original + * driver (there's no equivalent to refunding via a different driver). + */ + private function fixCaptureAction(Action $action): Action + { + return $action->action(function (array $data, Action $action) { + $transaction = Transaction::find($data['transaction']); + + if (! $transaction instanceof CoreTransaction) { + $action->failureNotification(fn () => Notification::make('capture_failure')->danger()->title('Transaction not found.')) + ->sendFailureNotification(); + + throw new Halt; + } + + $response = app(TransactionDriverAdapter::class)->capture( + $transaction, + (int) bcmul((string) $data['amount'], (string) $transaction->order->currency->factor), + ); + + if (! $response->success) { + $action->failureNotification( + fn () => Notification::make('capture_failure')->color('danger')->title($response->message) + )->sendFailureNotification(); + + throw new Halt; + } + + $action->success(); + }); + } + /** * @return array */ @@ -163,37 +207,4 @@ class OrderRefundActionsExtension extends ViewPageExtension return $reflected->getValue($object); } - - /** - * Wraps the action's own configured action() closure so that, if it - * halts (Lunar's closures throw via $action->halt() to signal failure — - * see this class's own docblock for why that alone never sends the - * notification queued via failureNotification()), we send that - * notification ourselves before letting the Halt continue propagating - * (still needed — it's what stops callMountedAction() from treating - * this as a success and closing the modal/committing the DB transaction). - * - * $this->evaluate() (not a plain call) matches exactly how Action::call() - * itself invokes the closure — Lunar's closures type-hint $data/$record/ - * $action and rely on Filament's own container-style parameter - * resolution, not positional arguments. - */ - private function fixFailureNotification(Action $action): Action - { - $originalAction = $action->getActionFunction(); - - if ($originalAction === null) { - return $action; - } - - return $action->action(function (array $arguments) use ($action, $originalAction) { - try { - return $action->evaluate($originalAction, $arguments); - } catch (Halt $exception) { - $action->sendFailureNotification(); - - throw $exception; - } - }); - } } diff --git a/src/Order/Filament/Extensions/OrderItemsTableExtension.php b/src/Order/Filament/Extensions/OrderItemsTableExtension.php index 8fda303..7a8bcc8 100644 --- a/src/Order/Filament/Extensions/OrderItemsTableExtension.php +++ b/src/Order/Filament/Extensions/OrderItemsTableExtension.php @@ -8,7 +8,7 @@ use Filament\Tables\Table; use Lunar\Admin\Support\Extending\BaseExtension; /** - * Same fix as OrderRefundActionsExtension, applied to the order lines + * Same fix as OrderActionsExtension, applied to the order lines * table's "bulk_refund" toolbar action (Lunar\Admin\...\OrderItemsTable:: * getBulkRefundAction()) — see that class's docblock for the underlying * Filament bug (failureNotification()+failure()+halt() never actually diff --git a/src/Order/Listeners/ApplyResolvedPaymentStatus.php b/src/Order/Listeners/ApplyResolvedPaymentStatus.php index 2df32a2..28d6524 100644 --- a/src/Order/Listeners/ApplyResolvedPaymentStatus.php +++ b/src/Order/Listeners/ApplyResolvedPaymentStatus.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Event; use Lunar\Models\Order; use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Order\Enums\PaymentStatus; +use Modules\Core\Order\Services\OrderStatusFlow; use Modules\Core\Order\Services\OrderStatusWriter; use Modules\Core\Order\Support\OrderStatus; use Modules\Core\Payment\Events\PaymentAuthorized; @@ -16,13 +17,16 @@ use Modules\Core\Payment\Events\PaymentRefunded; * Registered against PaymentCaptured, PaymentAuthorized, AND * PaymentRefunded (see OrderServiceProvider). * - * A capture/authorization only ever writes Order::paid/paid_at (via - * OrderStatusWriter::markPaid()) — never `status`. Confirmed with the - * user: status leaving 'awaiting_payment' is always a staff-driven - * "Update Status" click, regardless of payment method — no special-casing - * prepaid vs. cash-on-delivery. A prepaid order briefly sitting at - * 'awaiting_payment' with paid = true (until staff notice and advance it) - * is expected, not a bug. + * PaymentCaptured writes both Order::paid/paid_at (via + * OrderStatusWriter::markPaid()) AND advances `status` out of + * 'awaiting_payment' to the next step in the order's flow (see + * OrderStatusFlow::nextOptions()) — re-confirmed with the user: a + * captured payment, manual or via Stripe's webhook, should never leave an + * order sitting at 'awaiting_payment'. Only fires when status is still + * exactly 'awaiting_payment', so a duplicate/delayed capture event never + * regresses an order staff already advanced further. PaymentAuthorized + * only marks paid — an authorization is not yet captured funds, so + * status stays put until the actual capture. * * A refund still moves `status` (returned -> refunded/partially_refunded) * — refunds are a normal step in Modules\Core\Order\Services\ @@ -44,6 +48,7 @@ class ApplyResolvedPaymentStatus { public function __construct( private readonly OrderStatusWriter $writer, + private readonly OrderStatusFlow $flow, ) {} public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void @@ -66,12 +71,30 @@ class ApplyResolvedPaymentStatus $this->writer->markPaid($order, $event::class); + if ($event instanceof PaymentCaptured) { + $this->advancePastAwaitingPayment($order, $event); + } + if (! $wasPlaced) { $order->update(['placed_at' => $order->placed_at ?? now()]); Event::dispatch(new OrderPlaced($order)); } } + private function advancePastAwaitingPayment(Order $order, PaymentCaptured $event): void + { + if ($order->status !== 'awaiting_payment') { + return; + } + + $next = $this->flow->nextOptions($order); + $target = array_key_first($next); + + if ($target !== null) { + $this->writer->write($order, $target, $event::class); + } + } + /** * Requires the refund Transaction row to already exist (Modules\Core\ * Order\Listeners\RecordPaymentTransaction must run first — see diff --git a/src/Payment/Drivers/BankTransferPaymentDriver.php b/src/Payment/Drivers/BankTransferPaymentDriver.php index 357dd5a..da8ab1f 100644 --- a/src/Payment/Drivers/BankTransferPaymentDriver.php +++ b/src/Payment/Drivers/BankTransferPaymentDriver.php @@ -22,7 +22,7 @@ use Modules\Core\Payment\Events\PaymentRefunded; * chooses this driver explicitly in the refund action, independent of * which driver the original payment went through (see * Payment\Support\TransactionDriverAdapter::refundVia() and - * Order\Filament\Extensions\OrderRefundActionsExtension). pay() exists so + * Order\Filament\Extensions\OrderActionsExtension). pay() exists so * the same driver also covers receiving a payment by bank transfer, but * the admin UI for that (bank reference, notes, proof-of-transfer upload) * is deliberately not built yet — see the follow-up work tracked from this diff --git a/src/Payment/Drivers/StripePaymentDriver.php b/src/Payment/Drivers/StripePaymentDriver.php index fdfd58a..f3eadc9 100644 --- a/src/Payment/Drivers/StripePaymentDriver.php +++ b/src/Payment/Drivers/StripePaymentDriver.php @@ -70,7 +70,6 @@ class StripePaymentDriver implements { return filled(config('services.stripe.key')); } - /** * Atomic charge — capture_method: automatic. Stripe still frequently * confirms into requires_action/requires_confirmation rather than diff --git a/src/Payment/Support/TransactionDriverAdapter.php b/src/Payment/Support/TransactionDriverAdapter.php index 24c8c02..4496a55 100644 --- a/src/Payment/Support/TransactionDriverAdapter.php +++ b/src/Payment/Support/TransactionDriverAdapter.php @@ -54,7 +54,7 @@ class TransactionDriverAdapter /** * The PaymentDriverRegistry key $transaction was originally taken * through — what refund()/capture() resolve against by default, and - * what Order\Filament\Extensions\OrderRefundActionsExtension defaults + * what Order\Filament\Extensions\OrderActionsExtension defaults * its "Refund via" driver Select to, before an admin overrides it. */ public function driverKeyFor(Transaction $transaction): ?string @@ -72,7 +72,7 @@ class TransactionDriverAdapter * when refunding through the transaction's own original driver. * * Called directly by Order\Filament\Extensions\ - * OrderRefundActionsExtension when the admin picks a different driver + * OrderActionsExtension when the admin picks a different driver * in the refund modal, bypassing Lunar\Models\Transaction::refund() * (whose fixed refund(int $amount, $notes = null) signature has no * room for a driver override) — see that extension's own docblock. From e4e008167a6463dfdd956767469b22cccb0addb4 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 21:22:45 +0300 Subject: [PATCH 04/13] Fix: Correct Display of last 4 digits of credit card --- src/Order/Services/TransactionRecorder.php | 2 ++ src/Payment/Drivers/StripePaymentDriver.php | 36 +++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/Order/Services/TransactionRecorder.php b/src/Order/Services/TransactionRecorder.php index fc13e29..423959a 100644 --- a/src/Order/Services/TransactionRecorder.php +++ b/src/Order/Services/TransactionRecorder.php @@ -49,6 +49,8 @@ class TransactionRecorder 'reference' => $result->reference, 'status' => $result->status->name, 'notes' => $result->failureReason, + 'card_type' => $result->meta['card_type'] ?? null, + 'last_four' => $result->meta['last_four'] ?? null, 'meta' => $result->meta, ]); } diff --git a/src/Payment/Drivers/StripePaymentDriver.php b/src/Payment/Drivers/StripePaymentDriver.php index f3eadc9..65342cf 100644 --- a/src/Payment/Drivers/StripePaymentDriver.php +++ b/src/Payment/Drivers/StripePaymentDriver.php @@ -170,6 +170,7 @@ class StripePaymentDriver implements reference: $paymentIntent->id, amount: $amount, raw: $paymentIntent->toArray(), + meta: $this->cardMetaFromIntent($paymentIntent), ); $paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED @@ -340,6 +341,7 @@ class StripePaymentDriver implements amount: $amount, failureReason: $paymentIntent->last_payment_error->message ?? null, raw: $paymentIntent->toArray(), + meta: $status === PaymentResultStatus::Pending ? [] : $this->cardMetaFromIntent($paymentIntent), continuation: $continuation, ); @@ -362,6 +364,40 @@ class StripePaymentDriver implements return $result; } + /** + * card_type/last_four for Modules\Core\Order\Services\ + * TransactionRecorder to map onto Transaction (see PaymentResult:: + * $meta's own docblock) — same fields, same source + * (payment_method_details on the underlying Charge) as lunarphp/ + * stripe's own StoreCharges, just reached via latest_charge instead of + * an order-level charge list, since this driver has no Order/Cart to + * enumerate charges from. + * + * @return array{card_type?: string, last_four?: string} + */ + private function cardMetaFromIntent(PaymentIntent $paymentIntent): array + { + $chargeId = $paymentIntent->latest_charge; + + if (blank($chargeId)) { + return []; + } + + $charge = Stripe::getCharge(is_string($chargeId) ? $chargeId : $chargeId->id); + + $paymentType = collect($charge->payment_method_details)->keys()->first(); + $details = collect($charge->payment_method_details)->first(); + + if (blank($details)) { + return []; + } + + return array_filter([ + 'card_type' => $details['brand'] ?? $paymentType, + 'last_four' => $details['last4'] ?? null, + ], fn ($value) => filled($value)); + } + private function declined(string $type, Price $amount, ApiErrorException $e, array $context, bool $authorizing): PaymentResult { $result = $this->failure($amount, $e); From 44894758400f8d4ba6f2436b3eafe05045e6926a Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 21:25:25 +0300 Subject: [PATCH 05/13] Bump version to 0.17.2 --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ composer.json | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6987027..e1c5657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ 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.17.2] - 2026-09-15 + +### Fixed +- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` never advanced `Order::status` past + `awaiting_payment` on a capture — only `paid`/`paid_at` were written, so a fully captured order + could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update + Status". Now, on `PaymentCaptured` (not `PaymentAuthorized` — an authorization isn't yet + captured funds), `status` advances to the next step in the order's flow + (`Modules\Core\Order\Services\OrderStatusFlow::nextOptions()`) — but only when it's still + exactly `awaiting_payment`, so a duplicate/delayed capture event never regresses an order staff + already moved further. +- The backoffice "Capture" action on the order page (Filament) called vendor Lunar's + `Lunar\Models\Transaction::capture()` directly, which resolves `Lunar\Facades\Payments` — an + entirely separate, unused driver registry — and never dispatched `Modules\Core\Payment\Events\ + PaymentCaptured`. This meant a manual capture from the admin panel never ran this app's own + payment pipeline at all (including the status-advance fix above). `Modules\Core\Order\Filament\ + Extensions\OrderActionsExtension` (renamed from `OrderRefundActionsExtension`, since it now + fixes both the refund and capture header actions — see below) now routes capture through + `Modules\Core\Payment\Support\TransactionDriverAdapter::capture()`, the same app-level path + checkout-time captures use. +- `Modules\Core\Payment\Drivers\StripePaymentDriver` never extracted a card's brand/last four + digits from Stripe's response, so `Lunar\Models\Transaction::card_type`/`last_four` were always + empty and the admin's "Payment of :amount on card ending :last_four" activity-log line rendered + with no digits — reproduced on both checkout-time and manual captures. Added + `cardMetaFromIntent()`, reading `payment_method_details` off the PaymentIntent's `latest_charge` + (same source `lunarphp/stripe`'s own `StoreCharges` uses), populated into `PaymentResult::$meta` + from `resultFromIntent()` and `capture()`. `Modules\Core\Order\Services\TransactionRecorder` + now maps `meta['card_type']`/`meta['last_four']` onto the `Transaction` row. Only applies to + transactions recorded after this change — existing rows are not backfilled. + +### Changed +- `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to + `OrderActionsExtension` — the class now fixes both the refund and capture header actions on the + order page, not just refund, so the old name undersold its scope. + ## [0.17.1] - 2026-09-15 ### Fixed diff --git a/composer.json b/composer.json index b9f92f0..6787ef5 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.17.1", + "version": "0.17.2", "autoload": { "psr-4": { "Modules\\Core\\": "src/" From 956e9e88a6a6e54b1f4479d91cb0cdcb668ab03f Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 21:38:16 +0300 Subject: [PATCH 06/13] Fix: Stripping Lunar's Stripe Driver with Boboko's Stripe Payment Driver --- composer.json | 2 +- ...01_create_stripe_payment_intents_table.php | 37 +++++ src/Payment/Drivers/StripePaymentDriver.php | 74 ++++------ .../Controllers/StripeWebhookController.php | 23 ++-- .../Middleware/StripeWebhookMiddleware.php | 51 +++++++ src/Payment/Models/StripePaymentIntent.php | 32 +++++ src/Payment/Support/StripeManager.php | 126 ++++++++++++++++++ src/Payment/routes/webhooks.php | 2 +- 8 files changed, 288 insertions(+), 59 deletions(-) create mode 100644 database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php create mode 100644 src/Payment/Http/Middleware/StripeWebhookMiddleware.php create mode 100644 src/Payment/Models/StripePaymentIntent.php create mode 100644 src/Payment/Support/StripeManager.php diff --git a/composer.json b/composer.json index 6787ef5..fe4154d 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "lunarphp/search": "*", "lunarphp/meilisearch": "*", "spatie/laravel-translation-loader": "^2.8", - "lunarphp/stripe": "^1.5" + "stripe/stripe-php": "^16.6" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php b/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php new file mode 100644 index 0000000..f0d9265 --- /dev/null +++ b/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php @@ -0,0 +1,37 @@ +prefix.'stripe_payment_intents', function (Blueprint $table) { + $table->id(); + $table->foreignId('cart_id')->constrained($this->prefix.'carts'); + $table->foreignId('order_id')->nullable()->constrained($this->prefix.'orders'); + $table->string('intent_id')->index(); + $table->string('status')->nullable(); + $table->string('event_id')->index()->nullable(); + $table->timestamp('processing_at')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists($this->prefix.'stripe_payment_intents'); + } +}; diff --git a/src/Payment/Drivers/StripePaymentDriver.php b/src/Payment/Drivers/StripePaymentDriver.php index 65342cf..b304a37 100644 --- a/src/Payment/Drivers/StripePaymentDriver.php +++ b/src/Payment/Drivers/StripePaymentDriver.php @@ -4,9 +4,6 @@ namespace Modules\Core\Payment\Drivers; use Lunar\DataTypes\Price; use Lunar\Models\Currency; -use Lunar\Stripe\Facades\Stripe; -use Lunar\Stripe\Managers\StripeManager; -use Lunar\Stripe\Models\StripePaymentIntent; use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Contracts\HandlesPaymentCallback; use Modules\Core\Payment\Contracts\SupportsAuthorization; @@ -26,17 +23,20 @@ use Modules\Core\Payment\Events\PaymentRefundFailed; use Modules\Core\Payment\Events\PaymentRefunded; use Modules\Core\Payment\Events\PaymentVoidFailed; use Modules\Core\Payment\Events\PaymentVoided; +use Modules\Core\Payment\Models\StripePaymentIntent; +use Modules\Core\Payment\Support\StripeManager; use Stripe\Exception\ApiErrorException; use Stripe\PaymentIntent; /** * Talks to Stripe's PaymentIntent API directly — deliberately NOT via - * Lunar\Stripe\Facades\Stripe::createIntent()/fetchOrCreateIntent(), which - * take a Lunar\Models\Cart and derive amount/currency from it. Payment - * must never receive a Cart (see docs/payments.md) — pay()/authorize() - * already receive $amount explicitly as their own required Lunar Price - * parameter (see PaymentResult's own docblock), the caller's job to - * assemble, same as every other driver. + * Lunar's own checkout flow (lunarphp/stripe, since removed — see + * Modules\Core\Payment\Support\StripeManager's own docblock), which took a + * Lunar\Models\Cart and derived amount/currency from it. Payment must + * never receive a Cart (see docs/payments.md) — pay()/authorize() already + * receive $amount explicitly as their own required Lunar Price parameter + * (see PaymentResult's own docblock), the caller's job to assemble, same + * as every other driver. * * Every amount that crosses this class's own boundary is converted right * there: Lunar's Price -> Stripe's minor-unit int going INTO a gateway @@ -45,12 +45,11 @@ use Stripe\PaymentIntent; * Nothing outside this class ever sees a Stripe-scaled integer. * * Correlating a later handleCallback() (a separate request — a webhook) - * back to whatever $context identified this attempt is solved the same - * way lunarphp/stripe's own StripePaymentType/ProcessStripeWebhook solve - * it: real cart_id/order_id columns on Lunar\Stripe\Models\ - * StripePaymentIntent (a table already owned by lunarphp/stripe, already - * shaped for exactly this), not a generic context blob. See - * docs/payments.md "Async resolution" for the full reasoning. + * back to whatever $context identified this attempt is solved via real + * cart_id/order_id columns on Modules\Core\Payment\Models\ + * StripePaymentIntent (a table this app now owns outright, already shaped + * for exactly this), not a generic context blob. See docs/payments.md + * "Async resolution" for the full reasoning. */ class StripePaymentDriver implements Configurable, @@ -61,10 +60,13 @@ class StripePaymentDriver implements SupportsRefunds, HandlesPaymentCallback { + public function __construct( + private readonly StripeManager $stripe, + ) {} + /** - * Same key lunarphp/stripe's own StripeManager reads its API key from - * (Stripe::setApiKey(config('services.stripe.key'))) — no key, no - * usable driver. + * Same key StripeManager reads its API key from — no key, no usable + * driver. */ public function isConfigured(): bool { @@ -114,7 +116,7 @@ class StripePaymentDriver implements } try { - $paymentIntent = Stripe::getClient()->paymentIntents->create($params); + $paymentIntent = $this->stripe->getClient()->paymentIntents->create($params); } catch (ApiErrorException $e) { return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual'); } @@ -128,7 +130,7 @@ class StripePaymentDriver implements { [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context, $data['type'] ?? ''); - $paymentIntent = Stripe::getClient()->paymentIntents->retrieve($reference); + $paymentIntent = $this->stripe->getClient()->paymentIntents->retrieve($reference); $authorizing = $paymentIntent->capture_method === PaymentIntent::CAPTURE_METHOD_MANUAL; @@ -136,7 +138,7 @@ class StripePaymentDriver implements // automatic capture_method, but Stripe stopped short of // capturing (rare, but the API contract allows it) — finish // the job pay() started. - $paymentIntent = Stripe::getClient()->paymentIntents->capture($reference); + $paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference); } $intentModel?->update(['status' => $paymentIntent->status]); @@ -151,7 +153,7 @@ class StripePaymentDriver implements [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); try { - $paymentIntent = Stripe::getClient()->paymentIntents->capture($reference, [ + $paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference, [ 'amount_to_capture' => StripeManager::toStripeAmount($amount->value, $amount->currency), ]); } catch (ApiErrorException $e) { @@ -185,7 +187,7 @@ class StripePaymentDriver implements [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); try { - $paymentIntent = Stripe::getClient()->paymentIntents->cancel($reference); + $paymentIntent = $this->stripe->getClient()->paymentIntents->cancel($reference); } catch (ApiErrorException $e) { $result = $this->failure($amount, $e, $reference); PaymentVoidFailed::dispatch($type, $result, $context); @@ -216,7 +218,7 @@ class StripePaymentDriver implements [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); try { - $refund = Stripe::getClient()->refunds->create([ + $refund = $this->stripe->getClient()->refunds->create([ 'payment_intent' => $reference, 'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency), ]); @@ -253,7 +255,7 @@ class StripePaymentDriver implements 'order_id' => $context['order_id'] ?? null, 'status' => $paymentIntent->status, 'payment_type' => $type, - 'context' => json_encode($context), + 'context' => $context, ]); } @@ -278,28 +280,10 @@ class StripePaymentDriver implements return [ $intentModel, $intentModel?->payment_type ?? $typeFallback, - $this->decodeContext($intentModel) ?? $context, + $intentModel?->context ?? $context, ]; } - /** - * StripePaymentIntent is a vendor model (lunarphp/stripe) with no cast - * declared for our own 'context' column (added by boboko-core's own - * migration, see database/migrations/..._add_context_to_stripe_ - * payment_intents.php) — we can't edit the vendor model to add one, so - * decode manually here instead of assuming Eloquent already did it. - * - * @return array|null - */ - private function decodeContext(?StripePaymentIntent $intentModel): ?array - { - if (! $intentModel || ! $intentModel->context) { - return null; - } - - return json_decode($intentModel->context, associative: true) ?: null; - } - /** * Converts a live Stripe PaymentIntent's own amount/currency back * into Lunar's Price — the one place this class reads a Stripe @@ -383,7 +367,7 @@ class StripePaymentDriver implements return []; } - $charge = Stripe::getCharge(is_string($chargeId) ? $chargeId : $chargeId->id); + $charge = $this->stripe->getCharge(is_string($chargeId) ? $chargeId : $chargeId->id); $paymentType = collect($charge->payment_method_details)->keys()->first(); $details = collect($charge->payment_method_details)->first(); diff --git a/src/Payment/Http/Controllers/StripeWebhookController.php b/src/Payment/Http/Controllers/StripeWebhookController.php index f60c534..67fc7f4 100644 --- a/src/Payment/Http/Controllers/StripeWebhookController.php +++ b/src/Payment/Http/Controllers/StripeWebhookController.php @@ -9,18 +9,17 @@ use Modules\Core\Payment\Drivers\StripePaymentDriver; use Stripe\Webhook; /** - * A boboko-owned webhook endpoint for Stripe — deliberately NOT - * lunarphp/stripe's own route (vendor/lunarphp/stripe/routes/webhooks.php), - * which dispatches into Lunar's own Payments::driver('stripe') flow (the - * flow StripePaymentDriver was built to replace, see that class's own - * docblock). Signature verification is handled by - * Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware, registered on this - * route (see src/Payment/routes/webhooks.php) — pure Stripe SDK - * verification + event-type filtering, safe to reuse even though this - * controller never touches the rest of that vendor package's flow. This - * controller verifies the signature again itself (Webhook::constructEvent()) - * to get the constructed Event object — the middleware doesn't stash one - * anywhere reusable, it only gates the request through. + * A boboko-owned webhook endpoint for Stripe — never went through Lunar's + * own Payments::driver('stripe') flow (the flow StripePaymentDriver was + * built to replace, see that class's own docblock), and lunarphp/stripe + * has since been removed entirely (see Modules\Core\Payment\Support\ + * StripeManager's own docblock). Signature verification is handled by + * Modules\Core\Payment\Http\Middleware\StripeWebhookMiddleware, registered + * on this route (see src/Payment/routes/webhooks.php) — pure Stripe SDK + * verification + event-type filtering. This controller verifies the + * signature again itself (Webhook::constructEvent()) to get the + * constructed Event object — the middleware doesn't stash one anywhere + * reusable, it only gates the request through. * * Resolves the driver directly by class, not via * Modules\Core\Payment\Services\PaymentDriverRegistry — this endpoint is diff --git a/src/Payment/Http/Middleware/StripeWebhookMiddleware.php b/src/Payment/Http/Middleware/StripeWebhookMiddleware.php new file mode 100644 index 0000000..6a1fd4c --- /dev/null +++ b/src/Payment/Http/Middleware/StripeWebhookMiddleware.php @@ -0,0 +1,51 @@ +header('Stripe-Signature'); + + try { + $event = Webhook::constructEvent( + $request->getContent(), + $stripeSig, + $secret + ); + } catch (UnexpectedValueException|SignatureVerificationException $e) { + abort(400, $e->getMessage()); + } + + if (! in_array( + $event->type, + [ + 'payment_intent.payment_failed', + 'payment_intent.succeeded', + ] + )) { + return response('', 200); + } + + return $next($request); + } +} diff --git a/src/Payment/Models/StripePaymentIntent.php b/src/Payment/Models/StripePaymentIntent.php new file mode 100644 index 0000000..afefe12 --- /dev/null +++ b/src/Payment/Models/StripePaymentIntent.php @@ -0,0 +1,32 @@ + 'array', + ]; +} diff --git a/src/Payment/Support/StripeManager.php b/src/Payment/Support/StripeManager.php new file mode 100644 index 0000000..dc2f82e --- /dev/null +++ b/src/Payment/Support/StripeManager.php @@ -0,0 +1,126 @@ + config('services.stripe.key'), + ]); + } + + public function getCharge(string $chargeId): Charge + { + return $this->getClient()->charges->retrieve($chargeId); + } + + /** + * Zero-decimal currencies, per Stripe. The amount sent to Stripe is the + * major unit amount as-is. + * + * @see https://docs.stripe.com/currencies#zero-decimal + */ + protected const ZERO_DECIMAL_CURRENCIES = [ + 'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', + 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf', + ]; + + /** + * Three-decimal currencies, per Stripe. The amount sent to Stripe is the + * major unit amount multiplied by 1000. + * + * @see https://docs.stripe.com/currencies#three-decimal + */ + protected const THREE_DECIMAL_CURRENCIES = ['bhd', 'jod', 'kwd', 'omr', 'tnd']; + + /** + * HUF, TWD and UGX are ISO zero-decimal currencies, but Stripe still + * requires amounts to be sent as if they had two decimal places. + * + * @see https://docs.stripe.com/currencies#special-cases + */ + protected const SPECIAL_ZERO_DECIMAL_CURRENCIES = ['huf', 'twd', 'ugx']; + + /** + * Convert a Lunar price value to the amount expected by Stripe. + * + * Lunar stores prices as integers scaled by `Currency::decimal_places`, + * which merchants can set independently of what Stripe expects for a + * given currency. This converts back to the major unit amount first, + * then re-scales it to whatever sub-unit Stripe requires for the + * currency, so the result is correct regardless of how the merchant has + * configured `Currency::decimal_places`. + * + * @see https://docs.stripe.com/currencies + */ + public static function toStripeAmount(int $value, CurrencyContract $currency): int + { + return self::rescale($value, max($currency->decimal_places, 0), self::stripeDecimalPlaces($currency)); + } + + /** + * Convert an amount received from Stripe back to a Lunar price value, + * scaled by `Currency::decimal_places`. Inverse of `toStripeAmount()`. + */ + public static function fromStripeAmount(int $amount, CurrencyContract $currency): int + { + return self::rescale($amount, self::stripeDecimalPlaces($currency), max($currency->decimal_places, 0)); + } + + /** + * The number of decimal places Stripe expects amounts in for a currency. + */ + protected static function stripeDecimalPlaces(CurrencyContract $currency): int + { + $code = strtolower($currency->code); + + // UGX is also in the zero-decimal list; the special case takes precedence. + if (in_array($code, self::SPECIAL_ZERO_DECIMAL_CURRENCIES, true)) { + return 2; + } + + if (in_array($code, self::ZERO_DECIMAL_CURRENCIES, true)) { + return 0; + } + + if (in_array($code, self::THREE_DECIMAL_CURRENCIES, true)) { + return 3; + } + + return 2; + } + + protected static function rescale(int $value, int $fromDecimalPlaces, int $toDecimalPlaces): int + { + $exponent = $toDecimalPlaces - $fromDecimalPlaces; + + if ($exponent >= 0) { + return $value * (10 ** $exponent); + } + + $divisor = 10 ** (-$exponent); + + return intdiv(abs($value) + intdiv($divisor, 2), $divisor) * ($value < 0 ? -1 : 1); + } +} diff --git a/src/Payment/routes/webhooks.php b/src/Payment/routes/webhooks.php index 96899be..2036925 100644 --- a/src/Payment/routes/webhooks.php +++ b/src/Payment/routes/webhooks.php @@ -2,8 +2,8 @@ use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Support\Facades\Route; -use Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware; use Modules\Core\Payment\Http\Controllers\StripeWebhookController; +use Modules\Core\Payment\Http\Middleware\StripeWebhookMiddleware; Route::post( config('payment.stripe.webhook_path', 'payments/stripe/webhook'), From 6a51b672c8b2a0952a07d3e1ae38952167ea9f71 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 21:40:35 +0300 Subject: [PATCH 07/13] Fix: Adding a check for hasTable --- ..._09_03_000001_create_stripe_payment_intents_table.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php b/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php index f0d9265..6f54166 100644 --- a/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php +++ b/database/migrations/2026_09_03_000001_create_stripe_payment_intents_table.php @@ -12,11 +12,20 @@ use Lunar\Base\Migration; * package's own classes over this same table). Timestamped to run just * before this app's own add_context_to_stripe_payment_intents migration, * which already alters this table. + * + * Guarded with hasTable(): on any environment that already ran + * lunarphp/stripe's own copy of this migration before the package was + * removed, the table already exists — this migration is only the one that + * actually creates it on a fresh install/database from now on. */ return new class extends Migration { public function up(): void { + if (Schema::hasTable($this->prefix.'stripe_payment_intents')) { + return; + } + Schema::create($this->prefix.'stripe_payment_intents', function (Blueprint $table) { $table->id(); $table->foreignId('cart_id')->constrained($this->prefix.'carts'); From e532c32cabe7c56fe43637a8e6bd879eae9a9ee5 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 21:49:03 +0300 Subject: [PATCH 08/13] Bump version to 0.17.3 --- CHANGELOG.md | 17 +++++++++++++++++ composer.json | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c5657..d70a66f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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.17.3] - 2026-09-15 + +### Changed +- Removed the `lunarphp/stripe` dependency in favour of depending on `stripe/stripe-php` directly. + `Modules\Core\Payment\Drivers\StripePaymentDriver` had already replaced every bit of Lunar's own + Stripe payment flow (checkout, webhook processing) with its own — all that remained load-bearing + from the package was raw API-client access, amount conversion, and a correlation table, none of + which are Lunar-specific. Added first-party replacements: `Modules\Core\Payment\Support\ + StripeManager` (API client + `toStripeAmount()`/`fromStripeAmount()`), `Modules\Core\Payment\ + Models\StripePaymentIntent` (now with a proper `context` array cast, replacing manual + `json_encode`/`json_decode`), and `Modules\Core\Payment\Http\Middleware\ + StripeWebhookMiddleware`. Added `database/migrations/..._create_stripe_payment_intents_table.php`, + a first-party copy of the vendor migration (guarded with `Schema::hasTable()` so it's a no-op on + any environment that already has the table from the vendor package's own earlier migration run, + and only actually creates it on a genuinely fresh install). No behavior change for consuming + apps — same table, same driver contract, same webhook endpoint. + ## [0.17.2] - 2026-09-15 ### Fixed diff --git a/composer.json b/composer.json index fe4154d..322bed7 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.17.2", + "version": "0.17.3", "autoload": { "psr-4": { "Modules\\Core\\": "src/" From 910ce0205d04b562ad640485f975b00e15907926 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 22:13:18 +0300 Subject: [PATCH 09/13] Feat: Adding command for backfilling all product skus --- src/Command/BackfillMissingSkusCommand.php | 60 ++++++++++++++++++++++ src/Providers/CoreServiceProvider.php | 3 +- 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/Command/BackfillMissingSkusCommand.php diff --git a/src/Command/BackfillMissingSkusCommand.php b/src/Command/BackfillMissingSkusCommand.php new file mode 100644 index 0000000..6bbbbed --- /dev/null +++ b/src/Command/BackfillMissingSkusCommand.php @@ -0,0 +1,60 @@ +option('dry-run'); + + $query = ProductVariant::query()->whereNull('sku'); + $total = $query->count(); + + if ($total === 0) { + $this->info('No variants are missing a SKU.'); + + return; + } + + $this->info(($dryRun ? '[dry-run] ' : '') . "Backfilling SKUs for {$total} variant(s)..."); + + $bar = $this->output->createProgressBar($total); + $bar->start(); + + $query->chunkById(500, function ($variants) use ($dryRun, $bar) { + foreach ($variants as $variant) { + $sku = "SKU-P{$variant->product_id}-V{$variant->id}"; + + if ($dryRun) { + $this->newLine(); + $this->line("Variant {$variant->id}: sku => {$sku}"); + } else { + $variant->update(['sku' => $sku]); + } + + $bar->advance(); + } + }); + + $bar->finish(); + $this->newLine(); + $this->info($dryRun ? 'Dry run complete — no changes were written.' : 'Done.'); + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 2756ca1..4c29232 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -5,6 +5,7 @@ namespace Modules\Core\Providers; use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; use Modules\Core\Command\AnonymizeCommand; +use Modules\Core\Command\BackfillMissingSkusCommand; use Modules\Core\Command\ExportCleanupCommand; use Modules\Core\Command\ExportCommand; use Modules\Core\Command\ImportCommand; @@ -36,7 +37,7 @@ class CoreServiceProvider extends ServiceProvider ], 'core-assets'); if ($this->app->runningInConsole()) { - $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class]); + $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class, BackfillMissingSkusCommand::class]); //Overriding lunar:install $this->app->booted(fn () => $this->commands([InstallLunarCommand::class])); From 02816fb9e70151a36722de463139705cf065fd8a Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 22:13:31 +0300 Subject: [PATCH 10/13] Bump Version to 0.17.4 --- CHANGELOG.md | 10 ++++++++++ composer.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d70a66f..8238d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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.17.4] - 2026-09-15 + +### Added +- `boboko:catalog:backfill-skus` — one-off Artisan command to generate a SKU + (`SKU-P{product_id}-V{variant_id}`) for every `Lunar\Models\ProductVariant` left with a `null` + SKU by the earlier Shopify import (the source export's `Variant SKU` column was genuinely blank + for these rows, not an importer mapping bug — see `Modules\MigrateImport\Shopify\ + ShopifyExportImporter`). Only touches variants missing a SKU; `--dry-run` lists what would + change without writing. + ## [0.17.3] - 2026-09-15 ### Changed diff --git a/composer.json b/composer.json index 322bed7..ab37599 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.17.3", + "version": "0.17.4", "autoload": { "psr-4": { "Modules\\Core\\": "src/" From ea73cc3562cf03ff55e314fcd2c89afac62c59a4 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 22:30:57 +0300 Subject: [PATCH 11/13] Feat: Translating States and Countries For Greece --- lang/el/countries.php | 21 +++++++++++ lang/el/states.php | 52 +++++++++++++++++++++++++++ src/Providers/CoreServiceProvider.php | 1 + 3 files changed, 74 insertions(+) create mode 100644 lang/el/countries.php create mode 100644 lang/el/states.php diff --git a/lang/el/countries.php b/lang/el/countries.php new file mode 100644 index 0000000..ead97b9 --- /dev/null +++ b/lang/el/countries.php @@ -0,0 +1,21 @@ +name) — core has no + * storefront UI of its own to wire this into (see docs/lunar.md). + * + * Only Greece is covered — this store operates within Greece; add further + * countries here as needed. + */ +return [ + 'Greece' => 'Ελλάδα', +]; diff --git a/lang/el/states.php b/lang/el/states.php new file mode 100644 index 0000000..fea5880 --- /dev/null +++ b/lang/el/states.php @@ -0,0 +1,52 @@ + 'Περιφερειακή Ενότητα Αχαΐας', + 'Aetolia-Acarnania Regional Unit' => 'Περιφερειακή Ενότητα Αιτωλοακαρνανίας', + 'Arcadia Prefecture' => 'Νομός Αρκαδίας', + 'Argolis Regional Unit' => 'Περιφερειακή Ενότητα Αργολίδας', + 'Attica Region' => 'Περιφέρεια Αττικής', + 'Boeotia Regional Unit' => 'Περιφερειακή Ενότητα Βοιωτίας', + 'Central Greece Region' => 'Περιφέρεια Στερεάς Ελλάδας', + 'Central Macedonia' => 'Κεντρική Μακεδονία', + 'Chania Regional Unit' => 'Περιφερειακή Ενότητα Χανίων', + 'Corfu Prefecture' => 'Νομός Κέρκυρας', + 'Corinthia Regional Unit' => 'Περιφερειακή Ενότητα Κορινθίας', + 'Crete Region' => 'Περιφέρεια Κρήτης', + 'Drama Regional Unit' => 'Περιφερειακή Ενότητα Δράμας', + 'East Attica Regional Unit' => 'Περιφερειακή Ενότητα Ανατολικής Αττικής', + 'East Macedonia and Thrace' => 'Ανατολική Μακεδονία και Θράκη', + 'Epirus Region' => 'Περιφέρεια Ηπείρου', + 'Euboea' => 'Εύβοια', + 'Grevena Prefecture' => 'Νομός Γρεβενών', + 'Imathia Regional Unit' => 'Περιφερειακή Ενότητα Ημαθίας', + 'Ioannina Regional Unit' => 'Περιφερειακή Ενότητα Ιωαννίνων', + 'Ionian Islands Region' => 'Περιφέρεια Ιονίων Νήσων', + 'Karditsa Regional Unit' => 'Περιφερειακή Ενότητα Καρδίτσας', + 'Kastoria Regional Unit' => 'Περιφερειακή Ενότητα Καστοριάς', + 'Kefalonia Prefecture' => 'Νομός Κεφαλληνίας', + 'Kilkis Regional Unit' => 'Περιφερειακή Ενότητα Κιλκίς', + 'Kozani Prefecture' => 'Νομός Κοζάνης', + 'Laconia' => 'Λακωνία', + 'Larissa Prefecture' => 'Νομός Λάρισας', + 'Lefkada Regional Unit' => 'Περιφερειακή Ενότητα Λευκάδας', + 'Pella Regional Unit' => 'Περιφερειακή Ενότητα Πέλλας', + 'Peloponnese Region' => 'Περιφέρεια Πελοποννήσου', + 'Phthiotis Prefecture' => 'Νομός Φθιώτιδας', + 'Preveza Prefecture' => 'Νομός Πρέβεζας', + 'Serres Prefecture' => 'Νομός Σερρών', + 'South Aegean' => 'Νότιο Αιγαίο', + 'Thessaloniki Regional Unit' => 'Περιφερειακή Ενότητα Θεσσαλονίκης', + 'West Greece Region' => 'Περιφέρεια Δυτικής Ελλάδας', + 'West Macedonia Region' => 'Περιφέρεια Δυτικής Μακεδονίας', +]; diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 4c29232..bd3f085 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -25,6 +25,7 @@ class CoreServiceProvider extends ServiceProvider $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core'); Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core'); $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); + $this->loadTranslationsFrom(__DIR__ . '/../../lang', 'core'); $this->publishes([ __DIR__ . '/../../config/core.php' => config_path('core.php'), From 97004234f035e6c6b2efc1533886816f51ac3ee7 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 23:51:10 +0300 Subject: [PATCH 12/13] Feat: Adding Translations to Payment and Shipping Methods, removing unecessary shipping method fulfillment type --- ...make_payment_methods_name_translatable.php | 65 ++++++++ ...ake_shipping_methods_name_translatable.php | 74 +++++++++ src/Command/InstallLunarCommand.php | 15 +- .../OrderPaymentMethodSummaryExtension.php | 4 +- .../Resources/PaymentMethodResource.php | 8 +- src/Payment/Models/PaymentMethod.php | 15 +- src/Providers/ShippingServiceProvider.php | 12 +- src/Shipping/Carriers/Acs/AcsRateDriver.php | 11 +- .../Carriers/BoxNow/BoxNowRateDriver.php | 8 +- .../Concerns/ResolvesFixedPricing.php | 5 +- .../Contracts/DeclaresFulfillmentType.php | 25 +++ .../ShippingMethodResourceExtension.php | 146 +++++++++++++++--- src/Shipping/Support/FulfillmentType.php | 60 +++++++ src/Shipping/Support/ShippingMethodName.php | 35 +++++ 14 files changed, 447 insertions(+), 36 deletions(-) create mode 100644 database/migrations/2026_09_15_000001_make_payment_methods_name_translatable.php create mode 100644 database/migrations/2026_09_15_000002_make_shipping_methods_name_translatable.php create mode 100644 src/Shipping/Contracts/DeclaresFulfillmentType.php create mode 100644 src/Shipping/Support/FulfillmentType.php create mode 100644 src/Shipping/Support/ShippingMethodName.php diff --git a/database/migrations/2026_09_15_000001_make_payment_methods_name_translatable.php b/database/migrations/2026_09_15_000001_make_payment_methods_name_translatable.php new file mode 100644 index 0000000..fc97701 --- /dev/null +++ b/database/migrations/2026_09_15_000001_make_payment_methods_name_translatable.php @@ -0,0 +1,65 @@ +value('code') ?? 'en'; + + $existing = DB::table('payment_methods')->pluck('name', 'id'); + + DB::statement('ALTER TABLE payment_methods ALTER COLUMN name DROP DEFAULT'); + DB::statement("ALTER TABLE payment_methods ALTER COLUMN name TYPE json USING NULL"); + + foreach ($existing as $id => $name) { + if ($name === null) { + continue; + } + + DB::table('payment_methods') + ->where('id', $id) + ->update(['name' => json_encode([$defaultLocale => $name])]); + } + } + + public function down(): void + { + $defaultLocale = Language::where('default', true)->value('code') ?? 'en'; + + $existing = DB::table('payment_methods')->pluck('name', 'id'); + + DB::statement('ALTER TABLE payment_methods ALTER COLUMN name TYPE varchar(255) USING NULL'); + + foreach ($existing as $id => $name) { + $decoded = json_decode((string) $name, true); + $flat = is_array($decoded) ? ($decoded[$defaultLocale] ?? reset($decoded) ?: null) : $name; + + DB::table('payment_methods')->where('id', $id)->update(['name' => $flat]); + } + } +}; diff --git a/database/migrations/2026_09_15_000002_make_shipping_methods_name_translatable.php b/database/migrations/2026_09_15_000002_make_shipping_methods_name_translatable.php new file mode 100644 index 0000000..2a64fdd --- /dev/null +++ b/database/migrations/2026_09_15_000002_make_shipping_methods_name_translatable.php @@ -0,0 +1,74 @@ +prefix.'shipping_methods'; + $defaultLocale = Language::where('default', true)->value('code') ?? 'en'; + + // The column is NOT NULL (vendor migration never marked it + // nullable) — converting via `USING NULL` first, then + // backfilling with a second UPDATE, violates that constraint + // before the backfill ever runs. json_build_object() converts + // each existing string in place, in the same statement, so the + // column is never transiently NULL. $defaultLocale is inlined + // (not bound) — parameter binding inside an ALTER TABLE ... USING + // expression isn't reliable across drivers; it's a Language::code + // value we control, not user input, so quote_literal-safe + // interpolation here is fine. + $quotedLocale = DB::getPdo()->quote($defaultLocale); + + DB::statement("ALTER TABLE {$table} ALTER COLUMN name TYPE json USING json_build_object({$quotedLocale}, name)"); + } + + public function down(): void + { + $table = $this->prefix.'shipping_methods'; + $defaultLocale = Language::where('default', true)->value('code') ?? 'en'; + + // Same NOT NULL constraint applies going back — ->>'{locale}' + // extracts the default locale's text value directly in the + // USING clause, falling back to the first key present via + // COALESCE for any row missing that locale (e.g. one only ever + // filled in via a non-default language). + $quotedLocale = DB::getPdo()->quote($defaultLocale); + + DB::statement( + "ALTER TABLE {$table} ALTER COLUMN name TYPE varchar(255) ". + "USING COALESCE(name->>{$quotedLocale}, (SELECT value FROM json_each_text(name) LIMIT 1))" + ); + } +}; diff --git a/src/Command/InstallLunarCommand.php b/src/Command/InstallLunarCommand.php index 1c0c040..c20f636 100644 --- a/src/Command/InstallLunarCommand.php +++ b/src/Command/InstallLunarCommand.php @@ -66,6 +66,16 @@ class InstallLunarCommand extends Command ]); } + if (! Language::where('code', 'el')->exists()) { + $this->components->info('Adding Greek language'); + + Language::create([ + 'code' => 'el', + 'name' => 'Greek', + 'default' => false, + ]); + } + if (! Currency::whereDefault(true)->exists()) { $this->components->info('Adding a default currency (USD)'); @@ -310,7 +320,10 @@ class InstallLunarCommand extends Command PaymentMethod::create([ 'type' => 'cash-on-delivery', - 'name' => 'Cash on Delivery', + 'name' => [ + 'en' => 'Cash on Delivery', + 'el' => 'Αντικαταβολή', + ], 'driver' => 'cash-on-delivery', 'capture_mode' => 'pay', 'position' => 0, diff --git a/src/Order/Filament/Extensions/OrderPaymentMethodSummaryExtension.php b/src/Order/Filament/Extensions/OrderPaymentMethodSummaryExtension.php index 426697f..29bb4f2 100644 --- a/src/Order/Filament/Extensions/OrderPaymentMethodSummaryExtension.php +++ b/src/Order/Filament/Extensions/OrderPaymentMethodSummaryExtension.php @@ -42,6 +42,8 @@ class OrderPaymentMethodSummaryExtension extends ViewPageExtension return null; } - return PaymentMethod::where('type', $type)->value('name') ?? $type; + $method = PaymentMethod::where('type', $type)->first(); + + return $method?->translate('name') ?? $type; } } diff --git a/src/Payment/Filament/Resources/PaymentMethodResource.php b/src/Payment/Filament/Resources/PaymentMethodResource.php index 363d24f..5a441d6 100644 --- a/src/Payment/Filament/Resources/PaymentMethodResource.php +++ b/src/Payment/Filament/Resources/PaymentMethodResource.php @@ -12,6 +12,7 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Table; use Illuminate\Support\Facades\Event; +use Lunar\Admin\Support\Forms\Components\TranslatedText; use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Events\PaymentMethodsReordered; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods; @@ -76,7 +77,7 @@ class PaymentMethodResource extends Resource ->sortable(), TextColumn::make('name') ->label('Name') - ->searchable(), + ->state(fn (PaymentMethod $record) => $record->translate('name')), TextColumn::make('type') ->label('Type'), TextColumn::make('driver') @@ -124,10 +125,9 @@ class PaymentMethodResource extends Resource public static function getFormComponents(): array { return [ - TextInput::make('name') + TranslatedText::make('name') ->label('Name') - ->required() - ->maxLength(255), + ->required(), TextInput::make('type') ->label('Type') ->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.') diff --git a/src/Payment/Models/PaymentMethod.php b/src/Payment/Models/PaymentMethod.php index 39a6016..6ff63ea 100644 --- a/src/Payment/Models/PaymentMethod.php +++ b/src/Payment/Models/PaymentMethod.php @@ -4,6 +4,7 @@ namespace Modules\Core\Payment\Models; use Illuminate\Database\Eloquent\Casts\AsArrayObject; use Illuminate\Database\Eloquent\Model; +use Lunar\Base\Traits\HasTranslations; /** * A merchant-configured payment method — the DB-instance layer, admin @@ -11,7 +12,16 @@ use Illuminate\Database\Eloquent\Model; * shipping_methods table already has (see docs/payments.md): * - type: unique, machine-facing slug (Cart::meta['payment_method'], * ApplyPaymentMethodFee's lookup key, every Payment event's $type). - * - name: admin-facing label. + * - name: admin-facing label, locale-keyed JSON (e.g. + * {"en": "Cash On Delivery", "el": "Αντικαταβολή"}) — same shape/ + * resolution as Product/Collection names (Lunar\Base\Traits\ + * HasTranslations), just applied directly to this column rather than + * through attribute_data, since this is a merchant settings row, not + * a catalog attribute. Rendered in Filament via Lunar's own + * Lunar\Admin\Support\Forms\Components\TranslatedText — one input per + * configured Language row, no bespoke translation UI. Resolve a + * display string with $method->translate('name') (locale defaults to + * app()->getLocale(), falling back to the store's default language). * - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key * — NOT the same as `type`, and not unique (two rows can share one * driver, e.g. two differently-named offline-style methods). @@ -28,12 +38,15 @@ use Illuminate\Database\Eloquent\Model; */ class PaymentMethod extends Model { + use HasTranslations; + protected $guarded = []; protected $casts = [ 'enabled' => 'boolean', 'position' => 'integer', 'driver_missing_at' => 'datetime', + 'name' => 'array', 'data' => AsArrayObject::class, ]; } diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php index 7e23709..55e8121 100644 --- a/src/Providers/ShippingServiceProvider.php +++ b/src/Providers/ShippingServiceProvider.php @@ -27,6 +27,7 @@ use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Listeners\InvalidateShippingOptions; +use Modules\Core\Shipping\Support\FulfillmentType; use Modules\Core\Shipping\Models\Shipment; class ShippingServiceProvider extends ServiceProvider @@ -80,8 +81,10 @@ class ShippingServiceProvider extends ServiceProvider // resolveCarrier() for the same lookup pattern already used to // resolve a carrier driver from it). // - // Reads ShippingMethod.data['fulfillment_type'] directly rather - // than through a ShippingMethod::macro('isStorePickup', ...) — + // Resolves via Modules\Core\Shipping\Support\FulfillmentType (driver- + // declared for acs/box-now, merchant-configured data['fulfillment_type'] + // fallback for table-rate-shipping's generic drivers) rather than + // through a ShippingMethod::macro('isStorePickup', ...) — // Lunar\Base\Traits\HasModelExtending::__callStatic() (used by // Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel) // intercepts EVERY unmatched static call, including macro() @@ -91,8 +94,7 @@ class ShippingServiceProvider extends ServiceProvider // false. (Lunar\Models\Order is unaffected because it declares // its own macro() method directly, bypassing __callStatic // entirely — that's why Order::macro('isStorePickupOrder', ...) - // below still works.) Defaults to 'carrier' (false) for any row - // saved before this field existed. + // below still works.) Order::macro('isStorePickupOrder', function () { /** @var Order $this */ $code = $this->shippingAddress?->shipping_option; @@ -108,7 +110,7 @@ class ShippingServiceProvider extends ServiceProvider // attribute avoids that entirely. $method = ShippingMethod::where('code', $code)->first(); - return ($method?->data['fulfillment_type'] ?? 'carrier') === 'store_pickup'; + return $method && FulfillmentType::isStorePickup($method); }); foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) { diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php index 74ce760..5a780a6 100644 --- a/src/Shipping/Carriers/Acs/AcsRateDriver.php +++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php @@ -10,10 +10,12 @@ use Lunar\Shipping\Models\ShippingRate; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Concerns\CachesLivePricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; +use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; use Modules\Core\Shipping\Contracts\SupportsLivePricing; +use Modules\Core\Shipping\Support\ShippingMethodName; use Modules\Core\Shipping\Support\WeightCalculator; -class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing +class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType { use ResolvesFixedPricing; use CachesLivePricing; @@ -30,6 +32,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing return 'ACS Courier'; } + public function fulfillmentType(): string + { + return 'carrier'; + } + public function description(): string { return 'Live rate quote from ACS Courier.'; @@ -84,7 +91,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing $amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100); return new ShippingOption( - name: $shippingMethod->name ?: $this->name(), + name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(), description: $shippingMethod->description ?: $this->description(), identifier: $shippingRate->getIdentifier(), price: new Price($amount, $cart->currency, 1), diff --git a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php index 46fe211..cd9c392 100644 --- a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php +++ b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php @@ -7,6 +7,7 @@ use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Models\ShippingRate; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; +use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; /** * Box Now has no pricing API, so this always resolves the method's normal @@ -14,7 +15,7 @@ use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; * flat-rate/ship-by drivers use. Does not implement SupportsLivePricing: * there is no live option to offer. */ -class BoxNowRateDriver implements ShippingRateInterface +class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType { use ResolvesFixedPricing; @@ -25,6 +26,11 @@ class BoxNowRateDriver implements ShippingRateInterface return 'Box Now Locker Delivery'; } + public function fulfillmentType(): string + { + return 'carrier'; + } + public function description(): string { return 'Deliver to a Box Now parcel locker.'; diff --git a/src/Shipping/Concerns/ResolvesFixedPricing.php b/src/Shipping/Concerns/ResolvesFixedPricing.php index d0a31bd..ea2ce52 100644 --- a/src/Shipping/Concerns/ResolvesFixedPricing.php +++ b/src/Shipping/Concerns/ResolvesFixedPricing.php @@ -6,6 +6,8 @@ use Lunar\DataTypes\ShippingOption; use Lunar\Facades\Pricing; use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingRate; +use Modules\Core\Shipping\Support\FulfillmentType; +use Modules\Core\Shipping\Support\ShippingMethodName; /** * Shared by any carrier driver that also supports Lunar's own price-break @@ -31,12 +33,13 @@ trait ResolvesFixedPricing } return new ShippingOption( - name: $shippingMethod->name ?: $this->name(), + name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(), description: $shippingMethod->description ?: $this->description(), identifier: $shippingRate->getIdentifier(), price: $pricing->matched->price, taxClass: $shippingRate->getTaxClass(), taxReference: $shippingRate->getTaxReference(), + collect: FulfillmentType::isStorePickup($shippingMethod), ); } } diff --git a/src/Shipping/Contracts/DeclaresFulfillmentType.php b/src/Shipping/Contracts/DeclaresFulfillmentType.php new file mode 100644 index 0000000..7eced62 --- /dev/null +++ b/src/Shipping/Contracts/DeclaresFulfillmentType.php @@ -0,0 +1,25 @@ +components([ - ...$this->replaceChargeByField( - $this->replaceDriverField($schema->getComponents()) - ), - $this->fulfillmentTypeSelect(), - ]); + return $schema->components( + $this->replaceFulfillmentTypeField( + $this->replaceChargeByField( + $this->replaceNameField( + $this->replaceDriverField($schema->getComponents()) + ) + ) + ) + ); } /** - * ShippingMethod.data['fulfillment_type'] — 'carrier' (default) or - * 'store_pickup'. Same free-form-`data`-column pattern as charge_by - * above, not a migrated column: ShippingMethod is a vendor - * (lunarphp/table-rate-shipping) table, and this codebase avoids - * forking vendor migrations for a merchant-configurable extra (see - * PaymentMethod.data.fee for the same convention on a different - * vendor-adjacent model). - * - * What this actually gates: Modules\Core\Shipping\Extensions\ - * OrderViewExtension's "Create Shipment" action only makes sense for - * a 'carrier' method (it books a real carrier voucher) — a - * 'store_pickup' order instead moves through Order.status - * 'ready-for-pickup' -> a staff "Mark Picked Up" action, no shipment - * ever created. See docs/checkout.md for the full status-flow design. + * Replaces the vendor's plain-string `name` TextInput with + * Lunar's own TranslatedText — `name` is now a locale-keyed JSON + * column (see database/migrations/..._make_shipping_methods_name_translatable.php), + * same shape/resolution as PaymentMethod.name and Product/Collection + * names (Lunar\Base\Traits\HasTranslations). */ + private function replaceNameField(array $components): array + { + return array_map(function (Component $component) { + if (method_exists($component, 'getName') && $component->getName() === 'name') { + return $this->translatedNameField(); + } + + if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { + $component->schema($this->replaceNameField($component->getChildComponents())); + } + + return $component; + }, $components); + } + + /** + * ShippingMethod.name is a locale-keyed JSON column (see database/ + * migrations/..._make_shipping_methods_name_translatable.php), but + * ShippingMethod is a vendor Eloquent model with no cast declared for + * it — Lunar\Shipping\Models\ShippingMethod only casts `data`, and + * there's no ModelManifest contract wired up to swap in a first-party + * subclass that adds one (Contracts\ShippingMethod exists but is + * never bound — see this class's own git history/nameColumn() for + * the same gap on the read side). TranslatedText itself round-trips + * plain array state, so afterStateHydrated()/dehydrateStateUsing() + * decode/encode the raw JSON string at the field boundary instead — + * the model attribute is a string on the way in and out, only ever + * an array while Filament's schema state holds it. + */ + private function translatedNameField(): TranslatedText + { + $field = TranslatedText::make('name') + ->label('Name') + ->required() + ->afterStateHydrated(function (TranslatedText $component, $state) { + $decoded = json_decode((string) $state, true); + $component->state(is_array($decoded) ? $decoded : []); + }) + ->dehydrateStateUsing(fn ($state) => json_encode(is_array($state) ? $state : [])); + + $field->expanded = true; + + return $field; + } + + /** + * Inserts the `fulfillment_type` Select right after `charge_by`, in + * the SAME Group (vendor's own `Group::make([getChargeByFormComponent()]) + * ->columns(2)`) — formerly appended at the very end of the whole + * form, disconnected from `driver`/`charge_by`, the decisions it + * actually relates to. Only rendered at all for a driver that DOESN'T + * already declare its own fulfillment type (see Modules\Core\Shipping\ + * Contracts\DeclaresFulfillmentType, Modules\Core\Shipping\Support\ + * FulfillmentType) — acs/box-now are unambiguously carrier-only, so + * asking a merchant to also pick "Carrier delivery" for every ACS/Box + * Now method was redundant, error-prone config with no real decision + * behind it. Still offered for table-rate-shipping's generic drivers + * (flat-rate, ship-by, free-shipping), which are genuinely ambiguous. + */ + private function replaceFulfillmentTypeField(array $components): array + { + $result = []; + + foreach ($components as $component) { + $result[] = $component; + + if (method_exists($component, 'getName') && $component->getName() === 'charge_by') { + $result[] = $this->fulfillmentTypeSelect(); + } elseif (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { + $component->schema($this->replaceFulfillmentTypeField($component->getChildComponents())); + } + } + + return $result; + } + private function fulfillmentTypeSelect(): Select { return Select::make('data.fulfillment_type') @@ -52,9 +125,23 @@ class ShippingMethodResourceExtension extends ResourceExtension ]) ->default('carrier') ->required() + ->visible(fn (Get $get) => $this->driverIsFulfillmentAmbiguous($get('../driver'))) ->helperText('Whether an order using this method is handed to a carrier, or collected by the customer in person.'); } + private function driverIsFulfillmentAmbiguous(?string $driver): bool + { + if (! $driver) { + return true; + } + + try { + return ! Shipping::driver($driver) instanceof DeclaresFulfillmentType; + } catch (InvalidArgumentException) { + return true; + } + } + /** * Extend the vendor's cart_total/weight charge_by Select with a third * "live" option — only offered when the currently selected driver @@ -127,6 +214,10 @@ class ShippingMethodResourceExtension extends ResourceExtension return $this->driverColumn(); } + if (method_exists($column, 'getName') && $column->getName() === 'name') { + return $this->nameColumn(); + } + return $column; }, $table->getColumns()) ); @@ -139,6 +230,21 @@ class ShippingMethodResourceExtension extends ResourceExtension ->formatStateUsing(fn ($state) => $this->driverLabel($state)); } + /** + * `name` is a locale-keyed JSON column (see Modules\Core\Shipping\ + * Support\ShippingMethodName's own docblock for why it needs manual + * decoding rather than a model cast). Uses ->state() rather than + * ->formatStateUsing(), which would otherwise have Filament iterate a + * would-be array state as a multi-value list (one formatted cell per + * locale) instead of a single string. + */ + private function nameColumn(): TextColumn + { + return TextColumn::make('name') + ->label('Name') + ->state(fn ($record) => ShippingMethodName::resolve($record)); + } + private function driverLabel(string $key): string { $driver = collect(Shipping::getSupportedDrivers())->get($key); diff --git a/src/Shipping/Support/FulfillmentType.php b/src/Shipping/Support/FulfillmentType.php new file mode 100644 index 0000000..84b6373 --- /dev/null +++ b/src/Shipping/Support/FulfillmentType.php @@ -0,0 +1,60 @@ +get($method->driver); + + if ($driver instanceof DeclaresFulfillmentType) { + return $driver->fulfillmentType(); + } + + return $method->data['fulfillment_type'] ?? 'carrier'; + } + + public static function isStorePickup(ShippingMethod $method): bool + { + return static::resolve($method) === 'store_pickup'; + } + + /** + * Whether the merchant-facing "Fulfillment type" Select should be + * shown at all for a given driver — hidden entirely for a driver that + * already declares its own fulfillment type, since there is no real + * decision left for the merchant to make. + */ + public static function isConfigurableFor(?string $driverKey): bool + { + if ($driverKey === null) { + return true; + } + + $driver = collect(Shipping::getSupportedDrivers())->get($driverKey); + + return ! $driver instanceof DeclaresFulfillmentType; + } +} diff --git a/src/Shipping/Support/ShippingMethodName.php b/src/Shipping/Support/ShippingMethodName.php new file mode 100644 index 0000000..572f6a1 --- /dev/null +++ b/src/Shipping/Support/ShippingMethodName.php @@ -0,0 +1,35 @@ + + * name` returns the raw JSON string, not a decoded array — this resolves + * it the same way Lunar\Base\Traits\HasTranslations::translate() would, + * shared by every rate driver that builds a Lunar\DataTypes\ShippingOption + * (Modules\Core\Shipping\Concerns\ResolvesFixedPricing, Modules\Core\ + * Shipping\Carriers\Acs\AcsRateDriver) plus the Filament table column. + */ +class ShippingMethodName +{ + public static function resolve(ShippingMethod $method, ?string $locale = null): ?string + { + $decoded = json_decode((string) $method->getRawOriginal('name'), true); + + if (! is_array($decoded)) { + return $method->getRawOriginal('name'); + } + + return Arr::get($decoded, $locale ?: app()->getLocale()) ?: Arr::first($decoded); + } +} From ccb2666495077ef86661637a421d13bcd9022b30 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 15 Sep 2026 23:52:48 +0300 Subject: [PATCH 13/13] Bump Version to 0.17.5 --- CHANGELOG.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++ composer.json | 2 +- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8238d4f..df2e7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,75 @@ 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.17.5] - 2026-09-15 + +### Added +- Greek translations for `Lunar\Models\Country`/`State` reference data (`lang/el/countries.php`, + `lang/el/states.php`), keyed by the exact English spellings Lunar's own installer seeds for + Greece (fetched from `data.lunarphp.io/countries+states.json`). Loaded via + `loadTranslationsFrom()` under the `core::` namespace — a plain lang file, not + `Modules\Core\Localization`'s DB-backed `TranslationService`, since this is fixed reference + data, not admin-editable UI copy. A consuming app's storefront looks these up itself (e.g. + `__('core::countries.'.$country->name)`) — core has no storefront UI of its own to wire this + into. +- `Modules\Core\Order\Filament\Extensions\OrderActionsExtension::fixCaptureAction()` — reroutes + the backoffice "Capture" header action through `Modules\Core\Payment\Support\ + TransactionDriverAdapter::capture()`, the same app-level payment pipeline checkout-time captures + use, instead of vendor Lunar's `Lunar\Models\Transaction::capture()` (which resolved + `Lunar\Facades\Payments`, an entirely separate, unused driver registry, and never dispatched + `Modules\Core\Payment\Events\PaymentCaptured`). +- `Modules\Core\Payment\Drivers\StripePaymentDriver::cardMetaFromIntent()` — extracts card + brand/last-four digits from the Stripe PaymentIntent's `latest_charge`, populated into + `PaymentResult::$meta` and mapped onto `Transaction.card_type`/`last_four` by + `Modules\Core\Order\Services\TransactionRecorder`. Fixes the admin activity log's "Payment of + :amount on card ending :last_four" line rendering with no digits, on both checkout-time and + manual captures. Only applies to transactions recorded after this change. +- `PaymentMethod.name` and `Lunar\Shipping\Models\ShippingMethod.name` are now locale-keyed JSON + columns, rendered in Filament via Lunar's own `Lunar\Admin\Support\Forms\Components\ + TranslatedText` — one input per configured `Language` row, same shape/resolution as + Product/Collection names. Existing plain-string rows are preserved under the store's default + language on migration. `ShippingMethod` has no model cast/`ModelManifest` extension point + available (vendor table, `Contracts\ShippingMethod` exists but is never bound by the package), + so its translation is decoded/encoded at the Filament field boundary and via the new + `Modules\Core\Shipping\Support\ShippingMethodName::resolve()` helper, rather than a model cast. +- `Modules\Core\Shipping\Contracts\DeclaresFulfillmentType` — lets a shipping rate driver declare + whether it fulfils via carrier delivery or in-store pickup as a hardcoded fact about the driver + (`AcsRateDriver`, `BoxNowRateDriver` both declare `'carrier'`), instead of asking a merchant to + also pick "Carrier delivery" on every row regardless of driver. The merchant-facing "Fulfillment + type" Select (`ShippingMethod.data['fulfillment_type']`) now only appears for + table-rate-shipping's generic drivers (flat-rate, ship-by, free-shipping), which are genuinely + ambiguous, and moved next to `charge_by` instead of trailing at the end of the form, + disconnected from the decisions it relates to. `Modules\Core\Shipping\Support\ + FulfillmentType::resolve()`/`isStorePickup()` is the new single source of truth, replacing a + direct `data['fulfillment_type']` read in `Order::isStorePickupOrder()`. + +### Fixed +- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` never advanced `Order::status` past + `awaiting_payment` on a capture — only `paid`/`paid_at` were written, so a fully captured order + could sit indefinitely at "awaiting payment" until a staff member manually clicked "Update + Status". Now, on `PaymentCaptured` (not `PaymentAuthorized`), `status` advances to the next step + in the order's flow, but only when it's still exactly `awaiting_payment`, so a duplicate/delayed + capture event never regresses an order staff already moved further. +- `Lunar\DataTypes\ShippingOption::$collect` (the flag `docs/checkout.md` documents as the + mechanism for detecting a pickup option at checkout) was never actually set by any shipping rate + driver — `Modules\Core\Shipping\Concerns\ResolvesFixedPricing` now populates it from the same + `FulfillmentType` resolution `Order::isStorePickupOrder()` uses, closing a real gap between + documented and actual behavior. + +### Changed +- `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension` renamed to + `OrderActionsExtension` — the class now fixes both the refund and capture header actions on the + order page, not just refund. +- Removed the `lunarphp/stripe` dependency in favour of depending on `stripe/stripe-php` directly. + `Modules\Core\Payment\Drivers\StripePaymentDriver` had already replaced every bit of Lunar's own + Stripe payment flow (checkout, webhook processing) with its own — all that remained load-bearing + from the package was raw API-client access, amount conversion, and a correlation table, none of + which are Lunar-specific. Added first-party replacements: `Modules\Core\Payment\Support\ + StripeManager`, `Modules\Core\Payment\Models\StripePaymentIntent`, `Modules\Core\Payment\Http\ + Middleware\StripeWebhookMiddleware`, and a first-party copy of the vendor's + `create_stripe_payment_intents_table` migration (guarded with `Schema::hasTable()`). No behavior + change for consuming apps. + ## [0.17.4] - 2026-09-15 ### Added diff --git a/composer.json b/composer.json index ab37599..f4c2cf6 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.17.4", + "version": "0.17.5", "autoload": { "psr-4": { "Modules\\Core\\": "src/"