Compare commits

...
5 Commits
20 changed files with 663 additions and 38 deletions
+79
View File
@@ -4,6 +4,85 @@ 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.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
- `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 ## [0.17.3] - 2026-09-15
### Changed ### Changed
+1 -1
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.17.3", "version": "0.17.5",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Language;
/**
* PaymentMethod.name becomes a locale-keyed JSON array (e.g.
* {"en": "Cash On Delivery", "el": "Αντικαταβολή"}), rendered in Filament
* via Lunar's own Lunar\Admin\Support\Forms\Components\TranslatedText —
* the same reusable component/data-shape Product/Collection names already
* use (Lunar\Base\Traits\HasTranslations), just applied directly to a
* plain column here rather than through attribute_data, since
* PaymentMethod is a merchant-configured settings row, not a translatable
* catalog attribute.
*
* Existing plain-string rows are preserved under the store's default
* Language code (falls back to 'en' if no Language row exists yet — this
* migration can run before lunar:install seeds one) rather than dropped,
* so an already-configured payment method's name isn't blanked out.
*
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
* (which requires doctrine/dbal — not installed in this project) —
* Postgres-specific (this project runs on `pgsql`, per its own docker
* setup), with an explicit USING clause since json isn't implicitly
* castable from varchar.
*/
return new class extends Migration
{
public function up(): 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 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]);
}
}
};
@@ -0,0 +1,74 @@
<?php
use Illuminate\Support\Facades\DB;
use Lunar\Base\Migration;
use Lunar\Models\Language;
/**
* ShippingMethod.name becomes a locale-keyed JSON array (e.g.
* {"en": "Standard Delivery", "el": "Κανονική Παράδοση"}), rendered in
* Filament via Lunar's own Lunar\Admin\Support\Forms\Components\
* TranslatedText (Modules\Core\Shipping\Extensions\
* ShippingMethodResourceExtension::replaceNameField()) — same shape/
* resolution as PaymentMethod.name (see its own migration,
* 2026_09_15_000001_make_payment_methods_name_translatable.php) and
* Product/Collection names (Lunar\Base\Traits\HasTranslations).
*
* ShippingMethod is a vendor (lunarphp/table-rate-shipping) table, but
* converting a vendor column's type via a migration is no different from
* any other schema change this project already makes against a vendor
* table (see database/migrations/2026_08_31_000001_create_payment_methods_table.php's
* sibling migrations for the same pattern against PaymentMethod) — there
* was no good reason to route this through `data.name` instead, unlike
* `data.fulfillment_type` which is a genuinely NEW field the vendor table
* never had at all.
*
* Existing plain-string rows are preserved under the store's default
* Language code (falls back to 'en' if no Language row exists yet)
* rather than dropped.
*
* Uses a raw `ALTER COLUMN ... TYPE` rather than Blueprint::change()
* (requires doctrine/dbal — not installed in this project) — Postgres-
* specific (this project runs on `pgsql`), with an explicit USING clause
* since json isn't implicitly castable from varchar.
*/
return new class extends Migration
{
public function up(): void
{
$table = $this->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))"
);
}
};
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* Greek translations for Lunar\Models\Country::name, keyed by the exact
* English spelling Lunar's own installer seeds (`lunar:import:address-data`
* fetches http://data.lunarphp.io/countries+states.json — see
* vendor/lunarphp/core/src/Console/Commands/Import/AddressData.php).
* `Country`/`State` have no i18n support of their own (plain string
* columns, no translatable trait) — this is a plain Laravel lang file, not
* Modules\Core\Localization's DB-backed TranslationService, since these
* names are fixed reference data seeded once, not editable UI copy (see
* docs/localization.md). A consuming app's storefront looks this up
* itself, e.g. __('core::countries.'.$country->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' => 'Ελλάδα',
];
+52
View File
@@ -0,0 +1,52 @@
<?php
/**
* Greek translations for Lunar\Models\State::name, keyed by the exact
* English spelling Lunar's own installer seeds for Greece
* (`lunar:import:address-data` — see lang/el/countries.php's own docblock
* for the full explanation of why this is a plain lang file, not
* Modules\Core\Localization's TranslationService).
*
* Covers every Greek state/regional-unit row in Lunar's seed dataset —
* scoped to Greece only, matching this store's operating country.
*/
return [
'Achaea Regional Unit' => 'Περιφερειακή Ενότητα Αχαΐας',
'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' => 'Περιφέρεια Δυτικής Μακεδονίας',
];
@@ -0,0 +1,60 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Lunar\Models\ProductVariant;
/**
* One-off backfill for variants the Shopify import left with a blank SKU —
* not an importer bug, the source CSV rows genuinely had no `Variant SKU`
* value (see Modules\MigrateImport\Shopify\ShopifyExportImporter) — so
* this synthesizes one instead of re-running the import. Format is
* "SKU-P{product_id}-V{variant_id}": deterministic and guaranteed unique
* without a uniqueness check, since product_id/variant_id already are.
* Only variants with a null `sku` are touched.
*/
class BackfillMissingSkusCommand extends Command
{
protected $signature = 'boboko:catalog:backfill-skus {--dry-run : List what would change without writing}';
protected $description = 'Generate a SKU for every product variant that is missing one';
public function handle(): void
{
$dryRun = (bool) $this->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.');
}
}
+14 -1
View File
@@ -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()) { if (! Currency::whereDefault(true)->exists()) {
$this->components->info('Adding a default currency (USD)'); $this->components->info('Adding a default currency (USD)');
@@ -310,7 +320,10 @@ class InstallLunarCommand extends Command
PaymentMethod::create([ PaymentMethod::create([
'type' => 'cash-on-delivery', 'type' => 'cash-on-delivery',
'name' => 'Cash on Delivery', 'name' => [
'en' => 'Cash on Delivery',
'el' => 'Αντικαταβολή',
],
'driver' => 'cash-on-delivery', 'driver' => 'cash-on-delivery',
'capture_mode' => 'pay', 'capture_mode' => 'pay',
'position' => 0, 'position' => 0,
@@ -42,6 +42,8 @@ class OrderPaymentMethodSummaryExtension extends ViewPageExtension
return null; return null;
} }
return PaymentMethod::where('type', $type)->value('name') ?? $type; $method = PaymentMethod::where('type', $type)->first();
return $method?->translate('name') ?? $type;
} }
} }
@@ -12,6 +12,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Modules\Core\Payment\Contracts\Configurable; use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Events\PaymentMethodsReordered; use Modules\Core\Payment\Events\PaymentMethodsReordered;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
@@ -76,7 +77,7 @@ class PaymentMethodResource extends Resource
->sortable(), ->sortable(),
TextColumn::make('name') TextColumn::make('name')
->label('Name') ->label('Name')
->searchable(), ->state(fn (PaymentMethod $record) => $record->translate('name')),
TextColumn::make('type') TextColumn::make('type')
->label('Type'), ->label('Type'),
TextColumn::make('driver') TextColumn::make('driver')
@@ -124,10 +125,9 @@ class PaymentMethodResource extends Resource
public static function getFormComponents(): array public static function getFormComponents(): array
{ {
return [ return [
TextInput::make('name') TranslatedText::make('name')
->label('Name') ->label('Name')
->required() ->required(),
->maxLength(255),
TextInput::make('type') TextInput::make('type')
->label('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.') ->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.')
+14 -1
View File
@@ -4,6 +4,7 @@ namespace Modules\Core\Payment\Models;
use Illuminate\Database\Eloquent\Casts\AsArrayObject; use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Lunar\Base\Traits\HasTranslations;
/** /**
* A merchant-configured payment method — the DB-instance layer, admin * 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): * shipping_methods table already has (see docs/payments.md):
* - type: unique, machine-facing slug (Cart::meta['payment_method'], * - type: unique, machine-facing slug (Cart::meta['payment_method'],
* ApplyPaymentMethodFee's lookup key, every Payment event's $type). * 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 * - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
* — NOT the same as `type`, and not unique (two rows can share one * — NOT the same as `type`, and not unique (two rows can share one
* driver, e.g. two differently-named offline-style methods). * driver, e.g. two differently-named offline-style methods).
@@ -28,12 +38,15 @@ use Illuminate\Database\Eloquent\Model;
*/ */
class PaymentMethod extends Model class PaymentMethod extends Model
{ {
use HasTranslations;
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
'enabled' => 'boolean', 'enabled' => 'boolean',
'position' => 'integer', 'position' => 'integer',
'driver_missing_at' => 'datetime', 'driver_missing_at' => 'datetime',
'name' => 'array',
'data' => AsArrayObject::class, 'data' => AsArrayObject::class,
]; ];
} }
+3 -1
View File
@@ -5,6 +5,7 @@ namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Modules\Core\Command\AnonymizeCommand; use Modules\Core\Command\AnonymizeCommand;
use Modules\Core\Command\BackfillMissingSkusCommand;
use Modules\Core\Command\ExportCleanupCommand; use Modules\Core\Command\ExportCleanupCommand;
use Modules\Core\Command\ExportCommand; use Modules\Core\Command\ExportCommand;
use Modules\Core\Command\ImportCommand; use Modules\Core\Command\ImportCommand;
@@ -24,6 +25,7 @@ class CoreServiceProvider extends ServiceProvider
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core'); $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core');
Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core'); Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core');
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadTranslationsFrom(__DIR__ . '/../../lang', 'core');
$this->publishes([ $this->publishes([
__DIR__ . '/../../config/core.php' => config_path('core.php'), __DIR__ . '/../../config/core.php' => config_path('core.php'),
@@ -36,7 +38,7 @@ class CoreServiceProvider extends ServiceProvider
], 'core-assets'); ], 'core-assets');
if ($this->app->runningInConsole()) { 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 //Overriding lunar:install
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class])); $this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
+7 -5
View File
@@ -27,6 +27,7 @@ use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions; use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
use Modules\Core\Shipping\Support\FulfillmentType;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
class ShippingServiceProvider extends ServiceProvider class ShippingServiceProvider extends ServiceProvider
@@ -80,8 +81,10 @@ class ShippingServiceProvider extends ServiceProvider
// resolveCarrier() for the same lookup pattern already used to // resolveCarrier() for the same lookup pattern already used to
// resolve a carrier driver from it). // resolve a carrier driver from it).
// //
// Reads ShippingMethod.data['fulfillment_type'] directly rather // Resolves via Modules\Core\Shipping\Support\FulfillmentType (driver-
// than through a ShippingMethod::macro('isStorePickup', ...) — // 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\Base\Traits\HasModelExtending::__callStatic() (used by
// Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel) // Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel)
// intercepts EVERY unmatched static call, including macro() // intercepts EVERY unmatched static call, including macro()
@@ -91,8 +94,7 @@ class ShippingServiceProvider extends ServiceProvider
// false. (Lunar\Models\Order is unaffected because it declares // false. (Lunar\Models\Order is unaffected because it declares
// its own macro() method directly, bypassing __callStatic // its own macro() method directly, bypassing __callStatic
// entirely — that's why Order::macro('isStorePickupOrder', ...) // entirely — that's why Order::macro('isStorePickupOrder', ...)
// below still works.) Defaults to 'carrier' (false) for any row // below still works.)
// saved before this field existed.
Order::macro('isStorePickupOrder', function () { Order::macro('isStorePickupOrder', function () {
/** @var Order $this */ /** @var Order $this */
$code = $this->shippingAddress?->shipping_option; $code = $this->shippingAddress?->shipping_option;
@@ -108,7 +110,7 @@ class ShippingServiceProvider extends ServiceProvider
// attribute avoids that entirely. // attribute avoids that entirely.
$method = ShippingMethod::where('code', $code)->first(); $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) { foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
+9 -2
View File
@@ -10,10 +10,12 @@ use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing; use Modules\Core\Shipping\Concerns\CachesLivePricing;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName;
use Modules\Core\Shipping\Support\WeightCalculator; use Modules\Core\Shipping\Support\WeightCalculator;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType
{ {
use ResolvesFixedPricing; use ResolvesFixedPricing;
use CachesLivePricing; use CachesLivePricing;
@@ -30,6 +32,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
return 'ACS Courier'; return 'ACS Courier';
} }
public function fulfillmentType(): string
{
return 'carrier';
}
public function description(): string public function description(): string
{ {
return 'Live rate quote from ACS Courier.'; 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); $amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
return new ShippingOption( return new ShippingOption(
name: $shippingMethod->name ?: $this->name(), name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(),
description: $shippingMethod->description ?: $this->description(), description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(), identifier: $shippingRate->getIdentifier(),
price: new Price($amount, $cart->currency, 1), price: new Price($amount, $cart->currency, 1),
@@ -7,6 +7,7 @@ use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; 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 * 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: * flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
* there is no live option to offer. * there is no live option to offer.
*/ */
class BoxNowRateDriver implements ShippingRateInterface class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
{ {
use ResolvesFixedPricing; use ResolvesFixedPricing;
@@ -25,6 +26,11 @@ class BoxNowRateDriver implements ShippingRateInterface
return 'Box Now Locker Delivery'; return 'Box Now Locker Delivery';
} }
public function fulfillmentType(): string
{
return 'carrier';
}
public function description(): string public function description(): string
{ {
return 'Deliver to a Box Now parcel locker.'; return 'Deliver to a Box Now parcel locker.';
@@ -6,6 +6,8 @@ use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\Pricing; use Lunar\Facades\Pricing;
use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate; 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 * Shared by any carrier driver that also supports Lunar's own price-break
@@ -31,12 +33,13 @@ trait ResolvesFixedPricing
} }
return new ShippingOption( return new ShippingOption(
name: $shippingMethod->name ?: $this->name(), name: ShippingMethodName::resolve($shippingMethod) ?: $this->name(),
description: $shippingMethod->description ?: $this->description(), description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(), identifier: $shippingRate->getIdentifier(),
price: $pricing->matched->price, price: $pricing->matched->price,
taxClass: $shippingRate->getTaxClass(), taxClass: $shippingRate->getTaxClass(),
taxReference: $shippingRate->getTaxReference(), taxReference: $shippingRate->getTaxReference(),
collect: FulfillmentType::isStorePickup($shippingMethod),
); );
} }
} }
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Shipping\Contracts;
/**
* Optional contract a shipping rate driver implements to declare whether
* it fulfils via carrier delivery or in-store pickup — e.g.
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver and BoxNowRateDriver
* are unambiguously carrier-only, so this is a hardcoded fact about the
* driver, not something a merchant should have to configure per row.
*
* table-rate-shipping's own generic drivers (flat-rate, ship-by,
* free-shipping) don't implement this — they're genuinely ambiguous (a
* merchant could configure one for either carrier delivery or store
* pickup), so Modules\Core\Shipping\Support\FulfillmentType::resolve()
* falls back to ShippingMethod.data['fulfillment_type'] (still merchant-
* overridable) only for drivers that don't implement this contract.
*/
interface DeclaresFulfillmentType
{
/**
* @return 'carrier'|'store_pickup'
*/
public function fulfillmentType(): string;
}
@@ -11,37 +11,110 @@ use Filament\Forms\Components\Select;
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;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName;
class ShippingMethodResourceExtension extends ResourceExtension class ShippingMethodResourceExtension extends ResourceExtension
{ {
public function extendForm(Schema $schema): Schema public function extendForm(Schema $schema): Schema
{ {
return $schema->components([ return $schema->components(
...$this->replaceChargeByField( $this->replaceFulfillmentTypeField(
$this->replaceChargeByField(
$this->replaceNameField(
$this->replaceDriverField($schema->getComponents()) $this->replaceDriverField($schema->getComponents())
), )
$this->fulfillmentTypeSelect(), )
]); )
);
} }
/** /**
* ShippingMethod.data['fulfillment_type'] — 'carrier' (default) or * Replaces the vendor's plain-string `name` TextInput with
* 'store_pickup'. Same free-form-`data`-column pattern as charge_by * Lunar's own TranslatedText — `name` is now a locale-keyed JSON
* above, not a migrated column: ShippingMethod is a vendor * column (see database/migrations/..._make_shipping_methods_name_translatable.php),
* (lunarphp/table-rate-shipping) table, and this codebase avoids * same shape/resolution as PaymentMethod.name and Product/Collection
* forking vendor migrations for a merchant-configurable extra (see * names (Lunar\Base\Traits\HasTranslations).
* 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.
*/ */
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 private function fulfillmentTypeSelect(): Select
{ {
return Select::make('data.fulfillment_type') return Select::make('data.fulfillment_type')
@@ -52,9 +125,23 @@ class ShippingMethodResourceExtension extends ResourceExtension
]) ])
->default('carrier') ->default('carrier')
->required() ->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.'); ->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 * Extend the vendor's cart_total/weight charge_by Select with a third
* "live" option — only offered when the currently selected driver * "live" option — only offered when the currently selected driver
@@ -127,6 +214,10 @@ class ShippingMethodResourceExtension extends ResourceExtension
return $this->driverColumn(); return $this->driverColumn();
} }
if (method_exists($column, 'getName') && $column->getName() === 'name') {
return $this->nameColumn();
}
return $column; return $column;
}, $table->getColumns()) }, $table->getColumns())
); );
@@ -139,6 +230,21 @@ class ShippingMethodResourceExtension extends ResourceExtension
->formatStateUsing(fn ($state) => $this->driverLabel($state)); ->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 private function driverLabel(string $key): string
{ {
$driver = collect(Shipping::getSupportedDrivers())->get($key); $driver = collect(Shipping::getSupportedDrivers())->get($key);
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Modules\Core\Shipping\Support;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
/**
* The single source of truth for "is this ShippingMethod a carrier
* delivery or an in-store pickup" — replaces a merchant-facing
* data['fulfillment_type'] Select that used to exist for every method
* regardless of driver. Modules\Core\Shipping\Carriers\Acs\AcsRateDriver
* and BoxNowRateDriver are unambiguously carrier-only (see
* Modules\Core\Shipping\Contracts\DeclaresFulfillmentType's own
* docblock), 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.
*
* table-rate-shipping's own generic drivers (flat-rate, ship-by,
* free-shipping) don't implement DeclaresFulfillmentType — a merchant
* could genuinely configure one for either purpose (e.g. "Flat Rate —
* Athens Store Pickup") — so those still fall back to the merchant-set
* data['fulfillment_type'], defaulting to 'carrier' when unset.
*/
class FulfillmentType
{
public static function resolve(ShippingMethod $method): string
{
$driver = collect(Shipping::getSupportedDrivers())->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;
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Shipping\Support;
use Illuminate\Support\Arr;
use Lunar\Shipping\Models\ShippingMethod;
/**
* ShippingMethod.name is a locale-keyed JSON column (see database/
* migrations/..._make_shipping_methods_name_translatable.php, and
* Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension for
* the Filament form/table side), 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 by the package to swap in a first-party subclass that adds
* one (Contracts\ShippingMethod exists but is never bound). So `$method->
* 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);
}
}