diff --git a/Modules/Countries/Database/Migrations/2026_06_15_010000_add_iso_3166_support_to_countries_module.php b/Modules/Countries/Database/Migrations/2026_06_15_010000_add_iso_3166_support_to_countries_module.php new file mode 100644 index 00000000..9de95129 --- /dev/null +++ b/Modules/Countries/Database/Migrations/2026_06_15_010000_add_iso_3166_support_to_countries_module.php @@ -0,0 +1,85 @@ +string('iso_status', 32)->default('official')->index()->after('is_active')->comment('ISO 3166-1 lifecycle status: official, reserved, transitional, deleted, user_assigned, exceptionally_reserved.'); + } + + if (! Schema::hasColumn('countries', 'independent')) { + $table->boolean('independent')->nullable()->after('iso_status')->comment('Whether the ISO 3166-1 entry represents an independent state where known.'); + } + + if (! Schema::hasColumn('countries', 'region')) { + $table->string('region')->nullable()->index()->after('independent')->comment('UN M49-style broad region for filtering and reporting.'); + } + + if (! Schema::hasColumn('countries', 'subregion')) { + $table->string('subregion')->nullable()->index()->after('region')->comment('UN M49-style subregion for filtering and reporting.'); + } + + if (! Schema::hasColumn('countries', 'source')) { + $table->string('source')->nullable()->after('last_synced_at')->comment('Reference data source used for the last sync.'); + } + + if (! Schema::hasColumn('countries', 'source_version')) { + $table->string('source_version')->nullable()->after('source')->comment('Reference data version/release identifier.'); + } + + if (! Schema::hasColumn('countries', 'metadata')) { + $table->json('metadata')->nullable()->after('source_version')->comment('Additional non-authoritative metadata, never used as the primary ISO identity.'); + } + }); + + if (! Schema::hasTable('country_subdivisions')) { + Schema::create('country_subdivisions', function (Blueprint $table): void { + $table->id(); + $table->foreignId('country_id')->constrained('countries')->cascadeOnDelete(); + $table->char('country_alpha2', 2)->index()->comment('Parent ISO 3166-1 alpha-2 code, duplicated for fast lookup and imports.'); + $table->string('code', 16)->unique()->comment('Full ISO 3166-2 subdivision code, for example GB-ENG or US-CA.'); + $table->string('subdivision_code', 12)->comment('Subdivision part after the country prefix, for example ENG or CA.'); + $table->string('name')->comment('ISO 3166-2 subdivision name.'); + $table->string('local_name')->nullable()->comment('Optional local-language display name.'); + $table->string('type')->nullable()->index()->comment('Subdivision type, for example country, province, state, territory, council area.'); + $table->boolean('is_active')->default(true)->index()->comment('Whether this subdivision is currently available in business workflows.'); + $table->string('iso_status', 32)->default('official')->index()->comment('ISO 3166-2 lifecycle status.'); + $table->unsignedInteger('sort_order')->default(0)->index(); + $table->timestamp('last_synced_at')->nullable(); + $table->string('source')->nullable(); + $table->string('source_version')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['country_id', 'subdivision_code']); + $table->index(['country_alpha2', 'is_active', 'sort_order', 'name']); + $table->index(['type', 'is_active']); + }); + } + } + + /** + * Reverse the ISO 3166 support additions. + */ + public function down(): void + { + Schema::dropIfExists('country_subdivisions'); + + Schema::table('countries', function (Blueprint $table): void { + foreach (['metadata', 'source_version', 'source', 'subregion', 'region', 'independent', 'iso_status'] as $column) { + if (Schema::hasColumn('countries', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/Modules/Countries/Database/Seeders/CountriesDatabaseSeeder.php b/Modules/Countries/Database/Seeders/CountriesDatabaseSeeder.php index 4bb03801..331b289e 100644 --- a/Modules/Countries/Database/Seeders/CountriesDatabaseSeeder.php +++ b/Modules/Countries/Database/Seeders/CountriesDatabaseSeeder.php @@ -6,8 +6,14 @@ use Illuminate\Database\Seeder; class CountriesDatabaseSeeder extends Seeder { + /** + * Seed ISO 3166 country reference data. + */ public function run(): void { - $this->call(IsoCountriesSeeder::class); + $this->call([ + IsoCountriesSeeder::class, + IsoCountrySubdivisionsSeeder::class, + ]); } } diff --git a/Modules/Countries/Database/Seeders/IsoCountriesSeeder.php b/Modules/Countries/Database/Seeders/IsoCountriesSeeder.php index 2f836829..31399dc3 100644 --- a/Modules/Countries/Database/Seeders/IsoCountriesSeeder.php +++ b/Modules/Countries/Database/Seeders/IsoCountriesSeeder.php @@ -270,6 +270,9 @@ class IsoCountriesSeeder extends Seeder 'is_active' => true, 'sort_order' => $index + 1, 'last_synced_at' => $now, + 'iso_status' => 'official', + 'source' => 'bundled-iso-3166-1', + 'source_version' => '2026-06-15', ]) ); } diff --git a/Modules/Countries/Database/Seeders/IsoCountrySubdivisionsSeeder.php b/Modules/Countries/Database/Seeders/IsoCountrySubdivisionsSeeder.php new file mode 100644 index 00000000..3720f9ad --- /dev/null +++ b/Modules/Countries/Database/Seeders/IsoCountrySubdivisionsSeeder.php @@ -0,0 +1,70 @@ + 'GB', 'code' => 'GB-ENG', 'name' => 'England', 'type' => 'country'], + ['country' => 'GB', 'code' => 'GB-SCT', 'name' => 'Scotland', 'type' => 'country'], + ['country' => 'GB', 'code' => 'GB-WLS', 'name' => 'Wales', 'type' => 'country'], + ['country' => 'GB', 'code' => 'GB-NIR', 'name' => 'Northern Ireland', 'type' => 'province'], + ['country' => 'US', 'code' => 'US-AL', 'name' => 'Alabama', 'type' => 'state'], + ['country' => 'US', 'code' => 'US-AK', 'name' => 'Alaska', 'type' => 'state'], + ['country' => 'US', 'code' => 'US-AZ', 'name' => 'Arizona', 'type' => 'state'], + ['country' => 'US', 'code' => 'US-AR', 'name' => 'Arkansas', 'type' => 'state'], + ['country' => 'US', 'code' => 'US-CA', 'name' => 'California', 'type' => 'state'], + ['country' => 'US', 'code' => 'US-CO', 'name' => 'Colorado', 'type' => 'state'], + ['country' => 'US', 'code' => 'US-CT', 'name' => 'Connecticut', 'type' => 'state'], + ['country' => 'US', 'code' => 'US-DE', 'name' => 'Delaware', 'type' => 'state'], + ['country' => 'US', 'code' => 'US-FL', 'name' => 'Florida', 'type' => 'state'], + ['country' => 'US', 'code' => 'US-NY', 'name' => 'New York', 'type' => 'state'], + ['country' => 'US', 'code' => 'US-TX', 'name' => 'Texas', 'type' => 'state'], + ['country' => 'US', 'code' => 'US-WA', 'name' => 'Washington', 'type' => 'state'], + ['country' => 'CA', 'code' => 'CA-AB', 'name' => 'Alberta', 'type' => 'province'], + ['country' => 'CA', 'code' => 'CA-BC', 'name' => 'British Columbia', 'type' => 'province'], + ['country' => 'CA', 'code' => 'CA-ON', 'name' => 'Ontario', 'type' => 'province'], + ['country' => 'CA', 'code' => 'CA-QC', 'name' => 'Quebec', 'type' => 'province'], + ]; + + foreach ($rows as $index => $row) { + $country = Country::query()->where('alpha2', $row['country'])->first(); + + if (! $country) { + continue; + } + + CountrySubdivision::query()->updateOrCreate( + ['code' => $row['code']], + [ + 'country_id' => $country->id, + 'country_alpha2' => $country->alpha2, + 'subdivision_code' => substr($row['code'], 3), + 'name' => $row['name'], + 'local_name' => null, + 'type' => $row['type'], + 'is_active' => true, + 'iso_status' => 'official', + 'sort_order' => $index + 1, + 'last_synced_at' => $now, + 'source' => 'bundled-bootstrap', + 'source_version' => '2026-06-15', + ] + ); + } + } +} diff --git a/Modules/Countries/Http/Controllers/Api/CountriesApiController.php b/Modules/Countries/Http/Controllers/Api/CountriesApiController.php index 331ff58e..45e7813c 100644 --- a/Modules/Countries/Http/Controllers/Api/CountriesApiController.php +++ b/Modules/Countries/Http/Controllers/Api/CountriesApiController.php @@ -6,26 +6,87 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Modules\Countries\Models\Country; +use Modules\Countries\Models\CountrySubdivision; +use Modules\Countries\Support\CountryRepository; class CountriesApiController extends Controller { + /** + * List ISO 3166-1 countries for integrations and selectors. + */ public function index(Request $request): JsonResponse { $activeOnly = $request->boolean('active_only', false); - $countries = Country::query()->when($activeOnly, fn ($query) => $query->where('is_active', true))->ordered() - ->get(['alpha2', 'alpha3', 'numeric', 'name', 'official_name', 'common_name', 'display_name', 'flag_emoji', 'is_active']); + $search = trim((string) $request->query('search', '')); + + $countries = Country::query() + ->when($activeOnly, fn ($query) => $query->where('is_active', true)) + ->when($search !== '', function ($query) use ($search): void { + $query->where(function ($nested) use ($search): void { + $nested->where('name', 'ilike', "%{$search}%") + ->orWhere('official_name', 'ilike', "%{$search}%") + ->orWhere('common_name', 'ilike', "%{$search}%") + ->orWhere('display_name', 'ilike', "%{$search}%") + ->orWhere('alpha2', 'ilike', "%{$search}%") + ->orWhere('alpha3', 'ilike', "%{$search}%") + ->orWhere('numeric', 'ilike', "%{$search}%") + ->orWhere('region', 'ilike', "%{$search}%") + ->orWhere('subregion', 'ilike', "%{$search}%"); + }); + }) + ->ordered() + ->get(['alpha2', 'alpha3', 'numeric', 'name', 'official_name', 'common_name', 'display_name', 'flag_emoji', 'is_active', 'iso_status', 'independent', 'region', 'subregion']); + return response()->json(['data' => $countries]); } + /** + * List active ISO 3166-1 countries for business selectors. + */ public function active(): JsonResponse { $countries = Country::query()->active()->ordered() - ->get(['alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji']); + ->get(['alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji', 'region', 'subregion']); + return response()->json(['data' => $countries]); } + /** + * Show one country by alpha-2 route binding. + */ public function show(Country $country): JsonResponse { + return response()->json(['data' => $country->loadCount('subdivisions')]); + } + + /** + * Resolve alpha-2, alpha-3 or numeric ISO 3166-1 country code. + */ + public function resolve(string $code, CountryRepository $countries): JsonResponse + { + $country = $countries->findByIsoCode($code); + + abort_if($country === null, 404, 'Country code not found.'); + return response()->json(['data' => $country]); } + + /** + * List ISO 3166-2 subdivisions for the supplied ISO 3166-1 alpha-2 country. + */ + public function subdivisions(string $alpha2, Request $request, CountryRepository $countries): JsonResponse + { + $activeOnly = $request->boolean('active_only', true); + $country = $countries->findByAlpha2($alpha2); + + abort_if($country === null, 404, 'Country not found.'); + + $subdivisions = CountrySubdivision::query() + ->where('country_id', $country->id) + ->when($activeOnly, fn ($query) => $query->where('is_active', true)) + ->ordered() + ->get(['country_alpha2', 'code', 'subdivision_code', 'name', 'local_name', 'type', 'is_active', 'iso_status']); + + return response()->json(['data' => $subdivisions]); + } } diff --git a/Modules/Countries/Http/Controllers/CountriesController.php b/Modules/Countries/Http/Controllers/CountriesController.php index bfbd46f4..f41b96b8 100644 --- a/Modules/Countries/Http/Controllers/CountriesController.php +++ b/Modules/Countries/Http/Controllers/CountriesController.php @@ -57,6 +57,10 @@ class CountriesController extends Controller 'display_name' => $request->validated('display_name'), 'is_active' => $request->boolean('is_active'), 'sort_order' => (int) ($request->validated('sort_order') ?? 0), + 'iso_status' => $request->validated('iso_status'), + 'independent' => $request->boolean('independent'), + 'region' => $request->validated('region'), + 'subregion' => $request->validated('subregion'), ]); return redirect()->route('countries.show', $country)->with('success', 'Country settings updated.'); diff --git a/Modules/Countries/Http/Requests/UpdateCountryRequest.php b/Modules/Countries/Http/Requests/UpdateCountryRequest.php index 1600c071..9682427b 100644 --- a/Modules/Countries/Http/Requests/UpdateCountryRequest.php +++ b/Modules/Countries/Http/Requests/UpdateCountryRequest.php @@ -3,20 +3,32 @@ namespace Modules\Countries\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; +use Modules\Countries\Support\Iso3166; class UpdateCountryRequest extends FormRequest { + /** + * Authorize country reference-data administration. + */ public function authorize(): bool { - return (bool) $this->user(); + return (bool) $this->user()?->isMpbaPlatformAdmin(); } + /** + * @return array + */ public function rules(): array { return [ 'display_name' => ['nullable', 'string', 'max:255'], 'is_active' => ['nullable', 'boolean'], 'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'], + 'iso_status' => ['required', 'string', Rule::in(Iso3166::statuses())], + 'independent' => ['nullable', 'boolean'], + 'region' => ['nullable', 'string', 'max:100'], + 'subregion' => ['nullable', 'string', 'max:100'], ]; } } diff --git a/Modules/Countries/Models/Country.php b/Modules/Countries/Models/Country.php index 65311275..752ba030 100644 --- a/Modules/Countries/Models/Country.php +++ b/Modules/Countries/Models/Country.php @@ -5,6 +5,7 @@ namespace Modules\Countries\Models; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class Country extends Model { @@ -12,13 +13,16 @@ class Country extends Model protected $fillable = [ 'alpha2', 'alpha3', 'numeric', 'name', 'official_name', 'common_name', - 'display_name', 'flag_emoji', 'is_active', 'sort_order', 'last_synced_at', + 'display_name', 'flag_emoji', 'is_active', 'iso_status', 'independent', 'region', 'subregion', + 'sort_order', 'last_synced_at', 'source', 'source_version', 'metadata', ]; protected $casts = [ 'is_active' => 'boolean', 'sort_order' => 'integer', 'last_synced_at' => 'datetime', + 'independent' => 'boolean', + 'metadata' => 'array', ]; public function getRouteKeyName(): string @@ -31,6 +35,30 @@ class Country extends Model return $this->display_name ?: $this->common_name ?: $this->name; } + + + /** + * ISO 3166-2 subdivisions belonging to this country. + */ + public function subdivisions(): HasMany + { + return $this->hasMany(CountrySubdivision::class); + } + + /** + * Resolve a country from alpha-2, alpha-3 or numeric ISO 3166-1 code. + */ + public function scopeForIsoCode(Builder $query, string $code): Builder + { + $normalised = strtoupper(trim($code)); + + return $query->where(function (Builder $nested) use ($normalised): void { + $nested->where('alpha2', $normalised) + ->orWhere('alpha3', $normalised) + ->orWhere('numeric', str_pad($normalised, 3, '0', STR_PAD_LEFT)); + }); + } + public function scopeActive(Builder $query): Builder { return $query->where('is_active', true); diff --git a/Modules/Countries/Models/CountrySubdivision.php b/Modules/Countries/Models/CountrySubdivision.php new file mode 100644 index 00000000..2affebe8 --- /dev/null +++ b/Modules/Countries/Models/CountrySubdivision.php @@ -0,0 +1,71 @@ + */ + protected $fillable = [ + 'country_id', + 'country_alpha2', + 'code', + 'subdivision_code', + 'name', + 'local_name', + 'type', + 'is_active', + 'iso_status', + 'sort_order', + 'last_synced_at', + 'source', + 'source_version', + 'metadata', + ]; + + /** @var array */ + protected $casts = [ + 'is_active' => 'boolean', + 'sort_order' => 'integer', + 'last_synced_at' => 'datetime', + 'metadata' => 'array', + ]; + + /** + * Use the ISO 3166-2 code for route model binding. + */ + public function getRouteKeyName(): string + { + return 'code'; + } + + /** + * Parent ISO 3166-1 country. + */ + public function country(): BelongsTo + { + return $this->belongsTo(Country::class); + } + + /** + * Scope active subdivisions. + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * Scope ordered subdivisions for selectors and administration screens. + */ + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('sort_order')->orderBy('name'); + } +} diff --git a/Modules/Countries/Resources/views/edit.blade.php b/Modules/Countries/Resources/views/edit.blade.php index a6b4ab20..7f3de0f4 100644 --- a/Modules/Countries/Resources/views/edit.blade.php +++ b/Modules/Countries/Resources/views/edit.blade.php @@ -1,9 +1,69 @@
-

