diff --git a/Boot.php b/Boot.php
index 72230d7..187c3cf 100644
--- a/Boot.php
+++ b/Boot.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Core\Commerce;
+namespace Core\Mod\Commerce;
use Core\Events\AdminPanelBooting;
use Core\Events\ApiRoutesRegistering;
@@ -11,11 +11,11 @@ use Core\Events\WebRoutesRegistering;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
-use Core\Commerce\Listeners\ProvisionSocialHostSubscription;
-use Core\Commerce\Listeners\RewardAgentReferralOnSubscription;
-use Core\Commerce\Services\PaymentGateway\BTCPayGateway;
-use Core\Commerce\Services\PaymentGateway\PaymentGatewayContract;
-use Core\Commerce\Services\PaymentGateway\StripeGateway;
+use Core\Mod\Commerce\Listeners\ProvisionSocialHostSubscription;
+use Core\Mod\Commerce\Listeners\RewardAgentReferralOnSubscription;
+use Core\Mod\Commerce\Services\PaymentGateway\BTCPayGateway;
+use Core\Mod\Commerce\Services\PaymentGateway\PaymentGatewayContract;
+use Core\Mod\Commerce\Services\PaymentGateway\StripeGateway;
/**
* Commerce Module Boot
@@ -46,9 +46,9 @@ class Boot extends ServiceProvider
// Laravel event listeners (not lifecycle events)
Event::subscribe(ProvisionSocialHostSubscription::class);
- Event::listen(\Core\Commerce\Events\SubscriptionCreated::class, RewardAgentReferralOnSubscription::class);
- Event::listen(\Core\Commerce\Events\SubscriptionRenewed::class, Listeners\ResetUsageOnRenewal::class);
- Event::listen(\Core\Commerce\Events\OrderPaid::class, Listeners\CreateReferralCommission::class);
+ Event::listen(\Core\Mod\Commerce\Events\SubscriptionCreated::class, RewardAgentReferralOnSubscription::class);
+ Event::listen(\Core\Mod\Commerce\Events\SubscriptionRenewed::class, Listeners\ResetUsageOnRenewal::class);
+ Event::listen(\Core\Mod\Commerce\Events\OrderPaid::class, Listeners\CreateReferralCommission::class);
}
public function register(): void
@@ -59,21 +59,21 @@ class Boot extends ServiceProvider
);
// Core Services
- $this->app->singleton(\Core\Commerce\Services\CommerceService::class);
- $this->app->singleton(\Core\Commerce\Services\SubscriptionService::class);
- $this->app->singleton(\Core\Commerce\Services\InvoiceService::class);
- $this->app->singleton(\Core\Commerce\Services\PermissionMatrixService::class);
- $this->app->singleton(\Core\Commerce\Services\CouponService::class);
- $this->app->singleton(\Core\Commerce\Services\TaxService::class);
- $this->app->singleton(\Core\Commerce\Services\CurrencyService::class);
- $this->app->singleton(\Core\Commerce\Services\ContentOverrideService::class);
- $this->app->singleton(\Core\Commerce\Services\DunningService::class);
- $this->app->singleton(\Core\Commerce\Services\SkuParserService::class);
- $this->app->singleton(\Core\Commerce\Services\SkuBuilderService::class);
- $this->app->singleton(\Core\Commerce\Services\CreditNoteService::class);
- $this->app->singleton(\Core\Commerce\Services\PaymentMethodService::class);
- $this->app->singleton(\Core\Commerce\Services\UsageBillingService::class);
- $this->app->singleton(\Core\Commerce\Services\ReferralService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\CommerceService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\SubscriptionService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\InvoiceService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\PermissionMatrixService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\CouponService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\TaxService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\CurrencyService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\ContentOverrideService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\DunningService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\SkuParserService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\SkuBuilderService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\CreditNoteService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\PaymentMethodService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\UsageBillingService::class);
+ $this->app->singleton(\Core\Mod\Commerce\Services\ReferralService::class);
// Payment Gateways
$this->app->singleton('commerce.gateway.btcpay', function ($app) {
diff --git a/Concerns/HasContentOverrides.php b/Concerns/HasContentOverrides.php
index f492536..c49bf85 100644
--- a/Concerns/HasContentOverrides.php
+++ b/Concerns/HasContentOverrides.php
@@ -2,12 +2,12 @@
declare(strict_types=1);
-namespace Core\Commerce\Concerns;
+namespace Core\Mod\Commerce\Concerns;
use Illuminate\Database\Eloquent\Relations\MorphMany;
-use Core\Commerce\Models\ContentOverride;
-use Core\Commerce\Models\Entity;
-use Core\Commerce\Services\ContentOverrideService;
+use Core\Mod\Commerce\Models\ContentOverride;
+use Core\Mod\Commerce\Models\Entity;
+use Core\Mod\Commerce\Services\ContentOverrideService;
/**
* Trait for models that can have content overrides.
diff --git a/Console/CleanupExpiredOrders.php b/Console/CleanupExpiredOrders.php
index a541589..3fcd5d1 100644
--- a/Console/CleanupExpiredOrders.php
+++ b/Console/CleanupExpiredOrders.php
@@ -1,10 +1,10 @@
info('Stage 5: Expired Subscriptions');
if ($dryRun) {
- $count = \Core\Commerce\Models\Subscription::query()
+ $count = \Core\Mod\Commerce\Models\Subscription::query()
->active()
->whereNotNull('cancelled_at')
->where('current_period_end', '<=', now())
diff --git a/Console/RefreshExchangeRates.php b/Console/RefreshExchangeRates.php
index c228a1d..78fa83d 100644
--- a/Console/RefreshExchangeRates.php
+++ b/Console/RefreshExchangeRates.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-namespace Core\Commerce\Console;
+namespace Core\Mod\Commerce\Console;
use Illuminate\Console\Command;
-use Core\Commerce\Services\CurrencyService;
+use Core\Mod\Commerce\Services\CurrencyService;
/**
* Refresh exchange rates from configured provider.
@@ -30,7 +30,7 @@ class RefreshExchangeRates extends Command
$this->line("Provider: {$provider}");
// Check if rates need refresh
- if (! $this->option('force') && ! \Core\Commerce\Models\ExchangeRate::needsRefresh()) {
+ if (! $this->option('force') && ! \Core\Mod\Commerce\Models\ExchangeRate::needsRefresh()) {
$this->info('Rates are still fresh. Use --force to refresh anyway.');
return self::SUCCESS;
diff --git a/Console/SendRenewalReminders.php b/Console/SendRenewalReminders.php
index 93492a5..5b5b3e0 100644
--- a/Console/SendRenewalReminders.php
+++ b/Console/SendRenewalReminders.php
@@ -1,10 +1,10 @@
format($this->total, $this->display_currency);
}
@@ -301,7 +301,7 @@ class Order extends Model
*/
public function getFormattedSubtotalAttribute(): string
{
- $currencyService = app(\Core\Commerce\Services\CurrencyService::class);
+ $currencyService = app(\Core\Mod\Commerce\Services\CurrencyService::class);
return $currencyService->format($this->subtotal, $this->display_currency);
}
@@ -311,7 +311,7 @@ class Order extends Model
*/
public function getFormattedTaxAmountAttribute(): string
{
- $currencyService = app(\Core\Commerce\Services\CurrencyService::class);
+ $currencyService = app(\Core\Mod\Commerce\Services\CurrencyService::class);
return $currencyService->format($this->tax_amount, $this->display_currency);
}
@@ -321,7 +321,7 @@ class Order extends Model
*/
public function getFormattedDiscountAmountAttribute(): string
{
- $currencyService = app(\Core\Commerce\Services\CurrencyService::class);
+ $currencyService = app(\Core\Mod\Commerce\Services\CurrencyService::class);
return $currencyService->format($this->discount_amount, $this->display_currency);
}
@@ -341,7 +341,7 @@ class Order extends Model
return $amount;
}
- return \Core\Commerce\Models\ExchangeRate::convert(
+ return \Core\Mod\Commerce\Models\ExchangeRate::convert(
$amount,
$this->display_currency,
$baseCurrency
@@ -363,7 +363,7 @@ class Order extends Model
return $amount;
}
- return \Core\Commerce\Models\ExchangeRate::convert(
+ return \Core\Mod\Commerce\Models\ExchangeRate::convert(
$amount,
$baseCurrency,
$this->display_currency
diff --git a/Models/OrderItem.php b/Models/OrderItem.php
index 0602d03..77bd4dc 100644
--- a/Models/OrderItem.php
+++ b/Models/OrderItem.php
@@ -1,6 +1,6 @@
currency;
- $currencyService = app(\Core\Commerce\Services\CurrencyService::class);
+ $currencyService = app(\Core\Mod\Commerce\Services\CurrencyService::class);
return $currencyService->format($amount, $currency, isCents: true);
}
diff --git a/Models/ProductAssignment.php b/Models/ProductAssignment.php
index 16ecf0e..1071f2f 100644
--- a/Models/ProductAssignment.php
+++ b/Models/ProductAssignment.php
@@ -2,11 +2,11 @@
declare(strict_types=1);
-namespace Core\Commerce\Models;
+namespace Core\Mod\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
-use Core\Commerce\Concerns\HasContentOverrides;
+use Core\Mod\Commerce\Concerns\HasContentOverrides;
/**
* Product Assignment - Links products to M2/M3 entities.
diff --git a/Models/ProductPrice.php b/Models/ProductPrice.php
index 4d72725..166e9a8 100644
--- a/Models/ProductPrice.php
+++ b/Models/ProductPrice.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Core\Commerce\Models;
+namespace Core\Mod\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
diff --git a/Models/Referral.php b/Models/Referral.php
index c239248..06e65ec 100644
--- a/Models/Referral.php
+++ b/Models/Referral.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Core\Commerce\Models;
+namespace Core\Mod\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Illuminate\Database\Eloquent\Model;
diff --git a/Models/ReferralCode.php b/Models/ReferralCode.php
index ec0f1a8..b28fd61 100644
--- a/Models/ReferralCode.php
+++ b/Models/ReferralCode.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Core\Commerce\Models;
+namespace Core\Mod\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Illuminate\Database\Eloquent\Model;
diff --git a/Models/ReferralCommission.php b/Models/ReferralCommission.php
index 8c5ab89..ebec500 100644
--- a/Models/ReferralCommission.php
+++ b/Models/ReferralCommission.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Core\Commerce\Models;
+namespace Core\Mod\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Illuminate\Database\Eloquent\Model;
diff --git a/Models/ReferralPayout.php b/Models/ReferralPayout.php
index 3fc857a..bbea189 100644
--- a/Models/ReferralPayout.php
+++ b/Models/ReferralPayout.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Core\Commerce\Models;
+namespace Core\Mod\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Illuminate\Database\Eloquent\Model;
diff --git a/Models/Refund.php b/Models/Refund.php
index 61d33ea..e7ed92f 100644
--- a/Models/Refund.php
+++ b/Models/Refund.php
@@ -1,6 +1,6 @@
+ */
+ public static array $listens = [
+ AdminPanelBooting::class => 'onAdminPanel',
+ ];
+
+ /**
+ * Bootstrap the service.
+ */
+ public function boot(): void
+ {
+ app(AdminMenuRegistry::class)->register($this);
+ }
+
+ /**
+ * Get the service definition for seeding platform_services.
+ */
+ public static function definition(): array
+ {
+ return [
+ 'code' => 'commerce',
+ 'module' => 'Commerce',
+ 'name' => 'Commerce',
+ 'tagline' => 'Orders and subscriptions',
+ 'description' => 'Manage orders, subscriptions, and billing for your digital products.',
+ 'icon' => 'shopping-cart',
+ 'color' => 'green',
+ 'entitlement_code' => 'core.srv.commerce',
+ 'sort_order' => 70,
+ ];
+ }
+
+ /**
+ * Admin menu items for this service.
+ */
+ public function adminMenuItems(): array
+ {
+ $isServices = request()->routeIs('hub.services') && request()->route('service') === 'commerce';
+
+ return [
+ [
+ 'group' => 'services',
+ 'service' => 'commerce',
+ 'priority' => 70,
+ 'entitlement' => 'core.srv.commerce',
+ 'item' => fn () => [
+ 'label' => 'Commerce',
+ 'icon' => 'shopping-cart',
+ 'color' => 'green',
+ 'href' => route('hub.services', ['service' => 'commerce']),
+ 'active' => $isServices,
+ 'children' => [
+ ['label' => 'Dashboard', 'icon' => 'gauge', 'href' => route('hub.services', ['service' => 'commerce']), 'active' => $isServices && in_array(request()->route('tab'), [null, 'dashboard'])],
+ ['label' => 'Orders', 'icon' => 'receipt', 'href' => route('hub.services', ['service' => 'commerce', 'tab' => 'orders']), 'active' => $isServices && request()->route('tab') === 'orders', 'badge' => $this->pendingOrders()],
+ ['label' => 'Subscriptions', 'icon' => 'rotate', 'href' => route('hub.services', ['service' => 'commerce', 'tab' => 'subscriptions']), 'active' => $isServices && request()->route('tab') === 'subscriptions'],
+ ['label' => 'Coupons', 'icon' => 'ticket', 'href' => route('hub.services', ['service' => 'commerce', 'tab' => 'coupons']), 'active' => $isServices && request()->route('tab') === 'coupons'],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Get pending orders count.
+ */
+ protected function pendingOrders(): ?int
+ {
+ $count = Order::whereIn('status', ['pending', 'processing'])->count();
+
+ return $count ?: null;
+ }
+
+ /**
+ * Register admin panel components.
+ */
+ public function onAdminPanel(AdminPanelBooting $event): void
+ {
+ // Service-specific admin routes could go here
+ // Components are registered by Core\Commerce
+ }
+
+ public function menuPermissions(): array
+ {
+ return [];
+ }
+
+ public function canViewMenu(?object $user, ?object $workspace): bool
+ {
+ return $user !== null;
+ }
+
+ public static function version(): ServiceVersion
+ {
+ return new ServiceVersion(1, 0, 0);
+ }
+
+ /**
+ * Service dependencies.
+ */
+ public static function dependencies(): array
+ {
+ return [];
+ }
+}
diff --git a/Services/CheckoutRateLimiter.php b/Services/CheckoutRateLimiter.php
index 9ea3602..3dcacea 100644
--- a/Services/CheckoutRateLimiter.php
+++ b/Services/CheckoutRateLimiter.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Core\Commerce\Services;
+namespace Core\Mod\Commerce\Services;
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
diff --git a/Services/CommerceService.php b/Services/CommerceService.php
index 7dd8f75..d824ec4 100644
--- a/Services/CommerceService.php
+++ b/Services/CommerceService.php
@@ -1,20 +1,20 @@
amount - $payment->amount_refunded) * 100);
diff --git a/Services/ContentOverrideService.php b/Services/ContentOverrideService.php
index 090f16e..7b61140 100644
--- a/Services/ContentOverrideService.php
+++ b/Services/ContentOverrideService.php
@@ -2,12 +2,12 @@
declare(strict_types=1);
-namespace Core\Commerce\Services;
+namespace Core\Mod\Commerce\Services;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
-use Core\Commerce\Models\ContentOverride;
-use Core\Commerce\Models\Entity;
+use Core\Mod\Commerce\Models\ContentOverride;
+use Core\Mod\Commerce\Models\Entity;
/**
* Content Override Service - Sparse override resolution for white-label commerce.
diff --git a/Services/CouponService.php b/Services/CouponService.php
index 31aaf70..1b0a81d 100644
--- a/Services/CouponService.php
+++ b/Services/CouponService.php
@@ -1,15 +1,15 @@
Parent:
- {{ \Core\Commerce\Models\Entity::find($parent_id)?->name }}
+ {{ \Core\Mod\Commerce\Models\Entity::find($parent_id)?->name }}
@endif
diff --git a/View/Blade/pdf/invoice.blade.php b/View/Blade/pdf/invoice.blade.php
index b028659..2872ec9 100644
--- a/View/Blade/pdf/invoice.blade.php
+++ b/View/Blade/pdf/invoice.blade.php
@@ -386,8 +386,8 @@
@endif
{{ $item->quantity }} |
- {{ app(\Core\Commerce\Services\CommerceService::class)->formatMoney($item->unit_price, $invoice->currency) }} |
- {{ app(\Core\Commerce\Services\CommerceService::class)->formatMoney($item->total, $invoice->currency) }} |
+ {{ app(\Core\Mod\Commerce\Services\CommerceService::class)->formatMoney($item->unit_price, $invoice->currency) }} |
+ {{ app(\Core\Mod\Commerce\Services\CommerceService::class)->formatMoney($item->total, $invoice->currency) }} |
@endforeach
@@ -400,12 +400,12 @@
Subtotal
- {{ app(\Core\Commerce\Services\CommerceService::class)->formatMoney($invoice->subtotal, $invoice->currency) }}
+ {{ app(\Core\Mod\Commerce\Services\CommerceService::class)->formatMoney($invoice->subtotal, $invoice->currency) }}
@if($invoice->discount_amount > 0)
Discount
- -{{ app(\Core\Commerce\Services\CommerceService::class)->formatMoney($invoice->discount_amount, $invoice->currency) }}
+ -{{ app(\Core\Mod\Commerce\Services\CommerceService::class)->formatMoney($invoice->discount_amount, $invoice->currency) }}
@endif
@if($invoice->tax_amount > 0)
@@ -417,12 +417,12 @@
VAT
@endif
-
{{ app(\Core\Commerce\Services\CommerceService::class)->formatMoney($invoice->tax_amount, $invoice->currency) }}
+
{{ app(\Core\Mod\Commerce\Services\CommerceService::class)->formatMoney($invoice->tax_amount, $invoice->currency) }}
@endif
Total
- {{ app(\Core\Commerce\Services\CommerceService::class)->formatMoney($invoice->total, $invoice->currency) }}
+ {{ app(\Core\Mod\Commerce\Services\CommerceService::class)->formatMoney($invoice->total, $invoice->currency) }}
diff --git a/View/Blade/web/checkout/checkout-page.blade.php b/View/Blade/web/checkout/checkout-page.blade.php
index 4956816..13064a9 100644
--- a/View/Blade/web/checkout/checkout-page.blade.php
+++ b/View/Blade/web/checkout/checkout-page.blade.php
@@ -428,7 +428,7 @@
@if ($displayCurrency !== $this->baseCurrency)
- Approx. {{ app(\Core\Commerce\Services\CurrencyService::class)->format($this->baseTotal, $this->baseCurrency) }}
+ Approx. {{ app(\Core\Mod\Commerce\Services\CurrencyService::class)->format($this->baseTotal, $this->baseCurrency) }}
at current rates
@endif
diff --git a/View/Modal/Admin/CouponManager.php b/View/Modal/Admin/CouponManager.php
index 0e78854..b106e45 100644
--- a/View/Modal/Admin/CouponManager.php
+++ b/View/Modal/Admin/CouponManager.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Core\Commerce\View\Modal\Admin;
+namespace Core\Mod\Commerce\View\Modal\Admin;
use Core\Mod\Tenant\Models\Package;
use Livewire\Attributes\Computed;
@@ -10,8 +10,8 @@ use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
-use Core\Commerce\Models\Coupon;
-use Core\Commerce\Services\CouponService;
+use Core\Mod\Commerce\Models\Coupon;
+use Core\Mod\Commerce\Services\CouponService;
#[Layout('hub::admin.layouts.app')]
#[Title('Coupons')]
diff --git a/View/Modal/Admin/CreditNoteManager.php b/View/Modal/Admin/CreditNoteManager.php
index d1b5215..8c1cb55 100644
--- a/View/Modal/Admin/CreditNoteManager.php
+++ b/View/Modal/Admin/CreditNoteManager.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Core\Commerce\View\Modal\Admin;
+namespace Core\Mod\Commerce\View\Modal\Admin;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
@@ -10,8 +10,8 @@ use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
-use Core\Commerce\Models\CreditNote;
-use Core\Commerce\Services\CreditNoteService;
+use Core\Mod\Commerce\Models\CreditNote;
+use Core\Mod\Commerce\Services\CreditNoteService;
#[Title('Credit Notes')]
class CreditNoteManager extends Component
diff --git a/View/Modal/Admin/Dashboard.php b/View/Modal/Admin/Dashboard.php
index 7cf84d0..662598d 100644
--- a/View/Modal/Admin/Dashboard.php
+++ b/View/Modal/Admin/Dashboard.php
@@ -2,11 +2,11 @@
declare(strict_types=1);
-namespace Core\Commerce\View\Modal\Admin;
+namespace Core\Mod\Commerce\View\Modal\Admin;
-use Core\Commerce\Models\Coupon;
-use Core\Commerce\Models\Order;
-use Core\Commerce\Models\Subscription;
+use Core\Mod\Commerce\Models\Coupon;
+use Core\Mod\Commerce\Models\Order;
+use Core\Mod\Commerce\Models\Subscription;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
diff --git a/View/Modal/Admin/EntityManager.php b/View/Modal/Admin/EntityManager.php
index 34abb52..15cd03c 100644
--- a/View/Modal/Admin/EntityManager.php
+++ b/View/Modal/Admin/EntityManager.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-namespace Core\Commerce\View\Modal\Admin;
+namespace Core\Mod\Commerce\View\Modal\Admin;
-use Core\Commerce\Models\Entity;
+use Core\Mod\Commerce\Models\Entity;
use Core\Mod\Tenant\Models\Workspace;
use Livewire\Attributes\Layout;
use Livewire\Component;
diff --git a/View/Modal/Admin/OrderManager.php b/View/Modal/Admin/OrderManager.php
index 936c717..f680188 100644
--- a/View/Modal/Admin/OrderManager.php
+++ b/View/Modal/Admin/OrderManager.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-namespace Core\Commerce\View\Modal\Admin;
+namespace Core\Mod\Commerce\View\Modal\Admin;
-use Core\Commerce\Models\Order;
+use Core\Mod\Commerce\Models\Order;
use Core\Mod\Tenant\Models\Workspace;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
diff --git a/View/Modal/Admin/PermissionMatrixManager.php b/View/Modal/Admin/PermissionMatrixManager.php
index d978d00..bb4ce23 100644
--- a/View/Modal/Admin/PermissionMatrixManager.php
+++ b/View/Modal/Admin/PermissionMatrixManager.php
@@ -2,13 +2,13 @@
declare(strict_types=1);
-namespace Core\Commerce\View\Modal\Admin;
+namespace Core\Mod\Commerce\View\Modal\Admin;
-use Core\Commerce\Models\Entity;
-use Core\Commerce\Models\PermissionMatrix;
-use Core\Commerce\Models\PermissionRequest;
-use Core\Commerce\Services\PermissionLockedException;
-use Core\Commerce\Services\PermissionMatrixService;
+use Core\Mod\Commerce\Models\Entity;
+use Core\Mod\Commerce\Models\PermissionMatrix;
+use Core\Mod\Commerce\Models\PermissionRequest;
+use Core\Mod\Commerce\Services\PermissionLockedException;
+use Core\Mod\Commerce\Services\PermissionMatrixService;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
diff --git a/View/Modal/Admin/ProductManager.php b/View/Modal/Admin/ProductManager.php
index f7b71d4..89e3f40 100644
--- a/View/Modal/Admin/ProductManager.php
+++ b/View/Modal/Admin/ProductManager.php
@@ -2,12 +2,12 @@
declare(strict_types=1);
-namespace Core\Commerce\View\Modal\Admin;
+namespace Core\Mod\Commerce\View\Modal\Admin;
-use Core\Commerce\Models\Entity;
-use Core\Commerce\Models\Product;
-use Core\Commerce\Models\ProductAssignment;
-use Core\Commerce\Services\ProductCatalogService;
+use Core\Mod\Commerce\Models\Entity;
+use Core\Mod\Commerce\Models\Product;
+use Core\Mod\Commerce\Models\ProductAssignment;
+use Core\Mod\Commerce\Services\ProductCatalogService;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
diff --git a/View/Modal/Admin/ReferralManager.php b/View/Modal/Admin/ReferralManager.php
index 4c2e88b..bf41f20 100644
--- a/View/Modal/Admin/ReferralManager.php
+++ b/View/Modal/Admin/ReferralManager.php
@@ -2,18 +2,18 @@
declare(strict_types=1);
-namespace Core\Commerce\View\Modal\Admin;
+namespace Core\Mod\Commerce\View\Modal\Admin;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
-use Core\Commerce\Models\Referral;
-use Core\Commerce\Models\ReferralCode;
-use Core\Commerce\Models\ReferralCommission;
-use Core\Commerce\Models\ReferralPayout;
-use Core\Commerce\Services\ReferralService;
+use Core\Mod\Commerce\Models\Referral;
+use Core\Mod\Commerce\Models\ReferralCode;
+use Core\Mod\Commerce\Models\ReferralCommission;
+use Core\Mod\Commerce\Models\ReferralPayout;
+use Core\Mod\Commerce\Services\ReferralService;
/**
* Admin dashboard for managing referrals, commissions, and payouts.
diff --git a/View/Modal/Admin/SubscriptionManager.php b/View/Modal/Admin/SubscriptionManager.php
index f4bff74..afbbdca 100644
--- a/View/Modal/Admin/SubscriptionManager.php
+++ b/View/Modal/Admin/SubscriptionManager.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-namespace Core\Commerce\View\Modal\Admin;
+namespace Core\Mod\Commerce\View\Modal\Admin;
-use Core\Commerce\Models\Subscription;
-use Core\Commerce\Services\SubscriptionService;
+use Core\Mod\Commerce\Models\Subscription;
+use Core\Mod\Commerce\Services\SubscriptionService;
use Core\Mod\Tenant\Models\Workspace;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
diff --git a/View/Modal/Web/ChangePlan.php b/View/Modal/Web/ChangePlan.php
index d7dde8c..8a7e2eb 100644
--- a/View/Modal/Web/ChangePlan.php
+++ b/View/Modal/Web/ChangePlan.php
@@ -1,6 +1,6 @@
+
+Example:
+ORGORG-WBUTS-WB500L # Original Organics → Waterbutts → 500L Water Butt
+ORGORG-PHONE-WB500L # Same product, telephone channel
+DRPSHP-THEIR1-WB500L # Dropshipper's storefront selling our product
+```
+
+This tracks:
+- Where the sale originated
+- Which facade/channel
+- Back to master SKU
+
+---
+
+## The Permission Matrix (Top-Down Immutable)
+
+### The Core Concept
+
+```
+If M1 says "NO" → Everything below is "NO"
+If M1 says "YES" → M2 can say "NO" for itself
+If M2 says "YES" → M3 can say "NO" for itself
+
+Permissions cascade DOWN, restrictions are IMMUTABLE from above.
+```
+
+### Visual Model
+
+```
+ M1 (Master)
+ ├── can_sell_alcohol: NO ──────────────┐
+ ├── can_discount: YES │
+ └── can_export: YES │
+ │ │
+ ┌────────────┼────────────┐ │
+ ▼ ▼ ▼ │
+ M2-Web M2-Phone M2-Voucher │
+ ├── can_sell_alcohol: [LOCKED NO] ◄──────────────┘
+ ├── can_discount: NO (restricted self)
+ └── can_export: YES (inherited)
+ │
+ ▼
+ M3-Dropshipper
+ ├── can_sell_alcohol: [LOCKED NO] (from M1)
+ ├── can_discount: [LOCKED NO] (from M2)
+ └── can_export: YES (can restrict to NO)
+```
+
+### The 3D Matrix
+
+```
+Dimension 1: Entity Hierarchy (M1 → M2 → M3)
+Dimension 2: Permission Keys (can_sell, can_discount, can_export, can_view_cost...)
+Dimension 3: Resource Scope (products, orders, customers, reports...)
+
+Permission = Matrix[Entity][Key][Scope]
+```
+
+---
+
+## The Internal WAF (Request-Level Enforcement)
+
+> **EXTRACTED:** This section moved to `CORE_BOUNCER_PLAN.md` as a framework-level concern.
+> The training mode / request whitelisting system applies to all modules, not just commerce.
+
+### Every Request is Gated
+
+```php
+// Not just "can user do X"
+// But "can THIS REQUEST from THIS ENTITY do THIS ACTION on THIS RESOURCE"
+
+POST /orders
+├── Entity: M2-Web (waterbutts.com)
+├── Action: order.create
+├── Resource: order
+├── Context: { customer_id: 123, products: [...] }
+│
+└── Matrix Check:
+ ├── Does M1 allow M2-Web to create orders? ✓
+ ├── Does M2-Web allow this product combination? ✓
+ ├── Does customer region allow these products? ✓
+ └── ALLOW
+```
+
+### Training Mode (Dev Mode Learning)
+
+```
+1. Developer goes to /admin/products
+2. Clicks "Create Product"
+3. System: "BLOCKED - No permission defined for:"
+ - Entity: M1-Admin
+ - Action: product.create
+ - Route: POST /admin/products
+
+4. Developer clicks [Allow for M1-Admin]
+5. Permission recorded in matrix
+6. Continue working
+
+Result: Complete map of every action in the system
+```
+
+### Production Mode (Strict Enforcement)
+
+```
+If permission not in matrix → 403 Forbidden
+No exceptions. No fallbacks. No "default allow".
+
+If it wasn't trained, it doesn't exist.
+```
+
+---
+
+## Laravel Implementation
+
+### 1. The Entity Hierarchy
+
+```php
+// database/migrations/create_commerce_entities_table.php
+
+Schema::create('commerce_entities', function (Blueprint $table) {
+ $table->id();
+ $table->string('code', 32)->unique(); // ORGORG, WBUTS, DRPSHP
+ $table->string('name');
+ $table->string('type'); // m1, m2, m3
+
+ // Hierarchy
+ $table->foreignId('parent_id')->nullable()->constrained('commerce_entities');
+ $table->string('path')->index(); // ORGORG/WBUTS/DRPSHP (materialized path)
+ $table->integer('depth')->default(0);
+
+ // Settings
+ $table->json('settings')->nullable();
+ $table->boolean('is_active')->default(true);
+
+ $table->timestamps();
+});
+
+// Entity types
+const TYPE_M1_MASTER = 'm1'; // Master company
+const TYPE_M2_FACADE = 'm2'; // Storefront/channel
+const TYPE_M3_DROPSHIP = 'm3'; // Dropshipper (inherits, doesn't manage)
+```
+
+### 2. The Permission Matrix
+
+```php
+// database/migrations/create_permission_matrix_table.php
+
+Schema::create('permission_matrix', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('entity_id')->constrained('commerce_entities');
+
+ // Permission definition
+ $table->string('key'); // product.create, order.refund
+ $table->string('scope')->nullable(); // Resource type or specific ID
+
+ // The value
+ $table->boolean('allowed')->default(false);
+ $table->boolean('locked')->default(false); // Set by parent, cannot override
+
+ // Audit
+ $table->string('source'); // 'inherited', 'explicit', 'trained'
+ $table->foreignId('set_by_entity_id')->nullable();
+ $table->timestamp('trained_at')->nullable(); // When it was learned
+ $table->string('trained_route')->nullable(); // Which route triggered training
+
+ $table->timestamps();
+
+ $table->unique(['entity_id', 'key', 'scope']);
+ $table->index(['key', 'scope']);
+});
+```
+
+### 3. The Request Log (Training Data)
+
+```php
+// database/migrations/create_permission_requests_table.php
+
+Schema::create('permission_requests', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('entity_id')->constrained('commerce_entities');
+
+ // Request details
+ $table->string('method'); // GET, POST, PUT, DELETE
+ $table->string('route'); // /admin/products
+ $table->string('action'); // product.create
+ $table->string('scope')->nullable();
+
+ // Context
+ $table->json('request_data')->nullable(); // Sanitized request params
+ $table->string('user_agent')->nullable();
+ $table->string('ip_address')->nullable();
+
+ // Result
+ $table->string('status'); // allowed, denied, pending
+ $table->boolean('was_trained')->default(false);
+
+ $table->timestamps();
+
+ $table->index(['entity_id', 'action', 'status']);
+ $table->index(['status', 'created_at']);
+});
+```
+
+### 4. The Matrix Service
+
+```php
+// app/Services/Commerce/PermissionMatrixService.php
+
+namespace App\Services\Commerce;
+
+use App\Models\Commerce\Entity;
+use App\Models\Commerce\PermissionMatrix;
+use Illuminate\Http\Request;
+
+class PermissionMatrixService
+{
+ protected bool $trainingMode;
+
+ public function __construct()
+ {
+ $this->trainingMode = config('commerce.matrix.training_mode', false);
+ }
+
+ /**
+ * Check if an entity can perform an action
+ */
+ public function can(Entity $entity, string $key, ?string $scope = null): PermissionResult
+ {
+ // Build the hierarchy path (M1 → M2 → M3)
+ $hierarchy = $this->getHierarchy($entity);
+
+ // Check from top down (M1 first)
+ foreach ($hierarchy as $ancestor) {
+ $permission = PermissionMatrix::where('entity_id', $ancestor->id)
+ ->where('key', $key)
+ ->where(function ($q) use ($scope) {
+ $q->whereNull('scope')->orWhere('scope', $scope);
+ })
+ ->first();
+
+ if ($permission) {
+ // If locked and denied at this level, everything below is denied
+ if ($permission->locked && !$permission->allowed) {
+ return PermissionResult::denied(
+ reason: "Locked by {$ancestor->name}",
+ locked_by: $ancestor
+ );
+ }
+
+ // If explicitly denied (not locked), continue checking
+ if (!$permission->allowed && !$permission->locked) {
+ return PermissionResult::denied(
+ reason: "Denied by {$ancestor->name}"
+ );
+ }
+ }
+ }
+
+ // Check the entity itself
+ $ownPermission = PermissionMatrix::where('entity_id', $entity->id)
+ ->where('key', $key)
+ ->where(function ($q) use ($scope) {
+ $q->whereNull('scope')->orWhere('scope', $scope);
+ })
+ ->first();
+
+ if ($ownPermission) {
+ return $ownPermission->allowed
+ ? PermissionResult::allowed()
+ : PermissionResult::denied(reason: "Denied by own policy");
+ }
+
+ // No permission found
+ return PermissionResult::undefined(key: $key, scope: $scope);
+ }
+
+ /**
+ * Gate a request through the matrix
+ */
+ public function gateRequest(Request $request, Entity $entity, string $action): PermissionResult
+ {
+ $scope = $this->extractScope($request);
+ $result = $this->can($entity, $action, $scope);
+
+ // Log the request
+ $this->logRequest($request, $entity, $action, $scope, $result);
+
+ // Training mode: undefined permissions become pending for approval
+ if ($result->isUndefined() && $this->trainingMode) {
+ return PermissionResult::pending(
+ key: $action,
+ scope: $scope,
+ training_url: route('commerce.matrix.train', [
+ 'entity' => $entity->id,
+ 'key' => $action,
+ 'scope' => $scope,
+ ])
+ );
+ }
+
+ // Production mode: undefined = denied
+ if ($result->isUndefined()) {
+ return PermissionResult::denied(
+ reason: "No permission defined for {$action}"
+ );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Train a permission (dev mode)
+ */
+ public function train(Entity $entity, string $key, ?string $scope, bool $allow, ?string $route = null): void
+ {
+ // Check if parent has locked this
+ $hierarchy = $this->getHierarchy($entity);
+ foreach ($hierarchy as $ancestor) {
+ $parentPerm = PermissionMatrix::where('entity_id', $ancestor->id)
+ ->where('key', $key)
+ ->where('locked', true)
+ ->first();
+
+ if ($parentPerm && !$parentPerm->allowed) {
+ throw new PermissionLockedException(
+ "Cannot train permission '{$key}' - locked by {$ancestor->name}"
+ );
+ }
+ }
+
+ PermissionMatrix::updateOrCreate(
+ ['entity_id' => $entity->id, 'key' => $key, 'scope' => $scope],
+ [
+ 'allowed' => $allow,
+ 'source' => 'trained',
+ 'trained_at' => now(),
+ 'trained_route' => $route,
+ ]
+ );
+ }
+
+ /**
+ * Lock a permission (cascades down)
+ */
+ public function lock(Entity $entity, string $key, bool $allowed, ?string $scope = null): void
+ {
+ // Set on this entity
+ PermissionMatrix::updateOrCreate(
+ ['entity_id' => $entity->id, 'key' => $key, 'scope' => $scope],
+ [
+ 'allowed' => $allowed,
+ 'locked' => true,
+ 'source' => 'explicit',
+ 'set_by_entity_id' => $entity->id,
+ ]
+ );
+
+ // Cascade to all descendants
+ $descendants = Entity::where('path', 'like', $entity->path . '/%')->get();
+ foreach ($descendants as $descendant) {
+ PermissionMatrix::updateOrCreate(
+ ['entity_id' => $descendant->id, 'key' => $key, 'scope' => $scope],
+ [
+ 'allowed' => $allowed,
+ 'locked' => true,
+ 'source' => 'inherited',
+ 'set_by_entity_id' => $entity->id,
+ ]
+ );
+ }
+ }
+
+ protected function getHierarchy(Entity $entity): Collection
+ {
+ // Return ancestors from root to parent (not including self)
+ $path = explode('/', trim($entity->path, '/'));
+ array_pop(); // Remove self
+
+ return Entity::whereIn('code', $path)
+ ->orderBy('depth')
+ ->get();
+ }
+}
+```
+
+### 5. The Middleware (WAF Integration)
+
+```php
+// app/Http/Middleware/CommerceMatrixGate.php
+
+namespace App\Http\Middleware;
+
+use App\Services\Commerce\PermissionMatrixService;
+use Closure;
+
+class CommerceMatrixGate
+{
+ public function __construct(
+ protected PermissionMatrixService $matrix
+ ) {}
+
+ public function handle(Request $request, Closure $next)
+ {
+ $entity = $this->resolveEntity($request);
+ $action = $this->resolveAction($request);
+
+ if (!$entity || !$action) {
+ return $next($request); // Not a commerce route
+ }
+
+ $result = $this->matrix->gateRequest($request, $entity, $action);
+
+ if ($result->isDenied()) {
+ return response()->json([
+ 'error' => 'permission_denied',
+ 'message' => $result->reason,
+ 'key' => $action,
+ ], 403);
+ }
+
+ if ($result->isPending()) {
+ // Training mode - show the training UI
+ if ($request->wantsJson()) {
+ return response()->json([
+ 'error' => 'permission_undefined',
+ 'message' => 'Permission not yet trained',
+ 'training_url' => $result->training_url,
+ 'key' => $result->key,
+ 'scope' => $result->scope,
+ ], 428); // Precondition Required
+ }
+
+ return response()->view('commerce.matrix.train-prompt', [
+ 'result' => $result,
+ 'request' => $request,
+ 'entity' => $entity,
+ ], 428);
+ }
+
+ return $next($request);
+ }
+
+ protected function resolveAction(Request $request): ?string
+ {
+ // Option 1: Route-based action mapping
+ $route = $request->route();
+ if ($route && $action = $route->getAction('matrix_action')) {
+ return $action;
+ }
+
+ // Option 2: Controller@method convention
+ if ($route) {
+ $controller = class_basename($route->getControllerClass());
+ $method = $route->getActionMethod();
+ return Str::snake($controller) . '.' . $method;
+ // ProductController@store → product_controller.store
+ }
+
+ // Option 3: REST convention
+ $method = $request->method();
+ $resource = $request->segment(2); // /api/products → products
+
+ return match($method) {
+ 'GET' => "{$resource}.view",
+ 'POST' => "{$resource}.create",
+ 'PUT', 'PATCH' => "{$resource}.update",
+ 'DELETE' => "{$resource}.delete",
+ default => null,
+ };
+ }
+}
+```
+
+### 6. Route Definition with Matrix Actions
+
+```php
+// routes/commerce.php
+
+Route::middleware(['auth', 'commerce.matrix'])->prefix('commerce')->group(function () {
+
+ // Explicit action mapping
+ Route::get('/products', [ProductController::class, 'index'])
+ ->matrixAction('product.list');
+
+ Route::post('/products', [ProductController::class, 'store'])
+ ->matrixAction('product.create');
+
+ Route::post('/orders/{order}/refund', [OrderController::class, 'refund'])
+ ->matrixAction('order.refund');
+
+ // Or use conventions and let middleware figure it out
+ Route::apiResource('customers', CustomerController::class);
+ // GET /customers → customer.index
+ // POST /customers → customer.store
+ // etc.
+});
+```
+
+### 7. The Training UI
+
+```blade
+{{-- resources/views/commerce/matrix/train-prompt.blade.php --}}
+
+
+
+
+
+
+
+
+
Permission Not Defined
+
Training Mode Active
+
+
+
+
+
Entity: {{ $entity->name }} ({{ $entity->type }})
+
Action: {{ $result->key }}
+
Scope: {{ $result->scope ?? 'global' }}
+
Route: {{ $request->method() }} {{ $request->path() }}
+
+
+
+
+
+
+
+```
+
+---
+
+## The Product Catalog Matrix
+
+### Master Catalog (M1)
+
+```php
+// M1 owns the master product catalog
+Schema::create('commerce_products', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('owner_entity_id')->constrained('commerce_entities'); // M1
+ $table->string('master_sku')->unique();
+
+ // Product data
+ $table->string('name');
+ $table->text('description')->nullable();
+ $table->decimal('cost_price', 10, 2); // True cost (M1 eyes only)
+ $table->decimal('base_price', 10, 2); // Default selling price
+
+ // Categorization
+ $table->json('categories')->nullable(); // Can be selected by M2s
+ $table->json('attributes')->nullable();
+
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+});
+```
+
+### Facade Product Selection (M2)
+
+```php
+// M2 selects products from M1's catalog
+Schema::create('commerce_facade_products', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('entity_id')->constrained('commerce_entities'); // M2
+ $table->foreignId('product_id')->constrained('commerce_products'); // From M1
+
+ // Facade-specific SKU
+ $table->string('facade_sku'); // M1-M2-
+
+ // Override pricing (if allowed by matrix)
+ $table->decimal('price_override', 10, 2)->nullable();
+ $table->decimal('sale_price', 10, 2)->nullable();
+
+ // Visibility
+ $table->boolean('is_visible')->default(true);
+ $table->integer('sort_order')->default(0);
+
+ // Facade-specific content
+ $table->string('display_name')->nullable(); // Override product name
+ $table->text('custom_description')->nullable();
+
+ $table->timestamps();
+
+ $table->unique(['entity_id', 'product_id']);
+ $table->unique(['entity_id', 'facade_sku']);
+});
+```
+
+### Dropshipper Inheritance (M3)
+
+```php
+// M3 inherits from M2 (or M1) with optional restrictions
+Schema::create('commerce_dropship_products', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('entity_id')->constrained('commerce_entities'); // M3
+ $table->foreignId('source_entity_id')->constrained('commerce_entities'); // M2 or M1
+ $table->foreignId('facade_product_id')->nullable(); // If inheriting from M2
+ $table->foreignId('product_id'); // Master product ref
+
+ $table->string('dropship_sku'); // Full lineage SKU
+
+ // Margin/pricing (what dropshipper pays vs sells for)
+ $table->decimal('wholesale_price', 10, 2); // What they pay M1
+ $table->decimal('suggested_retail', 10, 2)->nullable();
+
+ // Can they see cost?
+ // Controlled by permission matrix: product.view_cost
+
+ $table->timestamps();
+});
+```
+
+---
+
+## The Content Override Matrix (White-Label Engine)
+
+### The Core Insight
+
+**Don't copy data. Create sparse overrides. Resolve at runtime.**
+
+```
+M1 (Master) has content
+ │
+ │ (M2 sees M1's content by default)
+ ▼
+M2 customizes product name
+ │
+ │ Override entry: (M2, product, 123, name, "Custom Name")
+ │ Everything else still inherits from M1
+ ▼
+M3 (Dropshipper) inherits M2's view
+ │
+ │ (Sees M2's custom name, M1's everything else)
+ ▼
+M3 customizes description
+ │
+ │ Override entry: (M3, product, 123, description, "Their description")
+ │ Still has M2's name, M1's other fields
+ ▼
+Resolution: M3 sees merged content from all levels
+```
+
+### The Override Table
+
+```php
+Schema::create('commerce_content_overrides', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('entity_id')->constrained('commerce_entities');
+
+ // What's being overridden
+ $table->string('content_type'); // product, category, page, email_template, setting
+ $table->unsignedBigInteger('content_id'); // ID of the source content
+ $table->string('field'); // name, description, image, price, body, etc.
+
+ // The override value
+ $table->text('value')->nullable(); // The custom value
+ $table->string('value_type')->default('string'); // string, json, html, decimal, boolean
+
+ // Audit
+ $table->foreignId('created_by')->nullable()->constrained('users');
+ $table->timestamps();
+
+ // Unique: one override per entity+content+field
+ $table->unique(['entity_id', 'content_type', 'content_id', 'field'], 'content_override_unique');
+ $table->index(['content_type', 'content_id']);
+});
+```
+
+### Content Types
+
+```php
+// Everything that can be white-labeled
+const CONTENT_TYPES = [
+ 'product' => [
+ 'fields' => ['name', 'description', 'short_description', 'image', 'images', 'price', 'sale_price'],
+ 'model' => Product::class,
+ ],
+ 'category' => [
+ 'fields' => ['name', 'description', 'image', 'slug'],
+ 'model' => Category::class,
+ ],
+ 'page' => [
+ 'fields' => ['title', 'body', 'meta_title', 'meta_description'],
+ 'model' => Page::class,
+ ],
+ 'email_template' => [
+ 'fields' => ['subject', 'body', 'from_name'],
+ 'model' => EmailTemplate::class,
+ ],
+ 'setting' => [
+ 'fields' => ['value'], // site_name, logo, colors, etc.
+ 'model' => Setting::class,
+ ],
+ 'checkout_field' => [
+ 'fields' => ['label', 'placeholder', 'help_text', 'required', 'visible'],
+ 'model' => CheckoutField::class,
+ ],
+];
+```
+
+### The Resolution Service
+
+```php
+// app/Services/Commerce/ContentOverrideService.php
+
+namespace App\Services\Commerce;
+
+use App\Models\Commerce\Entity;
+use App\Models\Commerce\ContentOverride;
+use Illuminate\Database\Eloquent\Model;
+
+class ContentOverrideService
+{
+ /**
+ * Get content with all overrides applied
+ */
+ public function resolve(Entity $entity, string $contentType, Model $content): array
+ {
+ // Start with original content
+ $resolved = $content->toArray();
+
+ // Get hierarchy from M1 down to this entity
+ $hierarchy = $this->getHierarchyTopDown($entity);
+
+ // Apply overrides in order (M1 first, then M2, then M3, etc.)
+ foreach ($hierarchy as $ancestor) {
+ $overrides = ContentOverride::where('entity_id', $ancestor->id)
+ ->where('content_type', $contentType)
+ ->where('content_id', $content->id)
+ ->get()
+ ->keyBy('field');
+
+ foreach ($overrides as $field => $override) {
+ $resolved[$field] = $this->castValue($override->value, $override->value_type);
+ }
+ }
+
+ return $resolved;
+ }
+
+ /**
+ * Get a single field with override resolution
+ */
+ public function resolveField(Entity $entity, string $contentType, int $contentId, string $field, $default = null)
+ {
+ // Check from this entity up to root, return first override found
+ $hierarchy = $this->getHierarchyBottomUp($entity);
+
+ foreach ($hierarchy as $ancestor) {
+ $override = ContentOverride::where('entity_id', $ancestor->id)
+ ->where('content_type', $contentType)
+ ->where('content_id', $contentId)
+ ->where('field', $field)
+ ->first();
+
+ if ($override) {
+ return $this->castValue($override->value, $override->value_type);
+ }
+ }
+
+ return $default;
+ }
+
+ /**
+ * Set an override for an entity
+ */
+ public function override(Entity $entity, string $contentType, int $contentId, string $field, $value): ContentOverride
+ {
+ // Permission check
+ $this->checkCanOverride($entity, $contentType, $field);
+
+ return ContentOverride::updateOrCreate(
+ [
+ 'entity_id' => $entity->id,
+ 'content_type' => $contentType,
+ 'content_id' => $contentId,
+ 'field' => $field,
+ ],
+ [
+ 'value' => $this->serializeValue($value),
+ 'value_type' => $this->detectType($value),
+ 'created_by' => auth()->id(),
+ ]
+ );
+ }
+
+ /**
+ * Remove an override (revert to inherited value)
+ */
+ public function revert(Entity $entity, string $contentType, int $contentId, string $field): bool
+ {
+ return ContentOverride::where('entity_id', $entity->id)
+ ->where('content_type', $contentType)
+ ->where('content_id', $contentId)
+ ->where('field', $field)
+ ->delete() > 0;
+ }
+
+ /**
+ * Get all overrides for an entity (for admin UI)
+ */
+ public function getEntityOverrides(Entity $entity): Collection
+ {
+ return ContentOverride::where('entity_id', $entity->id)
+ ->orderBy('content_type')
+ ->orderBy('content_id')
+ ->get()
+ ->groupBy(['content_type', 'content_id']);
+ }
+
+ /**
+ * Check what's overridden vs inherited for an entity
+ */
+ public function getOverrideStatus(Entity $entity, string $contentType, Model $content): array
+ {
+ $fields = self::CONTENT_TYPES[$contentType]['fields'] ?? [];
+ $status = [];
+
+ foreach ($fields as $field) {
+ $override = ContentOverride::where('content_type', $contentType)
+ ->where('content_id', $content->id)
+ ->where('field', $field)
+ ->whereIn('entity_id', $this->getHierarchyIds($entity))
+ ->orderByRaw("FIELD(entity_id, " . implode(',', $this->getHierarchyIds($entity)) . ") DESC")
+ ->first();
+
+ $status[$field] = [
+ 'value' => $this->resolveField($entity, $contentType, $content->id, $field, $content->$field),
+ 'source' => $override ? $override->entity->name : 'original',
+ 'is_overridden' => $override && $override->entity_id === $entity->id,
+ 'inherited_from' => $override && $override->entity_id !== $entity->id ? $override->entity->name : null,
+ ];
+ }
+
+ return $status;
+ }
+
+ protected function castValue($value, string $type)
+ {
+ return match($type) {
+ 'json' => json_decode($value, true),
+ 'decimal' => (float) $value,
+ 'boolean' => (bool) $value,
+ 'integer' => (int) $value,
+ default => $value,
+ };
+ }
+}
+```
+
+### Eloquent Integration (Automatic Resolution)
+
+```php
+// app/Models/Commerce/Product.php
+
+namespace App\Models\Commerce;
+
+use App\Services\Commerce\ContentOverrideService;
+use Illuminate\Database\Eloquent\Model;
+
+class Product extends Model
+{
+ /**
+ * Get product with overrides for current entity context
+ */
+ public function forEntity(Entity $entity): array
+ {
+ return app(ContentOverrideService::class)
+ ->resolve($entity, 'product', $this);
+ }
+
+ /**
+ * Scope for entity-resolved products
+ */
+ public function scopeWithOverrides($query, Entity $entity)
+ {
+ // Returns products with override data merged
+ return $query->get()->map(fn ($product) => (object) $product->forEntity($entity));
+ }
+}
+
+// Usage in controller/view
+$product = Product::find(123);
+
+// Raw M1 data
+$product->name; // "500L Water Butt"
+
+// Resolved for M2
+$product->forEntity($m2Entity)['name']; // "Premium 500L Water Butt" (if overridden)
+
+// Resolved for M3 (dropshipper)
+$product->forEntity($m3Entity)['name']; // "AquaSave 500L Tank" (white-labeled)
+```
+
+### The Override UI Pattern
+
+```blade
+{{-- resources/views/commerce/products/edit-override.blade.php --}}
+{{-- Shows field with inheritance status and override toggle --}}
+
+@php
+ $overrideService = app(ContentOverrideService::class);
+ $status = $overrideService->getOverrideStatus($entity, 'product', $product);
+@endphp
+
+
+```
+
+### White-Label Store Generation
+
+When a dropshipper (M3) is created, they get a "premade store":
+
+```php
+// app/Services/Commerce/DropshipperOnboardingService.php
+
+class DropshipperOnboardingService
+{
+ public function provision(Entity $parent, array $data): Entity
+ {
+ // Create the M3 entity
+ $dropshipper = Entity::create([
+ 'code' => Str::upper(Str::slug($data['company_name'])),
+ 'name' => $data['company_name'],
+ 'type' => 'm3',
+ 'parent_id' => $parent->id,
+ 'path' => $parent->path . '/' . Str::upper(Str::slug($data['company_name'])),
+ ]);
+
+ // Inherit ALL products from parent (creates facade product links)
+ $this->inheritCatalog($dropshipper, $parent);
+
+ // Copy default page templates (but as inherited, not copied)
+ $this->linkPages($dropshipper, $parent);
+
+ // Set up default branding overrides
+ if ($data['brand_name']) {
+ app(ContentOverrideService::class)->override(
+ $dropshipper,
+ 'setting',
+ Setting::where('key', 'site_name')->first()->id,
+ 'value',
+ $data['brand_name']
+ );
+ }
+
+ if ($data['logo']) {
+ app(ContentOverrideService::class)->override(
+ $dropshipper,
+ 'setting',
+ Setting::where('key', 'logo')->first()->id,
+ 'value',
+ $data['logo']
+ );
+ }
+
+ // They now have a complete store, seeing parent's content
+ // Anything they edit creates an override entry
+ // White-label ready from day one
+
+ return $dropshipper;
+ }
+
+ protected function inheritCatalog(Entity $child, Entity $parent): void
+ {
+ // Link to all parent's products (no data copied)
+ $parentProducts = FacadeProduct::where('entity_id', $parent->id)->get();
+
+ foreach ($parentProducts as $fp) {
+ DropshipProduct::create([
+ 'entity_id' => $child->id,
+ 'source_entity_id' => $parent->id,
+ 'facade_product_id' => $fp->id,
+ 'product_id' => $fp->product_id,
+ 'dropship_sku' => $child->code . '-' . $fp->facade_sku,
+ 'wholesale_price' => $this->calculateWholesale($fp),
+ ]);
+ }
+ }
+}
+```
+
+### The Resolution Chain Visualized
+
+```
+Query: "What is product 123's name for M3-ACME?"
+
+ ┌─────────────────────────────────────────┐
+ │ RESOLUTION CHAIN │
+ └─────────────────────────────────────────┘
+
+Step 1: Check M3-ACME overrides
+ ┌─────────────────────────────────────────────────┐
+ │ commerce_content_overrides │
+ │ WHERE entity_id = M3-ACME │
+ │ AND content_type = 'product' │
+ │ AND content_id = 123 │
+ │ AND field = 'name' │
+ │ │
+ │ Result: NULL (no override) │
+ └─────────────────────────────────────────────────┘
+ │
+ ▼ (not found, check parent)
+
+Step 2: Check M2-WATERBUTTS overrides
+ ┌─────────────────────────────────────────────────┐
+ │ commerce_content_overrides │
+ │ WHERE entity_id = M2-WATERBUTTS │
+ │ AND content_type = 'product' │
+ │ AND content_id = 123 │
+ │ AND field = 'name' │
+ │ │
+ │ Result: "Premium 500L Water Butt" ✓ │
+ └─────────────────────────────────────────────────┘
+ │
+ ▼ (found!)
+
+Step 3: Return "Premium 500L Water Butt"
+ (M3-ACME sees M2's override, not M1's original)
+
+─────────────────────────────────────────────────────────────
+
+If M3-ACME later customizes the name:
+
+ ┌─────────────────────────────────────────────────┐
+ │ INSERT INTO commerce_content_overrides │
+ │ (entity_id, content_type, content_id, field, value) │
+ │ VALUES │
+ │ (M3-ACME, 'product', 123, 'name', 'AquaSave Tank') │
+ └─────────────────────────────────────────────────┘
+
+Now M3-ACME sees "AquaSave Tank"
+M2-WATERBUTTS still sees "Premium 500L Water Butt"
+M1-ORGORG still sees "500L Water Butt"
+```
+
+---
+
+## Order Flow Through the Matrix
+
+```
+Customer places order on waterbutts.com (M2)
+ │
+ ▼
+┌─────────────────────────────────────────┐
+│ Order Created │
+│ - entity_id: M2-WBUTS │
+│ - sku: ORGORG-WBUTS-WB500L │
+│ - customer sees: M2 branding │
+└────────────────┬────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────┐
+│ M1 Fulfillment Queue │
+│ - M1 sees all orders from all M2s │
+│ - Can filter by facade │
+│ - Ships with M2 branding (or neutral) │
+└────────────────┬────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────┐
+│ Reporting │
+│ - M1: Sees all, costs, margins │
+│ - M2: Sees own orders, no cost data │
+│ - M3: Sees own orders, wholesale price │
+└─────────────────────────────────────────┘
+```
+
+---
+
+## Permission Keys (Standard Set)
+
+```php
+// Product permissions
+'product.list' // View product list
+'product.view' // View product detail
+'product.view_cost' // See cost price (M1 only usually)
+'product.create' // Create new product (M1 only)
+'product.update' // Update product
+'product.delete' // Delete product
+'product.price_override' // Override price on facade
+
+// Order permissions
+'order.list' // View orders
+'order.view' // View order detail
+'order.create' // Create order
+'order.update' // Update order
+'order.cancel' // Cancel order
+'order.refund' // Process refund
+'order.export' // Export order data
+
+// Customer permissions
+'customer.list'
+'customer.view'
+'customer.view_email' // See customer email
+'customer.view_phone' // See customer phone
+'customer.export' // Export customer data (GDPR!)
+
+// Report permissions
+'report.sales' // Sales reports
+'report.revenue' // Revenue (might hide from M3)
+'report.cost' // Cost reports (M1 only)
+'report.margin' // Margin reports (M1 only)
+
+// System permissions
+'settings.view'
+'settings.update'
+'entity.create' // Create child entities
+'entity.manage' // Manage entity settings
+```
+
+---
+
+## Configuration
+
+```php
+// config/commerce.php
+
+return [
+ 'matrix' => [
+ // Training mode - undefined permissions prompt for approval
+ 'training_mode' => env('COMMERCE_MATRIX_TRAINING', false),
+
+ // Production mode - undefined = denied
+ 'strict_mode' => env('COMMERCE_MATRIX_STRICT', true),
+
+ // Log all permission checks (for audit)
+ 'log_all_checks' => env('COMMERCE_MATRIX_LOG_ALL', false),
+
+ // Log denied requests
+ 'log_denials' => true,
+
+ // Default action when permission undefined (only if strict=false)
+ 'default_allow' => false,
+ ],
+
+ 'entities' => [
+ 'types' => [
+ 'm1' => [
+ 'name' => 'Master Company',
+ 'can_have_children' => true,
+ 'child_types' => ['m2', 'm3'],
+ ],
+ 'm2' => [
+ 'name' => 'Facade/Storefront',
+ 'can_have_children' => true,
+ 'child_types' => ['m3'],
+ ],
+ 'm3' => [
+ 'name' => 'Dropshipper',
+ 'can_have_children' => true, // Can have own M2s!
+ 'child_types' => ['m2'],
+ 'inherits_catalog' => true,
+ ],
+ ],
+ ],
+
+ 'sku' => [
+ // SKU format: {m1_code}-{m2_code}-{master_sku}
+ 'separator' => '-',
+ 'include_m1' => true,
+ 'include_m2' => true,
+ ],
+];
+```
+
+---
+
+## The Beauty: Everything Connects
+
+```
+EntitlementService (what features you have access to)
+ │
+ ▼
+PermissionMatrixService (what actions you can take)
+ │
+ ▼
+CommerceMatrixGate Middleware (enforces on every request)
+ │
+ ▼
+Training Mode (learn permissions by using the app)
+ │
+ ▼
+Production Mode (if not trained, it doesn't work)
+```
+
+---
+
+## Warehouse & Fulfillment Layer
+
+### The Physical World Connection
+
+```
+Web Server
+ │
+ ├── Remote Print Queue ──────────────────────┐
+ │ (No VPN, just one browser tab open) │
+ │ ▼
+ │ ┌──────────────────┐
+ │ │ Warehouse │
+ │ │ │
+ │ Thermal Printer ◄────────────────┤ Shipping Labels │
+ │ (Courier-supplied) │ │
+ │ │ Pick/Pack Lists │
+ │ Office Jet ◄─────────────────────┤ (Perforated) │
+ │ (Perforated paper) │ │
+ │ │ BOM Sheets │
+ │ └──────────────────┘
+ │
+ └── Warehouse Knowledge
+ ├── Product locations (bin/shelf)
+ ├── Pick route optimization
+ └── Real-time stock positions
+```
+
+### Consignment System (Inbound)
+
+**Consignment** = Notification of incoming supply, a pre-arrival order
+
+```php
+Schema::create('commerce_consignments', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('entity_id')->constrained('commerce_entities'); // M1 usually
+ $table->string('reference'); // Supplier reference / PO number
+ $table->foreignId('supplier_id')->nullable();
+
+ // Status flow
+ $table->string('status'); // expected, in_transit, received, processed
+ $table->date('expected_date')->nullable();
+ $table->timestamp('received_at')->nullable();
+ $table->timestamp('processed_at')->nullable();
+
+ // Who handled it
+ $table->foreignId('received_by')->nullable()->constrained('users');
+ $table->foreignId('processed_by')->nullable()->constrained('users');
+
+ $table->text('notes')->nullable();
+ $table->timestamps();
+});
+
+Schema::create('commerce_consignment_items', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('consignment_id')->constrained('commerce_consignments');
+ $table->foreignId('product_id')->constrained('commerce_products');
+
+ $table->integer('quantity_expected');
+ $table->integer('quantity_received')->default(0);
+ $table->decimal('unit_cost', 10, 2)->nullable();
+
+ // Warehouse placement
+ $table->string('bin_location')->nullable(); // Where it went
+ $table->timestamps();
+});
+```
+
+### Consignment Processing Flow
+
+```
+Consignment Created (PO sent to supplier)
+ │
+ ▼
+Status: EXPECTED
+ │ (supplier ships)
+ ▼
+Status: IN_TRANSIT
+ │ (delivery arrives)
+ ▼
+Status: RECEIVED
+ │
+ ├── Stock quantities updated
+ ├── Back orders checked & processed
+ ├── Notifications triggered
+ ├── Warehouse locations updated
+ │
+ ▼
+Status: PROCESSED
+
+// When consignment is processed:
+foreach ($consignment->items as $item) {
+ // Update stock
+ $item->product->increment('stock_quantity', $item->quantity_received);
+
+ // Update warehouse location
+ WarehouseLocation::updateOrCreate(
+ ['product_id' => $item->product_id],
+ ['bin' => $item->bin_location, 'quantity' => $newQuantity]
+ );
+
+ // Process back orders
+ BackOrderService::processForProduct($item->product);
+
+ // Notify watchers
+ event(new StockReplenished($item->product, $item->quantity_received));
+}
+```
+
+### Warehouse Knowledge (Product Locations)
+
+```php
+Schema::create('commerce_warehouse_locations', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('warehouse_id')->constrained('commerce_warehouses');
+ $table->foreignId('product_id')->constrained('commerce_products');
+
+ // Physical location
+ $table->string('zone')->nullable(); // A, B, C (areas of warehouse)
+ $table->string('aisle')->nullable(); // 1, 2, 3
+ $table->string('rack')->nullable(); // R1, R2
+ $table->string('shelf')->nullable(); // S1, S2
+ $table->string('bin')->nullable(); // B1, B2
+ $table->string('full_location'); // A-1-R2-S3-B1 (computed)
+
+ // For pick route optimization
+ $table->integer('pick_sequence')->default(0); // Order in optimal pick route
+
+ $table->integer('quantity')->default(0);
+ $table->integer('min_quantity')->default(0); // Reorder trigger
+ $table->integer('max_quantity')->nullable(); // Bin capacity
+
+ $table->timestamps();
+
+ $table->unique(['warehouse_id', 'product_id']);
+ $table->index(['warehouse_id', 'pick_sequence']);
+});
+```
+
+### Order Batching System
+
+**The Cart/Checkout Flow for Warehouse Staff**
+
+```
+Front Office View:
+┌─────────────────────────────────────────────────────────────────┐
+│ ORDER BATCHING [Create Batch]│
+│ │
+│ Filter Orders: │
+│ ┌─────────────────────────────────────────────────────────────┐│
+│ │ [x] Contains: Water Butt 500L [ ] Contains: Compost ││
+│ │ [x] Ready to ship [ ] Has back-ordered items ││
+│ │ [ ] Priority orders [x] Standard delivery ││
+│ └─────────────────────────────────────────────────────────────┘│
+│ │
+│ ┌──────┬─────────────────────────────────────────────┬────────┐│
+│ │ □ │ #10234 - John Smith - 3 items │ £45.00 ││
+│ │ ☑ │ #10235 - Jane Doe - 1 item (WB500L) │ £29.99 ││
+│ │ ☑ │ #10236 - Bob Wilson - 2 items (WB500L, x2) │ £59.98 ││
+│ │ □ │ #10237 - Sue Brown - 5 items │ £89.50 ││
+│ │ ☑ │ #10238 - Tom Jones - 1 item (WB500L) │ £29.99 ││
+│ └──────┴─────────────────────────────────────────────┴────────┘│
+│ │
+│ Selected: 3 orders, 4x WB500L, 0x other │
+│ │
+│ [Add to Batch] [Clear Selection] │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+### Batch / Pick List Generation
+
+```php
+Schema::create('commerce_pick_batches', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('entity_id')->constrained('commerce_entities');
+ $table->foreignId('warehouse_id')->constrained('commerce_warehouses');
+ $table->foreignId('created_by')->constrained('users'); // Front office
+
+ $table->string('reference'); // BATCH-20241231-001
+ $table->string('status'); // created, picking, picked, packing, shipped
+
+ // Assignment
+ $table->foreignId('assigned_to')->nullable()->constrained('users'); // Warehouse picker
+
+ // Timestamps
+ $table->timestamp('picking_started_at')->nullable();
+ $table->timestamp('picking_completed_at')->nullable();
+ $table->timestamp('packing_started_at')->nullable();
+ $table->timestamp('shipped_at')->nullable();
+
+ $table->timestamps();
+});
+
+Schema::create('commerce_pick_batch_orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('batch_id')->constrained('commerce_pick_batches');
+ $table->foreignId('order_id')->constrained('commerce_orders');
+
+ $table->integer('sequence'); // Order within batch
+ $table->string('status'); // pending, picked, packed, shipped
+
+ $table->timestamps();
+});
+```
+
+### BOM (Bill of Materials) - The Pick List
+
+When batch is finalized, generate the BOM:
+
+```php
+// app/Services/Commerce/PickListService.php
+
+class PickListService
+{
+ public function generateBOM(PickBatch $batch): BillOfMaterials
+ {
+ $items = collect();
+
+ // Aggregate all products across all orders in batch
+ foreach ($batch->orders as $batchOrder) {
+ foreach ($batchOrder->order->items as $orderItem) {
+ $key = $orderItem->product_id;
+
+ if ($items->has($key)) {
+ $items[$key]['quantity'] += $orderItem->quantity;
+ $items[$key]['orders'][] = $batchOrder->order_id;
+ } else {
+ $items[$key] = [
+ 'product' => $orderItem->product,
+ 'quantity' => $orderItem->quantity,
+ 'orders' => [$batchOrder->order_id],
+ 'location' => $orderItem->product->warehouseLocation,
+ ];
+ }
+ }
+ }
+
+ // Sort by pick sequence (optimal route through warehouse)
+ $items = $items->sortBy(fn ($item) => $item['location']->pick_sequence);
+
+ return new BillOfMaterials(
+ batch: $batch,
+ items: $items,
+ generated_at: now()
+ );
+ }
+}
+```
+
+### Print Queue System
+
+**The Magic: Web Server → Physical Printers, No VPN**
+
+```php
+Schema::create('commerce_print_queue', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('entity_id')->constrained('commerce_entities');
+ $table->foreignId('warehouse_id')->constrained('commerce_warehouses');
+
+ // What to print
+ $table->string('document_type'); // shipping_label, pick_list, bom, packing_slip
+ $table->string('document_id'); // Reference to source
+ $table->foreignId('batch_id')->nullable();
+ $table->foreignId('order_id')->nullable();
+
+ // Printer target
+ $table->string('printer_id'); // thermal_1, officejet_1
+ $table->string('printer_type'); // thermal, inkjet, laser
+
+ // Content
+ $table->text('content')->nullable(); // Raw print data or template ref
+ $table->string('template')->nullable(); // blade template name
+ $table->json('template_data')->nullable(); // Data for template
+
+ // Status
+ $table->string('status')->default('queued'); // queued, printing, printed, failed
+ $table->timestamp('printed_at')->nullable();
+ $table->text('error')->nullable();
+
+ // Who requested
+ $table->foreignId('requested_by')->constrained('users');
+ $table->timestamps();
+
+ $table->index(['warehouse_id', 'status']);
+ $table->index(['printer_id', 'status']);
+});
+```
+
+### Print Client (Browser Tab)
+
+```javascript
+// resources/js/warehouse-print-client.js
+// This runs in ONE browser tab in the warehouse
+
+class WarehousePrintClient {
+ constructor(warehouseId) {
+ this.warehouseId = warehouseId;
+ this.printers = new Map();
+ this.polling = false;
+ }
+
+ async registerPrinter(printerId, printerType, nativeHandle) {
+ // nativeHandle could be:
+ // - Web USB API for thermal printers
+ // - Window.print() for regular printers
+ // - CUPS/IPP endpoint for network printers
+ this.printers.set(printerId, { type: printerType, handle: nativeHandle });
+
+ await fetch('/api/warehouse/printers/register', {
+ method: 'POST',
+ body: JSON.stringify({
+ warehouse_id: this.warehouseId,
+ printer_id: printerId,
+ printer_type: printerType,
+ capabilities: this.getCapabilities(nativeHandle)
+ })
+ });
+ }
+
+ startPolling() {
+ this.polling = true;
+ this.poll();
+ }
+
+ async poll() {
+ if (!this.polling) return;
+
+ try {
+ const response = await fetch(`/api/warehouse/${this.warehouseId}/print-queue`);
+ const jobs = await response.json();
+
+ for (const job of jobs) {
+ await this.processJob(job);
+ }
+ } catch (e) {
+ console.error('Print poll failed:', e);
+ }
+
+ // Poll every 2 seconds
+ setTimeout(() => this.poll(), 2000);
+ }
+
+ async processJob(job) {
+ const printer = this.printers.get(job.printer_id);
+ if (!printer) {
+ await this.markFailed(job.id, 'Printer not connected');
+ return;
+ }
+
+ try {
+ await this.markPrinting(job.id);
+
+ if (job.document_type === 'shipping_label') {
+ await this.printLabel(printer, job);
+ } else {
+ await this.printDocument(printer, job);
+ }
+
+ await this.markPrinted(job.id);
+ } catch (e) {
+ await this.markFailed(job.id, e.message);
+ }
+ }
+
+ async printLabel(printer, job) {
+ // For thermal printers - ZPL or EPL format
+ if (printer.type === 'thermal') {
+ const zpl = await this.fetchLabelZPL(job.document_id);
+ await printer.handle.write(zpl);
+ }
+ }
+
+ async printDocument(printer, job) {
+ // For regular printers - open in iframe, trigger print
+ const iframe = document.createElement('iframe');
+ iframe.style.display = 'none';
+ iframe.src = `/warehouse/print-preview/${job.document_type}/${job.document_id}`;
+ document.body.appendChild(iframe);
+
+ iframe.onload = () => {
+ iframe.contentWindow.print();
+ setTimeout(() => iframe.remove(), 5000);
+ };
+ }
+}
+
+// Initialize
+const client = new WarehousePrintClient(WAREHOUSE_ID);
+client.registerPrinter('thermal_1', 'thermal', thermalPrinterUSB);
+client.registerPrinter('officejet_1', 'inkjet', null); // Uses window.print()
+client.startPolling();
+```
+
+### Batch Workflow (Front Office → Warehouse)
+
+```
+FRONT OFFICE WAREHOUSE
+───────────── ─────────
+
+1. Filter orders by criteria
+ (product X, ready to ship)
+ │
+ ▼
+2. Select orders into batch
+ (cart-style UI)
+ │
+ ▼
+3. Create batch
+ [Create Batch] ──────────────────► Batch appears in
+ │ warehouse queue
+ ▼
+4. System generates: │
+ - BOM (aggregated products) │
+ - Pick list (sorted by location) │
+ - Shipping labels (all at once) │
+ │ │
+ ▼ ▼
+5. Print jobs queued ──────────────► 6. Print client receives
+ │ - Pick list (perforated)
+ │ - Shipping labels (thermal)
+ │ - BOM sheet
+ │ │
+ │ ▼
+ │ 7. Picker follows route
+ │ (optimized by pick_sequence)
+ │ │
+ │ ▼
+ │ 8. Each order packed
+ │ Label applied
+ │ Marked complete
+ │ │
+ ▼ ▼
+9. Status updates in real-time 10. Batch complete
+ (front office sees progress) Carrier pickup scheduled
+```
+
+### Document Templates
+
+```blade
+{{-- resources/views/warehouse/documents/pick-list.blade.php --}}
+{{-- Designed for perforated paper - each order on tearable section --}}
+
+@foreach($batch->orders as $batchOrder)
+
+
+
+
+ {{ $batchOrder->order->shipping_name }}
+ {{ $batchOrder->order->shipping_address }}
+
+
+
+
+ | Location |
+ SKU |
+ Product |
+ Qty |
+ Picked |
+
+ @foreach($batchOrder->order->items->sortBy('product.warehouseLocation.pick_sequence') as $item)
+
+ | {{ $item->product->warehouseLocation->full_location }} |
+ {{ $item->sku }} |
+ {{ $item->product->name }} |
+ {{ $item->quantity }} |
+ ☐ |
+
+ @endforeach
+
+
+
+ @if($batchOrder->order->notes)
+ Notes: {{ $batchOrder->order->notes }}
+ @endif
+
+
+ {{-- Tear line --}}
+
✂ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
+
+@endforeach
+```
+
+```blade
+{{-- resources/views/warehouse/documents/bom.blade.php --}}
+{{-- Aggregated Bill of Materials for entire batch --}}
+
+
+
Bill of Materials - Batch {{ $batch->reference }}
+
{{ $batch->orders->count() }} orders | Generated {{ now()->format('Y-m-d H:i') }}
+
+
+
+ | Pick Seq |
+ Location |
+ SKU |
+ Product |
+ Total Qty |
+ For Orders |
+
+ @foreach($bom->items as $item)
+
+ | {{ $item['location']->pick_sequence }} |
+ {{ $item['location']->full_location }} |
+ {{ $item['product']->master_sku }} |
+ {{ $item['product']->name }} |
+ {{ $item['quantity'] }} |
+ {{ implode(', ', array_map(fn($id) => "#{$id}", $item['orders'])) }} |
+
+ @endforeach
+
+
+
+ Total Items: {{ $bom->items->sum('quantity') }}
+ Unique Products: {{ $bom->items->count() }}
+
+
+```
+
+### Back Order Processing
+
+```php
+// app/Services/Commerce/BackOrderService.php
+
+class BackOrderService
+{
+ /**
+ * When consignment arrives, process back orders
+ */
+ public static function processForProduct(Product $product): void
+ {
+ $availableStock = $product->stock_quantity;
+
+ // Get back orders, oldest first
+ $backOrders = OrderItem::where('product_id', $product->id)
+ ->where('status', 'back_ordered')
+ ->orderBy('created_at')
+ ->with('order')
+ ->get();
+
+ foreach ($backOrders as $item) {
+ if ($availableStock >= $item->quantity) {
+ // Can fulfill this back order
+ $item->update(['status' => 'ready']);
+ $availableStock -= $item->quantity;
+
+ // Notify customer
+ event(new BackOrderReady($item->order, $item));
+
+ // Check if full order is now ready
+ if ($item->order->items->every(fn ($i) => $i->status === 'ready')) {
+ $item->order->update(['status' => 'ready_to_ship']);
+ event(new OrderReadyToShip($item->order));
+ }
+ } else {
+ // Partial or none - stop processing
+ break;
+ }
+ }
+
+ // Update product stock (may have allocated some)
+ $product->update(['stock_quantity' => $availableStock]);
+ }
+}
+```
+
+---
+
+## The Complete Flow
+
+```
+ ┌─────────────────────────────────────────────────────────────────┐
+ │ INBOUND (Consignment) │
+ │ │
+ │ PO Created → Expected → In Transit → Received → Processed │
+ │ │ │
+ │ ▼ │
+ │ Stock Updated │
+ │ Back Orders Processed │
+ │ Warehouse Locations Set │
+ └──────────────────────────────────────┬──────────────────────────┘
+ │
+┌─────────────────────────────────────────────────┼──────────────────────────────────────────────────┐
+│ │ COMMERCE │
+│ ▼ │
+│ Customer Order ──► Permission Matrix Check ──► Order Created ──► Awaiting Fulfillment │
+│ │ │ │
+│ │ (M1-M2-SKU tracking) │ │
+│ ▼ ▼ │
+│ Multi-Entity Front Office │
+│ Visibility Batching UI │
+└──────────────────────────────────────────────────┬─────────────────────────────────────────────────┘
+ │
+ ┌───────────────────────────────────────┼────────────────────────────┐
+ │ │ FULFILLMENT │
+ │ ▼ │
+ │ Batch Created ──► BOM Generated ──► Print Queue ──► Warehouse │
+ │ │ │ │
+ │ │ ▼ │
+ │ │ ┌─────────────────┐ │
+ │ │ │ Print Client │ │
+ │ │ │ (1 browser tab) │ │
+ │ │ └────────┬────────┘ │
+ │ │ │ │
+ │ │ ┌───────────────┼───────────────┐ │
+ │ │ ▼ ▼ ▼ │
+ │ │ Pick List BOM Sheet Labels │
+ │ │ (perforated) (thermal) │
+ │ │ │ │ │ │
+ │ │ └───────────────┼───────────────┘ │
+ │ │ ▼ │
+ │ │ PICK → PACK → SHIP │
+ │ │ │ │
+ │ ▼ ▼ │
+ │ Status Updates ◄──────────────── Batch Complete │
+ │ (real-time to front office) │
+ └────────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Implementation Phases
+
+### Phase 1: Core Matrix
+- [ ] Entity hierarchy (M1 → M2 → M3)
+- [ ] Permission matrix table
+- [ ] PermissionMatrixService
+- [ ] Basic can() checks
+
+### Phase 2: WAF Integration
+- [ ] CommerceMatrixGate middleware
+- [ ] Request logging
+- [ ] Action resolution (route → permission key)
+
+### Phase 3: Training Mode
+- [ ] Training UI (the click-to-allow modal)
+- [ ] Permission discovery
+- [ ] Bulk training tools
+
+### Phase 4: Product Catalog
+- [ ] Master catalog (M1)
+- [ ] Facade selection (M2)
+- [ ] Dropship inheritance (M3)
+- [ ] SKU lineage
+
+### Phase 5: Order Flow
+- [ ] Multi-entity order creation
+- [ ] Fulfillment routing
+- [ ] Entity-scoped reporting
+
+### Phase 6: Production Hardening
+- [ ] Strict mode enforcement
+- [ ] Audit logging
+- [ ] Permission export/import (for deployment)
+
+### Phase 7: Entitlement Integration
+
+Commerce-Entitlement lifecycle synchronisation. Connects payment events to workspace feature access.
+
+**Current State (Implemented):**
+- [x] `CommerceService->fulfillOrder()` provisions packages via `EntitlementService->provisionPackage()`
+- [x] Stripe/BTCPay webhooks trigger entitlement provisioning on checkout complete
+- [x] Subscription cancellation revokes entitlements via `ProvisionSocialHostSubscription` listener
+
+**Gaps to Address:**
+
+#### 7.1 Payment Failure Handling
+- [ ] `StripeWebhookController->handleInvoicePaymentFailed()` → suspend entitlements
+- [ ] `BTCPayWebhookController` payment expiry → suspend entitlements
+- [ ] Grace period configuration (default: 3 days)
+- [ ] `EntitlementService->suspendWorkspace()` integration
+
+#### 7.2 Dunning Workflow
+- [ ] Failed payment notification sequence (Day 0, 3, 7, 14)
+- [ ] Automatic retry scheduling
+- [ ] `WorkspaceSuspended` event dispatch
+- [ ] Admin visibility into suspended workspaces
+
+#### 7.3 Event-Driven Architecture
+- [ ] Webhooks dispatch domain events (not inline processing)
+- [ ] Listeners handle business logic independently
+- [ ] `PaymentCompleted`, `PaymentFailed`, `SubscriptionRenewed` events
+- [ ] Decoupled from specific payment provider
+
+#### 7.4 Legacy Cleanup
+- [ ] Remove `BlestaWebhookController` (unused)
+- [ ] Remove `BlestaApiAuth` middleware
+- [ ] Remove Blesta routes from `routes/api.php`
+- [ ] Remove `config/blesta.php`
+- [ ] Clean up any Blesta references in codebase
+
+#### 7.5 Testing
+- [ ] Payment failure → suspension flow tests
+- [ ] Dunning sequence tests
+- [ ] Grace period expiry tests
+- [ ] Multi-provider webhook tests (Stripe, BTCPay)
+
+---
+
+---
+
+## SKU System: One Trip or Go Home
+
+### Philosophy
+
+Every scan tells you everything. No lookups. No mistakes. One barcode = complete fulfillment knowledge.
+
+### Compound SKU Format
+
+```
+SKU-~*[-~*]...
+
+Where:
+ SKU = Base product identifier
+ - = Option separator
+ = Option code (color, size, ram, cover, etc.)
+ ~ = Value indicator
+ = Option value (black, XL, 16gb, etc.)
+ * = Quantity indicator (optional, default 1)
+ = Count of this option
+```
+
+### Examples
+
+```
+# Simple product with options
+LAPTOP-ram~16gb-ssd~512gb
+
+# Product with multiple of an accessory option
+LAPTOP-ram~16gb-ssd~512gb-cover~black*2
+
+# Multiple separate items (comma-separated)
+LAPTOP-ram~16gb,HDMI-length~2m,MOUSE-color~black
+
+# Bundle (pipe-separated = discounted group)
+LAPTOP-ram~16gb|MOUSE-color~black|PAD-size~xl
+```
+
+### Bundle Detection & Pricing
+
+The `|` character binds products into a bundle with potential price override.
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Input: LAPTOP-ram~16gb|MOUSE-color~black|PAD-size~xl │
+│ │
+│ Step 1: Detect Bundle (found |) │
+│ │
+│ Step 2: Strip Human Choices │
+│ → LAPTOP|MOUSE|PAD │
+│ │
+│ Step 3: Hash the Raw Combination │
+│ → hash("LAPTOP|MOUSE|PAD") = "abc123" │
+│ │
+│ Step 4: Lookup Bundle Discount │
+│ → bundle_hashes["abc123"] = "CYBERMON20" coupon │
+│ → Apply 20% bundle discount │
+│ │
+│ Step 5: Process Remainders │
+│ → ram~16gb, color~black, size~xl │
+│ → Feed into additional pricing rules │
+│ → (BOGO, volume discounts, upsell triggers) │
+└──────────────────────────────────────────────────────────────┘
+```
+
+### Bundle Hash Table
+
+```php
+// commerce_bundle_hashes
+Schema::create('commerce_bundle_hashes', function (Blueprint $table) {
+ $table->id();
+ $table->string('hash', 64)->unique(); // SHA256 of sorted base SKUs
+ $table->string('base_skus'); // "LAPTOP|MOUSE|PAD" (for debugging)
+ $table->string('coupon_code')->nullable(); // CYBERMON20
+ $table->decimal('fixed_price')->nullable(); // Or fixed bundle price
+ $table->decimal('discount_percent')->nullable();
+ $table->decimal('discount_amount')->nullable();
+ $table->unsignedBigInteger('entity_id'); // M1/M2/M3 scope
+ $table->boolean('active')->default(true);
+ $table->timestamps();
+
+ $table->index(['entity_id', 'active']);
+});
+```
+
+### SKU Parser Service
+
+```php
+class SkuParserService
+{
+ /**
+ * Parse a compound SKU string into structured data
+ */
+ public function parse(string $compoundSku): SkuParseResult
+ {
+ // Split by comma for multiple items
+ $items = explode(',', $compoundSku);
+
+ $parsedItems = [];
+ $currentBundle = [];
+
+ foreach ($items as $item) {
+ // Check for bundle separator
+ if (str_contains($item, '|')) {
+ $bundleParts = explode('|', $item);
+ foreach ($bundleParts as $part) {
+ $currentBundle[] = $this->parseItem($part);
+ }
+ $parsedItems[] = new BundleItem(
+ items: $currentBundle,
+ hash: $this->hashBundle($currentBundle)
+ );
+ $currentBundle = [];
+ } else {
+ $parsedItems[] = $this->parseItem($item);
+ }
+ }
+
+ return new SkuParseResult($parsedItems);
+ }
+
+ /**
+ * Parse single item: SKU-opt~val*qty-opt~val*qty
+ */
+ protected function parseItem(string $item): ParsedItem
+ {
+ $parts = explode('-', $item, 2);
+ $baseSku = $parts[0];
+ $options = [];
+
+ if (isset($parts[1])) {
+ // Split remaining by - for each option
+ preg_match_all('/([a-z_]+)~([^-*]+)(?:\*(\d+))?/i', $parts[1], $matches, PREG_SET_ORDER);
+
+ foreach ($matches as $match) {
+ $options[] = new SkuOption(
+ code: $match[1],
+ value: $match[2],
+ quantity: isset($match[3]) ? (int)$match[3] : 1
+ );
+ }
+ }
+
+ return new ParsedItem(
+ baseSku: $baseSku,
+ options: $options
+ );
+ }
+
+ /**
+ * Hash bundle for discount lookup (strips human choices)
+ */
+ protected function hashBundle(array $items): string
+ {
+ $baseSkus = collect($items)
+ ->map(fn($item) => $item->baseSku)
+ ->sort()
+ ->implode('|');
+
+ return hash('sha256', $baseSkus);
+ }
+}
+```
+
+### SKU Builder Service
+
+```php
+class SkuBuilderService
+{
+ /**
+ * Build compound SKU from cart/order data
+ */
+ public function build(array $lineItems): string
+ {
+ $skuParts = [];
+
+ foreach ($lineItems as $item) {
+ $sku = $item['base_sku'];
+
+ // Add options
+ foreach ($item['options'] ?? [] as $option) {
+ $sku .= "-{$option['code']}~{$option['value']}";
+ if (($option['quantity'] ?? 1) > 1) {
+ $sku .= "*{$option['quantity']}";
+ }
+ }
+
+ $skuParts[] = $sku;
+ }
+
+ // If bundle, join with |
+ if ($this->isBundle($lineItems)) {
+ return implode('|', $skuParts);
+ }
+
+ // Otherwise comma-separate
+ return implode(',', $skuParts);
+ }
+
+ /**
+ * Generate bundle hash for coupon creation
+ */
+ public function generateBundleHash(array $baseSkus): string
+ {
+ sort($baseSkus);
+ return hash('sha256', implode('|', $baseSkus));
+ }
+}
+```
+
+### Pricing Pipeline
+
+```php
+class SkuPricingService
+{
+ public function calculatePrice(SkuParseResult $parsed, Entity $entity): PricingResult
+ {
+ $lines = [];
+ $bundleDiscounts = [];
+
+ foreach ($parsed->items as $item) {
+ if ($item instanceof BundleItem) {
+ // Look up bundle discount
+ $bundle = CommerceBundleHash::where('hash', $item->hash)
+ ->where('entity_id', $entity->id)
+ ->where('active', true)
+ ->first();
+
+ if ($bundle) {
+ $bundleDiscounts[] = new BundleDiscount(
+ items: $item->items,
+ couponCode: $bundle->coupon_code,
+ discountPercent: $bundle->discount_percent,
+ discountAmount: $bundle->discount_amount,
+ fixedPrice: $bundle->fixed_price
+ );
+ }
+
+ // Price individual items within bundle
+ foreach ($item->items as $bundledItem) {
+ $lines[] = $this->priceItem($bundledItem, $entity);
+ }
+ } else {
+ $lines[] = $this->priceItem($item, $entity);
+ }
+ }
+
+ // Apply bundle discounts
+ foreach ($bundleDiscounts as $discount) {
+ $lines = $this->applyBundleDiscount($lines, $discount);
+ }
+
+ // Apply remainder-based rules (BOGO, volume, etc.)
+ $lines = $this->applyQuantityRules($lines, $entity);
+
+ return new PricingResult($lines, $bundleDiscounts);
+ }
+
+ protected function priceItem(ParsedItem $item, Entity $entity): PricedLine
+ {
+ $product = $this->findProduct($item->baseSku, $entity);
+ $basePrice = $product->getPrice($entity);
+
+ // Add option modifiers
+ foreach ($item->options as $option) {
+ $modifier = $product->getOptionModifier($option->code, $option->value);
+ $basePrice += ($modifier * $option->quantity);
+ }
+
+ return new PricedLine($item, $basePrice);
+ }
+}
+```
+
+### Warehouse Integration
+
+The compound SKU becomes the pick instruction:
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ PICK LIST │
+│ Order: #12345 │
+│─────────────────────────────────────────────────────────────────────│
+│ │
+│ [■■■■■■■■■■■■] LAPTOP-ram~16gb-ssd~512gb-cover~black*2 │
+│ │ │
+│ ├─ LAPTOP (A-12-3-1) ← bin location │
+│ │ └─ Option: ram~16gb ← pre-configured variant │
+│ │ └─ Option: ssd~512gb │
+│ │ │
+│ └─ COVER-BLACK (B-02-1-5) × 2 ← separate pick, quantity 2 │
+│ │
+│─────────────────────────────────────────────────────────────────────│
+│ Bundle: LAPTOP|MOUSE|PAD → CYBERMON20 applied │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+### Option Types
+
+Options can represent different things:
+
+```php
+// Option type definitions
+enum OptionType: string
+{
+ case VARIANT = 'variant'; // ram~16gb (pre-built into product)
+ case ACCESSORY = 'accessory'; // cover~black (separate SKU to pick)
+ case SERVICE = 'service'; // warranty~3yr (no physical pick)
+ case CUSTOMIZATION = 'custom'; // engrave~"John" (requires action)
+}
+
+// Option registry
+class SkuOptionRegistry
+{
+ protected array $options = [
+ 'ram' => ['type' => 'variant', 'affects_price' => true],
+ 'ssd' => ['type' => 'variant', 'affects_price' => true],
+ 'color' => ['type' => 'variant', 'affects_price' => false],
+ 'size' => ['type' => 'variant', 'affects_price' => true],
+ 'cover' => ['type' => 'accessory', 'sku_prefix' => 'COVER-'],
+ 'case' => ['type' => 'accessory', 'sku_prefix' => 'CASE-'],
+ 'warranty' => ['type' => 'service', 'affects_price' => true],
+ 'engrave' => ['type' => 'custom', 'requires_input' => true],
+ ];
+
+ public function resolveOption(string $code, string $value): ResolvedOption
+ {
+ $config = $this->options[$code] ?? throw new UnknownOptionException($code);
+
+ return match($config['type']) {
+ 'variant' => new VariantOption($code, $value),
+ 'accessory' => new AccessoryOption(
+ $code,
+ $value,
+ sku: $config['sku_prefix'] . strtoupper($value)
+ ),
+ 'service' => new ServiceOption($code, $value),
+ 'custom' => new CustomOption($code, $value),
+ };
+ }
+}
+```
+
+### M1-M2-M3 Integration
+
+The compound SKU carries entity lineage:
+
+```
+Full SKU with Lineage:
+ORGORG-WBUTS-WB500L-color~green-stand~oak*2
+
+Where:
+ ORGORG = M1 (Original Organics - master)
+ WBUTS = M2 (Waterbutts.com - storefront)
+ WB500L = Base product
+ -color~green-stand~oak*2 = Options
+```
+
+The entity prefix enables:
+- **Routing**: Order goes to correct fulfillment center
+- **Reporting**: Sales attributed to correct facade
+- **Pricing**: Entity-specific pricing rules applied
+- **Permissions**: Entity-specific product availability checked
+
+---
+
+## Session State Summary
+
+**What's Captured:**
+1. ✅ M1 → M2 → M3 Entity Hierarchy (master, facades, dropshippers)
+2. ✅ Permission Matrix (top-down immutable, cascading locks)
+3. ✅ Integrated WAF with Training Mode (click-to-allow, production strict)
+4. ✅ Product Catalog (master + facade selection + dropship inheritance)
+5. ✅ Content Override Matrix (sparse overrides, runtime resolution, white-label engine)
+6. ✅ Consignment System (inbound supply, auto stock updates, back order processing)
+7. ✅ Warehouse Knowledge (locations, pick sequences, bin tracking)
+8. ✅ Order Batching (front office cart-style selection)
+9. ✅ BOM/Pick List Generation (aggregated, route-optimized)
+10. ✅ Remote Print Queue (thermal labels, perforated pick lists, no VPN)
+11. ✅ Back Order Auto-Processing (FIFO fulfillment on stock arrival)
+12. ✅ SKU Encoding System ("one trip or go home")
+ - Compound SKU format: `SKU-opt~val*qty`
+ - Bundle detection via `|` separator
+ - Bundle hash → coupon lookup (strip human choices, hash base SKUs)
+ - Option types: variant, accessory, service, customization
+ - Entity lineage prefix: M1-M2-SKU-options
+
+**Pricing Note:**
+Pricing is NOT a separate system. It's the intersection of:
+- Permission Matrix (can_discount, max_discount_percent, can_sell_below_wholesale)
+- Content Overrides (sparse price overrides per entity)
+- SKU System (bundle hashes, option modifiers, volume rules)
+
+No separate pricing engine needed. Primitives compose.
+
+**Parked for Future:**
+- Carrier integrations ("Night Fright" and friends)
+- Returns flow
+- Financial reconciliation per entity
+
+---
+
+*Created: 2024-12-31*
+*Updated: 2024-12-31*
+*Status: Core Vision Captured - Ready for Implementation Planning*
+*Origin: The 2008 System That Was Ahead of Its Time*
diff --git a/changelog/2026/jan/NATIVE_COMMERCE_PLAN.md b/changelog/2026/jan/NATIVE_COMMERCE_PLAN.md
new file mode 100644
index 0000000..d01aff8
--- /dev/null
+++ b/changelog/2026/jan/NATIVE_COMMERCE_PLAN.md
@@ -0,0 +1,1093 @@
+# Native Commerce Plan: Replacing Blesta with Laravel
+
+## Executive Summary
+
+This document outlines the plan to replace the external Blesta billing system (order.host.uk.com) with native Laravel commerce built directly into Host Hub. The goal is to eliminate external dependencies, reduce complexity, and enable tighter integration with the existing entitlement system.
+
+**Key Finding**: The current entitlement system is **already decoupled** from payment processing. Whether Blesta or Stripe provisions a package, the same `EntitlementService->provisionPackage()` call is used. This means native commerce can be added without refactoring the entitlement model itself.
+
+---
+
+## Current Architecture
+
+### What Blesta Does Today
+| Function | Blesta | Host Hub |
+|----------|--------|----------|
+| Product Catalog | `packages` table (94 tables total) | `entitlement_packages` ✓ |
+| Subscriptions | `services` table | `entitlement_workspace_packages` ✓ |
+| Feature Limits | Custom via module | `entitlement_features` ✓ |
+| Usage Tracking | N/A | `entitlement_usage_records` ✓ |
+| Payment Processing | 20+ gateways | **MISSING** |
+| Invoices | Full engine | **MISSING** |
+| Checkout Flow | Full cart/checkout | **MISSING** |
+| Customer Portal | Self-service billing | **MISSING** |
+
+### Integration Flow Today
+```
+Host Hub ←─webhook─→ Blesta (order.host.uk.com)
+ ←──api───→ host_uk module
+ ↓
+ Stripe/PayPal
+```
+
+### What MixPost Enterprise Already Has
+The `packages/mixpost-enterprise` package contains a **complete billing system**:
+- Multi-gateway support (Stripe, Paddle, Paystack)
+- Subscription lifecycle management
+- Plan limits enforcement
+- Usage tracking
+- Admin panel for billing
+
+**Decision**: Rather than build from scratch, **leverage MixPost Enterprise patterns** or potentially extend its billing system for Host Hub's commerce needs.
+
+---
+
+## Proposed Architecture
+
+### Native Commerce Stack
+```
+Host Hub
+├── Commerce (new Laravel module)
+│ ├── Orders
+│ ├── Invoices
+│ ├── Payments (Stripe, BTCPay)
+│ └── Checkout
+├── Entitlements (existing)
+│ ├── Packages
+│ ├── Features
+│ ├── WorkspacePackages
+│ └── UsageRecords
+└── Workspace (existing)
+ └── User/Team ownership
+```
+
+### Why Not Just Use MixPost Enterprise Billing?
+MixPost Enterprise billing is tightly coupled to:
+- MixPost Workspace model (not Host Hub's Workspace)
+- Social media feature limits
+- MixPost-specific subscription states
+
+**Better approach**: Extract patterns and create Host Hub-native commerce that works across ALL modules (BioHost, Analytics, Push, Files, MixPost).
+
+---
+
+## Database Schema
+
+### New Tables
+
+```sql
+-- Orders (checkout → payment → fulfillment)
+CREATE TABLE orders (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ workspace_id BIGINT UNSIGNED NOT NULL,
+ user_id BIGINT UNSIGNED NOT NULL,
+
+ -- Order details
+ order_number VARCHAR(50) UNIQUE NOT NULL,
+ status ENUM('pending', 'processing', 'paid', 'failed', 'refunded', 'cancelled') DEFAULT 'pending',
+ type ENUM('new', 'renewal', 'upgrade', 'downgrade', 'addon') NOT NULL,
+
+ -- Financials
+ currency VARCHAR(3) NOT NULL DEFAULT 'GBP',
+ subtotal DECIMAL(10, 2) NOT NULL,
+ tax_amount DECIMAL(10, 2) DEFAULT 0,
+ discount_amount DECIMAL(10, 2) DEFAULT 0,
+ total DECIMAL(10, 2) NOT NULL,
+
+ -- Payment tracking
+ payment_method VARCHAR(50) NULL,
+ payment_gateway VARCHAR(50) NULL,
+ gateway_order_id VARCHAR(255) NULL,
+
+ -- References
+ coupon_id BIGINT UNSIGNED NULL,
+
+ -- Metadata
+ billing_address JSON NULL,
+ metadata JSON NULL,
+
+ paid_at TIMESTAMP NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ INDEX idx_workspace (workspace_id),
+ INDEX idx_user (user_id),
+ INDEX idx_status (status),
+ INDEX idx_order_number (order_number),
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+-- Order items (line breakdown)
+CREATE TABLE order_items (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ order_id BIGINT UNSIGNED NOT NULL,
+
+ -- What was ordered
+ item_type ENUM('package', 'addon', 'boost', 'custom') NOT NULL,
+ item_id BIGINT UNSIGNED NULL, -- FK to package/boost depending on type
+ item_code VARCHAR(100) NULL, -- Package or feature code
+
+ -- Details
+ description VARCHAR(500) NOT NULL,
+ quantity INT UNSIGNED DEFAULT 1,
+ unit_price DECIMAL(10, 2) NOT NULL,
+ line_total DECIMAL(10, 2) NOT NULL,
+
+ -- Billing
+ billing_cycle ENUM('monthly', 'yearly', 'onetime', 'lifetime') NOT NULL,
+
+ metadata JSON NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ INDEX idx_order (order_id),
+ FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
+);
+
+-- Invoices (billing documents)
+CREATE TABLE invoices (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ workspace_id BIGINT UNSIGNED NOT NULL,
+ order_id BIGINT UNSIGNED NULL,
+
+ -- Invoice details
+ invoice_number VARCHAR(50) UNIQUE NOT NULL,
+ status ENUM('draft', 'sent', 'paid', 'overdue', 'void', 'uncollectible') DEFAULT 'draft',
+
+ -- Financials
+ currency VARCHAR(3) NOT NULL DEFAULT 'GBP',
+ subtotal DECIMAL(10, 2) NOT NULL,
+ tax_amount DECIMAL(10, 2) DEFAULT 0,
+ discount_amount DECIMAL(10, 2) DEFAULT 0,
+ total DECIMAL(10, 2) NOT NULL,
+ amount_paid DECIMAL(10, 2) DEFAULT 0,
+ amount_due DECIMAL(10, 2) NOT NULL,
+
+ -- Dates
+ issue_date DATE NOT NULL,
+ due_date DATE NOT NULL,
+ paid_at TIMESTAMP NULL,
+
+ -- Billing info
+ billing_name VARCHAR(255) NULL,
+ billing_address JSON NULL,
+ tax_id VARCHAR(50) NULL,
+
+ -- PDF
+ pdf_path VARCHAR(500) NULL,
+
+ -- Auto-billing
+ auto_charge BOOLEAN DEFAULT FALSE,
+ charge_attempts INT DEFAULT 0,
+ last_charge_attempt TIMESTAMP NULL,
+ next_charge_attempt TIMESTAMP NULL,
+
+ metadata JSON NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ INDEX idx_workspace (workspace_id),
+ INDEX idx_status (status),
+ INDEX idx_due_date (due_date),
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,
+ FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE SET NULL
+);
+
+-- Invoice line items
+CREATE TABLE invoice_items (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ invoice_id BIGINT UNSIGNED NOT NULL,
+ order_item_id BIGINT UNSIGNED NULL,
+
+ description VARCHAR(500) NOT NULL,
+ quantity INT UNSIGNED DEFAULT 1,
+ unit_price DECIMAL(10, 2) NOT NULL,
+ line_total DECIMAL(10, 2) NOT NULL,
+
+ -- Tax
+ taxable BOOLEAN DEFAULT TRUE,
+ tax_rate DECIMAL(5, 2) DEFAULT 0,
+ tax_amount DECIMAL(10, 2) DEFAULT 0,
+
+ metadata JSON NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ INDEX idx_invoice (invoice_id),
+ FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
+);
+
+-- Payments (records of money received)
+CREATE TABLE payments (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ workspace_id BIGINT UNSIGNED NOT NULL,
+ invoice_id BIGINT UNSIGNED NULL,
+
+ -- Payment details
+ gateway VARCHAR(50) NOT NULL, -- stripe, btcpay, manual, credit
+ gateway_payment_id VARCHAR(255) NULL, -- pi_xxx, inv_xxx, etc.
+ gateway_customer_id VARCHAR(255) NULL, -- cus_xxx
+
+ -- Amount
+ currency VARCHAR(3) NOT NULL,
+ amount DECIMAL(10, 2) NOT NULL,
+ fee DECIMAL(10, 2) DEFAULT 0, -- Gateway processing fee
+ net_amount DECIMAL(10, 2) NOT NULL, -- amount - fee
+
+ -- Status
+ status ENUM('pending', 'processing', 'succeeded', 'failed', 'refunded', 'partially_refunded') DEFAULT 'pending',
+ failure_reason VARCHAR(500) NULL,
+
+ -- Payment method details
+ payment_method_type VARCHAR(50) NULL, -- card, crypto, bank_transfer
+ payment_method_last4 VARCHAR(4) NULL,
+ payment_method_brand VARCHAR(50) NULL,
+
+ -- Gateway raw data
+ gateway_response JSON NULL,
+
+ refunded_amount DECIMAL(10, 2) DEFAULT 0,
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ INDEX idx_workspace (workspace_id),
+ INDEX idx_invoice (invoice_id),
+ INDEX idx_gateway (gateway, gateway_payment_id),
+ INDEX idx_status (status),
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,
+ FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE SET NULL
+);
+
+-- Payment methods (saved for recurring)
+CREATE TABLE payment_methods (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ workspace_id BIGINT UNSIGNED NOT NULL,
+ user_id BIGINT UNSIGNED NOT NULL,
+
+ -- Gateway
+ gateway VARCHAR(50) NOT NULL,
+ gateway_payment_method_id VARCHAR(255) NOT NULL,
+ gateway_customer_id VARCHAR(255) NOT NULL,
+
+ -- Type
+ type VARCHAR(50) NOT NULL, -- card, bank_account, crypto_wallet
+
+ -- Card details (for display)
+ brand VARCHAR(50) NULL,
+ last_four VARCHAR(4) NULL,
+ exp_month TINYINT UNSIGNED NULL,
+ exp_year SMALLINT UNSIGNED NULL,
+
+ -- Status
+ is_default BOOLEAN DEFAULT FALSE,
+ is_active BOOLEAN DEFAULT TRUE,
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ UNIQUE KEY unique_gateway_method (gateway, gateway_payment_method_id),
+ INDEX idx_workspace (workspace_id),
+ INDEX idx_user (user_id),
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+-- Subscriptions (recurring billing state)
+CREATE TABLE subscriptions (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ workspace_id BIGINT UNSIGNED NOT NULL,
+ workspace_package_id BIGINT UNSIGNED NOT NULL,
+
+ -- Gateway subscription
+ gateway VARCHAR(50) NOT NULL,
+ gateway_subscription_id VARCHAR(255) NOT NULL,
+ gateway_customer_id VARCHAR(255) NOT NULL,
+ gateway_price_id VARCHAR(255) NULL,
+
+ -- Status
+ status ENUM('active', 'trialing', 'past_due', 'paused', 'cancelled', 'incomplete') DEFAULT 'active',
+
+ -- Billing cycle
+ current_period_start TIMESTAMP NOT NULL,
+ current_period_end TIMESTAMP NOT NULL,
+
+ -- Trial
+ trial_ends_at TIMESTAMP NULL,
+
+ -- Cancellation
+ cancel_at_period_end BOOLEAN DEFAULT FALSE,
+ cancelled_at TIMESTAMP NULL,
+ ended_at TIMESTAMP NULL,
+
+ metadata JSON NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ UNIQUE KEY unique_gateway_sub (gateway, gateway_subscription_id),
+ INDEX idx_workspace (workspace_id),
+ INDEX idx_status (status),
+ INDEX idx_period_end (current_period_end),
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,
+ FOREIGN KEY (workspace_package_id) REFERENCES entitlement_workspace_packages(id) ON DELETE CASCADE
+);
+
+-- Coupons (discount codes)
+CREATE TABLE coupons (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+
+ code VARCHAR(50) UNIQUE NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ description TEXT NULL,
+
+ -- Discount type
+ type ENUM('percentage', 'fixed_amount') NOT NULL,
+ value DECIMAL(10, 2) NOT NULL, -- 0.20 for 20%, or 500 for £5
+
+ -- Restrictions
+ min_amount DECIMAL(10, 2) NULL, -- Minimum order to apply
+ max_discount DECIMAL(10, 2) NULL, -- Cap for percentage discounts
+
+ -- Applicability
+ applies_to ENUM('all', 'packages', 'addons') DEFAULT 'all',
+ package_ids JSON NULL, -- Specific packages if applies_to = 'packages'
+
+ -- Limits
+ max_uses INT UNSIGNED NULL, -- Total uses allowed
+ max_uses_per_workspace INT UNSIGNED DEFAULT 1,
+ used_count INT UNSIGNED DEFAULT 0,
+
+ -- Duration
+ duration ENUM('once', 'repeating', 'forever') DEFAULT 'once',
+ duration_months INT UNSIGNED NULL, -- For 'repeating'
+
+ -- Validity
+ valid_from TIMESTAMP NULL,
+ valid_until TIMESTAMP NULL,
+ is_active BOOLEAN DEFAULT TRUE,
+
+ -- Stripe sync
+ stripe_coupon_id VARCHAR(255) NULL,
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ INDEX idx_code (code),
+ INDEX idx_active (is_active)
+);
+
+-- Coupon usage tracking
+CREATE TABLE coupon_usages (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ coupon_id BIGINT UNSIGNED NOT NULL,
+ workspace_id BIGINT UNSIGNED NOT NULL,
+ order_id BIGINT UNSIGNED NOT NULL,
+
+ discount_amount DECIMAL(10, 2) NOT NULL,
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ INDEX idx_coupon (coupon_id),
+ INDEX idx_workspace (workspace_id),
+ FOREIGN KEY (coupon_id) REFERENCES coupons(id) ON DELETE CASCADE,
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,
+ FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
+);
+
+-- Tax rates
+CREATE TABLE tax_rates (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+
+ country_code VARCHAR(2) NOT NULL,
+ state_code VARCHAR(10) NULL,
+
+ name VARCHAR(100) NOT NULL, -- "UK VAT", "California Sales Tax"
+ type ENUM('vat', 'sales_tax', 'gst') NOT NULL,
+ rate DECIMAL(5, 2) NOT NULL, -- 20.00 for 20%
+
+ -- Digital services special rules
+ is_digital_services BOOLEAN DEFAULT TRUE,
+
+ -- Validity
+ effective_from DATE NOT NULL,
+ effective_until DATE NULL,
+ is_active BOOLEAN DEFAULT TRUE,
+
+ -- Stripe sync
+ stripe_tax_rate_id VARCHAR(255) NULL,
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ INDEX idx_country (country_code),
+ INDEX idx_active (is_active)
+);
+
+-- Refunds
+CREATE TABLE refunds (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ payment_id BIGINT UNSIGNED NOT NULL,
+
+ -- Gateway
+ gateway_refund_id VARCHAR(255) NULL,
+
+ -- Amount
+ amount DECIMAL(10, 2) NOT NULL,
+ currency VARCHAR(3) NOT NULL,
+
+ -- Status
+ status ENUM('pending', 'succeeded', 'failed', 'cancelled') DEFAULT 'pending',
+
+ reason ENUM('duplicate', 'fraudulent', 'requested_by_customer', 'other') NULL,
+ notes TEXT NULL,
+
+ -- Who initiated
+ initiated_by BIGINT UNSIGNED NULL, -- User ID (admin)
+
+ gateway_response JSON NULL,
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ INDEX idx_payment (payment_id),
+ FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE
+);
+```
+
+### Extend Existing Tables
+
+```sql
+-- Add order reference to workspace packages
+ALTER TABLE entitlement_workspace_packages
+ADD COLUMN order_id BIGINT UNSIGNED NULL AFTER workspace_id,
+ADD COLUMN subscription_id BIGINT UNSIGNED NULL AFTER order_id,
+ADD FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE SET NULL,
+ADD FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL;
+
+-- Add pricing to packages
+ALTER TABLE entitlement_packages
+ADD COLUMN monthly_price DECIMAL(10, 2) NULL AFTER is_public,
+ADD COLUMN yearly_price DECIMAL(10, 2) NULL AFTER monthly_price,
+ADD COLUMN setup_fee DECIMAL(10, 2) DEFAULT 0 AFTER yearly_price,
+ADD COLUMN trial_days INT UNSIGNED DEFAULT 0 AFTER setup_fee,
+ADD COLUMN stripe_monthly_price_id VARCHAR(255) NULL,
+ADD COLUMN stripe_yearly_price_id VARCHAR(255) NULL;
+
+-- Add Stripe customer ID to workspaces
+ALTER TABLE workspaces
+ADD COLUMN stripe_customer_id VARCHAR(255) NULL,
+ADD COLUMN billing_email VARCHAR(255) NULL,
+ADD COLUMN billing_name VARCHAR(255) NULL,
+ADD COLUMN billing_address JSON NULL,
+ADD COLUMN tax_id VARCHAR(50) NULL,
+ADD INDEX idx_stripe_customer (stripe_customer_id);
+```
+
+---
+
+## Service Architecture
+
+### CommerceService
+```php
+yearly_price
+ : $package->monthly_price;
+
+ $subtotal = $price;
+ $discount = $coupon ? $this->calculateDiscount($coupon, $subtotal) : 0;
+ $tax = $this->calculateTax($workspace, $subtotal - $discount);
+ $total = $subtotal - $discount + $tax;
+
+ // Create order
+ $order = Order::create([
+ 'workspace_id' => $workspace->id,
+ 'user_id' => auth()->id(),
+ 'order_number' => $this->generateOrderNumber(),
+ 'type' => 'new',
+ 'currency' => 'GBP',
+ 'subtotal' => $subtotal,
+ 'discount_amount' => $discount,
+ 'tax_amount' => $tax,
+ 'total' => $total,
+ 'coupon_id' => $coupon?->id,
+ 'billing_address' => $workspace->billing_address,
+ ]);
+
+ // Create order item
+ OrderItem::create([
+ 'order_id' => $order->id,
+ 'item_type' => 'package',
+ 'item_id' => $package->id,
+ 'item_code' => $package->code,
+ 'description' => "{$package->name} ({$billingCycle})",
+ 'billing_cycle' => $billingCycle,
+ 'unit_price' => $price,
+ 'line_total' => $price,
+ ]);
+
+ // Record coupon usage
+ if ($coupon) {
+ $this->recordCouponUsage($coupon, $workspace, $order, $discount);
+ }
+
+ return $order;
+ }
+
+ /**
+ * Process checkout with Stripe
+ */
+ public function checkout(Order $order, string $successUrl, string $cancelUrl): string
+ {
+ // Create or get Stripe customer
+ $customer = $this->stripe->getOrCreateCustomer($order->workspace);
+
+ // Create Stripe Checkout Session
+ $session = $this->stripe->createCheckoutSession(
+ customer: $customer,
+ order: $order,
+ successUrl: $successUrl,
+ cancelUrl: $cancelUrl
+ );
+
+ // Store session ID
+ $order->update([
+ 'gateway_order_id' => $session->id,
+ 'payment_gateway' => 'stripe',
+ ]);
+
+ return $session->url;
+ }
+
+ /**
+ * Handle successful payment (called from webhook)
+ */
+ public function handlePaymentSuccess(Order $order, Payment $payment): void
+ {
+ DB::transaction(function () use ($order, $payment) {
+ // Update order status
+ $order->update([
+ 'status' => 'paid',
+ 'paid_at' => now(),
+ ]);
+
+ // Create invoice
+ $invoice = $this->invoices->createFromOrder($order);
+ $invoice->markAsPaid($payment);
+
+ // Provision entitlements
+ foreach ($order->items as $item) {
+ if ($item->item_type === 'package') {
+ $this->entitlements->provisionPackage(
+ workspace: $order->workspace,
+ packageCode: $item->item_code,
+ options: [
+ 'order_id' => $order->id,
+ 'billing_cycle' => $item->billing_cycle,
+ ]
+ );
+ } elseif ($item->item_type === 'boost') {
+ $this->entitlements->provisionBoost(
+ workspace: $order->workspace,
+ featureCode: $item->item_code,
+ options: ['order_id' => $order->id]
+ );
+ }
+ }
+
+ // Send confirmation email
+ $order->workspace->owner->notify(new OrderConfirmation($order, $invoice));
+ });
+ }
+
+ /**
+ * Handle subscription renewal (called from webhook)
+ */
+ public function handleRenewal(Subscription $subscription, Invoice $stripeInvoice): void
+ {
+ // Create Host Hub invoice
+ $invoice = $this->invoices->createFromStripeInvoice($stripeInvoice);
+
+ // Update workspace package dates
+ $subscription->workspacePackage->update([
+ 'billing_cycle_anchor' => $subscription->current_period_start,
+ 'expires_at' => $subscription->current_period_end,
+ ]);
+
+ // Reset cycle-bound boosts
+ $this->entitlements->expireCycleBoundBoosts($subscription->workspace);
+ }
+}
+```
+
+### StripeService
+```php
+stripe = new StripeClient(config('services.stripe.secret'));
+ }
+
+ public function getOrCreateCustomer(Workspace $workspace): \Stripe\Customer
+ {
+ if ($workspace->stripe_customer_id) {
+ return $this->stripe->customers->retrieve($workspace->stripe_customer_id);
+ }
+
+ $customer = $this->stripe->customers->create([
+ 'email' => $workspace->billing_email ?? $workspace->owner->email,
+ 'name' => $workspace->billing_name ?? $workspace->name,
+ 'metadata' => [
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $workspace->owner_id,
+ ],
+ ]);
+
+ $workspace->update(['stripe_customer_id' => $customer->id]);
+
+ return $customer;
+ }
+
+ public function createCheckoutSession(
+ \Stripe\Customer $customer,
+ Order $order,
+ string $successUrl,
+ string $cancelUrl
+ ): \Stripe\Checkout\Session {
+ $lineItems = $order->items->map(function ($item) {
+ $package = Package::find($item->item_id);
+ $priceId = $item->billing_cycle === 'yearly'
+ ? $package->stripe_yearly_price_id
+ : $package->stripe_monthly_price_id;
+
+ return [
+ 'price' => $priceId,
+ 'quantity' => $item->quantity,
+ ];
+ })->all();
+
+ $params = [
+ 'customer' => $customer->id,
+ 'mode' => 'subscription',
+ 'line_items' => $lineItems,
+ 'success_url' => $successUrl . '?session_id={CHECKOUT_SESSION_ID}',
+ 'cancel_url' => $cancelUrl,
+ 'metadata' => [
+ 'order_id' => $order->id,
+ 'workspace_id' => $order->workspace_id,
+ ],
+ 'subscription_data' => [
+ 'metadata' => [
+ 'order_id' => $order->id,
+ 'workspace_id' => $order->workspace_id,
+ ],
+ ],
+ ];
+
+ // Add coupon if present
+ if ($order->coupon && $order->coupon->stripe_coupon_id) {
+ $params['discounts'] = [
+ ['coupon' => $order->coupon->stripe_coupon_id],
+ ];
+ }
+
+ // Add trial if package has it
+ $package = $order->items->first()?->package;
+ if ($package?->trial_days > 0) {
+ $params['subscription_data']['trial_period_days'] = $package->trial_days;
+ }
+
+ return $this->stripe->checkout->sessions->create($params);
+ }
+
+ public function createBillingPortalSession(Workspace $workspace, string $returnUrl): string
+ {
+ $session = $this->stripe->billingPortal->sessions->create([
+ 'customer' => $workspace->stripe_customer_id,
+ 'return_url' => $returnUrl,
+ ]);
+
+ return $session->url;
+ }
+}
+```
+
+---
+
+## API Routes
+
+```php
+// routes/web.php - Public checkout
+Route::prefix('checkout')->group(function () {
+ Route::get('/', [CheckoutController::class, 'show'])->name('checkout');
+ Route::post('/create-order', [CheckoutController::class, 'createOrder']);
+ Route::get('/success', [CheckoutController::class, 'success'])->name('checkout.success');
+ Route::get('/cancel', [CheckoutController::class, 'cancel'])->name('checkout.cancel');
+});
+
+// routes/web.php - Billing portal (authenticated)
+Route::prefix('hub/billing')->middleware(['auth', 'verified'])->group(function () {
+ Route::get('/', [BillingController::class, 'index'])->name('billing.index');
+ Route::get('/invoices', [BillingController::class, 'invoices'])->name('billing.invoices');
+ Route::get('/invoices/{invoice}/pdf', [BillingController::class, 'downloadInvoice']);
+ Route::get('/payment-methods', [BillingController::class, 'paymentMethods']);
+ Route::post('/payment-methods', [BillingController::class, 'addPaymentMethod']);
+ Route::delete('/payment-methods/{id}', [BillingController::class, 'removePaymentMethod']);
+ Route::post('/portal', [BillingController::class, 'stripePortal']);
+ Route::post('/change-plan', [BillingController::class, 'changePlan']);
+ Route::post('/cancel', [BillingController::class, 'cancelSubscription']);
+});
+
+// routes/api.php - Webhooks
+Route::prefix('webhooks')->group(function () {
+ Route::post('/stripe', [StripeWebhookController::class, 'handle']);
+ Route::post('/btcpay', [BTCPayWebhookController::class, 'handle']);
+});
+
+// routes/api.php - Internal API (for MCP agents)
+Route::prefix('v1/commerce')->middleware(['auth:sanctum'])->group(function () {
+ Route::get('/orders', [CommerceApiController::class, 'listOrders']);
+ Route::get('/invoices', [CommerceApiController::class, 'listInvoices']);
+ Route::get('/usage', [CommerceApiController::class, 'getUsage']);
+ Route::post('/upgrade', [CommerceApiController::class, 'upgradePlan']);
+});
+```
+
+---
+
+## Webhook Handler
+
+```php
+getContent();
+ $signature = $request->header('Stripe-Signature');
+
+ try {
+ $event = Webhook::constructEvent(
+ $payload,
+ $signature,
+ config('services.stripe.webhook_secret')
+ );
+ } catch (\Exception $e) {
+ return response()->json(['error' => 'Invalid signature'], 400);
+ }
+
+ $method = 'handle' . Str::studly(str_replace('.', '_', $event->type));
+
+ if (method_exists($this, $method)) {
+ return $this->$method($event);
+ }
+
+ return response()->json(['received' => true]);
+ }
+
+ protected function handleCheckoutSessionCompleted($event)
+ {
+ $session = $event->data->object;
+ $orderId = $session->metadata->order_id ?? null;
+
+ if (!$orderId) {
+ return response()->json(['error' => 'No order ID'], 400);
+ }
+
+ $order = Order::find($orderId);
+ if (!$order) {
+ return response()->json(['error' => 'Order not found'], 404);
+ }
+
+ // Create payment record
+ $payment = Payment::create([
+ 'workspace_id' => $order->workspace_id,
+ 'gateway' => 'stripe',
+ 'gateway_payment_id' => $session->payment_intent,
+ 'gateway_customer_id' => $session->customer,
+ 'currency' => strtoupper($session->currency),
+ 'amount' => $session->amount_total / 100,
+ 'status' => 'succeeded',
+ 'payment_method_type' => 'card',
+ ]);
+
+ // Create subscription record
+ if ($session->subscription) {
+ $stripeSubscription = $this->stripe->subscriptions->retrieve($session->subscription);
+
+ // ... create local Subscription model
+ }
+
+ // Trigger order fulfillment
+ app(CommerceService::class)->handlePaymentSuccess($order, $payment);
+
+ return response()->json(['received' => true]);
+ }
+
+ protected function handleInvoicePaid($event)
+ {
+ $stripeInvoice = $event->data->object;
+ $subscriptionId = $stripeInvoice->subscription;
+
+ $subscription = Subscription::where('gateway_subscription_id', $subscriptionId)->first();
+ if (!$subscription) {
+ return response()->json(['received' => true]); // Not our subscription
+ }
+
+ // Handle renewal
+ app(CommerceService::class)->handleRenewal($subscription, $stripeInvoice);
+
+ return response()->json(['received' => true]);
+ }
+
+ protected function handleInvoicePaymentFailed($event)
+ {
+ $stripeInvoice = $event->data->object;
+ $subscriptionId = $stripeInvoice->subscription;
+
+ $subscription = Subscription::where('gateway_subscription_id', $subscriptionId)->first();
+ if (!$subscription) {
+ return response()->json(['received' => true]);
+ }
+
+ // Update subscription status
+ $subscription->update(['status' => 'past_due']);
+
+ // Send dunning email
+ $subscription->workspace->owner->notify(new PaymentFailed($subscription));
+
+ // Schedule suspension if payment not recovered
+ // (handled by separate cron job checking past_due subscriptions)
+
+ return response()->json(['received' => true]);
+ }
+
+ protected function handleCustomerSubscriptionDeleted($event)
+ {
+ $stripeSubscription = $event->data->object;
+
+ $subscription = Subscription::where('gateway_subscription_id', $stripeSubscription->id)->first();
+ if (!$subscription) {
+ return response()->json(['received' => true]);
+ }
+
+ // Cancel subscription
+ $subscription->update([
+ 'status' => 'cancelled',
+ 'ended_at' => now(),
+ ]);
+
+ // Cancel workspace package
+ app(EntitlementService::class)->cancelPackage(
+ $subscription->workspacePackage,
+ ['source' => 'stripe']
+ );
+
+ return response()->json(['received' => true]);
+ }
+}
+```
+
+---
+
+## MCP Agent Tools
+
+```php
+// app/Mcp/Tools/Commerce/GetBillingStatus.php
+class GetBillingStatus extends Tool
+{
+ public function name(): string
+ {
+ return 'get_billing_status';
+ }
+
+ public function description(): string
+ {
+ return 'Get billing status for a workspace including current plan, usage, and next billing date.';
+ }
+
+ public function execute(array $input): array
+ {
+ $workspace = Workspace::findOrFail($input['workspace_id']);
+ $entitlements = app(EntitlementService::class);
+
+ $activePackages = $entitlements->getActivePackages($workspace);
+ $subscription = $workspace->subscription;
+
+ return [
+ 'workspace' => $workspace->name,
+ 'plan' => $activePackages->first()?->package->name ?? 'None',
+ 'status' => $subscription?->status ?? 'no_subscription',
+ 'billing_cycle' => $subscription?->current_period_end?->format('Y-m-d'),
+ 'next_billing' => $subscription?->current_period_end?->format('Y-m-d'),
+ 'usage_summary' => $entitlements->getUsageSummary($workspace),
+ 'invoices_count' => $workspace->invoices()->count(),
+ 'outstanding_balance' => $workspace->invoices()->where('status', 'sent')->sum('amount_due'),
+ ];
+ }
+}
+
+// app/Mcp/Tools/Commerce/UpgradePlan.php
+class UpgradePlan extends Tool
+{
+ public function name(): string
+ {
+ return 'upgrade_plan';
+ }
+
+ public function description(): string
+ {
+ return 'Upgrade a workspace to a higher plan. Returns checkout URL for payment.';
+ }
+
+ public function execute(array $input): array
+ {
+ $workspace = Workspace::findOrFail($input['workspace_id']);
+ $package = Package::where('code', $input['package_code'])->firstOrFail();
+ $billingCycle = $input['billing_cycle'] ?? 'monthly';
+
+ $commerce = app(CommerceService::class);
+ $order = $commerce->createOrder($workspace, $package, $billingCycle);
+ $checkoutUrl = $commerce->checkout(
+ $order,
+ route('checkout.success'),
+ route('checkout.cancel')
+ );
+
+ return [
+ 'success' => true,
+ 'order_id' => $order->id,
+ 'order_number' => $order->order_number,
+ 'checkout_url' => $checkoutUrl,
+ 'message' => "Order created. Direct user to checkout URL to complete payment.",
+ ];
+ }
+}
+```
+
+---
+
+## Migration Strategy
+
+### Phase 1: Foundation (Week 1-2)
+1. Create database migrations for commerce tables
+2. Build Order, Invoice, Payment, Subscription models
+3. Implement StripeService with checkout
+4. Create webhook handler for Stripe events
+5. Wire up to existing EntitlementService
+
+### Phase 2: Checkout Flow (Week 2-3)
+1. Build checkout Livewire component
+2. Integrate with pricing page
+3. Add coupon/discount handling
+4. Create success/failure flows
+5. Send confirmation emails
+
+### Phase 3: Billing Portal (Week 3-4)
+1. Build billing dashboard
+2. Invoice list with PDF downloads
+3. Payment method management
+4. Plan change flow (upgrade/downgrade)
+5. Cancel subscription flow
+
+### Phase 4: Advanced Features (Week 4-5)
+1. Tax calculation by region
+2. Dunning/retry logic for failed payments
+3. Usage-based overage billing
+4. Proration for mid-cycle changes
+5. Refund processing
+
+### Phase 5: Blesta Migration (Week 5-6)
+1. Import existing customer data
+2. Sync subscriptions from Blesta
+3. Run both systems in parallel
+4. Gradual cutover (new customers first)
+5. Deprecate Blesta webhooks
+
+---
+
+## Configuration
+
+```php
+// config/commerce.php
+return [
+ 'currency' => env('COMMERCE_CURRENCY', 'GBP'),
+
+ 'stripe' => [
+ 'key' => env('STRIPE_KEY'),
+ 'secret' => env('STRIPE_SECRET'),
+ 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
+ ],
+
+ 'btcpay' => [
+ 'url' => env('BTCPAY_URL'),
+ 'store_id' => env('BTCPAY_STORE_ID'),
+ 'api_key' => env('BTCPAY_API_KEY'),
+ ],
+
+ 'billing' => [
+ 'invoice_prefix' => env('INVOICE_PREFIX', 'INV-'),
+ 'invoice_start_number' => env('INVOICE_START_NUMBER', 1000),
+ 'tax_enabled' => env('TAX_ENABLED', true),
+ 'default_tax_rate' => env('DEFAULT_TAX_RATE', 20), // UK VAT
+ ],
+
+ 'dunning' => [
+ 'retry_days' => [3, 5, 7], // Retry payment on these days after failure
+ 'suspend_after_days' => 14, // Suspend service after X days unpaid
+ 'cancel_after_days' => 30, // Cancel service after X days suspended
+ ],
+
+ 'trials' => [
+ 'default_days' => 14,
+ 'require_payment_method' => false,
+ ],
+];
+```
+
+---
+
+## Summary
+
+Native commerce for Host Hub will:
+1. **Eliminate Blesta dependency** - No external billing system
+2. **Leverage existing entitlements** - Works with current Package/Feature system
+3. **Enable tighter integration** - Direct connection to all Host Hub services
+4. **Support MCP agents** - Commerce tools for automated billing operations
+5. **Reduce complexity** - Single Laravel codebase for everything
+
+The architecture builds on proven patterns from both Blesta and MixPost Enterprise, adapted for Host Hub's specific needs.
\ No newline at end of file
diff --git a/changelog/2026/jan/code-review.md b/changelog/2026/jan/code-review.md
new file mode 100644
index 0000000..0f8f061
--- /dev/null
+++ b/changelog/2026/jan/code-review.md
@@ -0,0 +1,155 @@
+# Commerce Module Review
+
+**Updated:** 2026-01-21 - Rate limiting, idempotency keys, and expired order cleanup implemented
+
+## Overview
+
+The Commerce module is a comprehensive billing and subscription management system that handles:
+- Order creation and checkout flows (single purchases and subscriptions)
+- Multi-gateway payment processing (BTCPay primary, Stripe secondary)
+- Subscription lifecycle management (create, pause, cancel, renew)
+- Dunning system for failed payment recovery (retry, pause, suspend, cancel stages)
+- Invoice generation and PDF creation
+- Coupon/discount management
+- Tax calculation (UK VAT, EU OSS, US state taxes, Australian GST)
+- Refund processing
+- Permission matrix for multi-entity hierarchies (M1/M2/M3 model)
+- Webhook handling with deduplication and audit logging
+
+The module follows the modular monolith architecture with Boot.php registering services, routes, commands, and Livewire components.
+
+## Production Readiness Score: 94/100 (was 92/100 - checkout protection and cleanup added 2026-01-21)
+
+The module is well-architected with solid fundamentals. All critical issues fixed in Wave 4.
+
+## Critical Issues (Must Fix)
+
+- [x] **Webhook signature verification bypassed in tests** - VERIFIED: Production implementation properly calls `$this->gateway->verifyWebhookSignature()` and returns 401 on failure. Already secure.
+
+- [x] **Missing database indexes on webhook_events table** - VERIFIED: Migration already has `$table->unique(['gateway', 'event_id'])` index. Already correct.
+
+- [x] **Order.workspace scope uses wrong field** - FIXED: `scopeForWorkspace` now uses `orderable_type` and `orderable_id` for polymorphic relations.
+
+- [x] **Invoice workspace relationship missing for User orderables** - FIXED: Added `getWorkspaceIdAttribute()` accessor and `getResolvedWorkspace()` method to Order model. Webhook controllers updated to use these.
+
+- [x] **Missing transaction isolation in webhook handlers** - FIXED: Both `BTCPayWebhookController` and `StripeWebhookController` now wrap processing in `DB::transaction()`.
+
+- [x] **BTCPay gateway chargePaymentMethod** - FIXED: `CommerceService::retryInvoicePayment()` now correctly checks `$payment->status === 'succeeded'` and handles BTCPay pending payments appropriately.
+
+## Recommended Improvements
+
+- [x] **Add rate limiting per customer on checkout** - DONE: `CheckoutRateLimiter` service implemented with sliding window rate limiting. Uses workspace/user/IP hierarchy for throttle keys. 5 attempts per 15 minutes.
+
+- [x] **Add idempotency keys to order creation** - DONE: `idempotency_key` field added to orders table. Migration `2026_01_21_120000_add_idempotency_key_to_orders_table.php` created. Used in CommerceService and CheckoutPage.
+
+- [x] **Extract CouponValidationResult to dedicated file** - DONE: Extracted to `Data/CouponValidationResult.php`.
+
+- [x] **Add scheduled job for expired order cleanup** - DONE: `CleanupExpiredOrders` command (`commerce:cleanup-orders`) created. Cancels pending orders older than configurable TTL. Supports `--dry-run` and `--ttl` options. Chunked processing with logging.
+
+- [ ] **Add observability/metrics** - Consider adding metrics for: payment success rate, dunning recovery rate, webhook processing time, gateway errors.
+
+- [ ] **Standardise error responses in API controllers** - `CommerceController` methods should return consistent JSON error structures.
+
+- [x] **Add model factories for Commerce models** - DONE: Created OrderFactory, InvoiceFactory, SubscriptionFactory, CouponFactory, and PaymentFactory in `Database/Factories/`.
+
+- [ ] **Implement webhook retry mechanism** - Failed webhooks (logged with status `failed`) have no automatic retry. Consider dead-letter queue or scheduled retry.
+
+- [ ] **Add cancellation feedback collection** - Store cancellation reasons/feedback when users cancel subscriptions for product insights.
+
+- [ ] **Tax ID validation is config-gated but implementation unclear** - Config has `validate_tax_ids_api` but actual HMRC/VIES API integration not visible in TaxService.
+
+## Missing Features (Future)
+
+- [ ] **Usage-based billing** - Config has `features.usage_billing => false` as placeholder for metered billing.
+
+- [ ] **Multi-currency support** - Currently defaults to GBP. Config exists but full multi-currency handling not implemented.
+
+- [ ] **Credit notes** - No model for credit notes when issuing partial refunds or adjustments.
+
+- [ ] **Payment method management UI** - `PaymentMethods` Livewire component exists but no visible add/remove card flow.
+
+- [ ] **Subscription upgrade preview API** - `previewUpgrade` endpoint exists but no corresponding frontend to show prorated amounts.
+
+- [ ] **Bulk coupon generation** - CouponService can generate codes but no bulk creation for marketing campaigns.
+
+- [ ] **Affiliate/referral tracking** - `RewardAgentReferralOnSubscription` listener exists but full referral system incomplete.
+
+- [ ] **Warehouse/inventory system** - Models exist (`Warehouse`, `Inventory`, `InventoryMovement`) but services not implemented. May be for physical goods expansion.
+
+## Test Coverage Assessment
+
+**Current Coverage:**
+- `CheckoutFlowTest.php` - Basic checkout flow testing
+- `CompoundSkuTest.php` - SKU parsing and building
+- `ContentOverrideServiceTest.php` - Content override system
+- `CouponServiceTest.php` - Comprehensive coupon validation and calculation
+- `DunningServiceTest.php` - Thorough dunning lifecycle testing
+- `ProcessSubscriptionRenewalTest.php` - Renewal job testing
+- `RefundServiceTest.php` - Refund flow with mocked gateway
+- `SubscriptionServiceTest.php` - Subscription CRUD operations
+- `TaxServiceTest.php` - Tax calculation scenarios
+- `WebhookTest.php` - Extensive webhook handling for both gateways
+
+**Coverage Gaps:**
+- [ ] No unit tests (Unit/ directory empty)
+- [ ] No tests for `PermissionMatrixService` despite complex hierarchy logic
+- [ ] No tests for `InvoiceService` PDF generation
+- [ ] No tests for `ProductCatalogService` and `WarehouseService`
+- [ ] No integration tests for actual gateway API calls
+- [ ] No tests for admin Livewire components
+- [ ] No tests for web checkout Livewire components
+- [ ] Missing edge case tests for concurrent webhook delivery
+- [ ] No load/stress testing for high-volume scenarios
+
+**Test Quality:**
+- Tests use Pest with good describe/it structure
+- Proper use of RefreshDatabase trait
+- Good mocking of external dependencies
+- Notification and Event faking used appropriately
+
+## Security Concerns
+
+1. **Webhook signature verification** - Properly implemented using HMAC-SHA256 for BTCPay and Stripe's built-in verification. Signatures are masked in logs.
+
+2. **No explicit input validation on order metadata** - The `metadata` array passed to orders is stored directly. Consider schema validation to prevent injection of malicious data.
+
+3. **Invoice number generation uses sequential IDs** - Pattern `INV-{year}-{sequence}` is predictable. Not a vulnerability but consider if invoice numbers should be harder to enumerate.
+
+4. **PDF generation with user data** - Invoice PDFs include user-provided billing names/addresses. Ensure DomPDF or Snappy has XSS protections enabled.
+
+5. **Coupon code brute-forcing** - No rate limiting on coupon validation. Attackers could enumerate valid codes.
+
+6. **Permission matrix training mode** - `training_mode` config allows undefined permissions to prompt for approval. Ensure this is NEVER enabled in production (`COMMERCE_MATRIX_TRAINING=false`).
+
+7. **API authentication** - Provisioning API uses `commerce.api` middleware but implementation not visible. Verify bearer token validation is secure.
+
+8. **No audit logging for admin actions** - Coupon creation, refund processing, subscription cancellation by admins should be logged for compliance.
+
+## Notes
+
+### Architecture Observations
+- Clean separation between gateway-specific logic (PaymentGatewayContract implementations) and business logic (Services)
+- Good use of Laravel events for subscription lifecycle (SubscriptionCreated, SubscriptionUpdated, etc.)
+- Dunning service follows best practices with configurable retry intervals and grace periods
+- Webhook logging with deduplication prevents replay attacks and aids debugging
+
+### Configuration
+- Comprehensive config file covering all aspects of billing
+- Environment variable support for sensitive values (API keys, secrets)
+- Feature flags allow gradual rollout of capabilities
+
+### Code Quality
+- Consistent use of type hints and return types
+- Proper use of database transactions for multi-step operations
+- Good error handling in critical paths
+- UK English spelling used consistently (colour, organisation, centre)
+
+### Dependencies
+- `barryvdh/laravel-dompdf` for PDF generation
+- Stripe PHP SDK (implied by StripeGateway)
+- BTCPay Server API client (custom implementation)
+
+### Migration State
+- 11 migrations covering all models
+- Latest migrations dated 2026-01-21 (pause_count and webhook_events)
+- No down() methods visible - verify rollback capability
diff --git a/composer.json b/composer.json
index 4ca3e0f..27abc0e 100644
--- a/composer.json
+++ b/composer.json
@@ -1,44 +1,51 @@
{
- "name": "host-uk/core-commerce",
- "description": "Commerce, subscriptions and payments for Laravel",
- "keywords": ["laravel", "commerce", "stripe", "subscriptions", "payments"],
- "license": "EUPL-1.2",
- "require": {
- "php": "^8.2",
- "host-uk/core": "dev-main"
- },
- "require-dev": {
- "laravel/pint": "^1.18",
- "orchestra/testbench": "^9.0|^10.0",
- "pestphp/pest": "^3.0"
- },
- "autoload": {
- "psr-4": {
- "Core\\Commerce\\": ""
- }
- },
- "autoload-dev": {
- "psr-4": {
- "Core\\Commerce\\Tests\\": "Tests/"
- }
- },
- "extra": {
- "laravel": {
- "providers": [
- "Core\\Commerce\\Boot"
- ]
- }
- },
- "scripts": {
- "lint": "pint",
- "test": "pest"
- },
- "config": {
- "sort-packages": true,
- "allow-plugins": {
- "pestphp/pest-plugin": true
- }
- },
- "minimum-stability": "dev",
- "prefer-stable": true
+ "name": "host-uk/core-commerce",
+ "description": "Commerce, subscriptions and payments for Laravel",
+ "keywords": [
+ "laravel",
+ "commerce",
+ "stripe",
+ "subscriptions",
+ "payments"
+ ],
+ "license": "EUPL-1.2",
+ "require": {
+ "php": "^8.2",
+ "host-uk/core": "dev-main"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.18",
+ "orchestra/testbench": "^9.0|^10.0",
+ "pestphp/pest": "^3.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Core\\Mod\\Commerce\\": "",
+ "Core\\Service\\Commerce\\": "Service/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Core\\Mod\\Commerce\\Tests\\": "Tests/"
+ }
+ },
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Core\\Mod\\Commerce\\Boot"
+ ]
+ }
+ },
+ "scripts": {
+ "lint": "pint",
+ "test": "pest"
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "pestphp/pest-plugin": true
+ }
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true
}
diff --git a/routes/admin.php b/routes/admin.php
index 66a7edf..760869d 100644
--- a/routes/admin.php
+++ b/routes/admin.php
@@ -10,25 +10,25 @@ use Illuminate\Support\Facades\Route;
// Billing (user-facing hub pages)
Route::prefix('hub/billing')->name('hub.billing.')->group(function () {
- Route::get('/', \Core\Commerce\View\Modal\Web\Dashboard::class)->name('index');
- Route::get('/invoices', \Core\Commerce\View\Modal\Web\Invoices::class)->name('invoices');
- Route::get('/invoices/{invoice}/pdf', [\Core\Commerce\Controllers\InvoiceController::class, 'pdf'])->name('invoices.pdf');
- Route::get('/invoices/{invoice}/view', [\Core\Commerce\Controllers\InvoiceController::class, 'view'])->name('invoices.view');
- Route::get('/payment-methods', \Core\Commerce\View\Modal\Web\PaymentMethods::class)->name('payment-methods');
- Route::get('/subscription', \Core\Commerce\View\Modal\Web\Subscription::class)->name('subscription');
- Route::get('/change-plan', \Core\Commerce\View\Modal\Web\ChangePlan::class)->name('change-plan');
- Route::get('/affiliates', \Core\Commerce\View\Modal\Web\ReferralDashboard::class)->name('affiliates');
+ Route::get('/', \Core\Mod\Commerce\View\Modal\Web\Dashboard::class)->name('index');
+ Route::get('/invoices', \Core\Mod\Commerce\View\Modal\Web\Invoices::class)->name('invoices');
+ Route::get('/invoices/{invoice}/pdf', [\Core\Mod\Commerce\Controllers\InvoiceController::class, 'pdf'])->name('invoices.pdf');
+ Route::get('/invoices/{invoice}/view', [\Core\Mod\Commerce\Controllers\InvoiceController::class, 'view'])->name('invoices.view');
+ Route::get('/payment-methods', \Core\Mod\Commerce\View\Modal\Web\PaymentMethods::class)->name('payment-methods');
+ Route::get('/subscription', \Core\Mod\Commerce\View\Modal\Web\Subscription::class)->name('subscription');
+ Route::get('/change-plan', \Core\Mod\Commerce\View\Modal\Web\ChangePlan::class)->name('change-plan');
+ Route::get('/affiliates', \Core\Mod\Commerce\View\Modal\Web\ReferralDashboard::class)->name('affiliates');
});
// Commerce management (admin only - Hades tier)
Route::prefix('hub/commerce')->name('hub.commerce.')->group(function () {
- Route::get('/', \Core\Commerce\View\Modal\Admin\Dashboard::class)->name('dashboard');
- Route::get('/orders', \Core\Commerce\View\Modal\Admin\OrderManager::class)->name('orders');
- Route::get('/subscriptions', \Core\Commerce\View\Modal\Admin\SubscriptionManager::class)->name('subscriptions');
- Route::get('/coupons', \Core\Commerce\View\Modal\Admin\CouponManager::class)->name('coupons');
- Route::get('/entities', \Core\Commerce\View\Modal\Admin\EntityManager::class)->name('entities');
- Route::get('/permissions', \Core\Commerce\View\Modal\Admin\PermissionMatrixManager::class)->name('permissions');
- Route::get('/products', \Core\Commerce\View\Modal\Admin\ProductManager::class)->name('products');
- Route::get('/credit-notes', \Core\Commerce\View\Modal\Admin\CreditNoteManager::class)->name('credit-notes');
- Route::get('/referrals', \Core\Commerce\View\Modal\Admin\ReferralManager::class)->name('referrals');
+ Route::get('/', \Core\Mod\Commerce\View\Modal\Admin\Dashboard::class)->name('dashboard');
+ Route::get('/orders', \Core\Mod\Commerce\View\Modal\Admin\OrderManager::class)->name('orders');
+ Route::get('/subscriptions', \Core\Mod\Commerce\View\Modal\Admin\SubscriptionManager::class)->name('subscriptions');
+ Route::get('/coupons', \Core\Mod\Commerce\View\Modal\Admin\CouponManager::class)->name('coupons');
+ Route::get('/entities', \Core\Mod\Commerce\View\Modal\Admin\EntityManager::class)->name('entities');
+ Route::get('/permissions', \Core\Mod\Commerce\View\Modal\Admin\PermissionMatrixManager::class)->name('permissions');
+ Route::get('/products', \Core\Mod\Commerce\View\Modal\Admin\ProductManager::class)->name('products');
+ Route::get('/credit-notes', \Core\Mod\Commerce\View\Modal\Admin\CreditNoteManager::class)->name('credit-notes');
+ Route::get('/referrals', \Core\Mod\Commerce\View\Modal\Admin\ReferralManager::class)->name('referrals');
});
diff --git a/routes/api.php b/routes/api.php
index e98bea4..cc67822 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -3,9 +3,9 @@
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
-use Core\Commerce\Controllers\Api\CommerceController;
-use Core\Commerce\Controllers\Webhooks\BTCPayWebhookController;
-use Core\Commerce\Controllers\Webhooks\StripeWebhookController;
+use Core\Mod\Commerce\Controllers\Api\CommerceController;
+use Core\Mod\Commerce\Controllers\Webhooks\BTCPayWebhookController;
+use Core\Mod\Commerce\Controllers\Webhooks\StripeWebhookController;
/*
|--------------------------------------------------------------------------
diff --git a/routes/web.php b/routes/web.php
index 7c4e59c..d3d1f2b 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-use Core\Commerce\Controllers\MatrixTrainingController;
+use Core\Mod\Commerce\Controllers\MatrixTrainingController;
use Illuminate\Support\Facades\Route;
/*
diff --git a/tests/Feature/CheckoutFlowTest.php b/tests/Feature/CheckoutFlowTest.php
index 4c56686..8b77fc1 100644
--- a/tests/Feature/CheckoutFlowTest.php
+++ b/tests/Feature/CheckoutFlowTest.php
@@ -1,11 +1,11 @@
shouldReceive('refund')->andReturn([
'success' => true,
'refund_id' => 're_test_123',
diff --git a/tests/Feature/SubscriptionServiceTest.php b/tests/Feature/SubscriptionServiceTest.php
index 98c7223..de1a500 100644
--- a/tests/Feature/SubscriptionServiceTest.php
+++ b/tests/Feature/SubscriptionServiceTest.php
@@ -2,10 +2,10 @@
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
-use Core\Commerce\Exceptions\PauseLimitExceededException;
-use Core\Commerce\Models\Subscription;
-use Core\Commerce\Services\ProrationResult;
-use Core\Commerce\Services\SubscriptionService;
+use Core\Mod\Commerce\Exceptions\PauseLimitExceededException;
+use Core\Mod\Commerce\Models\Subscription;
+use Core\Mod\Commerce\Services\ProrationResult;
+use Core\Mod\Commerce\Services\SubscriptionService;
use Core\Mod\Tenant\Models\Feature;
use Core\Mod\Tenant\Models\Package;
use Core\Mod\Tenant\Models\User;
diff --git a/tests/Feature/TaxServiceTest.php b/tests/Feature/TaxServiceTest.php
index 56ecc08..0903a87 100644
--- a/tests/Feature/TaxServiceTest.php
+++ b/tests/Feature/TaxServiceTest.php
@@ -1,7 +1,7 @@