Skip to content
1 change: 1 addition & 0 deletions packages/table-rate-shipping/config/shipping-tables.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
* or add a callable to use your own logic (eg => [MyTaxRateCalculator::class, 'calculate'])
*/
'shipping_rate_tax_calculation' => 'default',

];
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,26 @@

namespace Lunar\Shipping\DataTransferObjects;

use Illuminate\Support\Collection;
use Lunar\Models\Contracts\Country as CountryContract;
use Lunar\Shipping\Facades\Postcode;

class PostcodeLookup
{
/**
* Initialise the postcode lookup class.
*/
public function __construct(
public CountryContract $country,
public string $postcode
) {
//
}

/**
* Return the postcode parts for this lookup, delegating to the country-matched resolver.
*
* @return Collection<int, string>
*/
public function getParts(): Collection
{
return Postcode::country($this->country)->getParts($this->postcode, $this->country);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Lunar\Shipping\Exceptions;

use Lunar\Exceptions\LunarException;

class NoPostcodeResolverException extends LunarException
{
public static function forCountry(string $iso2): self
{
return new self(sprintf(
'No postcode resolver is registered that supports country [%s]. Register a resolver via Postcode::addResolver() or ensure the default PostcodeResolver remains registered.',
$iso2
));
}
}
24 changes: 24 additions & 0 deletions packages/table-rate-shipping/src/Facades/Postcode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Lunar\Shipping\Facades;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Facade;
use Lunar\Models\Contracts\Country as CountryContract;
use Lunar\Shipping\Interfaces\PostcodeResolverInterface;
use Lunar\Shipping\Managers\PostcodeManager;

/**
* @method static \Lunar\Shipping\Managers\PostcodeManager addResolver(string|PostcodeResolverInterface $resolver)
* @method static PostcodeResolverInterface country(CountryContract $country)
* @method static Collection getResolvers()
*
* @see PostcodeManager
*/
class Postcode extends Facade
{
public static function getFacadeAccessor(): string
{
return PostcodeManager::class;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Lunar\Shipping\Interfaces;

use Illuminate\Support\Collection;
use Lunar\Models\Contracts\Country as CountryContract;

interface PostcodeResolverInterface
{
/**
* Whether this resolver supports the given country.
*/
public function supportsCountry(CountryContract $country): bool;

/**
* Return the postcode parts the resolver wants to match against zone records.
*
* @return Collection<int, string>
*/
public function getParts(string $postcode, CountryContract $country): Collection;
}
88 changes: 88 additions & 0 deletions packages/table-rate-shipping/src/Managers/PostcodeManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace Lunar\Shipping\Managers;

use Illuminate\Support\Collection;
use Lunar\Models\Contracts\Country as CountryContract;
use Lunar\Shipping\Exceptions\NoPostcodeResolverException;
use Lunar\Shipping\Interfaces\PostcodeResolverInterface;

class PostcodeManager
{
/**
* Registered resolvers, in registration order. Entries are either an already-resolved
* instance or a class-string that will be resolved through the container on first use.
*
* @var Collection<int, string|PostcodeResolverInterface>
*/
protected Collection $resolvers;

public function __construct()
{
$this->resolvers = collect();
}

/**
* Register a resolver. Class strings are resolved lazily via the container.
*/
public function addResolver(string|PostcodeResolverInterface $resolver): self
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice DX if this can accept an array of resolvers

{
$this->resolvers->push($resolver);

return $this;
}

/**
* Return the matching resolver for the given country. Iterates in reverse registration
* order — the last-registered resolver that supports the country wins.
*/
public function country(CountryContract $country): PostcodeResolverInterface
{
// Collection::reverse() preserves keys, so $index is the original slot in
// $this->resolvers — which resolveInstance() relies on for in-place caching.
foreach ($this->resolvers->reverse() as $index => $resolver) {
$instance = $this->resolveInstance($index, $resolver);

if ($instance->supportsCountry($country)) {
return $instance;
}
}

throw NoPostcodeResolverException::forCountry($country->iso2);
}

/**
* Access the raw resolver collection — mostly for diagnostic use.
*
* @return Collection<int, string|PostcodeResolverInterface>
*/
public function getResolvers(): Collection
{
return $this->resolvers;
}

/**
* Resolve a collection entry to a concrete interface instance, caching in place so
* subsequent calls reuse the same instance for the rest of the request.
*/
protected function resolveInstance(int $index, string|PostcodeResolverInterface $resolver): PostcodeResolverInterface
{
if ($resolver instanceof PostcodeResolverInterface) {
return $resolver;
}

$instance = app()->make($resolver);

if (! $instance instanceof PostcodeResolverInterface) {
throw new \InvalidArgumentException(sprintf(
'Postcode resolver [%s] must implement %s.',
$resolver,
PostcodeResolverInterface::class
));
}

$this->resolvers->put($index, $instance);

return $instance;
}
}
20 changes: 18 additions & 2 deletions packages/table-rate-shipping/src/Resolvers/PostcodeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,26 @@
namespace Lunar\Shipping\Resolvers;

use Illuminate\Support\Collection;
use Lunar\Models\Contracts\Country as CountryContract;
use Lunar\Shipping\Interfaces\PostcodeResolverInterface;

class PostcodeResolver
class PostcodeResolver implements PostcodeResolverInterface
{
public function getParts($postcode): Collection
/**
* ISO-2 country codes this resolver handles. An empty array matches every country,
* making this resolver a safe catch-all when registered first.
*
* @var array<int, string>
*/
protected array $countries = [];

public function supportsCountry(CountryContract $country): bool
{
return empty($this->countries)
|| in_array($country->iso2, $this->countries, true);
}

public function getParts(string $postcode, CountryContract $country): Collection
{
$postcode = str_replace(' ', '', strtoupper($postcode));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,7 @@ public function get(): Collection
if ($this->postcodeLookup) {
$builder->orWhere(function ($qb) {
$qb->whereHas('postcodes', function ($query) {
$postcodeParts = (new PostcodeResolver)->getParts(
$this->postcodeLookup->postcode
);
$query->whereIn('postcode', $postcodeParts);
$query->whereIn('postcode', $this->postcodeLookup->getParts());
})->where(function ($qb) {
$qb->whereHas('countries', function ($query) {
$query->where('country_id', $this->postcodeLookup->country->id);
Expand Down
9 changes: 9 additions & 0 deletions packages/table-rate-shipping/src/ShippingServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Lunar\Shipping\Database\State\MigrateCutoffToSchedule;
use Lunar\Shipping\DiscountTypes\ShippingDiscount;
use Lunar\Shipping\Interfaces\ShippingMethodManagerInterface;
use Lunar\Shipping\Managers\PostcodeManager;
use Lunar\Shipping\Managers\ShippingManager;
use Lunar\Shipping\Models\ShippingExclusion;
use Lunar\Shipping\Models\ShippingExclusionList;
Expand All @@ -25,12 +26,20 @@
use Lunar\Shipping\Models\ShippingZone;
use Lunar\Shipping\Models\ShippingZonePostcode;
use Lunar\Shipping\Observers\OrderObserver;
use Lunar\Shipping\Resolvers\PostcodeResolver;

class ShippingServiceProvider extends ServiceProvider
{
public function register()
{
$this->mergeConfigFrom(__DIR__.'/../config/shipping-tables.php', 'lunar.shipping-tables');

$this->app->singleton(PostcodeManager::class, function () {
$manager = new PostcodeManager;
$manager->addResolver(PostcodeResolver::class);

return $manager;
});
}

public function boot(ShippingModifiers $shippingModifiers)
Expand Down
36 changes: 36 additions & 0 deletions tests/shipping/Stubs/Resolvers/TestCustomPostcodeResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace Lunar\Tests\Shipping\Stubs\Resolvers;

use Illuminate\Support\Collection;
use Lunar\Models\Contracts\Country as CountryContract;
use Lunar\Shipping\Interfaces\PostcodeResolverInterface;

class TestCustomPostcodeResolver implements PostcodeResolverInterface
{
/**
* ISO-2 codes this test resolver claims. Override via subclass if you need a different set.
*
* @var array<int, string>
*/
protected array $countries = [];

public function supportsCountry(CountryContract $country): bool
{
return empty($this->countries)
|| in_array($country->iso2, $this->countries, true);
}

public function getParts(string $postcode, CountryContract $country): Collection
{
$postcode = str_replace(' ', '', strtoupper($postcode));

return collect([
$postcode,
substr($postcode, 0, 1).'*',
substr($postcode, 0, 2).'*',
substr($postcode, 0, 3).'*',
substr($postcode, 0, 4).'*',
])->filter()->unique()->values();
}
}
38 changes: 38 additions & 0 deletions tests/shipping/Unit/DataTransferObjects/PostcodeLookupTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Lunar\Models\Contracts\Country as CountryContract;
use Lunar\Models\Country;
use Lunar\Shipping\DataTransferObjects\PostcodeLookup;
use Lunar\Shipping\Facades\Postcode;
use Lunar\Shipping\Interfaces\PostcodeResolverInterface;
use Lunar\Tests\Shipping\TestCase;

uses(TestCase::class)
->group('shipping', 'shipping-postcode');

uses(RefreshDatabase::class);

test('getParts delegates to the resolver matched for the lookup country', function () {
$country = Country::factory()->create(['iso2' => 'GB']);

$stubbed = new class implements PostcodeResolverInterface
{
public function supportsCountry(CountryContract $country): bool
{
return $country->iso2 === 'GB';
}

public function getParts(string $postcode, CountryContract $country): Collection
{
return collect([sprintf('STUB:%s:%s', $country->iso2, $postcode)]);
}
};

Postcode::addResolver($stubbed);

$lookup = new PostcodeLookup($country, 'SW1A 1AA');

expect($lookup->getParts()->all())->toBe(['STUB:GB:SW1A 1AA']);
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
use Lunar\Tests\Shipping\TestCase;
use Lunar\Tests\Shipping\TestUtils;

uses(TestCase::class);
uses(TestCase::class)->group('shipping', 'shipping-driver', 'shipping-driver-collection');

uses(RefreshDatabase::class);
uses(TestUtils::class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
use Lunar\Tests\Shipping\TestCase;
use Lunar\Tests\Shipping\TestUtils;

uses(TestCase::class);
uses(TestCase::class)->group('shipping', 'shipping-driver', 'shipping-driver-flatrate');

uses(RefreshDatabase::class);
uses(TestUtils::class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
use Lunar\Tests\Shipping\TestCase;
use Lunar\Tests\Shipping\TestUtils;

uses(TestCase::class);
uses(TestCase::class)->group('shipping', 'shipping-driver', 'shipping-driver-freeshiping');

uses(RefreshDatabase::class);
uses(TestUtils::class);
Expand Down
2 changes: 1 addition & 1 deletion tests/shipping/Unit/Drivers/ShippingMethods/ShipByTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
use Lunar\Tests\Shipping\TestCase;
use Lunar\Tests\Shipping\TestUtils;

uses(TestCase::class);
uses(TestCase::class)->group('shipping', 'shipping-driver', 'shipping-driver-shipby');

uses(RefreshDatabase::class);
uses(TestUtils::class);
Expand Down
Loading
Loading