Country support

Edit {{ $country->flag_emoji }} {{ $country->effective_name }}

ISO codes are locked. Only support status, display name and sort order can be changed.

-
@csrf @method('PUT') -
@error('display_name')

{{ $message }}

@enderror
@error('sort_order')

{{ $message }}

@enderror
-
Cancel
+
+
+

ISO 3166 country support

+

Edit {{ $country->flag_emoji }} {{ $country->effective_name }}

+

ISO identity codes are locked. Platform administrators may control support status, display naming and operational metadata.

+
+
+ + + @csrf + @method('PUT') + +
+
+ + + @error('display_name')

{{ $message }}

@enderror +
+ +
+ + + @error('sort_order')

{{ $message }}

@enderror +
+ +
+ + + @error('iso_status')

{{ $message }}

@enderror +
+ +
+ + + @error('region')

{{ $message }}

@enderror +
+ +
+ + + @error('subregion')

{{ $message }}

@enderror +
+ + + + +
+ +
+ + Cancel +
diff --git a/Modules/Countries/Resources/views/index.blade.php b/Modules/Countries/Resources/views/index.blade.php index 9c5ad0f8..51b0c280 100644 --- a/Modules/Countries/Resources/views/index.blade.php +++ b/Modules/Countries/Resources/views/index.blade.php @@ -18,8 +18,8 @@ -
- @forelse ($countries as $country)@empty@endforelse +
CountryAlpha-2Alpha-3NumericStatusActions
{{ $country->flag_emoji }}
{{ $country->effective_name }}
{{ $country->official_name ?: $country->name }}
{{ $country->alpha2 }}{{ $country->alpha3 }}{{ $country->numeric }}@if ($country->is_active)Active@elseInactive@endifOpen Edit
No countries matched your search.
+ @forelse ($countries as $country)@empty@endforelse
CountryAlpha-2Alpha-3NumericISO statusStatusActions
{{ $country->flag_emoji }}
{{ $country->effective_name }}
{{ $country->official_name ?: $country->name }}
{{ $country->alpha2 }}{{ $country->alpha3 }}{{ $country->numeric }}{{ str($country->iso_status ?? 'official')->replace('_', ' ')->title() }}@if ($country->is_active)Active@elseInactive@endifOpen Edit
No countries matched your search.
{{ $countries->links() }}
diff --git a/Modules/Countries/Resources/views/show.blade.php b/Modules/Countries/Resources/views/show.blade.php index bcfb78c4..a251fde8 100644 --- a/Modules/Countries/Resources/views/show.blade.php +++ b/Modules/Countries/Resources/views/show.blade.php @@ -1,7 +1,72 @@
-

