5 Commits

Author SHA1 Message Date
arvanitakis d34e450526 Fix: Move carrier live-pricing choice onto ShippingMethod.charge_by
Reverts the earlier per-rate pricing_mode column in favor of extending Lunar's existing charge_by field (cart_total/weight) with a third "live" option, gated by a SupportsLivePricing capability check on the driver. Adds a shared ResolvesFixedPricing trait so any carrier driver can fall back to Lunar's normal price-break resolution, matching the vendor ShipBy driver's own charge_by handling instead of introducing a separate mechanism. Also fixes an incorrect Get() path in the admin form that silently hid the new "live" option.
2026-07-19 02:24:10 +03:00
arvanitakis 4acabe4185 Feature: Wire up carrier-agnostic shipping admin UI
Registers ShippingServiceProvider (carrier config, driver/fulfillment bindings, Rates page override) and wires the shipping admin surface into CorePlugin: dynamic carrier dropdown on Shipping Method create/edit, a "Create Shipment" order action resolved generically by carrier, and a Pickup Manifests page for carriers that support manifest batching.
2026-07-19 00:52:01 +03:00
arvanitakis 37c2e1194c Feature: Add Box Now locker delivery integration
Box Now rate driver (fixed pricing only — no live pricing API) and fulfillment service (delivery request creation, label printing, cancellation). Unlike ACS, Box Now books courier pickup automatically on delivery request creation, so no manifest/pickup-list step is implemented. Storefront locker selection is not yet built; createShipment() expects the chosen locker's locationId to be supplied by the caller.
2026-07-19 00:51:23 +03:00
arvanitakis 89255687a1 Feature: Add ACS courier integration
ACS rate driver (live price quotes via ACS_Price_Calculation, cached postcode-to-station lookups) and fulfillment service (voucher creation, label printing, end-of-day pickup manifest). Adds a per-rate pricing_mode column so admins can choose live API pricing vs. a fixed price on ACS-driven shipping rates, surfaced via a custom Rates page.
2026-07-19 00:50:51 +03:00
arvanitakis 3599329b57 Feature: Add ManifestResult DTO for carrier manifest batching
Completes the carrier-agnostic shipping abstraction (CarrierFulfillmentInterface, SupportsManifestBatching, Shipment model) with the result type SupportsManifestBatching::issueManifest() returns.
2026-07-19 00:50:15 +03:00
6 changed files with 148 additions and 155 deletions
@@ -1,26 +0,0 @@
<?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
{
$prefix = config('lunar.database.table_prefix');
Schema::table("{$prefix}shipping_rates", function (Blueprint $table) {
$table->string('pricing_mode')->default('live')->after('enabled');
});
}
public function down(): void
{
$prefix = config('lunar.database.table_prefix');
Schema::table("{$prefix}shipping_rates", function (Blueprint $table) {
$table->dropColumn('pricing_mode');
});
}
};
+7 -25
View File
@@ -4,15 +4,17 @@ namespace Modules\Core\Shipping\Carriers\Acs;
use Lunar\DataTypes\Price; use Lunar\DataTypes\Price;
use Lunar\DataTypes\ShippingOption; use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\Pricing;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; 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\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
{ {
use ResolvesFixedPricing;
public ShippingRate $shippingRate; public ShippingRate $shippingRate;
public function __construct( public function __construct(
@@ -36,39 +38,19 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
$shippingMethod = $shippingRate->shippingMethod; $shippingMethod = $shippingRate->shippingMethod;
$cart = $shippingOptionRequest->cart; $cart = $shippingOptionRequest->cart;
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
}
$postcode = $cart->shippingAddress?->postcode; $postcode = $cart->shippingAddress?->postcode;
if (! $postcode) { if (! $postcode) {
return null; return null;
} }
if (($shippingRate->pricing_mode ?? 'live') === 'fixed') {
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
}
return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode); return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode);
} }
private function resolveFixedPrice(ShippingRate $shippingRate, $shippingMethod, $cart): ?ShippingOption
{
$subTotal = $cart->lines->sum('subTotal.value');
$pricing = Pricing::for($shippingRate)->qty($subTotal)->get();
if (! $pricing->matched) {
return null;
}
return new ShippingOption(
name: $shippingMethod->name ?: $this->name(),
description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(),
price: $pricing->matched->price,
taxClass: $shippingRate->getTaxClass(),
taxReference: $shippingRate->getTaxReference(),
);
}
private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption
{ {
try { try {
@@ -3,19 +3,21 @@
namespace Modules\Core\Shipping\Carriers\BoxNow; namespace Modules\Core\Shipping\Carriers\BoxNow;
use Lunar\DataTypes\ShippingOption; use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\Pricing;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; 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;
/** /**
* Box Now has no pricing API, so this always resolves the admin-configured * Box Now has no pricing API, so this always resolves the method's normal
* price/price-breaks on the ShippingRate — the same mechanism the built-in * charge_by + price-break configuration — the same mechanism the built-in
* flat-rate driver uses. Unlike AcsRateDriver, this does not implement * flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
* SupportsLivePricing: there is nothing to toggle between. * there is no live option to offer.
*/ */
class BoxNowRateDriver implements ShippingRateInterface class BoxNowRateDriver implements ShippingRateInterface
{ {
use ResolvesFixedPricing;
public ShippingRate $shippingRate; public ShippingRate $shippingRate;
public function name(): string public function name(): string
@@ -30,25 +32,10 @@ class BoxNowRateDriver implements ShippingRateInterface
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{ {
$shippingRate = $shippingOptionRequest->shippingRate; return $this->resolveFixedPrice(
$shippingMethod = $shippingRate->shippingMethod; $shippingOptionRequest->shippingRate,
$cart = $shippingOptionRequest->cart; $shippingOptionRequest->shippingRate->shippingMethod,
$shippingOptionRequest->cart,
$subTotal = $cart->lines->sum('subTotal.value');
$pricing = Pricing::for($shippingRate)->qty($subTotal)->get();
if (! $pricing->matched) {
return null;
}
return new ShippingOption(
name: $shippingMethod->name ?: $this->name(),
description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(),
price: $pricing->matched->price,
taxClass: $shippingRate->getTaxClass(),
taxReference: $shippingRate->getTaxReference(),
); );
} }
@@ -0,0 +1,42 @@
<?php
namespace Modules\Core\Shipping\Concerns;
use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\Pricing;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate;
/**
* Shared by any carrier driver that also supports Lunar's own price-break
* pricing (charge_by = cart_total | weight) as a fallback to, or standalone
* alternative for, live API pricing. Mirrors the vendor ShipBy driver's
* charge_by handling exactly, so behavior is consistent with the rest of
* Lunar's shipping system rather than inventing a separate mechanism.
*/
trait ResolvesFixedPricing
{
private function resolveFixedPrice(ShippingRate $shippingRate, ShippingMethod $shippingMethod, $cart): ?ShippingOption
{
$chargeBy = $shippingMethod->data['charge_by'] ?? 'cart_total';
$tier = $chargeBy === 'weight'
? $cart->lines->load('purchasable')->sum(fn ($line) => ($line->purchasable->weight_value ?? 0) * $line->quantity)
: $cart->lines->sum('subTotal.value');
$pricing = Pricing::for($shippingRate)->qty($tier)->get();
if (! $pricing->matched) {
return null;
}
return new ShippingOption(
name: $shippingMethod->name ?: $this->name(),
description: $shippingMethod->description ?: $this->description(),
identifier: $shippingRate->getIdentifier(),
price: $pricing->matched->price,
taxClass: $shippingRate->getTaxClass(),
taxReference: $shippingRate->getTaxReference(),
);
}
}
@@ -6,20 +6,88 @@ use Filament\Forms\Components\Component;
use Filament\Forms\Components\Concerns\HasChildComponents; use Filament\Forms\Components\Concerns\HasChildComponents;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Form; 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;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
class ShippingMethodResourceExtension extends ResourceExtension class ShippingMethodResourceExtension extends ResourceExtension
{ {
public function extendForm(Form $form): Form public function extendForm(Form $form): Form
{ {
return $form->schema( return $form->schema(
$this->replaceChargeByField(
$this->replaceDriverField($form->getComponents()) $this->replaceDriverField($form->getComponents())
)
); );
} }
/**
* Extend the vendor's cart_total/weight charge_by Select with a third
* "live" option — only offered when the currently selected driver
* supports live pricing (see SupportsLivePricing). Picking it is what
* tells the driver to call its carrier API instead of resolving a
* price break.
*/
private function replaceChargeByField(array $components): array
{
return array_map(function (Component $component) {
if (method_exists($component, 'getName') && $component->getName() === 'charge_by') {
return $this->chargeBySelect();
}
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema(
$this->replaceChargeByField($component->getChildComponents())
);
}
return $component;
}, $components);
}
private function chargeBySelect(): Select
{
return Select::make('charge_by')
->label('Charge by')
->options(function (Get $get) {
$options = [
'cart_total' => 'Cart Total',
'weight' => 'Weight',
];
// "charge_by" is nested inside a Group with
// ->statePath('data'), while "driver" sits one level up, at
// the form root. Note: an *absolute* path here would need to
// additionally account for the page's own form wrapper
// (EditRecord::getFormStatePath() === 'data'), which relative
// paths never cross — so "../driver" (relative) is the
// correct, page-independent way to reach it, not an
// absolute 'driver' string.
if ($this->driverSupportsLivePricing($get('../driver'))) {
$options['live'] = 'Live API pricing';
}
return $options;
})
->live();
}
private function driverSupportsLivePricing(?string $driver): bool
{
if (! $driver) {
return false;
}
try {
return Shipping::driver($driver) instanceof SupportsLivePricing;
} catch (\InvalidArgumentException) {
return false;
}
}
public function extendTable(Table $table): Table public function extendTable(Table $table): Table
{ {
return $table->columns( return $table->columns(
@@ -78,6 +146,7 @@ class ShippingMethodResourceExtension extends ResourceExtension
->label('Type') ->label('Type')
->options(fn () => collect(Shipping::getSupportedDrivers()) ->options(fn () => collect(Shipping::getSupportedDrivers())
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()])) ->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
->default('flat-rate'); ->default('flat-rate')
->live();
} }
} }
@@ -2,16 +2,13 @@
namespace Modules\Core\Shipping\Filament\Pages; namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Forms\Components\Select;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Forms\Get; use Filament\Forms\Get;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
/** /**
* Bound in place of the vendor ManageShippingRates page via the container * Bound in place of the vendor ManageShippingRates page via the container
@@ -20,11 +17,13 @@ use Modules\Core\Shipping\Contracts\SupportsLivePricing;
* ShippingZoneResource::getPages() — is untouched; the container simply * ShippingZoneResource::getPages() — is untouched; the container simply
* hands back this subclass whenever the vendor class is resolved. * hands back this subclass whenever the vendor class is resolved.
* *
* Adds a per-rate "Pricing" toggle (live API vs. fixed price) for methods * Hides the price / price-break fields for a rate whose method has
* whose driver supports live pricing (see SupportsLivePricing). Rates on * charge_by = "live" (see ShippingMethodResourceExtension, which adds that
* methods without live pricing behave exactly as the vendor page always did * option to methods whose driver supports live pricing) — those fields
* — no toggle shown, price fields always required, vendor save logic used * would otherwise be dead configuration the driver never reads. Pricing
* as-is. * strategy (cart_total / weight / live) stays entirely on the Shipping
* Method, matching Lunar's own existing charge_by convention; nothing new
* is stored on the rate itself.
*/ */
class ManageShippingRates extends BaseManageShippingRates class ManageShippingRates extends BaseManageShippingRates
{ {
@@ -33,56 +32,13 @@ class ManageShippingRates extends BaseManageShippingRates
$form = parent::form($form); $form = parent::form($form);
return $form->schema( return $form->schema(
$this->insertPricingModeFieldAfterShippingMethod(
$this->hidePriceFieldsWhenLive($form->getComponents()) $this->hidePriceFieldsWhenLive($form->getComponents())
)
); );
} }
/**
* Insert the "Pricing" field immediately after "shipping_method_id" so
* it reads as a pair, rather than appending it elsewhere in the form.
*/
private function insertPricingModeFieldAfterShippingMethod(array $components): array
{
$result = [];
foreach ($components as $component) {
$result[] = $component;
if (method_exists($component, 'getName') && $component->getName() === 'shipping_method_id') {
$result[] = $this->pricingModeField();
}
}
return $result;
}
private function pricingModeField(): Select
{
return Select::make('pricing_mode')
->label('Pricing')
->options([
'live' => 'Use live API pricing',
'fixed' => 'Use a fixed price',
])
->default('live')
->live()
->visible(fn (Get $get) => static::methodHasLivePricing($get('shipping_method_id')))
->columnSpan(2);
}
/**
* Hide the vendor's price / price-break fields whenever this rate is
* set to live pricing — they'd otherwise be dead configuration,
* silently ignored by the driver. Only the base "price" field was ever
* required by the vendor form; the "prices" repeater (price breaks) is
* always optional, so its required() state is left untouched.
*/
private function hidePriceFieldsWhenLive(array $components): array private function hidePriceFieldsWhenLive(array $components): array
{ {
$isFixedOrNotLiveCapable = fn (Get $get) => ! static::methodHasLivePricing($get('shipping_method_id')) $isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live';
|| $get('pricing_mode') === 'fixed';
foreach ($components as $component) { foreach ($components as $component) {
if (! method_exists($component, 'getName')) { if (! method_exists($component, 'getName')) {
@@ -90,14 +46,11 @@ class ManageShippingRates extends BaseManageShippingRates
} }
if ($component->getName() === 'price') { if ($component->getName() === 'price') {
$component->visible($isFixedOrNotLiveCapable) $component->visible($isNotLive)->required($isNotLive)->dehydrated(true);
->required($isFixedOrNotLiveCapable)
->dehydrated(true);
} }
if ($component->getName() === 'prices') { if ($component->getName() === 'prices') {
$component->visible($isFixedOrNotLiveCapable) $component->visible($isNotLive)->dehydrated(true);
->dehydrated(true);
} }
} }
@@ -114,7 +67,7 @@ class ManageShippingRates extends BaseManageShippingRates
return TextColumn::make('basePrices.0') return TextColumn::make('basePrices.0')
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label')) ->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label'))
->formatStateUsing(function ($state, ShippingRate $record) { ->formatStateUsing(function ($state, ShippingRate $record) {
if (static::methodHasLivePricing($record->shipping_method_id) && $record->pricing_mode === 'live') { if (static::methodChargeBy($record->shipping_method_id) === 'live') {
return 'Live API pricing'; return 'Live API pricing';
} }
@@ -129,37 +82,23 @@ class ManageShippingRates extends BaseManageShippingRates
protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void
{ {
$isLive = static::methodHasLivePricing($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') {
&& ($data['pricing_mode'] ?? 'live') === 'live';
$shippingRate->pricing_mode = $isLive ? 'live' : 'fixed';
$shippingRate->save();
if ($isLive) {
return; return;
} }
parent::saveShippingRate($shippingRate, $data); parent::saveShippingRate($shippingRate, $data);
} }
protected static function methodHasLivePricing(ShippingMethod|int|string|null $method): bool protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string
{ {
if (blank($method)) { if (blank($method)) {
return false; return null;
} }
if (! $method instanceof ShippingMethod) { if (! $method instanceof ShippingMethod) {
$method = ShippingMethod::find($method); $method = ShippingMethod::find($method);
} }
if (! $method) { return $method?->data['charge_by'] ?? null;
return false;
}
try {
return Shipping::driver($method->driver) instanceof SupportsLivePricing;
} catch (\InvalidArgumentException) {
return false;
}
} }
} }