Compare commits

...
7 Commits
55 changed files with 984 additions and 171 deletions
+17
View File
@@ -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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.10.0] - 2026-08-31
### Changed
- **Breaking:** Upgraded `lunarphp/lunar`, `lunarphp/core`, `lunarphp/stripe`, `lunarphp/table-rate-shipping`, and `lunarphp/search` to `1.5.0`, and `filament/filament` to `v4.12.6` — the first Filament v4 admin panel on this codebase. `lunarphp/filament3-2fa` and `kalnoy/nestedset` are gone, replaced by Filament v4's native two-factor auth and `lunarphp/nestedset`. Ran Filament's automated `filament-v4` migration tool across `src/`, then hand-fixed three bugs it introduced or left behind: a stale `$infolist` variable reference in `CartResource`'s `ViewCart` page (the parameter had been renamed to `$schema` but the body wasn't updated), `ShippingMethodResourceExtension` rewritten to call `getDefaultChildComponents()` (returns `array|Schema`) instead of the type-safe `getChildComponents()` (always `array<Component>`), and — unrelated to the tool, but surfaced by the same PHP version bump — `InvalidCouponException`'s `readonly $code` property illegally shadowing the built-in `Exception::$code`, renamed to `$couponCode`. `LunarStaff::addActivitylogExcept()` updated for the renamed `two_factor_secret`/`two_factor_recovery_codes` staff columns (now `app_authentication_secret`/`app_authentication_recovery_codes`; `two_factor_confirmed_at` removed). Consuming apps must run `composer update boboko/core --with-all-dependencies` and `php artisan migrate`.
### Added
- `Modules\Core\Checkout\Contracts\PaymentDriver` — the abstraction every payment provider implements: `confirm(Cart $cart, string $type, string $fingerprint, array $data): Order` and `isConfigured(): bool`. A driver only ever calls `CheckoutService::placeOrder()` once it has, by whatever mechanism is native to that gateway, independently confirmed payment — never Lunar's raw `Cart::createOrder()`. This is what lets the storefront checkout sequence stay uniform regardless of which provider is active: set addresses, select shipping, hand off to whichever driver is configured, and the driver decides when (or whether) the order gets created.
- `Modules\Core\Payment\Drivers\OfflinePaymentDriver` — shared by every payment type with no real gateway to confirm against (`cash-in-hand`, `cash-on-delivery`): places the order immediately via `CheckoutService::placeOrder()`, then sets the order status from `config("lunar.payments.types.{$type}.authorized")` using the type actually confirmed, not a hardcoded key, since one driver instance serves multiple types.
- `Modules\Core\Payment\Drivers\StripePaymentDriver` — a fork, not a decoration, of `lunarphp/stripe`'s `StripePaymentType::authorize()`: that method is `final` and calls `Cart::createOrder()` directly with no seam to redirect into our fingerprint-checked `placeOrder()`, so this class reimplements its logic (intent retrieval, capture-on-policy, status mapping via `UpdateOrderFromIntent`) with that one substitution. Throws the new `Modules\Core\Payment\Exceptions\PaymentNotConfirmedException` on anything short of a genuinely confirmed payment intent — never falls through to placing an order on ambiguity.
- `CheckoutService::getPaymentMethods(): array` — every payment type currently offered to the storefront: every key in `config('lunar.payments.types')` that is both administratively enabled (`Modules\Core\Payment\Models\PaymentMethod::enabled`) and whose driver reports `isConfigured()` (e.g. Stripe with no API key set is never offered, regardless of the enabled toggle). `selectPaymentMethod(string $type)` and `confirmPayment(string $type, array $data)` both validate against this list, throwing the new `UnknownPaymentTypeException` for a type that isn't currently offered — re-checked in `confirmPayment()` too, since a type could be disabled between selection and confirmation.
- `CheckoutService::selectPaymentMethod()` snapshots `Cart::fingerprint()` into `cart->meta['checkout_fingerprint']` *after* saving the chosen type and recalculating — the fingerprint has to reflect the final total including any payment-type-specific adjustment (e.g. a COD surcharge), which only exists once `payment_method` is set. `confirmPayment()` reads this stored fingerprint internally rather than taking one as a parameter: a storefront should never need to know `Cart::fingerprint()` exists or capture it at exactly the right moment itself.
- `Modules\Core\Payment\Models\PaymentMethod` — one DB row per payment type key (matching `config('lunar.payments.types')`), `enabled` boolean plus a `data` jsonb column (starting with `fee`, the flat cash-on-delivery surcharge) — mirrors Lunar's own `Discount` model (a single jsonb column of keyed settings, not a fixed column per setting or a separate conditions table). Seeded idempotently by `InstallLunarCommand` (skip-if-exists per type, safe to re-run after installing a new payment-provider package), always `enabled: false` — a newly-seeded type shouldn't go live for shoppers before staff have configured and reviewed it. Admin-editable via the new `PaymentMethodResource` (inline enabled toggle, modal fee editor) under Settings.
- `ApplyCashOnDeliveryFee` now reads its surcharge from `PaymentMethod` instead of static config, so it's admin-editable without a deploy.
### Fixed
- `CashOnDeliveryPaymentDriver` renamed to `OfflinePaymentDriver` and generalized to work for any offline-style type — it previously hardcoded `'cash-on-delivery'` when reading the post-placement order status from config, which would have silently read the wrong type's status the moment a second offline type (`cash-in-hand`) used it.
## [0.9.0] - 2026-08-29 ## [0.9.0] - 2026-08-29
### Added ### Added
+8 -5
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.9.0", "version": "0.10.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -10,14 +10,15 @@
}, },
"require": { "require": {
"php": "^8.5", "php": "^8.5",
"lunarphp/lunar": "1.3.0", "lunarphp/lunar": "1.5.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"symfony/yaml": "^7.0", "symfony/yaml": "^7.0",
"lunarphp/table-rate-shipping": "^1.3", "lunarphp/table-rate-shipping": "1.5.0",
"lunarphp/search": "*", "lunarphp/search": "*",
"lunarphp/meilisearch": "*", "lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8" "spatie/laravel-translation-loader": "^2.8",
"lunarphp/stripe": "^1.5"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
@@ -27,7 +28,8 @@
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.6", "pestphp/pest": "^4.6",
"pestphp/pest-plugin-laravel": "^4.1" "pestphp/pest-plugin-laravel": "^4.1",
"filament/upgrade": "^4.0"
}, },
"extra": { "extra": {
"laravel": { "laravel": {
@@ -35,6 +37,7 @@
"Modules\\Core\\Providers\\CoreServiceProvider", "Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\PaymentServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider", "Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\CatalogServiceProvider", "Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\CartServiceProvider", "Modules\\Core\\Providers\\CartServiceProvider",
+46
View File
@@ -0,0 +1,46 @@
<?php
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
return [
/*
|--------------------------------------------------------------------------
| Lunar payment types merged in by Boboko Core
|--------------------------------------------------------------------------
|
| These are merged into config('lunar.payments.types') so every app using
| boboko-core gets cash-on-delivery out of the box, without publishing
| Lunar's own config.
|
| 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key —
| it's the Modules\Core\Checkout\Contracts\PaymentDriver class
| CheckoutService::confirmPayment() resolves via the container and calls
| confirm() on. Kept on the same row as 'driver' rather than a second,
| separately-keyed map, so a type's full definition — Lunar's driver,
| its config, and its PaymentDriver — lives in one place.
|
*/
'types' => [
'cash-on-delivery' => [
'driver' => 'offline',
'payment_driver' => OfflinePaymentDriver::class,
'authorized' => 'awaiting-payment',
'fee' => 0,
],
],
/*
|--------------------------------------------------------------------------
| Lunar cart pipeline additions
|--------------------------------------------------------------------------
|
| Appended to config('lunar.cart.pipelines.cart') after ApplyShipping so
| the cash-on-delivery fee is added to the shipping total before the
| final Calculate step sums everything up.
|
*/
'cart_pipeline' => [
ApplyCashOnDeliveryFee::class,
],
];
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_methods', function (Blueprint $table) {
$table->id();
$table->string('type')->unique();
$table->boolean('enabled')->default(true);
$table->json('data')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payment_methods');
}
};
@@ -2,18 +2,18 @@
namespace Modules\Core\Auth\Extensions; namespace Modules\Core\Auth\Extensions;
use Filament\Forms\Form; use Filament\Schemas\Schema;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
class StaffResourceExtension extends ResourceExtension class StaffResourceExtension extends ResourceExtension
{ {
public function extendForm(Form $form): Form public function extendForm(Schema $form): Schema
{ {
$schema = collect($form->getComponents()) $schema = collect($form->getComponents())
->reject(fn ($component) => method_exists($component, 'getName') && $component->getName() == 'password') ->reject(fn ($component) => method_exists($component, 'getName') && $component->getName() == 'password')
->values() ->values()
->all(); ->all();
return $form->schema($schema); return $form->components($schema);
} }
} }
+2 -2
View File
@@ -15,7 +15,7 @@ class Login extends SimplePage
{ {
use WithRateLimiting; use WithRateLimiting;
protected static string $view = 'core::auth.filament.pages.login'; protected string $view = 'core::auth.filament.pages.login';
public ?string $email = ''; public ?string $email = '';
public ?string $otp = ''; public ?string $otp = '';
@@ -78,7 +78,7 @@ class Login extends SimplePage
]); ]);
} }
if ($staff instanceof FilamentUser && !$staff->canAccessPanel(Filament::getCurrentPanel())) { if ($staff instanceof FilamentUser && !$staff->canAccessPanel(Filament::getCurrentOrDefaultPanel())) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => 'You do not have access to this panel.', 'email' => 'You do not have access to this panel.',
]); ]);
@@ -12,8 +12,8 @@ use RuntimeException;
*/ */
class InvalidCouponException extends RuntimeException class InvalidCouponException extends RuntimeException
{ {
public function __construct(public readonly string $code) public function __construct(public readonly string $couponCode)
{ {
parent::__construct("The coupon code \"{$code}\" is not valid."); parent::__construct("The coupon code \"{$couponCode}\" is not valid.");
} }
} }
+17 -13
View File
@@ -2,6 +2,10 @@
namespace Modules\Core\Cart\Filament\Resources; namespace Modules\Core\Cart\Filament\Resources;
use Filament\Tables\Columns\TextColumn;
use Filament\Actions\ViewAction;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ListCarts;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -24,9 +28,9 @@ class CartResource extends Resource
{ {
protected static ?string $model = Cart::class; protected static ?string $model = Cart::class;
protected static ?string $navigationIcon = 'heroicon-o-shopping-cart'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shopping-cart';
protected static ?string $navigationGroup = 'Sales'; protected static string | \UnitEnum | null $navigationGroup = 'Sales';
protected static ?string $modelLabel = 'Cart'; protected static ?string $modelLabel = 'Cart';
@@ -70,37 +74,37 @@ class CartResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('id') TextColumn::make('id')
->label('Cart') ->label('Cart')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('customer.full_name') TextColumn::make('customer.full_name')
->label('Customer') ->label('Customer')
->placeholder('—') ->placeholder('—')
->searchable() ->searchable()
->url(fn (Cart $record) => $record->customer_id !== null ->url(fn (Cart $record) => $record->customer_id !== null
? CustomerResource::getUrl('view', ['record' => $record->customer_id]) ? CustomerResource::getUrl('view', ['record' => $record->customer_id])
: null), : null),
Tables\Columns\TextColumn::make('user.email') TextColumn::make('user.email')
->label('User') ->label('User')
->placeholder('—') ->placeholder('—')
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('lines_count') TextColumn::make('lines_count')
->label('Lines') ->label('Lines')
->counts('lines') ->counts('lines')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('lines_sum_quantity') TextColumn::make('lines_sum_quantity')
->label('Items') ->label('Items')
->sum('lines', 'quantity') ->sum('lines', 'quantity')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('currency.code') TextColumn::make('currency.code')
->label('Currency'), ->label('Currency'),
Tables\Columns\TextColumn::make('updated_at') TextColumn::make('updated_at')
->label('Last activity') ->label('Last activity')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
]) ])
->actions([ ->recordActions([
Tables\Actions\ViewAction::make(), ViewAction::make(),
]) ])
->defaultSort('updated_at', 'desc'); ->defaultSort('updated_at', 'desc');
} }
@@ -108,8 +112,8 @@ class CartResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListCarts::route('/'), 'index' => ListCarts::route('/'),
'view' => Pages\ViewCart::route('/{record}'), 'view' => ViewCart::route('/{record}'),
]; ];
} }
@@ -2,7 +2,7 @@
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages; namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Resources\Components\Tab; use Filament\Schemas\Components\Tabs\Tab;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Cart\Filament\Resources\CartResource; use Modules\Core\Cart\Filament\Resources\CartResource;
@@ -2,11 +2,11 @@
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages; namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Section;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Infolist;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Lunar\Admin\Filament\Resources\CustomerResource; use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Models\Cart; use Lunar\Models\Cart;
@@ -44,10 +44,10 @@ class ViewCart extends ViewRecord
return $cart->calculate(); return $cart->calculate();
} }
public function infolist(Infolist $infolist): Infolist public function infolist(Schema $schema): Schema
{ {
return $infolist return $schema
->schema([ ->components([
Section::make('Cart') Section::make('Cart')
->columns(3) ->columns(3)
->schema([ ->schema([
@@ -2,7 +2,7 @@
namespace Modules\Core\Catalog\Contracts; namespace Modules\Core\Catalog\Contracts;
use Filament\Forms\Components\Component; use Filament\Schemas\Components\Component;
/** /**
* A Product Option Type describes how a category of Lunar `ProductOption` (e.g. * A Product Option Type describes how a category of Lunar `ProductOption` (e.g.
@@ -2,8 +2,8 @@
namespace Modules\Core\Catalog\Filament\Extensions; namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Schemas\Schema;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Catalog\Services\ProductOptionTypeManager; use Modules\Core\Catalog\Services\ProductOptionTypeManager;
@@ -16,7 +16,7 @@ use Modules\Core\Catalog\Services\ProductOptionTypeManager;
*/ */
class ProductOptionResourceExtension extends ResourceExtension class ProductOptionResourceExtension extends ResourceExtension
{ {
public function extendForm(Form $form): Form public function extendForm(Schema $schema): Schema
{ {
$options = collect(ProductOptionTypeManager::get()->all()) $options = collect(ProductOptionTypeManager::get()->all())
->keys() ->keys()
@@ -24,11 +24,11 @@ class ProductOptionResourceExtension extends ResourceExtension
->all(); ->all();
if ($options === []) { if ($options === []) {
return $form; return $schema;
} }
return $form->schema([ return $schema->components([
...$form->getComponents(), ...$schema->getComponents(),
Select::make('meta.option_type') Select::make('meta.option_type')
->label('Option Type') ->label('Option Type')
->options($options) ->options($options)
@@ -2,7 +2,7 @@
namespace Modules\Core\Catalog\Filament\Extensions; namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Forms\Form; use Filament\Schemas\Schema;
use Lunar\Admin\Support\Extending\RelationManagerExtension; use Lunar\Admin\Support\Extending\RelationManagerExtension;
use Lunar\Models\ProductOption; use Lunar\Models\ProductOption;
use Modules\Core\Catalog\Services\ProductOptionTypeManager; use Modules\Core\Catalog\Services\ProductOptionTypeManager;
@@ -15,7 +15,7 @@ use Modules\Core\Catalog\Services\ProductOptionTypeManager;
*/ */
class ValuesRelationManagerExtension extends RelationManagerExtension class ValuesRelationManagerExtension extends RelationManagerExtension
{ {
public function extendForm(Form $form): Form public function extendForm(Schema $schema): Schema
{ {
/** @var ProductOption $option */ /** @var ProductOption $option */
$option = $this->caller->getOwnerRecord(); $option = $this->caller->getOwnerRecord();
@@ -23,11 +23,11 @@ class ValuesRelationManagerExtension extends RelationManagerExtension
$type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null); $type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null);
if ($type === null) { if ($type === null) {
return $form; return $schema;
} }
return $form->schema([ return $schema->components([
...$form->getComponents(), ...$schema->getComponents(),
...$type->getMetaForm(), ...$type->getMetaForm(),
]); ]);
} }
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Modules\Core\Checkout\Contracts;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
/**
* A boboko-owned payment driver — wraps a payment gateway's own confirmation
* mechanics (Stripe's synchronous authorize() call, a redirect-based
* provider's async callback/webhook, anything else) behind one uniform
* moment: "payment is confirmed, place the order."
*
* confirm() is the only thing a driver is required to do: once it has,
* by whatever mechanism is native to that gateway, independently decided
* the payment succeeded, it calls Modules\Core\Checkout\Services\
* CheckoutService::placeOrder($fingerprint) itself — no driver ever calls
* Lunar\Models\Cart::createOrder() directly. This is what lets the
* storefront checkout sequence stay uniform regardless of which provider is
* active: set addresses, select shipping, hand off to whichever driver is
* configured, and the driver decides when (or whether) the order actually
* gets created. See docs/checkout.md / docs/payments.md.
*/
interface PaymentDriver
{
/**
* Whether this driver can actually be used right now — e.g. Stripe
* checking its own API key is present, an offline-style driver always
* returning true since it has no external dependency. Independent of
* Modules\Core\Payment\Models\PaymentMethod::enabled (the admin
* on/off toggle) — CheckoutService::getPaymentMethods() combines both:
* a type is only offered to the storefront if it's administratively
* enabled AND its driver reports itself configured.
*/
public function isConfigured(): bool;
/**
* $type is the payment type key being confirmed (e.g. 'cash-in-hand',
* 'cash-on-delivery', 'stripe') — passed through even though most
* drivers only ever serve one type, because a driver shared across
* several types (e.g. one "no real confirmation" offline driver behind
* both cash-in-hand and cash-on-delivery) needs it to look up that
* type's own config (e.g. its 'authorized' status) rather than another
* type's.
*
* $data carries whatever the gateway needs to confirm this specific
* payment (Stripe: ['payment_intent' => $id], a redirect-based
* provider: its callback payload) — passed explicitly by the caller
* (a controller, a webhook job) rather than a driver reaching into the
* global request(), so confirm() works the same whether it's called
* from a synchronous HTTP request or an async webhook/job with no
* active request at all.
*
* @param array<string, mixed> $data
*
* @throws FingerprintMismatchException
* @throws CartException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order;
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Models\Cart;
/**
* Dispatched by CheckoutService::selectPaymentMethod() — carries the plain
* type key (e.g. 'cash-on-delivery', 'stripe'), same convention as
* CartService's events (a plain reference the listener resolves further
* itself, rather than an already-resolved object) since a payment type key
* has nothing further to eagerly resolve the way a ShippingOption does.
*/
class PaymentMethodSelected
{
public function __construct(
public readonly Cart $cart,
public readonly string $type,
) {}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Checkout\Exceptions;
use RuntimeException;
/**
* Thrown by CheckoutService::selectPaymentMethod()/confirmPayment() when
* $type doesn't resolve to a registered Modules\Core\Checkout\Contracts\
* PaymentDriver (config('payment.drivers')) — same reasoning as
* InvalidShippingOptionException: nothing here has a matching Lunar
* exception type to reuse, so this is the boboko-owned signal instead of a
* silent no-op or an opaque container-resolution error.
*/
class UnknownPaymentTypeException extends RuntimeException
{
public function __construct(public readonly string $type)
{
parent::__construct("The payment type \"{$type}\" is not registered.");
}
}
+126 -2
View File
@@ -2,6 +2,8 @@
namespace Modules\Core\Checkout\Services; namespace Modules\Core\Checkout\Services;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Lunar\Base\Addressable; use Lunar\Base\Addressable;
@@ -10,11 +12,15 @@ use Lunar\Facades\ShippingManifest;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Cart\Services\CartService; use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Events\BillingAddressSet; use Modules\Core\Checkout\Events\BillingAddressSet;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Checkout\Events\PaymentMethodSelected;
use Modules\Core\Checkout\Events\ShippingAddressSet; use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Checkout\Events\ShippingOptionSelected; use Modules\Core\Checkout\Events\ShippingOptionSelected;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Payment\Models\PaymentMethod;
/** /**
* Storefront-facing checkout operations, mirroring * Storefront-facing checkout operations, mirroring
@@ -99,6 +105,10 @@ class CheckoutService
* stock adjusted the total, another tab modified the cart) rather than * stock adjusted the total, another tab modified the cart) rather than
* silently placing an order at a different total than what was shown. * silently placing an order at a different total than what was shown.
* *
* Not called directly by a storefront — see confirmPayment(), which is
* the only caller and supplies the fingerprint captured in
* selectPaymentMethod(), not one the storefront has to obtain itself.
*
* No exception wrapping: Lunar\Validation\Cart\ValidateCartForOrderCreation * No exception wrapping: Lunar\Validation\Cart\ValidateCartForOrderCreation
* (run inside Cart::createOrder()) already throws * (run inside Cart::createOrder()) already throws
* Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag * Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag
@@ -107,8 +117,8 @@ class CheckoutService
* render as form errors directly. FingerprintMismatchException * render as form errors directly. FingerprintMismatchException
* propagates the same way, for the same reason. * propagates the same way, for the same reason.
* *
* @throws \Lunar\Exceptions\FingerprintMismatchException * @throws FingerprintMismatchException
* @throws \Lunar\Exceptions\Carts\CartException * @throws CartException
*/ */
public function placeOrder(string $fingerprint): Order public function placeOrder(string $fingerprint): Order
{ {
@@ -121,4 +131,118 @@ class CheckoutService
return $order; return $order;
} }
/**
* Every payment type currently offered to the storefront — every key
* in config('lunar.payments.types') that is BOTH administratively
* enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) AND
* whose registered PaymentDriver reports itself usable right now
* (PaymentDriver::isConfigured() — e.g. Stripe with no API key set is
* never offered, regardless of the enabled toggle). A type with no
* PaymentMethod row at all (never seeded) is treated as not offered,
* same as disabled — nothing here creates one; see
* InstallLunarCommand::seedPaymentMethods().
*
* @return array<string>
*/
public function getPaymentMethods(): array
{
return PaymentMethod::where('enabled', true)
->pluck('type')
->filter(fn (string $type) => $this->resolvePaymentDriver($type)?->isConfigured() ?? false)
->values()
->all();
}
/**
* Records which payment type the shopper picked (Cart::meta
* ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\
* Cart\ApplyCashOnDeliveryFee to add that type's own cart-total
* adjustments before recalculation.
*
* Also snapshots Cart::fingerprint() into meta, *after* saving the
* chosen type — the fingerprint has to reflect the final total
* including any payment-type-specific adjustment (e.g. a COD
* surcharge), which only exists once payment_method is set and the
* cart recalculates. Captured here, server-side, rather than asked of
* the storefront: this is the last moment before confirmPayment() that
* the shopper's reviewed total is known, and confirmPayment() reads it
* back internally instead of taking a fingerprint parameter — a
* storefront should never need to know Cart::fingerprint() exists.
*
* Does not itself call a PaymentDriver — selecting a method and
* confirming payment against it are deliberately separate steps, same
* as selecting a shipping option happens before placing the order.
*
* @throws UnknownPaymentTypeException if $type isn't currently offered
* — see getPaymentMethods() for what that means (registered,
* administratively enabled, and its driver reports itself usable)
*/
public function selectPaymentMethod(string $type): Cart
{
if (! in_array($type, $this->getPaymentMethods(), true)) {
throw new UnknownPaymentTypeException($type);
}
$cart = $this->cart->currentOrCreate();
$cart->meta = [...$cart->meta->toArray(), 'payment_method' => $type];
$cart->save();
$cart = $cart->calculate();
$cart->meta = [...$cart->meta->toArray(), 'checkout_fingerprint' => $cart->fingerprint()];
$cart->save();
Event::dispatch(new PaymentMethodSelected($cart, $type));
return $cart;
}
/**
* Resolves $type's registered PaymentDriver and calls confirm() —
* the driver decides whether/when the order actually gets placed (see
* Modules\Core\Checkout\Contracts\PaymentDriver's docblock). $data
* carries whatever that driver needs (Stripe's payment_intent id, a
* future redirect-based provider's callback payload).
*
* The fingerprint passed to the driver is the one captured by
* selectPaymentMethod(), not supplied by the caller — see that
* method's docblock. Throws the same FingerprintMismatchException a
* caller-supplied one would if the cart's total has since changed;
* missing entirely (selectPaymentMethod() was never called for this
* cart) is treated the same as a mismatch, not a different error.
*
* @param array<string, mixed> $data
*
* @throws UnknownPaymentTypeException if $type isn't currently offered
* (see getPaymentMethods()) — re-checked here, not just in
* selectPaymentMethod(), since a type could be disabled between
* selection and confirmation
* @throws \Lunar\Exceptions\FingerprintMismatchException
* @throws \Lunar\Exceptions\Carts\CartException
*/
public function confirmPayment(string $type, array $data = []): Order
{
if (! in_array($type, $this->getPaymentMethods(), true)) {
throw new UnknownPaymentTypeException($type);
}
$cart = $this->cart->currentOrCreate();
$fingerprint = $cart->meta['checkout_fingerprint'] ?? '';
return $this->resolvePaymentDriver($type)->confirm($cart, $type, $fingerprint, $data);
}
/**
* Resolves $type's registered PaymentDriver, or null if $type has no
* 'payment_driver' registered in config('lunar.payments.types.<type>')
* at all — deliberately non-throwing so getPaymentMethods() can filter
* unresolvable types silently rather than treating "not registered"
* as an error condition when just checking availability.
*/
private function resolvePaymentDriver(string $type): ?PaymentDriver
{
$driverClass = config("lunar.payments.types.{$type}.payment_driver");
return $driverClass ? app($driverClass) : null;
}
} }
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Command; namespace Modules\Core\Command;
use Lunar\Admin\Models\Staff;
use Lunar\Admin\Console\Commands\MakeLunarAdminCommand; use Lunar\Admin\Console\Commands\MakeLunarAdminCommand;
use function Laravel\Prompts\text; use function Laravel\Prompts\text;
@@ -31,7 +32,7 @@ class CreateAdminCommand extends MakeLunarAdminCommand
required: true, required: true,
validate: fn (string $email): ?string => match (true) { validate: fn (string $email): ?string => match (true) {
! filter_var($email, FILTER_VALIDATE_EMAIL) => 'The email address must be valid.', ! filter_var($email, FILTER_VALIDATE_EMAIL) => 'The email address must be valid.',
\Lunar\Admin\Models\Staff::where('email', $email)->exists() => 'A user with this email address already exists', Staff::where('email', $email)->exists() => 'A user with this email address already exists',
default => null, default => null,
}, },
), ),
+6 -3
View File
@@ -2,6 +2,9 @@
namespace Modules\Core\Command; namespace Modules\Core\Command;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
use FilesystemIterator;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Modules\Core\ResultType\Error; use Modules\Core\ResultType\Error;
@@ -88,10 +91,10 @@ class ExportCommand extends Command
$zip->addFile($sqlFile, basename($sqlFile)); $zip->addFile($sqlFile, basename($sqlFile));
if (is_dir($filesDir)) { if (is_dir($filesDir)) {
$iterator = new \RecursiveIteratorIterator( $iterator = new RecursiveIteratorIterator(
new \RecursiveDirectoryIterator( new RecursiveDirectoryIterator(
$filesDir, $filesDir,
\FilesystemIterator::SKIP_DOTS, FilesystemIterator::SKIP_DOTS,
), ),
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
+37
View File
@@ -21,6 +21,7 @@ use Lunar\Models\TaxZone;
use Modules\Core\Localization\Models\LanguageLine; use Modules\Core\Localization\Models\LanguageLine;
use Modules\Core\Localization\Services\StorefrontLabels; use Modules\Core\Localization\Services\StorefrontLabels;
use Modules\Core\Localization\Services\TranslationService; use Modules\Core\Localization\Services\TranslationService;
use Modules\Core\Payment\Models\PaymentMethod;
/** /**
* Overrides Lunar's own lunar:install to skip the interactive prompts (migrate * Overrides Lunar's own lunar:install to skip the interactive prompts (migrate
@@ -247,6 +248,9 @@ class InstallLunarCommand extends Command
$this->components->info('Seeding storefront label translations'); $this->components->info('Seeding storefront label translations');
$this->seedStorefrontLabels($translations); $this->seedStorefrontLabels($translations);
$this->components->info('Seeding payment method settings');
$this->seedPaymentMethods();
$this->components->info('Publishing Filament assets'); $this->components->info('Publishing Filament assets');
$this->call('filament:assets'); $this->call('filament:assets');
@@ -278,4 +282,37 @@ class InstallLunarCommand extends Command
$translations->create('storefront', $key, $text); $translations->create('storefront', $key, $text);
} }
} }
/**
* Per-type skip-if-exists, same idempotent convention as
* seedStorefrontLabels() — a type already present (including one an
* admin has since edited via the Filament Payment Methods resource) is
* left untouched. Safe to re-run after a new payment type is added to
* config('lunar.payments.types') (e.g. installing a Stripe/Nexi
* package), which is the whole reason this isn't a one-time-only seed.
*
* Seeded disabled — a newly-seeded row (whether from this store's
* initial install, or a payment provider package installed later)
* shouldn't go live for shoppers before staff have actually reviewed
* it (real credentials configured, a fee set, etc.) and turned it on
* via the Payment Methods resource. See CheckoutService::
* getPaymentMethods(), which only offers a type once both 'enabled'
* here and its driver's own isConfigured() check pass.
*/
private function seedPaymentMethods(): void
{
$existingTypes = PaymentMethod::pluck('type');
foreach (array_keys(config('lunar.payments.types', [])) as $type) {
if ($existingTypes->contains($type)) {
continue;
}
PaymentMethod::create([
'type' => $type,
'enabled' => false,
'data' => [],
]);
}
}
} }
+6 -4
View File
@@ -2,6 +2,7 @@
namespace Modules\Core; namespace Modules\Core;
use Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder;
use Filament\Contracts\Plugin; use Filament\Contracts\Plugin;
use Filament\Panel; use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -24,6 +25,7 @@ use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension; use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview; use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Shipping\Extensions\OrderViewExtension; use Modules\Core\Shipping\Extensions\OrderViewExtension;
@@ -48,6 +50,7 @@ class CorePlugin implements Plugin
->resources([ ->resources([
LanguageLineResource::class, LanguageLineResource::class,
CartResource::class, CartResource::class,
PaymentMethodResource::class,
]) ])
->plugin(ShippingPlugin::make()) ->plugin(ShippingPlugin::make())
->pages([ManagePickupManifests::class]); ->pages([ManagePickupManifests::class]);
@@ -59,7 +62,7 @@ class CorePlugin implements Plugin
ValuesRelationManager::class => ValuesRelationManagerExtension::class, ValuesRelationManager::class => ValuesRelationManagerExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class, ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::class, ListShippingMethod::class => ShippingMethodListExtension::class,
OrderResource\Pages\ManageOrder::class => OrderViewExtension::class, ManageOrder::class => OrderViewExtension::class,
]); ]);
Product::macro('reviews', function (): HasMany { Product::macro('reviews', function (): HasMany {
@@ -73,9 +76,8 @@ class CorePlugin implements Plugin
'password', 'password',
'remember_token', 'remember_token',
'email_verified_at', 'email_verified_at',
'two_factor_secret', 'app_authentication_secret',
'two_factor_recovery_codes', 'app_authentication_recovery_codes',
'two_factor_confirmed_at',
]); ]);
LunarStaff::created(function (LunarStaff $staff) { LunarStaff::created(function (LunarStaff $staff) {
@@ -2,12 +2,12 @@
namespace Modules\Core\Customer\RelationManagers; namespace Modules\Core\Customer\RelationManagers;
use Filament\Forms\Components\Group; use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Schemas\Components\Group;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -38,9 +38,9 @@ class AddressRelationManager extends BaseAddressRelationManager
), ),
]) ])
->headerActions([ ->headerActions([
CreateAction::make()->form($this->addressForm()), CreateAction::make()->schema($this->addressForm()),
]) ])
->actions([ ->recordActions([
EditAction::make('editAddress') EditAction::make('editAddress')
->fillForm(fn (AddressContract $record): array => [ ->fillForm(fn (AddressContract $record): array => [
'line_one' => $record->line_one, 'line_one' => $record->line_one,
@@ -51,7 +51,7 @@ class AddressRelationManager extends BaseAddressRelationManager
'contact_email' => $record->contact_email, 'contact_email' => $record->contact_email,
'contact_phone' => $record->contact_phone, 'contact_phone' => $record->contact_phone,
]) ])
->form($this->addressForm()), ->schema($this->addressForm()),
DeleteAction::make('deleteAddress'), DeleteAction::make('deleteAddress'),
]); ]);
} }
@@ -2,6 +2,8 @@
namespace Modules\Core\Customer\RelationManagers; namespace Modules\Core\Customer\RelationManagers;
use Filament\Tables\Columns\TextColumn;
use Filament\Actions\EditAction;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -14,16 +16,16 @@ class UserRelationManager extends BaseUserRelationManager
public function getDefaultTable(Table $table): Table public function getDefaultTable(Table $table): Table
{ {
return $table->columns([ return $table->columns([
Tables\Columns\TextColumn::make('name') TextColumn::make('name')
->label(__('lunarpanel::user.table.name.label')), ->label(__('lunarpanel::user.table.name.label')),
Tables\Columns\TextColumn::make('email') TextColumn::make('email')
->label(__('lunarpanel::user.table.email.label')), ->label(__('lunarpanel::user.table.email.label')),
])->actions([ ])->recordActions([
Tables\Actions\EditAction::make('edit') EditAction::make('edit')
->after( ->after(
fn (Model $record) => CustomerUserEdited::dispatch($record) fn (Model $record) => CustomerUserEdited::dispatch($record)
) )
->form([ ->schema([
TextInput::make('email') TextInput::make('email')
->label(__('lunarpanel::user.form.email.label')) ->label(__('lunarpanel::user.form.email.label'))
->required() ->required()
@@ -2,8 +2,17 @@
namespace Modules\Core\Localization\Filament\Resources; namespace Modules\Core\Localization\Filament\Resources;
use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Fieldset;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages\ListLanguageLines;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages\CreateLanguageLine;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages\EditLanguageLine;
use Filament\Forms\Components\Textarea;
use Illuminate\Support\Collection;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -15,29 +24,29 @@ class LanguageLineResource extends Resource
{ {
protected static ?string $model = LanguageLine::class; protected static ?string $model = LanguageLine::class;
protected static ?string $navigationIcon = 'heroicon-o-language'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-language';
protected static ?string $navigationGroup = 'Settings'; protected static string | \UnitEnum | null $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Translation'; protected static ?string $modelLabel = 'Translation';
protected static ?string $pluralModelLabel = 'Translations'; protected static ?string $pluralModelLabel = 'Translations';
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form->schema([ return $schema->components([
Forms\Components\TextInput::make('group') TextInput::make('group')
->required() ->required()
->maxLength(255) ->maxLength(255)
->default('storefront') ->default('storefront')
->helperText('Namespace for this label, e.g. "storefront" for e-shop UI text.'), ->helperText('Namespace for this label, e.g. "storefront" for e-shop UI text.'),
Forms\Components\TextInput::make('key') TextInput::make('key')
->required() ->required()
->maxLength(255) ->maxLength(255)
->helperText('Dot-notation key, e.g. "nav.cart".'), ->helperText('Dot-notation key, e.g. "nav.cart".'),
Forms\Components\Fieldset::make('Translations') Fieldset::make('Translations')
->schema(static::localeInputs()), ->schema(static::localeInputs()),
]); ]);
} }
@@ -46,16 +55,16 @@ class LanguageLineResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('group') TextColumn::make('group')
->badge() ->badge()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('key') TextColumn::make('key')
->searchable() ->searchable()
->sortable(), ->sortable(),
...static::localeColumns(), ...static::localeColumns(),
]) ])
->filters([ ->filters([
Tables\Filters\SelectFilter::make('group') SelectFilter::make('group')
->options(fn () => LanguageLine::query()->distinct()->pluck('group', 'group')), ->options(fn () => LanguageLine::query()->distinct()->pluck('group', 'group')),
]) ])
->defaultSort('key'); ->defaultSort('key');
@@ -69,38 +78,38 @@ class LanguageLineResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListLanguageLines::route('/'), 'index' => ListLanguageLines::route('/'),
'create' => Pages\CreateLanguageLine::route('/create'), 'create' => CreateLanguageLine::route('/create'),
'edit' => Pages\EditLanguageLine::route('/{record}/edit'), 'edit' => EditLanguageLine::route('/{record}/edit'),
]; ];
} }
/** /**
* @return array<Forms\Components\Textarea> * @return array<Textarea>
*/ */
private static function localeInputs(): array private static function localeInputs(): array
{ {
return static::localeCodes() return static::localeCodes()
->map(fn (string $code) => Forms\Components\Textarea::make("text.{$code}") ->map(fn (string $code) => Textarea::make("text.{$code}")
->label(strtoupper($code)) ->label(strtoupper($code))
->rows(2)) ->rows(2))
->all(); ->all();
} }
/** /**
* @return array<Tables\Columns\TextColumn> * @return array<TextColumn>
*/ */
private static function localeColumns(): array private static function localeColumns(): array
{ {
return static::localeCodes() return static::localeCodes()
->map(fn (string $code) => Tables\Columns\TextColumn::make("text.{$code}") ->map(fn (string $code) => TextColumn::make("text.{$code}")
->label(strtoupper($code)) ->label(strtoupper($code))
->limit(40) ->limit(40)
->toggleable()) ->toggleable())
->all(); ->all();
} }
private static function localeCodes(): \Illuminate\Support\Collection private static function localeCodes(): Collection
{ {
return Language::query()->pluck('code'); return Language::query()->pluck('code');
} }
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages; namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
@@ -17,7 +18,7 @@ class EditLanguageLine extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() DeleteAction::make()
->action(function (LanguageLine $record) { ->action(function (LanguageLine $record) {
app(TranslationService::class)->delete($record); app(TranslationService::class)->delete($record);
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages; namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
@@ -13,7 +14,7 @@ class ListLanguageLines extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), CreateAction::make(),
]; ];
} }
} }
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\MigrateImport; namespace Modules\Core\MigrateImport;
use InvalidArgumentException;
use Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter; use Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter;
use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter; use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter;
@@ -15,7 +16,7 @@ class ImporterFactory
['judgeme', 'export'] => new JudgeMeExportImporter, ['judgeme', 'export'] => new JudgeMeExportImporter,
// ["woocommerce", "export"] => new WooCommerceExportImporter(), // ["woocommerce", "export"] => new WooCommerceExportImporter(),
// ["woocommerce", "api"] => new WooCommerceApiImporter(), // ["woocommerce", "api"] => new WooCommerceApiImporter(),
default => throw new \InvalidArgumentException( default => throw new InvalidArgumentException(
"No importer available for source \"{$spec->source}\" with type \"{$spec->type}\".", "No importer available for source \"{$spec->source}\" with type \"{$spec->type}\".",
), ),
}; };
@@ -2,6 +2,8 @@
namespace Modules\Core\MigrateImport\JudgeMe; namespace Modules\Core\MigrateImport\JudgeMe;
use RuntimeException;
class JudgeMeCsvReader class JudgeMeCsvReader
{ {
/** /**
@@ -12,7 +14,7 @@ class JudgeMeCsvReader
$handle = fopen($csvPath, 'r'); $handle = fopen($csvPath, 'r');
if ($handle === false) { if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}"); throw new RuntimeException("Could not open CSV file: {$csvPath}");
} }
$headers = fgetcsv($handle); $headers = fgetcsv($handle);
@@ -2,6 +2,7 @@
namespace Modules\Core\MigrateImport\JudgeMe; namespace Modules\Core\MigrateImport\JudgeMe;
use Throwable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Modules\Core\MigrateImport\Importer; use Modules\Core\MigrateImport\Importer;
@@ -75,7 +76,7 @@ class JudgeMeExportImporter implements Importer
foreach ($urls as $url) { foreach ($urls as $url) {
try { try {
$review->addMediaFromUrl($url)->toMediaCollection(ProductReview::IMAGES_COLLECTION); $review->addMediaFromUrl($url)->toMediaCollection(ProductReview::IMAGES_COLLECTION);
} catch (\Throwable $e) { } catch (Throwable $e) {
Log::warning('JudgeMe import: failed to download review image', [ Log::warning('JudgeMe import: failed to download review image', [
'review_id' => $review->id, 'review_id' => $review->id,
'url' => $url, 'url' => $url,
@@ -2,6 +2,8 @@
namespace Modules\Core\MigrateImport\Shopify; namespace Modules\Core\MigrateImport\Shopify;
use RuntimeException;
class ShopifyCsvReader class ShopifyCsvReader
{ {
/** /**
@@ -12,7 +14,7 @@ class ShopifyCsvReader
$handle = fopen($csvPath, 'r'); $handle = fopen($csvPath, 'r');
if ($handle === false) { if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}"); throw new RuntimeException("Could not open CSV file: {$csvPath}");
} }
$headers = fgetcsv($handle); $headers = fgetcsv($handle);
@@ -2,6 +2,8 @@
namespace Modules\Core\MigrateImport\Shopify; namespace Modules\Core\MigrateImport\Shopify;
use Lunar\Models\TaxClass;
use Lunar\Models\ProductOption;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Lunar\Models\Collection; use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup; use Lunar\Models\CollectionGroup;
@@ -120,7 +122,7 @@ class ShopifyExportImporter implements Importer
} }
/** /**
* @return array<int, \Lunar\Models\ProductOption> * @return array<int, ProductOption>
*/ */
private function attachOptions(Product $product, array $row): array private function attachOptions(Product $product, array $row): array
{ {
@@ -146,7 +148,7 @@ class ShopifyExportImporter implements Importer
string $handle, string $handle,
int $index, int $index,
array $row, array $row,
\Lunar\Models\TaxClass $taxClass, TaxClass $taxClass,
Currency $currency, Currency $currency,
array $options, array $options,
): void { ): void {
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Notification; namespace Modules\Core\Notification;
use Throwable;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
class NotificationRegistry class NotificationRegistry
@@ -44,7 +45,7 @@ class NotificationRegistry
$notification->delay($event->delaySeconds); $notification->delay($event->delaySeconds);
} }
$notification->notifiable()->notify($notification); $notification->notifiable()->notify($notification);
} catch (\Throwable $e) { } catch (Throwable $e) {
report($e); report($e);
} }
}); });
+6 -3
View File
@@ -2,6 +2,9 @@
namespace Modules\Core\Option; namespace Modules\Core\Option;
use InvalidArgumentException;
use Exception;
use RuntimeException;
use Traversable; use Traversable;
/** /**
@@ -39,7 +42,7 @@ final class LazyOption extends Option
public function __construct($callback, array $arguments = []) public function __construct($callback, array $arguments = [])
{ {
if (!is_callable($callback)) { if (!is_callable($callback)) {
throw new \InvalidArgumentException("Invalid callback given"); throw new InvalidArgumentException("Invalid callback given");
} }
$this->callback = $callback; $this->callback = $callback;
@@ -71,7 +74,7 @@ final class LazyOption extends Option
return $this->option()->getOrCall($callable); return $this->option()->getOrCall($callable);
} }
public function getOrThrow(\Exception $ex) public function getOrThrow(Exception $ex)
{ {
return $this->option()->getOrThrow($ex); return $this->option()->getOrThrow($ex);
} }
@@ -146,7 +149,7 @@ final class LazyOption extends Option
if ($option instanceof Option) { if ($option instanceof Option) {
$this->option = $option; $this->option = $option;
} else { } else {
throw new \RuntimeException( throw new RuntimeException(
sprintf("Expected instance of %s", Option::class), sprintf("Expected instance of %s", Option::class),
); );
} }
+4 -2
View File
@@ -2,6 +2,8 @@
namespace Modules\Core\Option; namespace Modules\Core\Option;
use RuntimeException;
use Exception;
use EmptyIterator; use EmptyIterator;
/** /**
@@ -24,7 +26,7 @@ final class None extends Option
public function get() public function get()
{ {
throw new \RuntimeException("None has no value."); throw new RuntimeException("None has no value.");
} }
public function getOrCall($callable) public function getOrCall($callable)
@@ -37,7 +39,7 @@ final class None extends Option
return $default; return $default;
} }
public function getOrThrow(\Exception $ex) public function getOrThrow(Exception $ex)
{ {
throw $ex; throw $ex;
} }
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Option; namespace Modules\Core\Option;
use Exception;
use ArrayAccess; use ArrayAccess;
use IteratorAggregate; use IteratorAggregate;
@@ -164,7 +165,7 @@ abstract class Option implements IteratorAggregate
abstract public function getOrCall($callable); abstract public function getOrCall($callable);
/** @return T */ /** @return T */
abstract public function getOrThrow(\Exception $ex); abstract public function getOrThrow(Exception $ex);
abstract public function isEmpty(): bool; abstract public function isEmpty(): bool;
+3 -2
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Option; namespace Modules\Core\Option;
use RuntimeException;
use ArrayIterator; use ArrayIterator;
use Exception; use Exception;
@@ -56,7 +57,7 @@ final class Some extends Option
return $this->value; return $this->value;
} }
public function getOrThrow(\Exception $ex) public function getOrThrow(Exception $ex)
{ {
return $this->value; return $this->value;
} }
@@ -88,7 +89,7 @@ final class Some extends Option
/** @var mixed */ /** @var mixed */
$rs = $callable($this->value); $rs = $callable($this->value);
if (!$rs instanceof Option) { if (!$rs instanceof Option) {
throw new \RuntimeException( throw new RuntimeException(
"Callables passed to flatMap() must return an Option. Maybe you should use map() instead?", "Callables passed to flatMap() must return an Option. Maybe you should use map() instead?",
); );
} }
@@ -0,0 +1,57 @@
<?php
namespace Modules\Core\Payment\Drivers;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Services\CheckoutService;
/**
* Shared by every payment type with no real gateway to confirm against —
* cash-in-hand, cash-on-delivery — where the shopper pays at pickup/on
* delivery, not at checkout. confirm() has nothing to wait on, so it places
* the order immediately, same as Lunar's own OfflinePayment would, but
* through CheckoutService::placeOrder() so it goes through the same
* fingerprint check every other driver does. $data is unused: nothing about
* this confirmation depends on gateway-specific payload.
*
* Sets the order status to config("lunar.payments.types.{$type}.authorized")
* afterward, using the type actually confirmed — not a hardcoded key —
* since this one driver is shared across multiple types.
* placeOrder() itself leaves the order at Lunar's configured draft_status,
* same as every driver is responsible for moving it on from.
*/
class OfflinePaymentDriver implements PaymentDriver
{
public function __construct(
private readonly CheckoutService $checkout,
) {}
/**
* Always true — no external dependency to be missing.
*/
public function isConfigured(): bool
{
return true;
}
/**
* @throws FingerprintMismatchException
* @throws CartException
* @throws DisallowMultipleCartOrdersException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
{
$order = $this->checkout->placeOrder($fingerprint);
$order->update([
'status' => config("lunar.payments.types.{$type}.authorized", $order->status),
]);
return $order->refresh();
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace Modules\Core\Payment\Drivers;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
use Lunar\Stripe\Actions\UpdateOrderFromIntent;
use Lunar\Stripe\Facades\Stripe;
use Lunar\Stripe\Models\StripePaymentIntent;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Services\CheckoutService;
use Modules\Core\Payment\Exceptions\PaymentNotConfirmedException;
use Stripe\PaymentIntent;
/**
* Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy
* Modules\Core\Checkout\Contracts\PaymentDriver — calls
* CheckoutService::placeOrder($fingerprint) at the moment Stripe confirms
* payment, instead of the vendor's own Cart::createOrder() call.
*
* This is a fork, not a decoration: StripePaymentType::authorize() is
* `final` and calls Cart::createOrder() directly with no seam to redirect
* that one call — so this class reimplements authorize()'s logic (intent
* retrieval, capture-on-policy, status mapping via UpdateOrderFromIntent)
* rather than wrapping the vendor method. Kept deliberately close to the
* original so a lunarphp/stripe upgrade is easy to diff against. See
* docs/payments.md.
*/
class StripePaymentDriver implements PaymentDriver
{
public function __construct(
private readonly CheckoutService $checkout,
) {}
/**
* Same key lunarphp/stripe's own StripeManager reads its API key from
* (Stripe::setApiKey(config('services.stripe.key')) in
* StripeManager::__construct()) — no key, no usable driver.
*/
public function isConfigured(): bool
{
return filled(config('services.stripe.key'));
}
/**
* @throws PaymentNotConfirmedException if Stripe hasn't confirmed the
* payment intent (wrong intent id, already processed, order already
* placed, or the gateway call itself fails) — nothing here should be
* treated as "place the order anyway."
* @throws FingerprintMismatchException
* @throws CartException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
{
$paymentIntentId = $data['payment_intent'];
$paymentIntentModel = StripePaymentIntent::where('intent_id', $paymentIntentId)->first();
if ($paymentIntentModel && ! $paymentIntentModel->isActive()) {
throw new PaymentNotConfirmedException('Payment intent already processed.');
}
if (! $paymentIntentModel) {
$paymentIntentModel = StripePaymentIntent::create([
'intent_id' => $paymentIntentId,
'cart_id' => $cart->id,
]);
}
$paymentIntentModel->update(['processing_at' => now()]);
$stripe = Stripe::getClient();
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (! $paymentIntent) {
throw new PaymentNotConfirmedException('Unable to locate payment intent.');
}
$policy = config('lunar.stripe.policy', 'automatic');
if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && $policy === 'automatic') {
$paymentIntent = $stripe->paymentIntents->capture($paymentIntentId);
}
if ($paymentIntent->status !== PaymentIntent::STATUS_SUCCEEDED) {
$paymentIntentModel->update(['status' => $paymentIntent->status]);
throw new PaymentNotConfirmedException(
$paymentIntent->last_payment_error->message ?? "Payment intent status: {$paymentIntent->status}."
);
}
try {
$order = $this->checkout->placeOrder($fingerprint);
} catch (DisallowMultipleCartOrdersException|CartException $e) {
throw new PaymentNotConfirmedException($e->getMessage(), previous: $e);
}
$paymentIntentModel->order_id = $order->id;
$paymentIntentModel->status = $paymentIntent->status;
$paymentIntentModel->processed_at = now();
$paymentIntentModel->save();
UpdateOrderFromIntent::execute($order, $paymentIntent);
return $order->refresh();
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Payment\Exceptions;
use RuntimeException;
use Throwable;
/**
* Thrown by a Modules\Core\Checkout\Contracts\PaymentDriver when the
* gateway has not confirmed payment — wrong/expired intent, already
* processed, or the gateway itself rejects the confirmation. A driver
* throws this instead of silently placing the order: CheckoutService::
* placeOrder() must only ever be called once a driver has positively
* confirmed payment, never as a fallback.
*/
class PaymentNotConfirmedException extends RuntimeException
{
public function __construct(string $message, ?Throwable $previous = null)
{
parent::__construct($message, previous: $previous);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Modules\Core\Payment\Filament\Resources;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* One row per payment type key (config('lunar.payments.types')), seeded by
* InstallLunarCommand — never created/deleted here, only edited. `enabled`
* toggles inline; `data.fee` (currently the only type-specific setting, for
* cash-on-delivery's flat surcharge — see ApplyCashOnDeliveryFee) is edited
* via a modal action rather than a dedicated form field, since not every
* type has the same data keys.
*/
class PaymentMethodResource extends Resource
{
protected static ?string $model = PaymentMethod::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Payment Method';
protected static ?string $pluralModelLabel = 'Payment Methods';
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('type')
->label('Type'),
ToggleColumn::make('enabled')
->label('Enabled'),
TextColumn::make('data.fee')
->label('Fee')
->formatStateUsing(fn (?int $state) => $state
? number_format($state / 100, 2)
: '—'),
TextColumn::make('updated_at')
->label('Last updated')
->dateTime(),
])
->recordActions([
static::editFeeAction(),
])
->defaultSort('type');
}
/**
* $data['fee'] is stored as an integer minor unit (cents), matching
* Lunar's own Price convention everywhere else in this codebase — the
* form collects/displays a decimal and converts at the boundary.
*/
private static function editFeeAction(): Action
{
return Action::make('edit_fee')
->label('Edit fee')
->icon('heroicon-o-pencil')
->schema([
TextInput::make('fee')
->label('Fee')
->numeric()
->minValue(0)
->step(0.01)
->helperText('Flat surcharge added when this payment method is selected.'),
])
->fillForm(fn (PaymentMethod $record) => [
'fee' => filled($record->data['fee'] ?? null) ? $record->data['fee'] / 100 : null,
])
->action(function (PaymentMethod $record, array $data) {
$record->update([
'data' => [
...$record->data->toArray(),
'fee' => filled($data['fee']) ? (int) round($data['fee'] * 100) : null,
],
]);
});
}
public static function getPages(): array
{
return [
'index' => ListPaymentMethods::route('/'),
];
}
public static function canCreate(): bool
{
return false;
}
public static function canDelete($record = null): bool
{
return false;
}
}
@@ -0,0 +1,11 @@
<?php
namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
class ListPaymentMethods extends ListRecords
{
protected static string $resource = PaymentMethodResource::class;
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Payment\Models;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model;
/**
* Admin-editable settings for one payment type key (matching a key in
* config('lunar.payments.types')) — enabled/disabled, and whatever type-
* specific data it needs (starts with 'fee' for cash-on-delivery's flat
* surcharge). Mirrors Lunar's own Discount model: a single jsonb 'data'
* column holding keyed settings, rather than a fixed column per setting or
* a separate conditions table — new settings are a code change (a new key
* read from data), not a migration.
*
* Seeded once per type by InstallLunarCommand (skip-if-exists, same
* idempotent convention as seedStorefrontLabels()) — never auto-created on
* read, so a read path stays a pure read.
*/
class PaymentMethod extends Model
{
protected $guarded = [];
protected $casts = [
'enabled' => 'boolean',
'data' => AsArrayObject::class,
];
}
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Payment\Pipelines\Cart;
use Closure;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\Cart as CartContract;
use Modules\Core\Payment\Models\PaymentMethod;
final class ApplyCashOnDeliveryFee
{
/**
* Called just before cart totals are calculated.
*
* @param Closure(CartContract): mixed $next
*/
public function handle(CartContract $cart, Closure $next): mixed
{
if (($cart->meta['payment_method'] ?? null) === 'cash-on-delivery') {
$fee = (int) (PaymentMethod::where('type', 'cash-on-delivery')->value('data->fee') ?? 0);
$cart->shippingTotal = new Price(
($cart->shippingTotal?->value ?? 0) + $fee,
$cart->currency,
1
);
}
return $next($cart);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider;
use Lunar\Pipelines\Cart\ApplyShipping;
class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/payment.php', 'payment');
}
public function boot(): void
{
config([
'lunar.payments.types' => array_merge(
config('lunar.payments.types', []),
config('payment.types', [])
),
]);
$cartPipeline = config('lunar.cart.pipelines.cart', []);
$insertAfter = array_search(ApplyShipping::class, $cartPipeline, true);
foreach (config('payment.cart_pipeline', []) as $pipe) {
if (in_array($pipe, $cartPipeline, true)) {
continue;
}
if ($insertAfter === false) {
$cartPipeline[] = $pipe;
} else {
array_splice($cartPipeline, $insertAfter + 1, 0, [$pipe]);
$insertAfter++;
}
}
config(['lunar.cart.pipelines.cart' => $cartPipeline]);
}
}
+10 -9
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Modules\Core\ResultType; namespace Modules\Core\ResultType;
use Modules\Core\Option\Option;
use Modules\Core\Option\None; use Modules\Core\Option\None;
use Modules\Core\Option\Some; use Modules\Core\Option\Some;
@@ -11,7 +12,7 @@ use Modules\Core\Option\Some;
* @template T * @template T
* @template E * @template E
* *
* @extends \Modules\Core\ResultType\Result<T,E> * @extends Result<T, E>
*/ */
final class Error extends Result final class Error extends Result
{ {
@@ -39,7 +40,7 @@ final class Error extends Result
* *
* @param F $value * @param F $value
* *
* @return \Modules\Core\ResultType\Result<T,F> * @return Result<T, F>
*/ */
public static function create($value): Error public static function create($value): Error
{ {
@@ -49,7 +50,7 @@ final class Error extends Result
/** /**
* Get the success option value. * Get the success option value.
* *
* @return \Modules\Core\Option\Option<T> * @return Option<T>
*/ */
public function success() public function success()
{ {
@@ -63,7 +64,7 @@ final class Error extends Result
* *
* @param callable(T):S $f * @param callable(T):S $f
* *
* @return \Modules\Core\ResultType\Result<S,E> * @return Result<S, E>
*/ */
public function map(callable $f): Result public function map(callable $f): Result
{ {
@@ -76,20 +77,20 @@ final class Error extends Result
* @template S * @template S
* @template F * @template F
* *
* @param callable(T):\Modules\Core\ResultType\Result<S,F> $f * @param callable(T):Result<S, F> $f
* *
* @return \Modules\Core\ResultType\Result<S,F> * @return Result<S, F>
*/ */
public function flatMap(callable $f): Result public function flatMap(callable $f): Result
{ {
/** @var \Modules\Core\ResultType\Result<S,F> */ /** @var Result<S, F> */
return self::create($this->value); return self::create($this->value);
} }
/** /**
* Get the error option value. * Get the error option value.
* *
* @return \Modules\Core\Option\Option<E> * @return Option<E>
*/ */
public function error(): Some public function error(): Some
{ {
@@ -103,7 +104,7 @@ final class Error extends Result
* *
* @param callable(E):F $f * @param callable(E):F $f
* *
* @return \Modules\Core\ResultType\Result<T,F> * @return Result<T, F>
*/ */
public function mapError(callable $f): Result public function mapError(callable $f): Result
{ {
+8 -6
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Modules\Core\ResultType; namespace Modules\Core\ResultType;
use Modules\Core\Option\Option;
/** /**
* @template T * @template T
* @template E * @template E
@@ -13,7 +15,7 @@ abstract class Result
/** /**
* Get the success option value. * Get the success option value.
* *
* @return \Modules\Core\Option\Option<T> * @return Option<T>
*/ */
abstract public function success(); abstract public function success();
@@ -24,7 +26,7 @@ abstract class Result
* *
* @param callable(T):S $f * @param callable(T):S $f
* *
* @return \Modules\Core\ResultType\Result<S,E> * @return Result<S, E>
*/ */
abstract public function map(callable $f); abstract public function map(callable $f);
@@ -34,16 +36,16 @@ abstract class Result
* @template S * @template S
* @template F * @template F
* *
* @param callable(T):\Modules\Core\ResultType\Result<S,F> $f * @param callable(T):Result<S, F> $f
* *
* @return \Modules\Core\ResultType\Result<S,F> * @return Result<S, F>
*/ */
abstract public function flatMap(callable $f); abstract public function flatMap(callable $f);
/** /**
* Get the error option value. * Get the error option value.
* *
* @return \Modules\Core\Option\Option<E> * @return Option<E>
*/ */
abstract public function error(); abstract public function error();
@@ -54,7 +56,7 @@ abstract class Result
* *
* @param callable(E):F $f * @param callable(E):F $f
* *
* @return \Modules\Core\ResultType\Result<T,F> * @return Result<T, F>
*/ */
abstract public function mapError(callable $f); abstract public function mapError(callable $f);
} }
+9 -8
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Modules\Core\ResultType; namespace Modules\Core\ResultType;
use Modules\Core\Option\Option;
use Modules\Core\Option\None; use Modules\Core\Option\None;
use Modules\Core\Option\Some; use Modules\Core\Option\Some;
@@ -11,7 +12,7 @@ use Modules\Core\Option\Some;
* @template T * @template T
* @template E * @template E
* *
* @extends \Modules\Core\ResultType\Result<T,E> * @extends Result<T, E>
*/ */
final class Success extends Result final class Success extends Result
{ {
@@ -39,7 +40,7 @@ final class Success extends Result
* *
* @param S $value * @param S $value
* *
* @return \Modules\Core\ResultType\Result<S,E> * @return Result<S, E>
*/ */
public static function create($value): Success public static function create($value): Success
{ {
@@ -49,7 +50,7 @@ final class Success extends Result
/** /**
* Get the success option value. * Get the success option value.
* *
* @return \Modules\Core\Option\Option<T> * @return Option<T>
*/ */
public function success(): Some public function success(): Some
{ {
@@ -63,7 +64,7 @@ final class Success extends Result
* *
* @param callable(T):S $f * @param callable(T):S $f
* *
* @return \Modules\Core\ResultType\Result<S,E> * @return Result<S, E>
*/ */
public function map(callable $f): Result public function map(callable $f): Result
{ {
@@ -76,9 +77,9 @@ final class Success extends Result
* @template S * @template S
* @template F * @template F
* *
* @param callable(T):\Modules\Core\ResultType\Result<S,F> $f * @param callable(T):Result<S, F> $f
* *
* @return \Modules\Core\ResultType\Result<S,F> * @return Result<S, F>
*/ */
public function flatMap(callable $f) public function flatMap(callable $f)
{ {
@@ -88,7 +89,7 @@ final class Success extends Result
/** /**
* Get the error option value. * Get the error option value.
* *
* @return \Modules\Core\Option\Option<E> * @return Option<E>
*/ */
public function error() public function error()
{ {
@@ -102,7 +103,7 @@ final class Success extends Result
* *
* @param callable(E):F $f * @param callable(E):F $f
* *
* @return \Modules\Core\ResultType\Result<T,F> * @return Result<T, F>
*/ */
public function mapError(callable $f): Result public function mapError(callable $f): Result
{ {
@@ -2,15 +2,15 @@
namespace Modules\Core\Review\Filament\Pages; namespace Modules\Core\Review\Filament\Pages;
use Filament\Forms\Components\Group; use Filament\Schemas\Schema;
use Filament\Schemas\Components\Group;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -40,10 +40,10 @@ class ManageProductReviews extends BaseManageRelatedRecords
return 'Reviews'; return 'Reviews';
} }
public function form(Form $form): Form public function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
TextInput::make('rating') TextInput::make('rating')
->label('Rating') ->label('Rating')
->disabled(), ->disabled(),
@@ -127,12 +127,12 @@ class ManageProductReviews extends BaseManageRelatedRecords
->filters([ ->filters([
// //
]) ])
->actions([ ->recordActions([
ViewAction::make(), ViewAction::make(),
Action::make('reply') Action::make('reply')
->label(fn (ProductReview $record) => $record->reply ? 'Edit reply' : 'Reply') ->label(fn (ProductReview $record) => $record->reply ? 'Edit reply' : 'Reply')
->icon('heroicon-o-chat-bubble-left-right') ->icon('heroicon-o-chat-bubble-left-right')
->form([ ->schema([
Textarea::make('reply') Textarea::make('reply')
->label('Reply') ->label('Reply')
->required(), ->required(),
@@ -146,7 +146,7 @@ class ManageProductReviews extends BaseManageRelatedRecords
}), }),
DeleteAction::make(), DeleteAction::make(),
]) ])
->bulkActions([ ->toolbarActions([
DeleteBulkAction::make(), DeleteBulkAction::make(),
]); ]);
} }
@@ -2,6 +2,7 @@
namespace Modules\Core\Shipping\Carriers\Acs; namespace Modules\Core\Shipping\Carriers\Acs;
use RuntimeException;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Lunar\Models\Order; use Lunar\Models\Order;
@@ -88,7 +89,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
public function cancelShipment(Shipment $shipment): void public function cancelShipment(Shipment $shipment): void
{ {
if ($shipment->manifest_reference) { if ($shipment->manifest_reference) {
throw new \RuntimeException('Cannot cancel a shipment already included in an issued manifest.'); throw new RuntimeException('Cannot cancel a shipment already included in an issued manifest.');
} }
$this->client->call('ACS_Delete_Voucher', [ $this->client->call('ACS_Delete_Voucher', [
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Shipping\Concerns; namespace Modules\Core\Shipping\Concerns;
use Closure;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Lunar\DataTypes\ShippingOption; use Lunar\DataTypes\ShippingOption;
use Lunar\Models\Cart; use Lunar\Models\Cart;
@@ -27,7 +28,7 @@ use Lunar\Shipping\Models\ShippingRate;
*/ */
trait CachesLivePricing trait CachesLivePricing
{ {
private function cached(ShippingRate $shippingRate, Cart $cart, \Closure $resolve): ?ShippingOption private function cached(ShippingRate $shippingRate, Cart $cart, Closure $resolve): ?ShippingOption
{ {
return Cache::remember( return Cache::remember(
"shipping.live_price.{$shippingRate->id}.{$cart->id}", "shipping.live_price.{$shippingRate->id}.{$cart->id}",
+14 -9
View File
@@ -2,6 +2,11 @@
namespace Modules\Core\Shipping\Extensions; namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Closure;
use Throwable;
use Filament\Actions; use Filament\Actions;
use Filament\Forms; use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@@ -20,28 +25,28 @@ class OrderViewExtension extends ViewPageExtension
return $actions; return $actions;
} }
private function createShipmentAction(): Actions\Action private function createShipmentAction(): Action
{ {
return Actions\Action::make('create_shipment') return Action::make('create_shipment')
->label('Create Shipment') ->label('Create Shipment')
->icon('heroicon-o-truck') ->icon('heroicon-o-truck')
->modalSubmitActionLabel('Create Shipment') ->modalSubmitActionLabel('Create Shipment')
->form([ ->schema([
Forms\Components\TextInput::make('weight') TextInput::make('weight')
->label('Package weight (kg)') ->label('Package weight (kg)')
->numeric() ->numeric()
->minValue(0) ->minValue(0)
->helperText('Leave blank to use the carrier\'s default.'), ->helperText('Leave blank to use the carrier\'s default.'),
Forms\Components\TextInput::make('destination_location_id') TextInput::make('destination_location_id')
->label('Box Now locker ID') ->label('Box Now locker ID')
->helperText('Only required for Box Now shipments.') ->helperText('Only required for Box Now shipments.')
->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null), ->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null),
Forms\Components\Toggle::make('confirm') Toggle::make('confirm')
->label('Confirm') ->label('Confirm')
->helperText('This will create a real shipment with the carrier.') ->helperText('This will create a real shipment with the carrier.')
->rules([ ->rules([
function () { function () {
return function (string $attribute, $value, \Closure $fail) { return function (string $attribute, $value, Closure $fail) {
if ($value !== true) { if ($value !== true) {
$fail('Please confirm before creating the shipment.'); $fail('Please confirm before creating the shipment.');
} }
@@ -49,7 +54,7 @@ class OrderViewExtension extends ViewPageExtension
}, },
]), ]),
]) ])
->action(function (Order $record, array $data, Actions\Action $action) { ->action(function (Order $record, array $data, Action $action) {
$service = $this->resolveFulfillmentService($record); $service = $this->resolveFulfillmentService($record);
if (! $service) { if (! $service) {
@@ -70,7 +75,7 @@ class OrderViewExtension extends ViewPageExtension
try { try {
$service->createShipment($record, $request); $service->createShipment($record, $request);
} catch (\Throwable $e) { } catch (Throwable $e) {
report($e); report($e);
Notification::make() Notification::make()
@@ -2,8 +2,9 @@
namespace Modules\Core\Shipping\Extensions; namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\CreateAction;
use Filament\Schemas\Components\Group;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Lunar\Admin\Support\Extending\BaseExtension; use Lunar\Admin\Support\Extending\BaseExtension;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
@@ -22,8 +23,8 @@ class ShippingMethodListExtension extends BaseExtension
public function headerActions(array $actions): array public function headerActions(array $actions): array
{ {
foreach ($actions as $action) { foreach ($actions as $action) {
if ($action instanceof Actions\CreateAction) { if ($action instanceof CreateAction) {
$action->form([ $action->schema([
ShippingMethodResource::getNameFormComponent(), ShippingMethodResource::getNameFormComponent(),
Group::make([ Group::make([
ShippingMethodResource::getCodeFormComponent(), ShippingMethodResource::getCodeFormComponent(),
@@ -2,11 +2,12 @@
namespace Modules\Core\Shipping\Extensions; namespace Modules\Core\Shipping\Extensions;
use Filament\Forms\Components\Component; use Filament\Schemas\Schema;
use Filament\Forms\Components\Concerns\HasChildComponents; use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Concerns\HasChildComponents;
use Filament\Schemas\Components\Utilities\Get;
use InvalidArgumentException;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
@@ -15,11 +16,11 @@ use Modules\Core\Shipping\Contracts\SupportsLivePricing;
class ShippingMethodResourceExtension extends ResourceExtension class ShippingMethodResourceExtension extends ResourceExtension
{ {
public function extendForm(Form $form): Form public function extendForm(Schema $schema): Schema
{ {
return $form->schema( return $schema->components(
$this->replaceChargeByField( $this->replaceChargeByField(
$this->replaceDriverField($form->getComponents()) $this->replaceDriverField($schema->getComponents())
) )
); );
} }
@@ -83,7 +84,7 @@ class ShippingMethodResourceExtension extends ResourceExtension
try { try {
return Shipping::driver($driver) instanceof SupportsLivePricing; return Shipping::driver($driver) instanceof SupportsLivePricing;
} catch (\InvalidArgumentException) { } catch (InvalidArgumentException) {
return false; return false;
} }
} }
@@ -2,10 +2,10 @@
namespace Modules\Core\Shipping\Filament\Pages; namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable; use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
@@ -21,7 +21,7 @@ class ManagePickupManifests extends Page implements HasTable
{ {
use InteractsWithTable; use InteractsWithTable;
protected static ?string $navigationIcon = 'heroicon-o-truck'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-truck';
protected static ?string $navigationLabel = 'Pickup Manifests'; protected static ?string $navigationLabel = 'Pickup Manifests';
@@ -38,11 +38,11 @@ class ManagePickupManifests extends Page implements HasTable
* OrderResource uses 1) so this page never competes to be first even as * OrderResource uses 1) so this page never competes to be first even as
* more Sales-group items are added later. * more Sales-group items are added later.
*/ */
protected static ?string $navigationGroup = 'Sales'; protected static string | \UnitEnum | null $navigationGroup = 'Sales';
protected static ?int $navigationSort = 100; protected static ?int $navigationSort = 100;
protected static string $view = 'core::shipping.filament.pages.manage-pickup-manifests'; protected string $view = 'core::shipping.filament.pages.manage-pickup-manifests';
public function table(Table $table): Table public function table(Table $table): Table
{ {
@@ -54,13 +54,13 @@ class ManagePickupManifests extends Page implements HasTable
TextColumn::make('order.reference')->label('Order'), TextColumn::make('order.reference')->label('Order'),
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'), TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
]) ])
->actions([ ->recordActions([
Action::make('print') Action::make('print')
->label('Print') ->label('Print')
->icon('heroicon-o-printer') ->icon('heroicon-o-printer')
->action(fn (Shipment $record) => $this->printShipment($record)), ->action(fn (Shipment $record) => $this->printShipment($record)),
]) ])
->bulkActions([ ->toolbarActions([
BulkAction::make('print_selected') BulkAction::make('print_selected')
->label('Print selected') ->label('Print selected')
->icon('heroicon-o-printer') ->icon('heroicon-o-printer')
@@ -2,9 +2,9 @@
namespace Modules\Core\Shipping\Filament\Pages; namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -36,12 +36,12 @@ use Lunar\Shipping\Models\ShippingRate;
*/ */
class ManageShippingRates extends BaseManageShippingRates class ManageShippingRates extends BaseManageShippingRates
{ {
public function form(Form $form): Form public function form(Schema $schema): Schema
{ {
$form = parent::form($form); $schema = parent::form($schema);
return $form->schema( return $schema->components(
$this->labelPriceFieldsAsFallbackWhenLive($form->getComponents()) $this->labelPriceFieldsAsFallbackWhenLive($schema->getComponents())
); );
} }