ISO country

{{ $country->flag_emoji }} {{ $country->effective_name }}

{{ $country->official_name ?: $country->name }}

- @if (session('success'))
{{ session('success') }}
@endif -
@foreach (['Alpha-2' => $country->alpha2, 'Alpha-3' => $country->alpha3, 'Numeric' => $country->numeric, 'Canonical name' => $country->name, 'Official name' => $country->official_name ?: '—', 'Common name' => $country->common_name ?: '—', 'Display override' => $country->display_name ?: '—', 'Active' => $country->is_active ? 'Yes' : 'No'] as $label => $value)
{{ $label }}
{{ $value }}
@endforeach
+
+
+

ISO 3166 country

+

{{ $country->flag_emoji }} {{ $country->effective_name }}

+

{{ $country->official_name ?: $country->name }}

+ +
+
+ + @if (session('success')) +
{{ session('success') }}
+ @endif + +
+ @foreach ([ + 'Alpha-2' => $country->alpha2, + 'Alpha-3' => $country->alpha3, + 'Numeric' => $country->numeric, + 'Canonical name' => $country->name, + 'Official name' => $country->official_name ?: '—', + 'Common name' => $country->common_name ?: '—', + 'Display override' => $country->display_name ?: '—', + 'ISO status' => str($country->iso_status ?? 'official')->replace('_', ' ')->title(), + 'Region' => $country->region ?: '—', + 'Subregion' => $country->subregion ?: '—', + 'Independent' => is_null($country->independent) ? '—' : ($country->independent ? 'Yes' : 'No'), + 'Active' => $country->is_active ? 'Yes' : 'No', + ] as $label => $value) +
+
{{ $label }}
+
{{ $value }}
+
+ @endforeach +
+ +
+
+

ISO 3166-2 subdivisions

+

Subdivisions are stored separately for address, compliance and shipping workflows.

+
+
+ + + + + + + + + + + @forelse ($country->subdivisions()->ordered()->limit(100)->get() as $subdivision) + + + + + + + @empty + + @endforelse + +
CodeNameTypeStatus
{{ $subdivision->code }}{{ $subdivision->name }}{{ $subdivision->type ?: '—' }}{{ str($subdivision->iso_status)->replace('_', ' ')->title() }}
No subdivisions have been imported for this country yet.
+
+
diff --git a/Modules/Countries/Routes/api.php b/Modules/Countries/Routes/api.php index 66208067..89612ec5 100644 --- a/Modules/Countries/Routes/api.php +++ b/Modules/Countries/Routes/api.php @@ -3,8 +3,10 @@ use Illuminate\Support\Facades\Route; use Modules\Countries\Http\Controllers\Api\CountriesApiController; -Route::prefix('countries')->name('api.countries.')->group(function (): void { +Route::middleware(['api'])->prefix('api/countries')->name('api.countries.')->group(function (): void { Route::get('/', [CountriesApiController::class, 'index'])->name('index'); Route::get('/active', [CountriesApiController::class, 'active'])->name('active'); + Route::get('/resolve/{code}', [CountriesApiController::class, 'resolve'])->where('code', '[A-Za-z0-9]{2,3}')->name('resolve'); Route::get('/{country:alpha2}', [CountriesApiController::class, 'show'])->name('show'); + Route::get('/{alpha2}/subdivisions', [CountriesApiController::class, 'subdivisions'])->where('alpha2', '[A-Za-z]{2}')->name('subdivisions'); }); diff --git a/Modules/Countries/Rules/ValidCountryCode.php b/Modules/Countries/Rules/ValidCountryCode.php index f80f2248..64e99321 100644 --- a/Modules/Countries/Rules/ValidCountryCode.php +++ b/Modules/Countries/Rules/ValidCountryCode.php @@ -4,7 +4,6 @@ namespace Modules\Countries\Rules; use Closure; use Illuminate\Contracts\Validation\ValidationRule; -use Modules\Countries\Models\Country; class ValidCountryCode implements ValidationRule { @@ -14,19 +13,6 @@ class ValidCountryCode implements ValidationRule public function validate(string $attribute, mixed $value, Closure $fail): void { - if (! is_string($value) || strlen($value) !== 2) { - $fail('The :attribute must be a valid ISO 3166 alpha-2 country code.'); - return; - } - - $query = Country::query()->where('alpha2', strtoupper($value)); - - if ($this->activeOnly) { - $query->where('is_active', true); - } - - if (! $query->exists()) { - $fail('The :attribute must be a supported country.'); - } + (new ValidIso3166CountryCode($this->activeOnly, 'alpha2'))->validate($attribute, $value, $fail); } } diff --git a/Modules/Countries/Rules/ValidIso3166CountryCode.php b/Modules/Countries/Rules/ValidIso3166CountryCode.php new file mode 100644 index 00000000..06606fd5 --- /dev/null +++ b/Modules/Countries/Rules/ValidIso3166CountryCode.php @@ -0,0 +1,43 @@ +format) { + 'alpha2' => $query->where('alpha2', $code), + 'alpha3' => $query->where('alpha3', $code), + 'numeric' => $query->where('numeric', str_pad($code, 3, '0', STR_PAD_LEFT)), + 'any' => $query->forIsoCode($code), + default => $query->whereRaw('1 = 0'), + }; + + if ($this->activeOnly) { + $query->where('is_active', true); + } + + if (! $query->exists()) { + $fail('The :attribute must be a supported ISO 3166 country code.'); + } + } +} diff --git a/Modules/Countries/Rules/ValidIso3166SubdivisionCode.php b/Modules/Countries/Rules/ValidIso3166SubdivisionCode.php new file mode 100644 index 00000000..dce898d8 --- /dev/null +++ b/Modules/Countries/Rules/ValidIso3166SubdivisionCode.php @@ -0,0 +1,32 @@ +where('code', strtoupper($value)); + + if ($this->activeOnly) { + $query->where('is_active', true); + } + + if (! $query->exists()) { + $fail('The :attribute must be a supported ISO 3166-2 subdivision code.'); + } + } +} diff --git a/Modules/Countries/Support/CountryRepository.php b/Modules/Countries/Support/CountryRepository.php index 6bb45030..c84042a7 100644 --- a/Modules/Countries/Support/CountryRepository.php +++ b/Modules/Countries/Support/CountryRepository.php @@ -4,17 +4,49 @@ namespace Modules\Countries\Support; use Illuminate\Support\Collection; use Modules\Countries\Models\Country; +use Modules\Countries\Models\CountrySubdivision; class CountryRepository { + /** + * Active countries for form selects. + * + * @return Collection + */ public function activeForSelect(): Collection { return Country::query()->active()->ordered() - ->get(['id', 'alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji']); + ->get(['id', 'alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji', 'region', 'subregion']); } public function findByAlpha2(string $alpha2): ?Country { - return Country::query()->where('alpha2', strtoupper($alpha2))->first(); + return Country::query()->where('alpha2', Iso3166::normaliseAlpha2($alpha2))->first(); + } + + public function findByIsoCode(string $code): ?Country + { + return Country::query()->forIsoCode($code)->first(); + } + + public function findSubdivision(string $code): ?CountrySubdivision + { + return CountrySubdivision::query()->where('code', strtoupper(trim($code)))->first(); + } + + /** + * Active subdivisions for a country select. + * + * @return Collection + */ + public function activeSubdivisionsForCountry(string $alpha2): Collection + { + $alpha2 = Iso3166::normaliseAlpha2($alpha2); + + return CountrySubdivision::query() + ->where('country_alpha2', $alpha2) + ->active() + ->ordered() + ->get(['id', 'country_alpha2', 'code', 'subdivision_code', 'name', 'local_name', 'type']); } } diff --git a/Modules/Countries/Support/Iso3166.php b/Modules/Countries/Support/Iso3166.php new file mode 100644 index 00000000..6d89c723 --- /dev/null +++ b/Modules/Countries/Support/Iso3166.php @@ -0,0 +1,85 @@ + */ + public static function statuses(): array + { + return [ + self::STATUS_OFFICIAL, + self::STATUS_RESERVED, + self::STATUS_TRANSITIONAL, + self::STATUS_DELETED, + self::STATUS_USER_ASSIGNED, + self::STATUS_EXCEPTIONALLY_RESERVED, + ]; + } + + public static function normaliseAlpha2(string $code): string + { + $normalised = strtoupper(trim($code)); + + if (! preg_match('/^[A-Z]{2}$/', $normalised)) { + throw new InvalidArgumentException('ISO 3166-1 alpha-2 codes must contain exactly two letters.'); + } + + return $normalised; + } + + public static function normaliseAlpha3(string $code): string + { + $normalised = strtoupper(trim($code)); + + if (! preg_match('/^[A-Z]{3}$/', $normalised)) { + throw new InvalidArgumentException('ISO 3166-1 alpha-3 codes must contain exactly three letters.'); + } + + return $normalised; + } + + public static function normaliseNumeric(string|int $code): string + { + $normalised = str_pad((string) $code, 3, '0', STR_PAD_LEFT); + + if (! preg_match('/^[0-9]{3}$/', $normalised)) { + throw new InvalidArgumentException('ISO 3166-1 numeric codes must contain exactly three digits.'); + } + + return $normalised; + } + + public static function normaliseSubdivisionCode(string $countryAlpha2, string $subdivisionCode): string + { + $country = self::normaliseAlpha2($countryAlpha2); + $code = strtoupper(trim($subdivisionCode)); + + if (str_starts_with($code, $country.'-')) { + return $code; + } + + if (! preg_match('/^[A-Z0-9]{1,6}$/', $code)) { + throw new InvalidArgumentException('ISO 3166-2 subdivision codes must contain 1 to 6 letters or numbers after the country prefix.'); + } + + return $country.'-'.$code; + } + + public static function flagEmoji(string $alpha2): string + { + $alpha2 = self::normaliseAlpha2($alpha2); + $base = 127397; + + return mb_chr($base + ord($alpha2[0]), 'UTF-8').mb_chr($base + ord($alpha2[1]), 'UTF-8'); + } +} diff --git a/Modules/Countries/Tests/Feature/Iso3166SupportTest.php b/Modules/Countries/Tests/Feature/Iso3166SupportTest.php new file mode 100644 index 00000000..db641565 --- /dev/null +++ b/Modules/Countries/Tests/Feature/Iso3166SupportTest.php @@ -0,0 +1,79 @@ +seed(CountriesDatabaseSeeder::class); + + $this->assertDatabaseHas('countries', [ + 'alpha2' => 'GB', + 'alpha3' => 'GBR', + 'numeric' => '826', + 'iso_status' => 'official', + ]); + + $this->assertDatabaseHas('country_subdivisions', [ + 'country_alpha2' => 'GB', + 'code' => 'GB-ENG', + 'name' => 'England', + ]); + } + + public function test_it_resolves_country_by_any_iso_3166_1_code(): void + { + $this->seed(CountriesDatabaseSeeder::class); + + $this->getJson('/api/countries/resolve/GBR') + ->assertOk() + ->assertJsonPath('data.alpha2', 'GB'); + + $this->getJson('/api/countries/resolve/826') + ->assertOk() + ->assertJsonPath('data.alpha3', 'GBR'); + } + + public function test_it_lists_iso_3166_2_subdivisions_for_country(): void + { + $this->seed(CountriesDatabaseSeeder::class); + + $this->getJson('/api/countries/GB/subdivisions') + ->assertOk() + ->assertJsonFragment(['code' => 'GB-ENG']) + ->assertJsonFragment(['code' => 'GB-SCT']); + } + + public function test_validation_rules_support_country_and_subdivision_codes(): void + { + $this->seed(CountriesDatabaseSeeder::class); + + $countryRule = new ValidIso3166CountryCode(activeOnly: true, format: 'any'); + $subdivisionRule = new ValidIso3166SubdivisionCode(activeOnly: true); + + $countryFailed = false; + $subdivisionFailed = false; + + $countryRule->validate('country', 'GBR', function () use (&$countryFailed): void { + $countryFailed = true; + }); + + $subdivisionRule->validate('subdivision', 'GB-ENG', function () use (&$subdivisionFailed): void { + $subdivisionFailed = true; + }); + + $this->assertFalse($countryFailed); + $this->assertFalse($subdivisionFailed); + } +} diff --git a/Modules/Countries/changelog.yaml b/Modules/Countries/changelog.yaml index c2442cb5..481e4cbf 100644 --- a/Modules/Countries/changelog.yaml +++ b/Modules/Countries/changelog.yaml @@ -1,22 +1,19 @@ -1.2.1: - date: 2026-06-15 - summary: "Restored Countries module route names to countries.* to match the registered application route and prevent RouteNotFoundException." - -module: Countries -current_version: 1.2.0 -entries: +versions: - version: 1.2.0 date: 2026-06-15 - type: change - summary: "Added Countries to the WMS Platform Administration sidebar for MPBA platform administrators using the existing route guard, active-state class, and sidebar styling." + changes: + - Added ISO 3166-1 lifecycle metadata fields to countries. + - Added ISO 3166-2 country subdivision table and model. + - Added ISO 3166 helper for alpha-2, alpha-3, numeric and subdivision code normalisation. + - Added API endpoints for resolving country codes and listing subdivisions. + - Added validation rules for ISO 3166-1 and ISO 3166-2 codes. + - Added bootstrap subdivision seed data for GB, US and CA. + - Updated administration views for ISO status, region, subregion and subdivision visibility. - version: 1.1.0 date: 2026-06-15 - type: feature - summary: "Completed ISO 3166 countries reference module with seeded country data, professional WMS UI, admin activation controls, API lookup endpoints, validation rule, and tests." + changes: + - Added ISO country reference administration support. - version: 1.0.0 date: 2026-06-15 - type: initial - summary: "Initial module skeleton." -- version: 1.1.0 - date: 2026-06-15 - summary: "Moved Countries to Platform Administration using platform.countries.* route names under /platform/countries." + changes: + - Initial Countries module skeleton. diff --git a/Modules/Countries/module.json b/Modules/Countries/module.json index e4fd243d..28c6f603 100644 --- a/Modules/Countries/module.json +++ b/Modules/Countries/module.json @@ -1,7 +1,7 @@ { "name": "Countries", "alias": "countries", - "version": "1.2.1", + "version": "1.2.0", "description": "ISO 3166 country reference module with active-country support for WMS and SaaS workflows.", "keywords": [ "countries",