monorepo sepration

This commit is contained in:
Snider 2026-01-27 00:24:22 +00:00
parent aa53f48850
commit a74a02f406
211 changed files with 38322 additions and 616 deletions

View file

@ -1,76 +0,0 @@
APP_NAME="Core PHP App"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost
APP_LOCALE=en_GB
APP_FALLBACK_LOCALE=en_GB
APP_FAKER_LOCALE=en_GB
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=core
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Core PHP Framework
CORE_CACHE_DISCOVERY=true
# CDN Configuration (optional)
CDN_ENABLED=false
CDN_DRIVER=bunny
BUNNYCDN_API_KEY=
BUNNYCDN_STORAGE_ZONE=
BUNNYCDN_PULL_ZONE=
# Flux Pro (optional)
FLUX_LICENSE_KEY=

View file

@ -1,62 +0,0 @@
# Package Workflows
These workflow templates are for **library packages** (host-uk/core, host-uk/core-api, etc.), not application projects.
## README Badges
Add these badges to your package README (replace `{package}` with your package name):
```markdown
[![CI](https://github.com/host-uk/{package}/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/{package}/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/host-uk/{package}/graph/badge.svg)](https://codecov.io/gh/host-uk/{package})
[![Latest Version](https://img.shields.io/packagist/v/host-uk/{package})](https://packagist.org/packages/host-uk/{package})
[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/{package})](https://packagist.org/packages/host-uk/{package})
[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE)
```
## Usage
Copy the relevant workflows to your library's `.github/workflows/` directory:
```bash
# In your library repo
mkdir -p .github/workflows
cp path/to/core-template/.github/package-workflows/ci.yml .github/workflows/
cp path/to/core-template/.github/package-workflows/release.yml .github/workflows/
```
## Workflows
### ci.yml
- Runs on push/PR to main
- Tests against PHP 8.2, 8.3, 8.4
- Tests against Laravel 11 and 12
- Runs Pint linting
- Runs Pest tests
### release.yml
- Triggers on version tags (v*)
- Generates changelog using git-cliff
- Creates GitHub release
## Requirements
For these workflows to work, your package needs:
1. **cliff.toml** - Copy from core-template root
2. **Pest configured** - `composer require pestphp/pest --dev`
3. **Pint configured** - `composer require laravel/pint --dev`
4. **CODECOV_TOKEN** - Add to repo secrets for coverage uploads
5. **FUNDING.yml** - Copy `.github/FUNDING.yml` for sponsor button
## Recommended composer.json scripts
```json
{
"scripts": {
"lint": "pint",
"test": "pest",
"test:coverage": "pest --coverage"
}
}
```

View file

@ -1,55 +0,0 @@
# CI workflow for library packages (host-uk/core-*, etc.)
# Copy this to .github/workflows/ci.yml in library repos
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4]
laravel: [11.*, 12.*]
exclude:
- php: 8.2
laravel: 12.*
name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
coverage: pcov
- name: Install dependencies
run: |
composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update
composer update --prefer-dist --no-interaction --no-progress
- name: Run Pint
run: vendor/bin/pint --test
- name: Run tests
run: vendor/bin/pest --ci --coverage --coverage-clover coverage.xml
- name: Upload coverage to Codecov
if: matrix.php == '8.3' && matrix.laravel == '12.*'
uses: codecov/codecov-action@v4
with:
files: coverage.xml
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -1,40 +0,0 @@
# Release workflow for library packages
# Copy this to .github/workflows/release.yml in library repos
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
name: Create Release
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
uses: orhun/git-cliff-action@v3
with:
config: cliff.toml
args: --latest --strip header
env:
OUTPUT: CHANGELOG.md
- name: Create release
uses: softprops/action-gh-release@v2
with:
body_path: CHANGELOG.md
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

160
Boot.php Normal file
View file

@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace Core\Commerce;
use Core\Events\AdminPanelBooting;
use Core\Events\ApiRoutesRegistering;
use Core\Events\ConsoleBooting;
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;
/**
* Commerce Module Boot
*
* Orders, subscriptions, and billing engine.
*
* Service layer: Service\Commerce\Boot
*/
class Boot extends ServiceProvider
{
protected string $moduleName = 'commerce';
/**
* Events this module listens to for lazy loading.
*
* @var array<class-string, string>
*/
public static array $listens = [
AdminPanelBooting::class => 'onAdminPanel',
ApiRoutesRegistering::class => 'onApiRoutes',
WebRoutesRegistering::class => 'onWebRoutes',
ConsoleBooting::class => 'onConsole',
];
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/Migrations');
// 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);
}
public function register(): void
{
$this->mergeConfigFrom(
__DIR__.'/config.php',
$this->moduleName
);
// 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);
// Payment Gateways
$this->app->singleton('commerce.gateway.btcpay', function ($app) {
return new BTCPayGateway;
});
$this->app->singleton('commerce.gateway.stripe', function ($app) {
return new StripeGateway;
});
$this->app->bind(PaymentGatewayContract::class, function ($app) {
$defaultGateway = config('commerce.gateways.btcpay.enabled')
? 'btcpay'
: 'stripe';
return $app->make("commerce.gateway.{$defaultGateway}");
});
}
// -------------------------------------------------------------------------
// Event-driven handlers
// -------------------------------------------------------------------------
public function onAdminPanel(AdminPanelBooting $event): void
{
$event->views($this->moduleName, __DIR__.'/View/Blade');
if (file_exists(__DIR__.'/Routes/admin.php')) {
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
}
// Admin Livewire components
$event->livewire('commerce.admin.subscription-manager', View\Modal\Admin\SubscriptionManager::class);
$event->livewire('commerce.admin.order-manager', View\Modal\Admin\OrderManager::class);
$event->livewire('commerce.admin.coupon-manager', View\Modal\Admin\CouponManager::class);
$event->livewire('commerce.admin.dashboard', View\Modal\Admin\Dashboard::class);
$event->livewire('commerce.admin.entity-manager', View\Modal\Admin\EntityManager::class);
$event->livewire('commerce.admin.permission-matrix-manager', View\Modal\Admin\PermissionMatrixManager::class);
$event->livewire('commerce.admin.product-manager', View\Modal\Admin\ProductManager::class);
$event->livewire('commerce.admin.credit-note-manager', View\Modal\Admin\CreditNoteManager::class);
$event->livewire('commerce.admin.referral-manager', View\Modal\Admin\ReferralManager::class);
}
public function onApiRoutes(ApiRoutesRegistering $event): void
{
if (file_exists(__DIR__.'/Routes/api.php')) {
$event->routes(fn () => Route::middleware('api')->group(__DIR__.'/Routes/api.php'));
}
}
public function onWebRoutes(WebRoutesRegistering $event): void
{
if (file_exists(__DIR__.'/Routes/web.php')) {
$event->routes(fn () => Route::middleware(['web', 'auth'])->group(__DIR__.'/Routes/web.php'));
}
// Note: Checkout routes are provided by each frontage (lt.hn, Hub, etc.)
// Commerce module provides the backend services only
// Web/User facing Livewire components (for Hub integration)
$event->livewire('commerce.web.subscription', View\Modal\Web\Subscription::class);
$event->livewire('commerce.web.invoices', View\Modal\Web\Invoices::class);
$event->livewire('commerce.web.dashboard', View\Modal\Web\Dashboard::class);
$event->livewire('commerce.web.payment-methods', View\Modal\Web\PaymentMethods::class);
$event->livewire('commerce.web.change-plan', View\Modal\Web\ChangePlan::class);
$event->livewire('commerce.web.checkout-page', View\Modal\Web\CheckoutPage::class);
$event->livewire('commerce.web.checkout-success', View\Modal\Web\CheckoutSuccess::class);
$event->livewire('commerce.web.checkout-cancel', View\Modal\Web\CheckoutCancel::class);
$event->livewire('commerce.web.currency-selector', View\Modal\Web\CurrencySelector::class);
$event->livewire('commerce.web.usage-dashboard', View\Modal\Web\UsageDashboard::class);
$event->livewire('commerce.web.referral-dashboard', View\Modal\Web\ReferralDashboard::class);
}
public function onConsole(ConsoleBooting $event): void
{
$event->command(Console\ProcessDunning::class);
$event->command(Console\SendRenewalReminders::class);
$event->command(Console\PlantSubscriberTrees::class);
$event->command(Console\CleanupExpiredOrders::class);
$event->command(Console\RefreshExchangeRates::class);
$event->command(Console\SyncUsageToStripe::class);
$event->command(Console\MatureReferralCommissions::class);
}
}

View file

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Concerns;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Core\Commerce\Models\ContentOverride;
use Core\Commerce\Models\Entity;
use Core\Commerce\Services\ContentOverrideService;
/**
* Trait for models that can have content overrides.
*
* Add to Product, Category, Page, etc. to enable white-label customisation.
*
* Usage:
* $product->getOverriddenAttribute('name', $entity);
* $product->forEntity($entity); // Returns array with all overrides applied
* $product->setOverride($entity, 'name', 'Custom Name');
*/
trait HasContentOverrides
{
/**
* Get all content overrides for this model.
*/
public function contentOverrides(): MorphMany
{
return $this->morphMany(ContentOverride::class, 'overrideable');
}
/**
* Get overrides for a specific entity.
*/
public function overridesFor(Entity $entity): MorphMany
{
return $this->contentOverrides()->where('entity_id', $entity->id);
}
/**
* Get an attribute value with overrides applied for an entity.
*
* Resolution: entity parent parent M1 (original)
*/
public function getOverriddenAttribute(string $field, Entity $entity): mixed
{
return app(ContentOverrideService::class)->get($entity, $this, $field);
}
/**
* Get multiple attributes with overrides applied.
*/
public function getOverriddenAttributes(array $fields, Entity $entity): array
{
$result = [];
$service = app(ContentOverrideService::class);
foreach ($fields as $field) {
$result[$field] = $service->get($entity, $this, $field);
}
return $result;
}
/**
* Get all model data with overrides applied for an entity.
*
* Returns the full model as an array with all applicable overrides merged in.
*/
public function forEntity(Entity $entity, ?array $fields = null): array
{
return app(ContentOverrideService::class)->getEffective($entity, $this, $fields);
}
/**
* Set an override for this model.
*/
public function setOverride(Entity $entity, string $field, mixed $value): ContentOverride
{
return app(ContentOverrideService::class)->set($entity, $this, $field, $value);
}
/**
* Set multiple overrides at once.
*/
public function setOverrides(Entity $entity, array $overrides): array
{
return app(ContentOverrideService::class)->setBulk($entity, $this, $overrides);
}
/**
* Clear an override (revert to inherited/original).
*/
public function clearOverride(Entity $entity, string $field): bool
{
return app(ContentOverrideService::class)->clear($entity, $this, $field);
}
/**
* Clear all overrides for an entity.
*/
public function clearAllOverrides(Entity $entity): int
{
return app(ContentOverrideService::class)->clearAll($entity, $this);
}
/**
* Get override status for specified fields.
*
* Returns array with value, source, is_overridden, inherited_from, etc.
*/
public function getOverrideStatus(Entity $entity, array $fields): array
{
return app(ContentOverrideService::class)->getOverrideStatus($entity, $this, $fields);
}
/**
* Check if this model has any overrides for an entity.
*/
public function hasOverridesFor(Entity $entity): bool
{
return app(ContentOverrideService::class)->hasOverrides($entity, $this);
}
/**
* Get which fields are overridden by an entity.
*/
public function getOverriddenFieldsFor(Entity $entity): array
{
return app(ContentOverrideService::class)->getOverriddenFields($entity, $this);
}
/**
* Scope to load models with override data for an entity.
*
* Note: This returns models; use forEntity() on each to get resolved values.
*/
public function scopeWithOverridesFor($query, Entity $entity)
{
return $query->with(['contentOverrides' => function ($q) use ($entity) {
$hierarchy = $entity->getHierarchy();
$q->whereIn('entity_id', $hierarchy->pluck('id'));
}]);
}
/**
* Get the fields that can be overridden.
*
* Override this in your model to restrict which fields can be customised.
*/
public function getOverrideableFields(): array
{
// Default: allow common content fields
return [
'name',
'description',
'short_description',
'image_url',
'gallery_urls',
'meta_title',
'meta_description',
];
}
/**
* Check if a field can be overridden.
*/
public function canOverrideField(string $field): bool
{
$allowed = $this->getOverrideableFields();
// If empty array, all fields allowed
if (empty($allowed)) {
return true;
}
return in_array($field, $allowed, true);
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace Core\Commerce\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Core\Commerce\Models\Order;
class CleanupExpiredOrders extends Command
{
protected $signature = 'commerce:cleanup-orders
{--dry-run : Show what would happen without making changes}
{--ttl= : Override session TTL in minutes (default from config)}';
protected $description = 'Cancel pending orders older than the checkout session TTL';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$ttlMinutes = $this->option('ttl') ?? config('commerce.checkout.session_ttl', 30);
if ($dryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
$this->info("Cleaning up pending orders older than {$ttlMinutes} minutes...");
$cutoffTime = now()->subMinutes((int) $ttlMinutes);
// Find pending orders older than the TTL
$query = Order::where('status', 'pending')
->where('created_at', '<', $cutoffTime);
$count = $query->count();
if ($count === 0) {
$this->info('No expired orders to clean up.');
return self::SUCCESS;
}
$this->info("Found {$count} expired pending order(s).");
if ($dryRun) {
$this->table(
['Order Number', 'Created At', 'Total'],
$query->get()->map(fn ($order) => [
$order->order_number,
$order->created_at->format('Y-m-d H:i:s'),
$order->total,
])->toArray()
);
return self::SUCCESS;
}
// Cancel expired orders
$cancelled = 0;
$failed = 0;
$query->chunk(100, function ($orders) use (&$cancelled, &$failed) {
foreach ($orders as $order) {
try {
$order->cancel();
$cancelled++;
Log::info('Expired order cancelled', [
'order_id' => $order->id,
'order_number' => $order->order_number,
'created_at' => $order->created_at->toIso8601String(),
]);
} catch (\Exception $e) {
$failed++;
Log::error('Failed to cancel expired order', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
}
}
});
$this->info("Cancelled {$cancelled} expired order(s).");
if ($failed > 0) {
$this->warn("Failed to cancel {$failed} order(s). Check logs for details.");
}
Log::info('Expired order cleanup completed', [
'cancelled' => $cancelled,
'failed' => $failed,
'ttl_minutes' => $ttlMinutes,
]);
return self::SUCCESS;
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Console;
use Illuminate\Console\Command;
use Core\Commerce\Services\ReferralService;
/**
* Mature referral commissions that are past their maturation date.
*
* Should be run daily via scheduler.
*/
class MatureReferralCommissions extends Command
{
protected $signature = 'commerce:mature-commissions';
protected $description = 'Mature referral commissions that are past their maturation date';
public function handle(ReferralService $referralService): int
{
$count = $referralService->matureReadyCommissions();
$this->info("Matured {$count} commissions.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Console;
use Core\Commerce\Models\Subscription;
use Mod\Trees\Models\TreePlanting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* Plants trees for active subscribers.
*
* Part of the Trees for Agents programme. Subscribers get:
* - 1 tree/month for Starter/Pro/Creator/Agency plans
* - 2 trees/month for Enterprise plans
*
* This command is idempotent - running multiple times in the same month
* will not create duplicate tree plantings.
*/
class PlantSubscriberTrees extends Command
{
protected $signature = 'trees:subscriber-monthly
{--dry-run : Show what would be planted without actually planting}
{--force : Ignore monthly check and plant regardless}';
protected $description = 'Plant monthly trees for active subscribers';
/**
* Execute the console command.
*/
public function handle(): int
{
$dryRun = $this->option('dry-run');
$force = $this->option('force');
$month = now()->format('Y-m');
$this->info("Trees for Agents: Monthly subscriber planting for {$month}");
$this->newLine();
if ($dryRun) {
$this->warn('DRY RUN MODE - No trees will actually be planted');
$this->newLine();
}
// Get all active subscriptions
$subscriptions = Subscription::query()
->active()
->with(['workspace', 'workspacePackage.package'])
->get();
if ($subscriptions->isEmpty()) {
$this->info('No active subscriptions found.');
return self::SUCCESS;
}
$this->info("Found {$subscriptions->count()} active subscriptions");
$this->newLine();
$planted = 0;
$skipped = 0;
$errors = 0;
foreach ($subscriptions as $subscription) {
$result = $this->processSubscription($subscription, $month, $dryRun, $force);
match ($result) {
'planted' => $planted++,
'skipped' => $skipped++,
'error' => $errors++,
};
}
$this->newLine();
$this->table(
['Status', 'Count'],
[
['Planted', $planted],
['Skipped (already planted)', $skipped],
['Errors', $errors],
]
);
if ($dryRun) {
$this->newLine();
$this->warn('DRY RUN COMPLETE - No trees were actually planted');
}
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Process a single subscription for tree planting.
*/
protected function processSubscription(
Subscription $subscription,
string $month,
bool $dryRun,
bool $force
): string {
$workspace = $subscription->workspace;
if (! $workspace) {
$this->error(" [ERROR] Subscription #{$subscription->id} has no workspace");
return 'error';
}
// Check if already planted this month (idempotency)
if (! $force && $this->hasPlantedThisMonth($workspace->id, $month)) {
$this->line(" [SKIP] {$workspace->name} - already planted in {$month}");
return 'skipped';
}
// Determine tree count based on package tier
$trees = $this->getTreeCountForSubscription($subscription);
$packageName = $this->getPackageName($subscription);
if ($dryRun) {
$this->info(" [DRY RUN] Would plant {$trees} tree(s) for {$workspace->name} ({$packageName})");
return 'planted';
}
// Create the tree planting record
$planting = TreePlanting::create([
'provider' => null,
'model' => null,
'source' => TreePlanting::SOURCE_SUBSCRIPTION,
'trees' => $trees,
'user_id' => null,
'workspace_id' => $workspace->id,
'status' => TreePlanting::STATUS_PENDING,
'metadata' => [
'subscription_id' => $subscription->id,
'package' => $packageName,
'month' => $month,
],
]);
// Confirm the tree immediately
$planting->markConfirmed();
Log::info('Subscriber monthly tree planted', [
'tree_planting_id' => $planting->id,
'workspace_id' => $workspace->id,
'workspace_name' => $workspace->name,
'trees' => $trees,
'package' => $packageName,
'month' => $month,
]);
$this->info(" [PLANTED] {$trees} tree(s) for {$workspace->name} ({$packageName})");
return 'planted';
}
/**
* Check if this workspace has already had trees planted this month.
*/
protected function hasPlantedThisMonth(int $workspaceId, string $month): bool
{
// Parse the month string (YYYY-MM format)
$date = \Carbon\Carbon::createFromFormat('Y-m', $month);
$startOfMonth = $date->copy()->startOfMonth();
$endOfMonth = $date->copy()->endOfMonth();
return TreePlanting::query()
->where('workspace_id', $workspaceId)
->where('source', TreePlanting::SOURCE_SUBSCRIPTION)
->whereBetween('created_at', [$startOfMonth, $endOfMonth])
->exists();
}
/**
* Get the number of trees for this subscription tier.
*
* Enterprise: 2 trees/month
* All others: 1 tree/month
*/
protected function getTreeCountForSubscription(Subscription $subscription): int
{
$packageCode = $subscription->workspacePackage?->package?->code ?? '';
// Enterprise packages get 2 trees
if (str_contains(strtolower($packageCode), 'enterprise')) {
return 2;
}
return 1;
}
/**
* Get the package name for display.
*/
protected function getPackageName(Subscription $subscription): string
{
return $subscription->workspacePackage?->package?->name
?? $subscription->workspacePackage?->package?->code
?? 'Unknown';
}
}

288
Console/ProcessDunning.php Normal file
View file

@ -0,0 +1,288 @@
<?php
namespace Core\Commerce\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Core\Commerce\Services\DunningService;
use Core\Commerce\Services\SubscriptionService;
class ProcessDunning extends Command
{
protected $signature = 'commerce:process-dunning
{--dry-run : Show what would happen without making changes}
{--stage= : Process only a specific stage (retry, pause, suspend, cancel, expire)}';
protected $description = 'Process dunning for failed payments - retry charges, pause, suspend, and cancel subscriptions';
public function __construct(
protected DunningService $dunning,
protected SubscriptionService $subscriptions
) {
parent::__construct();
}
public function handle(): int
{
if (! config('commerce.dunning.enabled', true)) {
$this->info('Dunning is disabled.');
return self::SUCCESS;
}
$dryRun = $this->option('dry-run');
$stage = $this->option('stage');
if ($dryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
$this->info('Processing dunning...');
$this->newLine();
$results = [
'retried' => 0,
'paused' => 0,
'suspended' => 0,
'cancelled' => 0,
'expired' => 0,
];
// Process stages based on option or all
if (! $stage || $stage === 'retry') {
$results['retried'] = $this->processRetries($dryRun);
}
if (! $stage || $stage === 'pause') {
$results['paused'] = $this->processPauses($dryRun);
}
if (! $stage || $stage === 'suspend') {
$results['suspended'] = $this->processSuspensions($dryRun);
}
if (! $stage || $stage === 'cancel') {
$results['cancelled'] = $this->processCancellations($dryRun);
}
if (! $stage || $stage === 'expire') {
$results['expired'] = $this->processExpired($dryRun);
}
$this->newLine();
$this->info('Dunning Summary:');
$this->table(
['Action', 'Count'],
[
['Payment retries attempted', $results['retried']],
['Subscriptions paused', $results['paused']],
['Workspaces suspended', $results['suspended']],
['Subscriptions cancelled', $results['cancelled']],
['Subscriptions expired', $results['expired']],
]
);
Log::info('Dunning process completed', $results);
return self::SUCCESS;
}
/**
* Process payment retries for overdue invoices.
*/
protected function processRetries(bool $dryRun): int
{
$this->info('Stage 1: Payment Retries');
$invoices = $this->dunning->getInvoicesDueForRetry();
if ($invoices->isEmpty()) {
$this->line(' No invoices due for retry');
return 0;
}
$count = 0;
foreach ($invoices as $invoice) {
$this->line(" Processing invoice {$invoice->invoice_number}...");
if ($dryRun) {
$this->comment(" Would retry payment (attempt {$invoice->charge_attempts})");
$count++;
continue;
}
try {
$success = $this->dunning->retryPayment($invoice);
if ($success) {
$this->info(' Payment successful');
} else {
$this->warn(' Payment failed - next retry scheduled');
}
$count++;
} catch (\Exception $e) {
$this->error(" Error: {$e->getMessage()}");
Log::error('Dunning retry failed', [
'invoice_id' => $invoice->id,
'error' => $e->getMessage(),
]);
}
}
return $count;
}
/**
* Process subscription pauses (after max retries exhausted).
*/
protected function processPauses(bool $dryRun): int
{
$this->info('Stage 2: Subscription Pauses');
$subscriptions = $this->dunning->getSubscriptionsForPause();
if ($subscriptions->isEmpty()) {
$this->line(' No subscriptions to pause');
return 0;
}
$count = 0;
foreach ($subscriptions as $subscription) {
$this->line(" Pausing subscription {$subscription->id} (workspace {$subscription->workspace_id})...");
if ($dryRun) {
$this->comment(' Would pause subscription');
$count++;
continue;
}
try {
$this->dunning->pauseSubscription($subscription);
$this->info(' Subscription paused');
$count++;
} catch (\Exception $e) {
$this->error(" Error: {$e->getMessage()}");
Log::error('Dunning pause failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
return $count;
}
/**
* Process workspace suspensions.
*/
protected function processSuspensions(bool $dryRun): int
{
$this->info('Stage 3: Workspace Suspensions');
$subscriptions = $this->dunning->getSubscriptionsForSuspension();
if ($subscriptions->isEmpty()) {
$this->line(' No workspaces to suspend');
return 0;
}
$count = 0;
foreach ($subscriptions as $subscription) {
$this->line(" Suspending workspace {$subscription->workspace_id}...");
if ($dryRun) {
$this->comment(' Would suspend workspace entitlements');
$count++;
continue;
}
try {
$this->dunning->suspendWorkspace($subscription);
$this->info(' Workspace suspended');
$count++;
} catch (\Exception $e) {
$this->error(" Error: {$e->getMessage()}");
Log::error('Dunning suspension failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
return $count;
}
/**
* Process subscription cancellations.
*/
protected function processCancellations(bool $dryRun): int
{
$this->info('Stage 4: Subscription Cancellations');
$subscriptions = $this->dunning->getSubscriptionsForCancellation();
if ($subscriptions->isEmpty()) {
$this->line(' No subscriptions to cancel');
return 0;
}
$count = 0;
foreach ($subscriptions as $subscription) {
$this->line(" Cancelling subscription {$subscription->id}...");
if ($dryRun) {
$this->comment(' Would cancel subscription due to non-payment');
$count++;
continue;
}
try {
$this->dunning->cancelSubscription($subscription);
$this->info(' Subscription cancelled');
$count++;
} catch (\Exception $e) {
$this->error(" Error: {$e->getMessage()}");
Log::error('Dunning cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
return $count;
}
/**
* Process expired subscriptions (cancelled with period ended).
*/
protected function processExpired(bool $dryRun): int
{
$this->info('Stage 5: Expired Subscriptions');
if ($dryRun) {
$count = \Core\Commerce\Models\Subscription::query()
->active()
->whereNotNull('cancelled_at')
->where('current_period_end', '<=', now())
->count();
$this->line(" Would expire {$count} subscriptions");
return $count;
}
$expired = $this->subscriptions->processExpired();
$this->line(" Expired {$expired} subscriptions");
return $expired;
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Console;
use Illuminate\Console\Command;
use Core\Commerce\Services\CurrencyService;
/**
* Refresh exchange rates from configured provider.
*
* Should be scheduled to run periodically (e.g., hourly).
*/
class RefreshExchangeRates extends Command
{
protected $signature = 'commerce:refresh-exchange-rates
{--force : Force refresh even if rates are fresh}';
protected $description = 'Refresh exchange rates from the configured provider';
public function handle(CurrencyService $currencyService): int
{
$this->info('Refreshing exchange rates...');
$baseCurrency = $currencyService->getBaseCurrency();
$provider = config('commerce.currencies.exchange_rates.provider', 'ecb');
$this->line("Base currency: {$baseCurrency}");
$this->line("Provider: {$provider}");
// Check if rates need refresh
if (! $this->option('force') && ! \Core\Commerce\Models\ExchangeRate::needsRefresh()) {
$this->info('Rates are still fresh. Use --force to refresh anyway.');
return self::SUCCESS;
}
$rates = $currencyService->refreshExchangeRates();
if (empty($rates)) {
$this->error('No rates were updated. Check logs for errors.');
return self::FAILURE;
}
$this->info('Updated '.count($rates).' exchange rates:');
$rows = [];
foreach ($rates as $currency => $rate) {
$rows[] = [$baseCurrency, $currency, number_format($rate, 6)];
}
$this->table(['From', 'To', 'Rate'], $rows);
return self::SUCCESS;
}
}

View file

@ -0,0 +1,123 @@
<?php
namespace Core\Commerce\Console;
use Core\Commerce\Models\Subscription;
use Core\Commerce\Notifications\UpcomingRenewal;
use Core\Commerce\Services\CommerceService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SendRenewalReminders extends Command
{
protected $signature = 'commerce:renewal-reminders
{--days=7 : Days before renewal to send reminder}
{--dry-run : Show what would happen without sending}';
protected $description = 'Send renewal reminder emails to customers with upcoming subscription renewals';
public function __construct(
protected CommerceService $commerce
) {
parent::__construct();
}
public function handle(): int
{
if (! config('commerce.notifications.upcoming_renewal', true)) {
$this->info('Renewal reminder notifications are disabled.');
return self::SUCCESS;
}
$days = (int) $this->option('days');
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->warn('DRY RUN MODE - No emails will be sent');
}
$this->info("Finding subscriptions renewing in {$days} days...");
// Find subscriptions renewing soon that haven't been reminded
$subscriptions = Subscription::query()
->active()
->whereNull('cancelled_at')
->where('current_period_end', '>', now())
->where('current_period_end', '<=', now()->addDays($days))
->whereDoesntHave('metadata', function ($query) use ($days) {
// Skip if already reminded for this period
$query->where('last_renewal_reminder', '>=', now()->subDays($days));
})
->with(['workspace', 'workspacePackage.package'])
->get();
if ($subscriptions->isEmpty()) {
$this->info('No subscriptions require reminders.');
return self::SUCCESS;
}
$this->info("Found {$subscriptions->count()} subscriptions to remind.");
$sent = 0;
foreach ($subscriptions as $subscription) {
$owner = $subscription->workspace?->owner();
if (! $owner) {
$this->warn(" Skipping subscription {$subscription->id} - no workspace owner");
continue;
}
$package = $subscription->workspacePackage?->package;
$billingCycle = $this->guessBillingCycle($subscription);
$amount = $package?->getPrice($billingCycle) ?? 0;
$this->line(" Sending reminder to {$owner->email} for subscription {$subscription->id}...");
if ($dryRun) {
$sent++;
continue;
}
try {
$owner->notify(new UpcomingRenewal(
$subscription,
$amount,
config('commerce.currency', 'GBP')
));
// Record that we sent the reminder
$subscription->update([
'metadata' => array_merge($subscription->metadata ?? [], [
'last_renewal_reminder' => now()->toISOString(),
]),
]);
$sent++;
$this->info(' ✓ Sent');
} catch (\Exception $e) {
$this->error(" ✗ Failed: {$e->getMessage()}");
Log::error('Renewal reminder failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
$this->newLine();
$this->info("Sent {$sent} renewal reminders.");
return self::SUCCESS;
}
protected function guessBillingCycle(Subscription $subscription): string
{
$periodDays = $subscription->current_period_start
?->diffInDays($subscription->current_period_end);
return ($periodDays ?? 30) > 32 ? 'yearly' : 'monthly';
}
}

View file

@ -0,0 +1,122 @@
<?php
namespace Core\Commerce\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Core\Commerce\Models\Subscription;
use Core\Commerce\Services\UsageBillingService;
/**
* Sync usage records to Stripe metered billing.
*
* Run periodically to ensure usage is reported to Stripe
* for metered billing invoices.
*/
class SyncUsageToStripe extends Command
{
protected $signature = 'commerce:sync-usage
{--subscription= : Sync only a specific subscription ID}
{--dry-run : Show what would be synced without making changes}';
protected $description = 'Sync usage records to Stripe metered billing API';
public function __construct(
protected UsageBillingService $usageBilling
) {
parent::__construct();
}
public function handle(): int
{
if (! config('commerce.features.usage_billing', false)) {
$this->info('Usage billing is disabled.');
return self::SUCCESS;
}
if (! config('commerce.usage_billing.sync_to_stripe', true)) {
$this->info('Stripe sync is disabled.');
return self::SUCCESS;
}
$dryRun = $this->option('dry-run');
$subscriptionId = $this->option('subscription');
if ($dryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
$this->info('Syncing usage to Stripe...');
$this->newLine();
// Get subscriptions to sync
$query = Subscription::query()
->where('gateway', 'stripe')
->whereNotNull('gateway_subscription_id')
->active();
if ($subscriptionId) {
$query->where('id', $subscriptionId);
}
$subscriptions = $query->get();
if ($subscriptions->isEmpty()) {
$this->info('No Stripe subscriptions found to sync.');
return self::SUCCESS;
}
$this->info("Found {$subscriptions->count()} subscription(s) to sync.");
$this->newLine();
$totalSynced = 0;
$errors = 0;
$this->withProgressBar($subscriptions, function (Subscription $subscription) use ($dryRun, &$totalSynced, &$errors) {
if ($dryRun) {
// Count unsynced usage for preview
$unsynced = $subscription->usageRecords()
->whereNull('synced_at')
->where('quantity', '>', 0)
->count();
$totalSynced += $unsynced;
return;
}
try {
$synced = $this->usageBilling->syncToStripe($subscription);
$totalSynced += $synced;
} catch (\Exception $e) {
$errors++;
Log::error('Failed to sync usage to Stripe', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
});
$this->newLine(2);
if ($dryRun) {
$this->info("Would sync {$totalSynced} usage record(s).");
} else {
$this->info("Synced {$totalSynced} usage record(s) to Stripe.");
if ($errors > 0) {
$this->warn("{$errors} subscription(s) had sync errors. Check logs for details.");
}
}
Log::info('Usage sync completed', [
'synced' => $totalSynced,
'errors' => $errors,
'dry_run' => $dryRun,
]);
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
}

33
Contracts/Orderable.php Normal file
View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Contracts;
/**
* Contract for entities that can place orders.
*
* Implemented by User, Workspace, or any entity that needs billing.
*/
interface Orderable
{
/**
* Get the billing name for orders.
*/
public function getBillingName(): ?string;
/**
* Get the billing email for orders.
*/
public function getBillingEmail(): string;
/**
* Get the billing address for orders.
*/
public function getBillingAddress(): ?array;
/**
* Get the tax country code (for tax calculation).
*/
public function getTaxCountry(): ?string;
}

View file

@ -0,0 +1,484 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Tenant\Models\Package;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Core\Commerce\Models\Invoice;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\Subscription;
use Core\Commerce\Services\CommerceService;
use Core\Commerce\Services\InvoiceService;
use Core\Commerce\Services\SubscriptionService;
/**
* Commerce REST API for MCP agents and external integrations.
*
* Provides read access to orders, invoices, subscriptions, and usage,
* plus plan upgrade/downgrade capabilities.
*/
class CommerceController extends Controller
{
public function __construct(
protected CommerceService $commerceService,
protected SubscriptionService $subscriptionService,
protected InvoiceService $invoiceService,
) {}
/**
* Get the current workspace from the authenticated user.
*/
protected function getWorkspace(Request $request): ?Workspace
{
$user = Auth::user();
if (! $user instanceof \Core\Mod\Tenant\Models\User) {
return null;
}
// Allow workspace_id override for admin users
if ($request->has('workspace_id') && $user->isAdmin()) {
return Workspace::find($request->get('workspace_id'));
}
return $user->defaultHostWorkspace();
}
/**
* List orders for the workspace.
*
* GET /api/v1/commerce/orders
*/
public function orders(Request $request): JsonResponse
{
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
$query = $workspace->orders()
->with(['items', 'invoice'])
->latest();
if ($status = $request->get('status')) {
$query->where('status', $status);
}
$orders = $query->paginate($request->get('per_page', 25));
return response()->json($orders);
}
/**
* Get a specific order.
*
* GET /api/v1/commerce/orders/{order}
*/
public function showOrder(Request $request, Order $order): JsonResponse
{
$workspace = $this->getWorkspace($request);
if (! $workspace || $order->workspace_id !== $workspace->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$order->load(['items', 'payments', 'invoice']);
return response()->json(['data' => $order]);
}
/**
* List invoices for the workspace.
*
* GET /api/v1/commerce/invoices
*/
public function invoices(Request $request): JsonResponse
{
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
$query = $workspace->invoices()
->with(['items'])
->latest();
if ($status = $request->get('status')) {
$query->where('status', $status);
}
$invoices = $query->paginate($request->get('per_page', 25));
return response()->json($invoices);
}
/**
* Get a specific invoice.
*
* GET /api/v1/commerce/invoices/{invoice}
*/
public function showInvoice(Request $request, Invoice $invoice): JsonResponse
{
$workspace = $this->getWorkspace($request);
if (! $workspace || $invoice->workspace_id !== $workspace->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$invoice->load(['items', 'payment']);
return response()->json(['data' => $invoice]);
}
/**
* Download invoice PDF.
*
* GET /api/v1/commerce/invoices/{invoice}/download
*/
public function downloadInvoice(Request $request, Invoice $invoice)
{
$workspace = $this->getWorkspace($request);
if (! $workspace || $invoice->workspace_id !== $workspace->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
return $this->invoiceService->downloadPdf($invoice);
}
/**
* Get current subscription status.
*
* GET /api/v1/commerce/subscription
*/
public function subscription(Request $request): JsonResponse
{
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
$subscription = $workspace->subscriptions()
->with(['order.items'])
->active()
->latest()
->first();
if (! $subscription) {
return response()->json([
'data' => null,
'message' => 'No active subscription',
]);
}
return response()->json([
'data' => $subscription,
'next_billing_date' => $subscription->current_period_end?->toIso8601String(),
'is_cancelled' => $subscription->cancel_at_period_end,
]);
}
/**
* Get usage summary for the workspace.
*
* GET /api/v1/commerce/usage
*/
public function usage(Request $request): JsonResponse
{
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
$entitlements = app(\Core\Mod\Tenant\Services\EntitlementService::class);
$summary = $entitlements->getUsageSummary($workspace);
return response()->json([
'data' => $summary,
'workspace_id' => $workspace->id,
'period' => now()->format('Y-m'),
]);
}
/**
* Preview a plan change (upgrade/downgrade).
*
* POST /api/v1/commerce/upgrade/preview
*/
public function previewUpgrade(Request $request): JsonResponse
{
$validated = $request->validate([
'package_code' => 'required|string|exists:entitlement_packages,code',
]);
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
$subscription = $workspace->subscriptions()
->with('workspacePackage.package')
->active()
->first();
if (! $subscription) {
return response()->json([
'error' => 'No active subscription to upgrade',
], 400);
}
try {
$newPackage = Package::where('code', $validated['package_code'])->firstOrFail();
$currentPackage = $subscription->workspacePackage?->package;
$billingCycle = $subscription->billing_cycle ?? 'monthly';
$proration = $this->subscriptionService->previewPlanChange(
$subscription,
$newPackage,
$billingCycle
);
return response()->json([
'data' => [
'current_plan' => [
'name' => $currentPackage?->name ?? 'Current Plan',
'code' => $currentPackage?->code,
'price' => $proration->currentPlanPrice,
],
'new_plan' => [
'name' => $newPackage->name,
'code' => $newPackage->code,
'price' => $proration->newPlanPrice,
],
'billing_cycle' => $billingCycle,
'proration' => [
'days_remaining' => $proration->daysRemaining,
'total_period_days' => $proration->totalPeriodDays,
'used_percentage' => round($proration->usedPercentage * 100, 2),
'credit_amount' => $proration->creditAmount,
'prorated_new_cost' => $proration->proratedNewPlanCost,
'net_amount' => $proration->netAmount,
],
'effective_date' => now()->toIso8601String(),
'next_billing_amount' => $proration->newPlanPrice,
'next_billing_date' => $subscription->current_period_end?->toIso8601String(),
'is_upgrade' => $proration->isUpgrade(),
'is_downgrade' => $proration->isDowngrade(),
'requires_payment' => $proration->requiresPayment(),
'currency' => $proration->currency,
],
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Unable to preview plan change',
'message' => $e->getMessage(),
], 400);
}
}
/**
* Execute a plan change (upgrade/downgrade).
*
* POST /api/v1/commerce/upgrade
*/
public function executeUpgrade(Request $request): JsonResponse
{
$validated = $request->validate([
'package_code' => 'required|string|exists:entitlement_packages,code',
'prorate' => 'boolean',
]);
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
$subscription = $workspace->subscriptions()->active()->first();
if (! $subscription) {
return response()->json([
'error' => 'No active subscription to upgrade',
], 400);
}
try {
$newPackage = Package::where('code', $validated['package_code'])->firstOrFail();
$result = $this->subscriptionService->changePlan(
$subscription,
$newPackage,
$validated['prorate'] ?? true
);
return response()->json([
'data' => $result,
'message' => 'Plan changed successfully',
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Unable to change plan',
'message' => $e->getMessage(),
], 400);
}
}
/**
* Cancel the current subscription.
*
* POST /api/v1/commerce/cancel
*/
public function cancelSubscription(Request $request): JsonResponse
{
$validated = $request->validate([
'immediately' => 'boolean',
'reason' => 'nullable|string|max:500',
]);
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
$subscription = $workspace->subscriptions()->active()->first();
if (! $subscription) {
return response()->json([
'error' => 'No active subscription to cancel',
], 400);
}
try {
$this->subscriptionService->cancel(
$subscription,
$validated['immediately'] ?? false,
$validated['reason'] ?? null
);
return response()->json([
'message' => $validated['immediately'] ?? false
? 'Subscription cancelled immediately'
: 'Subscription will be cancelled at end of billing period',
'ends_at' => $subscription->fresh()->current_period_end?->toIso8601String(),
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Unable to cancel subscription',
'message' => $e->getMessage(),
], 400);
}
}
/**
* Resume a cancelled subscription.
*
* POST /api/v1/commerce/resume
*/
public function resumeSubscription(Request $request): JsonResponse
{
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
$subscription = $workspace->subscriptions()
->where('cancel_at_period_end', true)
->where('status', 'active')
->first();
if (! $subscription) {
return response()->json([
'error' => 'No cancelled subscription to resume',
], 400);
}
try {
$this->subscriptionService->resume($subscription);
return response()->json([
'message' => 'Subscription resumed successfully',
'data' => $subscription->fresh(),
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Unable to resume subscription',
'message' => $e->getMessage(),
], 400);
}
}
/**
* Get billing overview (summary of all billing data).
*
* GET /api/v1/commerce/billing
*/
public function billing(Request $request): JsonResponse
{
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
$subscription = $workspace->subscriptions()
->with(['order.items'])
->active()
->latest()
->first();
$unpaidInvoices = $workspace->invoices()
->pending()
->sum('amount_due');
$recentPayments = $workspace->payments()
->where('status', 'succeeded')
->latest()
->take(5)
->get();
$defaultPaymentMethod = $workspace->paymentMethods()
->where('is_default', true)
->where('is_active', true)
->first();
return response()->json([
'data' => [
'subscription' => $subscription ? [
'id' => $subscription->id,
'status' => $subscription->status,
'plan_name' => $subscription->order?->items->first()?->name,
'current_period_end' => $subscription->current_period_end?->toIso8601String(),
'cancel_at_period_end' => $subscription->cancel_at_period_end,
] : null,
'outstanding_balance' => $unpaidInvoices,
'currency' => config('commerce.currency', 'GBP'),
'payment_method' => $defaultPaymentMethod ? [
'type' => $defaultPaymentMethod->type,
'brand' => $defaultPaymentMethod->brand,
'last_four' => $defaultPaymentMethod->last_four,
'exp_month' => $defaultPaymentMethod->exp_month,
'exp_year' => $defaultPaymentMethod->exp_year,
] : null,
'recent_payments' => $recentPayments->map(fn ($p) => [
'amount' => $p->amount,
'currency' => $p->currency,
'status' => $p->status,
'created_at' => $p->created_at->toIso8601String(),
]),
],
]);
}
}

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Controllers;
use Core\Front\Controller;
use Core\Commerce\Models\Invoice;
use Core\Commerce\Services\InvoiceService;
use Core\Mod\Tenant\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class InvoiceController extends Controller
{
public function __construct(
protected InvoiceService $invoiceService
) {}
/**
* Download invoice PDF.
*/
public function pdf(Request $request, Invoice $invoice): StreamedResponse|Response
{
// Verify the invoice belongs to the user's workspace
$user = Auth::user();
if (! $user instanceof User) {
abort(403, 'Unauthorised');
}
$workspace = $user->defaultHostWorkspace();
if (! $workspace || $invoice->workspace_id !== $workspace->id) {
abort(403, 'You do not have access to this invoice.');
}
// Only allow downloading paid invoices
if (! $invoice->isPaid()) {
abort(403, 'This invoice cannot be downloaded yet.');
}
// Use the download method from InvoiceService
return $this->invoiceService->downloadPdf($invoice);
}
/**
* View invoice in browser.
*/
public function view(Request $request, Invoice $invoice): Response
{
// Verify the invoice belongs to the user's workspace
$user = Auth::user();
if (! $user instanceof User) {
abort(403, 'Unauthorised');
}
$workspace = $user->defaultHostWorkspace();
if (! $workspace || $invoice->workspace_id !== $workspace->id) {
abort(403, 'You do not have access to this invoice.');
}
// Generate PDF and get the content
$path = $this->invoiceService->getPdf($invoice);
$content = Storage::disk(config('commerce.pdf.storage_disk', 'local'))->get($path);
return response($content, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="invoice-'.$invoice->invoice_number.'.pdf"',
]);
}
}

View file

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Controllers;
use Core\Front\Controller;
use Core\Commerce\Models\Entity;
use Core\Commerce\Services\PermissionLockedException;
use Core\Commerce\Services\PermissionMatrixService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
/**
* Handles permission matrix training in development mode.
*/
class MatrixTrainingController extends Controller
{
public function __construct(
protected PermissionMatrixService $matrix
) {}
/**
* Process a training decision.
*/
public function train(Request $request): RedirectResponse
{
$validated = $request->validate([
'entity_id' => 'required|exists:commerce_entities,id',
'key' => 'required|string|max:255',
'scope' => 'nullable|string|max:255',
'allow' => 'required|in:0,1',
'lock' => 'nullable|in:0,1',
'route' => 'nullable|string|max:2048',
'return_url' => 'nullable|url',
]);
$entity = Entity::findOrFail($validated['entity_id']);
$allow = (bool) $validated['allow'];
$lock = (bool) ($validated['lock'] ?? false);
try {
if ($lock) {
// Lock the permission (cascades to descendants)
$this->matrix->lock(
entity: $entity,
key: $validated['key'],
allowed: $allow,
scope: $validated['scope'] ?? null
);
} else {
// Train the permission (just for this entity)
$this->matrix->train(
entity: $entity,
key: $validated['key'],
scope: $validated['scope'] ?? null,
allow: $allow,
route: $validated['route'] ?? null
);
}
// Mark any pending requests as trained
$this->matrix->markRequestsTrained(
$entity,
$validated['key'],
$validated['scope'] ?? null
);
$message = $allow
? "Permission '{$validated['key']}' allowed for {$entity->name}"
: "Permission '{$validated['key']}' denied for {$entity->name}";
if ($lock) {
$message .= ' (locked)';
}
// Redirect back to the original URL if provided
if ($returnUrl = $validated['return_url'] ?? null) {
return redirect($returnUrl)->with('success', $message);
}
return redirect()->back()->with('success', $message);
} catch (PermissionLockedException $e) {
return redirect()->back()->withErrors(['error' => $e->getMessage()]);
}
}
/**
* Show pending permission requests.
*/
public function pending(Request $request)
{
$entityId = $request->get('entity');
$entity = $entityId ? Entity::find($entityId) : null;
$requests = $this->matrix->getPendingRequests($entity);
return view('commerce::web.matrix.pending', [
'requests' => $requests,
'entity' => $entity,
'entities' => Entity::active()->orderBy('path')->get(),
]);
}
/**
* Bulk train permissions.
*/
public function bulkTrain(Request $request): RedirectResponse
{
$validated = $request->validate([
'decisions' => 'required|array',
'decisions.*.entity_id' => 'required|exists:commerce_entities,id',
'decisions.*.key' => 'required|string',
'decisions.*.scope' => 'nullable|string',
'decisions.*.allow' => 'required|in:0,1',
]);
$trained = 0;
$errors = [];
foreach ($validated['decisions'] as $decision) {
try {
$entity = Entity::find($decision['entity_id']);
$this->matrix->train(
entity: $entity,
key: $decision['key'],
scope: $decision['scope'] ?? null,
allow: (bool) $decision['allow']
);
$this->matrix->markRequestsTrained(
$entity,
$decision['key'],
$decision['scope'] ?? null
);
$trained++;
} catch (PermissionLockedException $e) {
$errors[] = $e->getMessage();
}
}
if ($errors) {
return redirect()->back()
->with('success', "Trained {$trained} permissions")
->withErrors($errors);
}
return redirect()->back()->with('success', "Trained {$trained} permissions");
}
}

View file

@ -0,0 +1,220 @@
<?php
namespace Core\Commerce\Controllers\Webhooks;
use Core\Front\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\Payment;
use Core\Commerce\Notifications\OrderConfirmation;
use Core\Commerce\Services\CommerceService;
use Core\Commerce\Services\PaymentGateway\BTCPayGateway;
use Core\Commerce\Services\WebhookLogger;
/**
* Handle BTCPay Server webhooks.
*
* BTCPay sends webhooks for invoice state changes:
* - InvoiceCreated: Invoice was created
* - InvoiceReceivedPayment: Payment detected (0 confirmations)
* - InvoiceProcessing: Payment processing (waiting for confirmations)
* - InvoiceExpired: Invoice expired without full payment
* - InvoiceSettled: Payment fully confirmed
* - InvoiceInvalid: Payment was invalid/rejected
*/
class BTCPayWebhookController extends Controller
{
public function __construct(
protected BTCPayGateway $gateway,
protected CommerceService $commerce,
protected WebhookLogger $webhookLogger,
) {}
public function handle(Request $request): Response
{
$payload = $request->getContent();
$signature = $request->header('BTCPay-Sig');
// Verify webhook signature
if (! $this->gateway->verifyWebhookSignature($payload, $signature)) {
Log::warning('BTCPay webhook signature verification failed');
return response('Invalid signature', 401);
}
$event = $this->gateway->parseWebhookEvent($payload);
// Log the webhook event for audit trail
$this->webhookLogger->startFromParsedEvent('btcpay', $event, $payload, $request);
Log::info('BTCPay webhook received', [
'type' => $event['type'],
'id' => $event['id'],
]);
try {
// Wrap all webhook processing in a transaction to ensure data integrity
$response = DB::transaction(function () use ($event) {
return match ($event['type']) {
'invoice.created' => $this->handleInvoiceCreated($event),
'invoice.payment_received' => $this->handlePaymentReceived($event),
'invoice.processing' => $this->handleProcessing($event),
'invoice.paid', 'payment.settled' => $this->handleSettled($event),
'invoice.expired' => $this->handleExpired($event),
'invoice.failed' => $this->handleFailed($event),
default => $this->handleUnknownEvent($event),
};
});
$this->webhookLogger->success($response);
return $response;
} catch (\Exception $e) {
Log::error('BTCPay webhook processing error', [
'type' => $event['type'],
'error' => $e->getMessage(),
]);
$this->webhookLogger->fail($e->getMessage(), 500);
return response('Processing error', 500);
}
}
protected function handleUnknownEvent(array $event): Response
{
$this->webhookLogger->skip('Unhandled event type: '.$event['type']);
return response('Unhandled event type', 200);
}
protected function handleInvoiceCreated(array $event): Response
{
// Invoice created - no action needed
return response('OK', 200);
}
protected function handlePaymentReceived(array $event): Response
{
// Payment detected but not confirmed
$order = $this->findOrderByInvoiceId($event['id']);
if ($order) {
// Update order status to show payment is incoming
$order->update(['status' => 'processing']);
}
return response('OK', 200);
}
protected function handleProcessing(array $event): Response
{
// Payment is being processed (waiting for confirmations)
$order = $this->findOrderByInvoiceId($event['id']);
if ($order) {
$order->update(['status' => 'processing']);
}
return response('OK', 200);
}
protected function handleSettled(array $event): Response
{
// Payment fully confirmed - fulfil the order
$order = $this->findOrderByInvoiceId($event['id']);
if (! $order) {
Log::warning('BTCPay webhook: Order not found', ['invoice_id' => $event['id']]);
return response('Order not found', 200);
}
// Link webhook event to order for audit trail
$this->webhookLogger->linkOrder($order);
// Skip if already paid
if ($order->isPaid()) {
return response('Already processed', 200);
}
// Get invoice details from BTCPay
$invoiceData = $this->gateway->getCheckoutSession($event['id']);
// Create payment record
$payment = Payment::create([
'workspace_id' => $order->workspace_id,
'order_id' => $order->id,
'invoice_id' => null, // Will be set by fulfillOrder
'gateway' => 'btcpay',
'gateway_payment_id' => $event['id'],
'amount' => $order->total,
'currency' => $order->currency,
'status' => 'succeeded',
'paid_at' => now(),
'gateway_response' => $invoiceData['raw'] ?? [],
]);
// Fulfil the order (provisions entitlements, creates invoice)
$this->commerce->fulfillOrder($order, $payment);
// Send confirmation email
$this->sendOrderConfirmation($order);
Log::info('BTCPay order fulfilled', [
'order_id' => $order->id,
'order_number' => $order->order_number,
'payment_id' => $payment->id,
]);
return response('OK', 200);
}
protected function handleExpired(array $event): Response
{
// Invoice expired - mark order as failed
$order = $this->findOrderByInvoiceId($event['id']);
if ($order && ! $order->isPaid()) {
$order->markAsFailed('Payment expired');
}
return response('OK', 200);
}
protected function handleFailed(array $event): Response
{
// Payment invalid/rejected
$order = $this->findOrderByInvoiceId($event['id']);
if ($order && ! $order->isPaid()) {
$order->markAsFailed('Payment rejected');
}
return response('OK', 200);
}
protected function findOrderByInvoiceId(string $invoiceId): ?Order
{
return Order::where('gateway', 'btcpay')
->where('gateway_session_id', $invoiceId)
->first();
}
protected function sendOrderConfirmation(Order $order): void
{
if (! config('commerce.notifications.order_confirmation', true)) {
return;
}
// Use resolved workspace to handle both Workspace and User orderables
$workspace = $order->getResolvedWorkspace();
$owner = $workspace?->owner();
if ($owner) {
$owner->notify(new OrderConfirmation($order));
}
}
}

View file

@ -0,0 +1,495 @@
<?php
namespace Core\Commerce\Controllers\Webhooks;
use Carbon\Carbon;
use Core\Front\Controller;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\EntitlementService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\Payment;
use Core\Commerce\Models\PaymentMethod;
use Core\Commerce\Models\Subscription;
use Core\Commerce\Notifications\OrderConfirmation;
use Core\Commerce\Notifications\PaymentFailed;
use Core\Commerce\Notifications\SubscriptionCancelled;
use Core\Commerce\Services\CommerceService;
use Core\Commerce\Services\InvoiceService;
use Core\Commerce\Services\PaymentGateway\StripeGateway;
use Core\Commerce\Services\WebhookLogger;
/**
* Handle Stripe webhooks.
*
* Key events:
* - checkout.session.completed: One-time payment or subscription started
* - invoice.paid: Subscription renewal successful
* - invoice.payment_failed: Payment failed
* - customer.subscription.updated: Plan change, pause, etc.
* - customer.subscription.deleted: Subscription cancelled
* - payment_method.attached/detached: Card updates
*/
class StripeWebhookController extends Controller
{
public function __construct(
protected StripeGateway $gateway,
protected CommerceService $commerce,
protected InvoiceService $invoiceService,
protected EntitlementService $entitlements,
protected WebhookLogger $webhookLogger,
) {}
public function handle(Request $request): Response
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
// Verify webhook signature
if (! $this->gateway->verifyWebhookSignature($payload, $signature)) {
Log::warning('Stripe webhook signature verification failed');
return response('Invalid signature', 401);
}
$event = $this->gateway->parseWebhookEvent($payload);
// Log the webhook event for audit trail
$this->webhookLogger->startFromParsedEvent('stripe', $event, $payload, $request);
Log::info('Stripe webhook received', [
'type' => $event['type'],
'id' => $event['id'],
]);
try {
// Wrap all webhook processing in a transaction to ensure data integrity
$response = DB::transaction(function () use ($event) {
return match ($event['type']) {
'checkout.session.completed' => $this->handleCheckoutCompleted($event),
'invoice.paid' => $this->handleInvoicePaid($event),
'invoice.payment_failed' => $this->handleInvoicePaymentFailed($event),
'customer.subscription.created' => $this->handleSubscriptionCreated($event),
'customer.subscription.updated' => $this->handleSubscriptionUpdated($event),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event),
'payment_method.attached' => $this->handlePaymentMethodAttached($event),
'payment_method.detached' => $this->handlePaymentMethodDetached($event),
'payment_method.updated' => $this->handlePaymentMethodUpdated($event),
'setup_intent.succeeded' => $this->handleSetupIntentSucceeded($event),
default => $this->handleUnknownEvent($event),
};
});
$this->webhookLogger->success($response);
return $response;
} catch (\Exception $e) {
Log::error('Stripe webhook processing error', [
'type' => $event['type'],
'error' => $e->getMessage(),
]);
$this->webhookLogger->fail($e->getMessage(), 500);
return response('Processing error', 500);
}
}
protected function handleUnknownEvent(array $event): Response
{
$this->webhookLogger->skip('Unhandled event type: '.$event['type']);
return response('Unhandled event type', 200);
}
protected function handleCheckoutCompleted(array $event): Response
{
$session = $event['raw']['data']['object'];
$orderId = $session['metadata']['order_id'] ?? null;
if (! $orderId) {
Log::warning('Stripe checkout.session.completed: No order_id in metadata');
return response('No order_id', 200);
}
$order = Order::find($orderId);
if (! $order) {
Log::warning('Stripe checkout: Order not found', ['order_id' => $orderId]);
return response('Order not found', 200);
}
// Link webhook event to order for audit trail
$this->webhookLogger->linkOrder($order);
// Skip if already paid
if ($order->isPaid()) {
return response('Already processed', 200);
}
// Create payment record
$payment = Payment::create([
'workspace_id' => $order->workspace_id,
'order_id' => $order->id,
'gateway' => 'stripe',
'gateway_payment_id' => $session['payment_intent'] ?? $session['id'],
'amount' => ($session['amount_total'] ?? 0) / 100,
'currency' => strtoupper($session['currency'] ?? 'GBP'),
'status' => 'succeeded',
'paid_at' => now(),
'gateway_response' => $session,
]);
// Handle subscription if present
if (! empty($session['subscription'])) {
$this->createOrUpdateSubscriptionFromSession($order, $session);
}
// Fulfil the order
$this->commerce->fulfillOrder($order, $payment);
// Send confirmation
$this->sendOrderConfirmation($order);
Log::info('Stripe order fulfilled', [
'order_id' => $order->id,
'order_number' => $order->order_number,
]);
return response('OK', 200);
}
protected function handleInvoicePaid(array $event): Response
{
$invoice = $event['raw']['data']['object'];
$subscriptionId = $invoice['subscription'] ?? null;
if (! $subscriptionId) {
// One-time invoice, not subscription
return response('OK', 200);
}
$subscription = Subscription::where('gateway', 'stripe')
->where('gateway_subscription_id', $subscriptionId)
->first();
if (! $subscription) {
Log::warning('Stripe invoice.paid: Subscription not found', ['subscription_id' => $subscriptionId]);
return response('Subscription not found', 200);
}
// Link webhook event to subscription for audit trail
$this->webhookLogger->linkSubscription($subscription);
// Update subscription period
$subscription->renew(
Carbon::createFromTimestamp($invoice['period_start']),
Carbon::createFromTimestamp($invoice['period_end'])
);
// Create payment record
$payment = Payment::create([
'workspace_id' => $subscription->workspace_id,
'gateway' => 'stripe',
'gateway_payment_id' => $invoice['payment_intent'] ?? $invoice['id'],
'amount' => ($invoice['amount_paid'] ?? 0) / 100,
'currency' => strtoupper($invoice['currency'] ?? 'GBP'),
'status' => 'succeeded',
'paid_at' => now(),
'gateway_response' => $invoice,
]);
// Create local invoice
$this->invoiceService->createForRenewal(
$subscription->workspace,
$payment->amount,
'Subscription renewal',
$payment
);
Log::info('Stripe subscription renewed', [
'subscription_id' => $subscription->id,
'payment_id' => $payment->id,
]);
return response('OK', 200);
}
protected function handleInvoicePaymentFailed(array $event): Response
{
$invoice = $event['raw']['data']['object'];
$subscriptionId = $invoice['subscription'] ?? null;
if (! $subscriptionId) {
return response('OK', 200);
}
$subscription = Subscription::where('gateway', 'stripe')
->where('gateway_subscription_id', $subscriptionId)
->first();
if ($subscription) {
$subscription->markPastDue();
// Send notification
$owner = $subscription->workspace->owner();
if ($owner && config('commerce.notifications.payment_failed', true)) {
$owner->notify(new PaymentFailed($subscription));
}
}
return response('OK', 200);
}
protected function handleSubscriptionCreated(array $event): Response
{
// Usually handled by checkout.session.completed
// This is a fallback for direct API subscription creation
return response('OK', 200);
}
protected function handleSubscriptionUpdated(array $event): Response
{
$stripeSubscription = $event['raw']['data']['object'];
$subscription = Subscription::where('gateway', 'stripe')
->where('gateway_subscription_id', $stripeSubscription['id'])
->first();
if (! $subscription) {
return response('Subscription not found', 200);
}
$subscription->update([
'status' => $this->mapStripeStatus($stripeSubscription['status']),
'cancel_at_period_end' => $stripeSubscription['cancel_at_period_end'] ?? false,
'current_period_start' => Carbon::createFromTimestamp($stripeSubscription['current_period_start']),
'current_period_end' => Carbon::createFromTimestamp($stripeSubscription['current_period_end']),
]);
return response('OK', 200);
}
protected function handleSubscriptionDeleted(array $event): Response
{
$stripeSubscription = $event['raw']['data']['object'];
$subscription = Subscription::where('gateway', 'stripe')
->where('gateway_subscription_id', $stripeSubscription['id'])
->first();
if ($subscription) {
$subscription->update([
'status' => 'cancelled',
'ended_at' => now(),
]);
// Revoke entitlements
$workspacePackage = $subscription->workspacePackage;
if ($workspacePackage) {
$this->entitlements->revokePackage(
$subscription->workspace,
$workspacePackage->package->code
);
}
// Send notification
$owner = $subscription->workspace->owner();
if ($owner && config('commerce.notifications.subscription_cancelled', true)) {
$owner->notify(new SubscriptionCancelled($subscription));
}
}
return response('OK', 200);
}
protected function handlePaymentMethodAttached(array $event): Response
{
$stripePaymentMethod = $event['raw']['data']['object'];
$customerId = $stripePaymentMethod['customer'] ?? null;
if (! $customerId) {
return response('OK', 200);
}
$workspace = Workspace::where('stripe_customer_id', $customerId)->first();
if (! $workspace) {
return response('Workspace not found', 200);
}
// Check if payment method already exists
$exists = PaymentMethod::where('gateway', 'stripe')
->where('gateway_payment_method_id', $stripePaymentMethod['id'])
->exists();
if (! $exists) {
PaymentMethod::create([
'workspace_id' => $workspace->id,
'gateway' => 'stripe',
'gateway_payment_method_id' => $stripePaymentMethod['id'],
'type' => $stripePaymentMethod['type'] ?? 'card',
'last_four' => $stripePaymentMethod['card']['last4'] ?? null,
'brand' => $stripePaymentMethod['card']['brand'] ?? null,
'exp_month' => $stripePaymentMethod['card']['exp_month'] ?? null,
'exp_year' => $stripePaymentMethod['card']['exp_year'] ?? null,
'is_default' => false,
]);
}
return response('OK', 200);
}
protected function handlePaymentMethodDetached(array $event): Response
{
$stripePaymentMethod = $event['raw']['data']['object'];
// Soft-delete by marking as inactive (don't hard delete for audit trail)
PaymentMethod::where('gateway', 'stripe')
->where('gateway_payment_method_id', $stripePaymentMethod['id'])
->update(['is_active' => false]);
return response('OK', 200);
}
/**
* Handle payment method updates (e.g., card expiry update from card networks).
*/
protected function handlePaymentMethodUpdated(array $event): Response
{
$stripePaymentMethod = $event['raw']['data']['object'];
$paymentMethod = PaymentMethod::where('gateway', 'stripe')
->where('gateway_payment_method_id', $stripePaymentMethod['id'])
->first();
if ($paymentMethod) {
$card = $stripePaymentMethod['card'] ?? [];
$paymentMethod->update([
'brand' => $card['brand'] ?? $paymentMethod->brand,
'last_four' => $card['last4'] ?? $paymentMethod->last_four,
'exp_month' => $card['exp_month'] ?? $paymentMethod->exp_month,
'exp_year' => $card['exp_year'] ?? $paymentMethod->exp_year,
]);
}
return response('OK', 200);
}
/**
* Handle setup intent success (new payment method added via hosted setup page).
*/
protected function handleSetupIntentSucceeded(array $event): Response
{
$setupIntent = $event['raw']['data']['object'];
$customerId = $setupIntent['customer'] ?? null;
$paymentMethodId = $setupIntent['payment_method'] ?? null;
if (! $customerId || ! $paymentMethodId) {
return response('OK', 200);
}
$workspace = Workspace::where('stripe_customer_id', $customerId)->first();
if (! $workspace) {
Log::warning('Stripe setup_intent.succeeded: Workspace not found', ['customer_id' => $customerId]);
return response('Workspace not found', 200);
}
// The payment_method.attached webhook should handle creating the record
// But we can also ensure it exists here as a fallback
$exists = PaymentMethod::where('gateway', 'stripe')
->where('gateway_payment_method_id', $paymentMethodId)
->exists();
if (! $exists) {
// Fetch payment method details from Stripe
try {
$this->gateway->attachPaymentMethod($workspace, $paymentMethodId);
Log::info('Payment method created from setup_intent', [
'workspace_id' => $workspace->id,
'payment_method_id' => $paymentMethodId,
]);
} catch (\Exception $e) {
Log::warning('Failed to attach payment method from setup_intent', [
'workspace_id' => $workspace->id,
'payment_method_id' => $paymentMethodId,
'error' => $e->getMessage(),
]);
}
}
return response('OK', 200);
}
protected function createOrUpdateSubscriptionFromSession(Order $order, array $session): void
{
$stripeSubscriptionId = $session['subscription'];
// Check if subscription already exists
$subscription = Subscription::where('gateway_subscription_id', $stripeSubscriptionId)->first();
if ($subscription) {
return;
}
// Get subscription details from Stripe
$stripeSubscription = $this->gateway->getInvoice($stripeSubscriptionId);
// Find workspace package from order items
$packageItem = $order->items->firstWhere('type', 'package');
$workspace = $order->getResolvedWorkspace();
$workspacePackage = ($packageItem?->package && $workspace)
? $workspace->workspacePackages()
->where('package_id', $packageItem->package_id)
->first()
: null;
Subscription::create([
'workspace_id' => $order->workspace_id,
'workspace_package_id' => $workspacePackage?->id,
'gateway' => 'stripe',
'gateway_subscription_id' => $stripeSubscriptionId,
'gateway_customer_id' => $session['customer'],
'gateway_price_id' => $stripeSubscription['items']['data'][0]['price']['id'] ?? null,
'status' => $this->mapStripeStatus($stripeSubscription['status'] ?? 'active'),
'current_period_start' => Carbon::createFromTimestamp($stripeSubscription['current_period_start'] ?? time()),
'current_period_end' => Carbon::createFromTimestamp($stripeSubscription['current_period_end'] ?? time() + 2592000),
]);
}
protected function mapStripeStatus(string $status): string
{
return match ($status) {
'active' => 'active',
'trialing' => 'trialing',
'past_due' => 'past_due',
'paused' => 'paused',
'canceled', 'cancelled' => 'cancelled',
'incomplete', 'incomplete_expired' => 'incomplete',
default => 'active',
};
}
protected function sendOrderConfirmation(Order $order): void
{
if (! config('commerce.notifications.order_confirmation', true)) {
return;
}
// Use resolved workspace to handle both Workspace and User orderables
$workspace = $order->getResolvedWorkspace();
$owner = $workspace?->owner();
if ($owner) {
$owner->notify(new OrderConfirmation($order));
}
}
}

77
Data/BundleItem.php Normal file
View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Data;
/**
* A bundle of items (pipe-separated in SKU string).
*
* Example: LAPTOP-ram~16gb|MOUSE|PAD becomes BundleItem([LAPTOP, MOUSE, PAD items], hash)
*
* The hash is computed from sorted base SKUs (stripping options) for discount lookup.
*/
readonly class BundleItem
{
/**
* @param array<ParsedItem> $items
*/
public function __construct(
public array $items,
public string $hash,
) {}
/**
* Build the string representation (pipe-separated).
*/
public function toString(): string
{
return implode('|', array_map(
fn (ParsedItem $item) => $item->toString(),
$this->items
));
}
/**
* Get just the base SKUs (for display/debugging).
*/
public function getBaseSkus(): array
{
return array_map(
fn (ParsedItem $item) => $item->baseSku,
$this->items
);
}
/**
* Get the sorted base SKU string (what the hash is computed from).
*/
public function getBaseSkuString(): string
{
$skus = $this->getBaseSkus();
sort($skus);
return implode('|', $skus);
}
/**
* Check if bundle contains a specific base SKU.
*/
public function containsSku(string $baseSku): bool
{
return in_array(strtoupper($baseSku), array_map('strtoupper', $this->getBaseSkus()), true);
}
/**
* Count of items in bundle.
*/
public function count(): int
{
return count($this->items);
}
public function __toString(): string
{
return $this->toString();
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace Core\Commerce\Data;
use Core\Commerce\Models\Coupon;
/**
* Coupon validation result.
*/
class CouponValidationResult
{
public function __construct(
public readonly bool $isValid,
public readonly ?Coupon $coupon,
public readonly ?string $error,
) {}
public static function valid(Coupon $coupon): self
{
return new self(true, $coupon, null);
}
public static function invalid(string $error): self
{
return new self(false, null, $error);
}
public function isValid(): bool
{
return $this->isValid;
}
public function getDiscount(float $amount): float
{
if (! $this->isValid || ! $this->coupon) {
return 0;
}
return $this->coupon->calculateDiscount($amount);
}
}

65
Data/ParsedItem.php Normal file
View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Data;
/**
* A parsed SKU item with its options.
*
* Example: LAPTOP-ram~16gb-ssd~512gb becomes ParsedItem('LAPTOP', [ram, ssd options])
*/
readonly class ParsedItem
{
/**
* @param array<SkuOption> $options
*/
public function __construct(
public string $baseSku,
public array $options = [],
) {}
/**
* Build the string representation.
*/
public function toString(): string
{
if (empty($this->options)) {
return $this->baseSku;
}
$optionStrings = array_map(
fn (SkuOption $opt) => $opt->toString(),
$this->options
);
return $this->baseSku.'-'.implode('-', $optionStrings);
}
/**
* Get option by code.
*/
public function getOption(string $code): ?SkuOption
{
foreach ($this->options as $option) {
if (strtolower($option->code) === strtolower($code)) {
return $option;
}
}
return null;
}
/**
* Check if item has a specific option.
*/
public function hasOption(string $code): bool
{
return $this->getOption($code) !== null;
}
public function __toString(): string
{
return $this->toString();
}
}

38
Data/SkuOption.php Normal file
View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Data;
/**
* A single option on a SKU.
*
* Example: ram~16gb*2 becomes SkuOption('ram', '16gb', 2)
*/
readonly class SkuOption
{
public function __construct(
public string $code,
public string $value,
public int $quantity = 1,
) {}
/**
* Build the string representation.
*/
public function toString(): string
{
$str = "{$this->code}~{$this->value}";
if ($this->quantity > 1) {
$str .= "*{$this->quantity}";
}
return $str;
}
public function __toString(): string
{
return $this->toString();
}
}

138
Data/SkuParseResult.php Normal file
View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Data;
use Illuminate\Support\Collection;
/**
* Result of parsing a compound SKU string.
*
* Contains a mix of ParsedItem (single items) and BundleItem (pipe-grouped items).
*
* Example input: "LAPTOP-ram~16gb|MOUSE,HDMI-length~2m"
* Results in: [BundleItem(LAPTOP, MOUSE), ParsedItem(HDMI)]
*/
readonly class SkuParseResult
{
/**
* @param array<ParsedItem|BundleItem> $items
*/
public function __construct(
public array $items,
) {}
/**
* Get all items as a collection.
*
* @return Collection<ParsedItem|BundleItem>
*/
public function collect(): Collection
{
return collect($this->items);
}
/**
* Get only single items (not bundles).
*
* @return Collection<ParsedItem>
*/
public function singles(): Collection
{
return $this->collect()->filter(
fn ($item) => $item instanceof ParsedItem
);
}
/**
* Get only bundles.
*
* @return Collection<BundleItem>
*/
public function bundles(): Collection
{
return $this->collect()->filter(
fn ($item) => $item instanceof BundleItem
);
}
/**
* Check if result contains any bundles.
*/
public function hasBundles(): bool
{
return $this->bundles()->isNotEmpty();
}
/**
* Get all base SKUs (flattened from items and bundles).
*/
public function getAllBaseSkus(): array
{
$skus = [];
foreach ($this->items as $item) {
if ($item instanceof BundleItem) {
$skus = array_merge($skus, $item->getBaseSkus());
} else {
$skus[] = $item->baseSku;
}
}
return $skus;
}
/**
* Get all bundle hashes (for discount lookup).
*/
public function getBundleHashes(): array
{
return $this->bundles()
->map(fn (BundleItem $bundle) => $bundle->hash)
->values()
->all();
}
/**
* Total count of line items (bundles count as 1).
*/
public function count(): int
{
return count($this->items);
}
/**
* Total count of individual products (bundle items expanded).
*/
public function productCount(): int
{
$count = 0;
foreach ($this->items as $item) {
if ($item instanceof BundleItem) {
$count += $item->count();
} else {
$count++;
}
}
return $count;
}
/**
* Rebuild the compound SKU string.
*/
public function toString(): string
{
return implode(',', array_map(
fn ($item) => $item->toString(),
$this->items
));
}
public function __toString(): string
{
return $this->toString();
}
}

24
Events/OrderPaid.php Normal file
View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\Payment;
/**
* Event fired when an order is successfully paid.
*/
class OrderPaid
{
use Dispatchable;
use SerializesModels;
public function __construct(
public Order $order,
public Payment $payment
) {}
}

View file

@ -0,0 +1,17 @@
<?php
namespace Core\Commerce\Events;
use Core\Commerce\Models\Subscription;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SubscriptionCancelled
{
use Dispatchable, SerializesModels;
public function __construct(
public Subscription $subscription,
public bool $immediate = false
) {}
}

View file

@ -0,0 +1,16 @@
<?php
namespace Core\Commerce\Events;
use Core\Commerce\Models\Subscription;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SubscriptionCreated
{
use Dispatchable, SerializesModels;
public function __construct(
public Subscription $subscription
) {}
}

View file

@ -0,0 +1,17 @@
<?php
namespace Core\Commerce\Events;
use Core\Commerce\Models\Subscription;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SubscriptionRenewed
{
use Dispatchable, SerializesModels;
public function __construct(
public Subscription $subscription,
public ?\DateTimeInterface $previousPeriodEnd = null
) {}
}

View file

@ -0,0 +1,17 @@
<?php
namespace Core\Commerce\Events;
use Core\Commerce\Models\Subscription;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SubscriptionUpdated
{
use Dispatchable, SerializesModels;
public function __construct(
public Subscription $subscription,
public ?string $previousStatus = null
) {}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Exceptions;
use Exception;
use Core\Commerce\Models\Subscription;
/**
* Exception thrown when a subscription has exceeded its pause cycle limit.
*/
class PauseLimitExceededException extends Exception
{
public function __construct(
public readonly Subscription $subscription,
public readonly int $maxPauseCycles,
string $message = '',
) {
$message = $message ?: sprintf(
'Subscription has reached the maximum number of pause cycles (%d).',
$maxPauseCycles
);
parent::__construct($message);
}
}

View file

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Jobs;
use Core\Commerce\Events\SubscriptionRenewed;
use Core\Commerce\Models\Subscription;
use Core\Mod\Tenant\Models\EntitlementLog;
use Core\Mod\Tenant\Models\WorkspacePackage;
use Core\Mod\Tenant\Services\EntitlementService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Process subscription renewal: extend package, reset cycle boosts, update usage.
*
* This job is dispatched when a subscription renews (payment succeeds for new period).
* It ensures entitlements are extended and cycle-bound boosts are reset.
*/
class ProcessSubscriptionRenewal implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
public Subscription $subscription,
public ?\DateTimeInterface $newPeriodEnd = null
) {}
public function handle(EntitlementService $entitlements): void
{
$workspace = $this->subscription->workspace;
if (! $workspace) {
Log::warning('ProcessSubscriptionRenewal: Subscription has no workspace', [
'subscription_id' => $this->subscription->id,
]);
return;
}
$workspacePackage = $this->subscription->workspacePackage;
if (! $workspacePackage) {
Log::warning('ProcessSubscriptionRenewal: Subscription has no workspace package', [
'subscription_id' => $this->subscription->id,
]);
return;
}
$previousExpiry = $workspacePackage->expires_at;
$newExpiry = $this->newPeriodEnd ?? $this->subscription->current_period_end;
// 1. Extend package expiry
$workspacePackage->update([
'expires_at' => $newExpiry,
'billing_cycle_anchor' => now(),
'status' => WorkspacePackage::STATUS_ACTIVE,
]);
// 2. Expire cycle-bound boosts from the previous billing cycle
$entitlements->expireCycleBoundBoosts($workspace);
// 3. Invalidate entitlement cache
$entitlements->invalidateCache($workspace);
// 4. Log the renewal
EntitlementLog::logPackageAction(
$workspace,
EntitlementLog::ACTION_PACKAGE_RENEWED,
$workspacePackage,
source: EntitlementLog::SOURCE_COMMERCE,
metadata: [
'subscription_id' => $this->subscription->id,
'previous_expires_at' => $previousExpiry?->toIso8601String(),
'new_expires_at' => $newExpiry?->toIso8601String(),
]
);
Log::info('Subscription renewal processed', [
'subscription_id' => $this->subscription->id,
'workspace_id' => $workspace->id,
'package_code' => $workspacePackage->package->code ?? 'unknown',
'new_expiry' => $newExpiry?->toIso8601String(),
]);
// 5. Fire event for any additional listeners
event(new SubscriptionRenewed($this->subscription, $previousExpiry));
}
}

452
Lang/en_GB/commerce.php Normal file
View file

@ -0,0 +1,452 @@
<?php
declare(strict_types=1);
/**
* Commerce module translations (en_GB).
*
* Key structure: section.subsection.key
*/
return [
// Dashboard
'dashboard' => [
'title' => 'Commerce Dashboard',
'subtitle' => 'Revenue overview and order management',
],
// Common actions
'actions' => [
'view_orders' => 'View Orders',
'add_product' => 'Add Product',
'new_coupon' => 'New Coupon',
'new_entity' => 'New M1 Entity',
'add_permission' => 'Add Permission',
'edit' => 'Edit',
'delete' => 'Delete',
'cancel' => 'Cancel',
'close' => 'Close',
'save' => 'Save',
'create' => 'Create',
'update' => 'Update',
'assign' => 'Assign Product',
'entity_hierarchy' => 'Entity Hierarchy',
],
// Common sections
'sections' => [
'quick_actions' => 'Quick Actions',
'recent_orders' => 'Recent Orders',
],
// Common table columns
'table' => [
'order' => 'Order',
'workspace' => 'Workspace',
'status' => 'Status',
'total' => 'Total',
'product' => 'Product',
'sku' => 'SKU',
'price' => 'Price',
'stock' => 'Stock',
'assignments' => 'Assignments',
'actions' => 'Actions',
],
// Common filters
'filters' => [
'entity' => 'Entity',
'all_entities' => 'All Entities',
'search' => 'Search',
'search_placeholder' => 'Search by name or SKU...',
'category' => 'Category',
'all_categories' => 'All Categories',
'stock_status' => 'Stock Status',
'all' => 'All',
'in_stock' => 'In Stock',
'low_stock' => 'Low Stock',
'out_of_stock' => 'Out of Stock',
'backorder' => 'Backorder',
'status' => 'Status',
],
// Common form fields
'form' => [
'sku' => 'SKU',
'type' => 'Type',
'name' => 'Name',
'description' => 'Description',
'category' => 'Category',
'subcategory' => 'Subcategory',
'price' => 'Price (pence)',
'cost_price' => 'Cost Price',
'rrp' => 'RRP',
'stock_quantity' => 'Stock Quantity',
'low_stock_threshold' => 'Low Stock Threshold',
'tax_class' => 'Tax Class',
'track_stock' => 'Track stock',
'allow_backorder' => 'Allow backorder',
'active' => 'Active',
'featured' => 'Featured',
'visible' => 'Visible',
'code' => 'Code',
'currency' => 'Currency',
'timezone' => 'Timezone',
'domain' => 'Domain (optional)',
'linked_workspace' => 'Linked Workspace (optional)',
],
// Product types
'product_types' => [
'simple' => 'Simple',
'variable' => 'Variable',
'bundle' => 'Bundle',
'virtual' => 'Virtual',
'subscription' => 'Subscription',
],
// Tax classes
'tax_classes' => [
'standard' => 'Standard (20%)',
'reduced' => 'Reduced (5%)',
'zero' => 'Zero (0%)',
'exempt' => 'Exempt',
],
// Products
'products' => [
'title' => 'Product Catalog',
'subtitle' => 'Manage master product catalog and entity assignments',
'empty' => 'No products found for this entity.',
'empty_no_entity' => 'Select an entity to view products.',
'create_first' => 'Create your first product',
'units' => 'units',
'not_tracked' => 'Not tracked',
'uncategorised' => 'Uncategorised',
'entities' => 'entities',
'modal' => [
'create_title' => 'Create Product',
'edit_title' => 'Edit Product',
],
'actions' => [
'create' => 'Create Product',
'update' => 'Update Product',
'delete_confirm' => 'Delete this product?',
],
],
// Product assignments
'assignments' => [
'title' => 'Assign Product to Entity',
'entity' => 'Entity',
'select_entity' => 'Select entity...',
'price_override' => 'Price Override (pence)',
'price_placeholder' => 'Leave blank for default',
'margin_percent' => 'Margin %',
'name_override' => 'Name Override',
'name_placeholder' => 'Leave blank for default',
],
// Orders
'orders' => [
'title' => 'Orders',
'subtitle' => 'Manage customer orders',
'empty' => 'No orders found.',
'empty_dashboard' => 'No orders yet',
'search_placeholder' => 'Search orders...',
'all_statuses' => 'All statuses',
'all_types' => 'All types',
'all_time' => 'All time',
'date_range' => [
'today' => 'Today',
'7d' => 'Last 7 days',
'30d' => 'Last 30 days',
'90d' => 'Last 90 days',
'this_month' => 'This month',
'last_month' => 'Last month',
],
'detail' => [
'summary' => 'Order Summary',
'totals' => 'Order Totals',
'status' => 'Status',
'type' => 'Type',
'payment_gateway' => 'Payment Gateway',
'paid_at' => 'Paid At',
'not_paid' => 'Not paid',
'customer' => 'Customer Information',
'name' => 'Name',
'email' => 'Email',
'workspace' => 'Workspace',
'items' => 'Order Items',
'subtotal' => 'Subtotal',
'discount' => 'Discount',
'tax' => 'Tax',
'total' => 'Total',
'invoice' => 'Invoice',
'view_invoice' => 'View Invoice',
],
'update_status' => [
'title' => 'Update Order Status',
'new_status' => 'New Status',
'note' => 'Note (optional)',
'note_placeholder' => 'Reason for status change...',
],
],
// Subscriptions
'subscriptions' => [
'title' => 'Subscriptions',
'subtitle' => 'Manage workspace subscriptions',
'empty' => 'No subscriptions found.',
'search_placeholder' => 'Search workspaces...',
'all_statuses' => 'All statuses',
'all_gateways' => 'All gateways',
'detail' => [
'title' => 'Subscription Details',
'summary' => 'Subscription Summary',
'status' => 'Status',
'gateway' => 'Gateway',
'billing_cycle' => 'Billing Cycle',
'created' => 'Created',
'workspace' => 'Workspace',
'package' => 'Package',
'current_period' => 'Billing Period',
'billing_progress' => 'Billing Progress',
'days_remaining' => 'days remaining',
'start' => 'Start',
'end' => 'End',
'gateway_details' => 'Gateway Details',
'subscription_id' => 'Subscription ID',
'customer_id' => 'Customer ID',
'price_id' => 'Price ID',
'cancellation' => 'Cancellation',
'cancelled_at' => 'Cancelled at',
'reason' => 'Reason',
'ended_at' => 'Ended at',
'will_end_at_period_end' => 'Will end at period end',
'trial' => 'Trial Period',
'trial_ends' => 'Ends',
],
'update_status' => [
'title' => 'Update Subscription Status',
'workspace' => 'Workspace',
'new_status' => 'New Status',
'note' => 'Note (optional)',
'note_placeholder' => 'Reason for status change...',
],
'extend' => [
'title' => 'Extend Subscription Period',
'current_period_ends' => 'Current Period Ends',
'extend_by_days' => 'Extend by (days)',
'new_end_date' => 'New end date',
'action' => 'Extend Period',
],
],
// Coupons
'coupons' => [
'title' => 'Coupons',
'subtitle' => 'Manage discount codes',
'empty' => 'No coupons found. Create your first coupon to get started.',
'search_placeholder' => 'Search codes or names...',
'all_coupons' => 'All coupons',
'modal' => [
'create_title' => 'Create Coupon',
'edit_title' => 'Edit Coupon',
],
'sections' => [
'basic_info' => 'Basic Information',
'discount_settings' => 'Discount Settings',
'applicability' => 'Applicability',
'usage_limits' => 'Usage Limits',
'validity_period' => 'Validity Period',
],
'form' => [
'code' => 'Code',
'code_placeholder' => 'SUMMER2025',
'name' => 'Name',
'name_placeholder' => 'Summer Sale',
'description' => 'Description (optional)',
'discount_type' => 'Discount Type',
'percentage' => 'Percentage (%)',
'fixed_amount' => 'Fixed amount (GBP)',
'discount_percent' => 'Discount %',
'discount_amount' => 'Discount amount',
'min_amount' => 'Minimum order amount (optional)',
'max_discount' => 'Maximum discount (optional)',
'no_limit' => 'No limit',
'applies_to' => 'Applies to',
'all_packages' => 'All packages',
'specific_packages' => 'Specific packages',
'packages' => 'Packages',
'max_uses' => 'Max total uses (optional)',
'unlimited' => 'Unlimited',
'max_uses_per_workspace' => 'Max per workspace',
'duration' => 'Duration',
'apply_once' => 'Apply once',
'apply_repeating' => 'Apply for X months',
'apply_forever' => 'Apply forever',
'duration_months' => 'Number of months',
'valid_from' => 'Valid from (optional)',
'valid_until' => 'Valid until (optional)',
],
'actions' => [
'create' => 'Create Coupon',
'update' => 'Update Coupon',
],
'bulk' => [
'generate_button' => 'Bulk Generate',
'modal_title' => 'Bulk Generate Coupons',
'generation_settings' => 'Generation Settings',
'count' => 'Number of coupons',
'code_prefix' => 'Code prefix (optional)',
'code_prefix_placeholder' => 'PROMO',
'generate_action' => 'Generate Coupons',
'generated' => ':count coupon(s) generated successfully.',
],
],
// Entities
'entities' => [
'title' => 'Commerce Entities',
'subtitle' => 'Manage M1/M2/M3 entity hierarchy',
'empty' => 'No entities yet',
'create_first' => 'Create your first M1 entity',
'hierarchy' => 'Entity Hierarchy',
'stats' => [
'total' => 'Total Entities',
'm1_masters' => 'M1 Masters',
'm2_facades' => 'M2 Facades',
'm3_dropshippers' => 'M3 Dropshippers',
'active' => 'Active',
],
'types' => [
'm1' => 'M1 (Master)',
'm2' => 'M2 (Facade)',
'm3' => 'M3 (Dropshipper)',
],
'modal' => [
'create_title' => 'Create Entity',
'edit_title' => 'Edit Entity',
],
'form' => [
'code' => 'Code',
'code_placeholder' => 'ORGORG',
'name' => 'Name',
'name_placeholder' => 'Original Organics Ltd',
'type' => 'Type',
'parent' => 'Parent',
],
'delete' => [
'title' => 'Delete Entity',
'confirm' => 'Are you sure you want to delete this entity? This action cannot be undone. All associated permissions will also be deleted.',
],
'status' => [
'active' => 'Active',
'inactive' => 'Inactive',
],
'actions' => [
'add_child' => 'Add child entity',
'activate' => 'Activate',
'deactivate' => 'Deactivate',
'edit' => 'Edit entity',
'delete' => 'Delete entity',
],
],
// Permission Matrix
'permissions' => [
'title' => 'Permission Matrix',
'subtitle' => 'Train and manage entity permissions',
'empty' => 'No permissions trained yet',
'empty_help' => 'Permissions will appear here as you train them through the matrix.',
'search_placeholder' => 'Search permissions...',
'stats' => [
'total' => 'Total Permissions',
'allowed' => 'Allowed',
'denied' => 'Denied',
'locked' => 'Locked',
'pending' => 'Pending',
],
'pending_requests' => 'Pending Requests',
'trained_permissions' => 'Trained Permissions',
'table' => [
'entity' => 'Entity',
'action' => 'Action',
'route' => 'Route',
'time' => 'Time',
'permission_key' => 'Permission Key',
'scope' => 'Scope',
'status' => 'Status',
'source' => 'Source',
],
'status' => [
'allowed' => 'Allowed',
'denied' => 'Denied',
'locked' => 'Locked',
],
'actions' => [
'train' => 'Train',
'allow_selected' => 'Allow Selected',
'deny_selected' => 'Deny Selected',
'unlock' => 'Unlock',
'delete' => 'Delete',
],
'train_modal' => [
'title' => 'Train Permission',
'entity' => 'Entity',
'select_entity' => 'Select entity...',
'permission_key' => 'Permission Key',
'key_placeholder' => 'product.create',
'key_help' => 'e.g., product.create, order.view, refund.process',
'scope' => 'Scope (optional)',
'scope_placeholder' => 'Leave empty for global',
'decision' => [
'allow' => 'Allow',
'deny' => 'Deny',
],
'lock' => [
'label' => 'Lock this permission',
'help' => 'Child entities cannot override this decision. Use for critical restrictions.',
],
'action' => 'Train Permission',
],
],
// Common status labels
'status' => [
'none' => 'None',
'unknown' => 'unknown',
'global' => 'global',
'active' => 'Active',
'inactive' => 'Inactive',
'featured' => 'Featured',
],
// Referrals
'referrals' => [
'title' => 'Referrals',
'subtitle' => 'Manage affiliate referrals, commissions, and payouts',
],
// Bulk actions
'bulk' => [
'export' => 'Export',
'delete' => 'Delete',
'change_status' => 'Change Status',
'extend_period' => 'Extend 30 days',
'activate' => 'Activate',
'deactivate' => 'Deactivate',
'no_selection' => 'Please select at least one item.',
'export_success' => ':count item(s) exported successfully.',
'status_updated' => ':count item(s) updated to :status.',
'period_extended' => ':count subscription(s) extended by :days days.',
'activated' => ':count coupon(s) activated.',
'deactivated' => ':count coupon(s) deactivated.',
'deleted' => ':count coupon(s) deleted.',
'skipped_used' => ':count coupon(s) skipped (already used).',
'confirm_delete_title' => 'Confirm Bulk Delete',
'confirm_delete_message' => 'Are you sure you want to delete :count coupon(s)?',
'delete_warning' => 'Coupons that have been used cannot be deleted and will be skipped.',
],
];

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
use Core\Commerce\Events\OrderPaid;
use Core\Commerce\Services\ReferralService;
/**
* Creates referral commission when an order is paid.
*
* Checks if the order's user was referred and creates a commission
* record that will mature after the refund/chargeback period.
*/
class CreateReferralCommission implements ShouldQueue
{
public function __construct(
protected ReferralService $referralService
) {}
/**
* Handle the order paid event.
*/
public function handle(OrderPaid $event): void
{
$order = $event->order;
// Skip if no user on order
if (! $order->user) {
return;
}
// Skip free orders
if ($order->total <= 0) {
return;
}
try {
$commission = $this->referralService->createCommissionForOrder($order);
if ($commission) {
Log::info('Referral commission created for order', [
'order_id' => $order->id,
'commission_id' => $commission->id,
'amount' => $commission->commission_amount,
]);
}
} catch (\Exception $e) {
Log::error('Failed to create referral commission', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
}
}
}

View file

@ -0,0 +1,296 @@
<?php
namespace Core\Commerce\Listeners;
use Core\Commerce\Jobs\ProcessSubscriptionRenewal;
use Core\Commerce\Events\SubscriptionCancelled;
use Core\Commerce\Events\SubscriptionCreated;
use Core\Commerce\Events\SubscriptionRenewed;
use Core\Commerce\Events\SubscriptionUpdated;
use Core\Mod\Tenant\Models\Package;
use Core\Mod\Tenant\Models\WorkspacePackage;
use Core\Mod\Tenant\Services\EntitlementService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
/**
* Provisions SocialHost entitlements when subscriptions are created/modified.
*
* This listener bridges Commerce subscriptions to Entitlement packages,
* automatically granting or revoking SocialHost features based on subscription status.
*/
class ProvisionSocialHostSubscription implements ShouldQueue
{
public function __construct(
protected EntitlementService $entitlementService
) {}
/**
* Handle subscription created event.
*/
public function handleSubscriptionCreated(SubscriptionCreated $event): void
{
$subscription = $event->subscription;
if (! $this->isSocialHostProduct($subscription)) {
return;
}
$this->provisionPackage($subscription);
Log::info('SocialHost subscription provisioned', [
'subscription_id' => $subscription->id,
'workspace_id' => $subscription->workspace_id,
'product' => $subscription->product->slug ?? 'unknown',
]);
}
/**
* Handle subscription updated event.
*/
public function handleSubscriptionUpdated(SubscriptionUpdated $event): void
{
$subscription = $event->subscription;
if (! $this->isSocialHostProduct($subscription)) {
return;
}
// Handle plan changes (upgrades/downgrades)
if ($subscription->wasChanged('product_id') || $subscription->wasChanged('price_id')) {
// Remove old package
$this->revokePackage($subscription, $event->previousStatus);
// Add new package
$this->provisionPackage($subscription);
}
// Handle status changes
if ($subscription->wasChanged('status')) {
if ($subscription->status === 'active') {
$this->provisionPackage($subscription);
} elseif (in_array($subscription->status, ['cancelled', 'expired'])) {
$this->revokePackage($subscription);
}
}
Log::info('SocialHost subscription updated', [
'subscription_id' => $subscription->id,
'workspace_id' => $subscription->workspace_id,
'status' => $subscription->status,
]);
}
/**
* Handle subscription cancelled event.
*/
public function handleSubscriptionCancelled(SubscriptionCancelled $event): void
{
$subscription = $event->subscription;
if (! $this->isSocialHostProduct($subscription)) {
return;
}
if ($event->immediate) {
// Immediate cancellation - revoke now
$this->revokePackage($subscription);
} else {
// End of period cancellation - package stays until period ends
// The scheduled task will handle revocation at period end
Log::info('SocialHost subscription scheduled for cancellation', [
'subscription_id' => $subscription->id,
'ends_at' => $subscription->current_period_end,
]);
}
}
/**
* Check if this is a SocialHost subscription.
*/
protected function isSocialHostProduct($subscription): bool
{
// Check via workspace package -> package
$workspacePackage = $subscription->workspacePackage;
if ($workspacePackage && $workspacePackage->package) {
$packageCode = $workspacePackage->package->code;
return str_starts_with($packageCode, 'social-');
}
// Check subscription metadata
$metadata = $subscription->metadata ?? [];
if (isset($metadata['product_line']) && $metadata['product_line'] === 'socialhost') {
return true;
}
return false;
}
/**
* Provision the entitlement package for the subscription.
*/
protected function provisionPackage($subscription): void
{
$workspacePackage = $subscription->workspacePackage;
$workspace = $subscription->workspace;
if (! $workspace) {
return;
}
// If we already have a workspace package linked, just ensure it's active
if ($workspacePackage) {
$workspacePackage->update([
'status' => WorkspacePackage::STATUS_ACTIVE,
'expires_at' => $subscription->current_period_end,
'metadata' => array_merge($workspacePackage->metadata ?? [], [
'subscription_id' => $subscription->id,
'source' => 'commerce',
]),
]);
return;
}
// Otherwise, get package from subscription metadata
$packageCode = $subscription->metadata['package_code'] ?? null;
if (! $packageCode) {
Log::warning('SocialHost subscription missing package_code', [
'subscription_id' => $subscription->id,
]);
return;
}
$package = Package::where('code', $packageCode)->first();
if (! $package) {
Log::warning('SocialHost package not found', [
'package_code' => $packageCode,
]);
return;
}
// Check if already provisioned
$existing = WorkspacePackage::where([
'workspace_id' => $workspace->id,
'package_id' => $package->id,
])->first();
if ($existing) {
// Update existing assignment
$existing->update([
'status' => WorkspacePackage::STATUS_ACTIVE,
'expires_at' => $subscription->current_period_end,
'metadata' => array_merge($existing->metadata ?? [], [
'subscription_id' => $subscription->id,
'source' => 'commerce',
]),
]);
// Link to subscription
$subscription->update(['workspace_package_id' => $existing->id]);
} else {
// Create new assignment
$newPackage = WorkspacePackage::create([
'workspace_id' => $workspace->id,
'package_id' => $package->id,
'status' => WorkspacePackage::STATUS_ACTIVE,
'expires_at' => $subscription->current_period_end,
'metadata' => [
'subscription_id' => $subscription->id,
'source' => 'commerce',
],
]);
// Link to subscription
$subscription->update(['workspace_package_id' => $newPackage->id]);
}
}
/**
* Revoke the entitlement package for the subscription.
*/
protected function revokePackage($subscription, ?string $previousPackageCode = null): void
{
$workspacePackage = $subscription->workspacePackage;
if ($workspacePackage) {
$workspacePackage->update([
'status' => WorkspacePackage::STATUS_CANCELLED,
]);
return;
}
// Fallback to package code lookup
$workspace = $subscription->workspace;
if (! $workspace) {
return;
}
$packageCode = $previousPackageCode ?? ($subscription->metadata['package_code'] ?? null);
if (! $packageCode) {
return;
}
$package = Package::where('code', $packageCode)->first();
if (! $package) {
return;
}
// Deactivate the package assignment
WorkspacePackage::where([
'workspace_id' => $workspace->id,
'package_id' => $package->id,
])->update([
'status' => WorkspacePackage::STATUS_CANCELLED,
]);
}
/**
* Handle subscription renewed event.
*
* Dispatches a job to process the renewal asynchronously.
*/
public function handleSubscriptionRenewed(SubscriptionRenewed $event): void
{
$subscription = $event->subscription;
if (! $this->isSocialHostProduct($subscription)) {
return;
}
// Dispatch renewal processing job
ProcessSubscriptionRenewal::dispatch(
$subscription,
$subscription->current_period_end
);
Log::info('SocialHost subscription renewal queued', [
'subscription_id' => $subscription->id,
'workspace_id' => $subscription->workspace_id,
'new_period_end' => $subscription->current_period_end?->toIso8601String(),
]);
}
/**
* Get the events this listener should handle.
*/
public function subscribe($events): array
{
return [
SubscriptionCreated::class => 'handleSubscriptionCreated',
SubscriptionUpdated::class => 'handleSubscriptionUpdated',
SubscriptionCancelled::class => 'handleSubscriptionCancelled',
SubscriptionRenewed::class => 'handleSubscriptionRenewed',
];
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace Core\Commerce\Listeners;
use Core\Commerce\Events\SubscriptionRenewed;
use Core\Commerce\Services\UsageBillingService;
/**
* Reset usage records when a subscription renews.
*
* Creates fresh usage records for the new billing period.
*/
class ResetUsageOnRenewal
{
public function __construct(
protected UsageBillingService $usageBilling
) {}
public function handle(SubscriptionRenewed $event): void
{
if (! config('commerce.features.usage_billing', false)) {
return;
}
$this->usageBilling->onPeriodReset($event->subscription);
}
}

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Listeners;
use Core\Commerce\Events\SubscriptionCreated;
use Mod\Trees\Models\TreePlanting;
use Core\Mod\Tenant\Models\AgentReferralBonus;
use Illuminate\Support\Facades\Log;
/**
* Rewards agents when their referred user subscribes.
*
* Part of the Trees for Agents conversion bonus system:
* 1. If the referred user had a queued tree, it plants immediately
* 2. The agent gets a guaranteed next referral (skips queue)
*
* This creates a virtuous cycle: agents who refer real users that convert
* get rewarded with immediate trees and guaranteed future referrals.
*/
class RewardAgentReferralOnSubscription
{
/**
* Handle the subscription created event.
*/
public function handle(SubscriptionCreated $event): void
{
$subscription = $event->subscription;
$workspace = $subscription->workspace;
if (! $workspace) {
return;
}
// Get all user IDs in this workspace
$userIds = $workspace->users()->pluck('users.id')->toArray();
if (empty($userIds)) {
return;
}
// Find agent referral tree plantings for these users
$agentReferrals = TreePlanting::forAgent()
->whereIn('user_id', $userIds)
->whereIn('status', [TreePlanting::STATUS_QUEUED, TreePlanting::STATUS_PENDING])
->get();
if ($agentReferrals->isEmpty()) {
return; // No agent referrals to reward
}
foreach ($agentReferrals as $planting) {
$this->processConversion($planting);
}
}
/**
* Process a conversion for a single tree planting.
*/
protected function processConversion(TreePlanting $planting): void
{
$wasQueued = $planting->isQueued();
// If the tree was queued, plant it immediately
if ($wasQueued) {
$planting->update(['status' => TreePlanting::STATUS_PENDING]);
$planting->markConfirmed();
Log::info('Queued tree planted immediately on conversion', [
'tree_planting_id' => $planting->id,
'provider' => $planting->provider,
'model' => $planting->model,
'user_id' => $planting->user_id,
]);
}
// Grant the agent a guaranteed next referral
$bonus = AgentReferralBonus::grantGuaranteedReferral(
$planting->provider ?? 'unknown',
$planting->model
);
Log::info('Agent referral bonus granted on conversion', [
'provider' => $planting->provider,
'model' => $planting->model,
'total_conversions' => $bonus->total_conversions,
'next_referral_guaranteed' => $bonus->next_referral_guaranteed,
]);
}
}

89
Mail/InvoiceGenerated.php Normal file
View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Mail;
use Core\Commerce\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
/**
* Email notification sent when an invoice is generated.
*/
class InvoiceGenerated extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public Invoice $invoice
) {}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
$subject = $this->invoice->status === 'paid'
? "Your Host UK Invoice #{$this->invoice->invoice_number}"
: "Invoice #{$this->invoice->invoice_number} - Payment Required";
return new Envelope(
subject: $subject,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
$this->invoice->load(['workspace', 'items']);
return new Content(
markdown: 'commerce::emails.invoice-generated',
with: [
'invoice' => $this->invoice,
'workspace' => $this->invoice->workspace,
'items' => $this->invoice->items,
'isPaid' => $this->invoice->status === 'paid',
'viewUrl' => route('hub.billing.invoices.view', $this->invoice),
'downloadUrl' => route('hub.billing.invoices.pdf', $this->invoice),
],
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
// Only attach PDF if it exists
if (! $this->invoice->pdf_path) {
return [];
}
$disk = config('commerce.pdf.storage_disk', 'local');
if (! Storage::disk($disk)->exists($this->invoice->pdf_path)) {
return [];
}
return [
Attachment::fromStorageDisk($disk, $this->invoice->pdf_path)
->as("invoice-{$this->invoice->invoice_number}.pdf")
->withMime('application/pdf'),
];
}
}

100
Mcp/Tools/CreateCoupon.php Normal file
View file

@ -0,0 +1,100 @@
<?php
namespace Core\Commerce\Mcp\Tools;
use Core\Commerce\Models\Coupon;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class CreateCoupon extends Tool
{
protected string $description = 'Create a new discount coupon code';
public function handle(Request $request): Response
{
$code = strtoupper($request->input('code'));
$name = $request->input('name');
$type = $request->input('type', 'percentage');
$value = $request->input('value');
$duration = $request->input('duration', 'once');
$maxUses = $request->input('max_uses');
$validUntil = $request->input('valid_until');
// Validate code format
if (! preg_match('/^[A-Z0-9_-]+$/', $code)) {
return Response::text(json_encode([
'error' => 'Invalid code format. Use only uppercase letters, numbers, hyphens, and underscores.',
]));
}
// Check for existing code
if (Coupon::where('code', $code)->exists()) {
return Response::text(json_encode([
'error' => 'A coupon with this code already exists.',
]));
}
// Validate type
if (! in_array($type, ['percentage', 'fixed_amount'])) {
return Response::text(json_encode([
'error' => 'Invalid type. Use percentage or fixed_amount.',
]));
}
// Validate value
if ($type === 'percentage' && ($value < 1 || $value > 100)) {
return Response::text(json_encode([
'error' => 'Percentage value must be between 1 and 100.',
]));
}
try {
$coupon = Coupon::create([
'code' => $code,
'name' => $name,
'type' => $type,
'value' => $value,
'duration' => $duration,
'max_uses' => $maxUses,
'max_uses_per_workspace' => 1,
'valid_until' => $validUntil ? \Carbon\Carbon::parse($validUntil) : null,
'is_active' => true,
'applies_to' => 'all',
]);
return Response::text(json_encode([
'success' => true,
'coupon' => [
'id' => $coupon->id,
'code' => $coupon->code,
'name' => $coupon->name,
'type' => $coupon->type,
'value' => (float) $coupon->value,
'duration' => $coupon->duration,
'max_uses' => $coupon->max_uses,
'valid_until' => $coupon->valid_until?->toDateString(),
'is_active' => $coupon->is_active,
],
], JSON_PRETTY_PRINT));
} catch (\Exception $e) {
return Response::text(json_encode([
'error' => 'Failed to create coupon: '.$e->getMessage(),
]));
}
}
public function schema(JsonSchema $schema): array
{
return [
'code' => $schema->string('Unique coupon code (uppercase letters, numbers, hyphens, underscores)')->required(),
'name' => $schema->string('Display name for the coupon')->required(),
'type' => $schema->string('Discount type: percentage or fixed_amount (default: percentage)'),
'value' => $schema->number('Discount value (percentage 1-100 or fixed amount)')->required(),
'duration' => $schema->string('How long discount applies: once, repeating, or forever (default: once)'),
'max_uses' => $schema->integer('Maximum total uses (null for unlimited)'),
'valid_until' => $schema->string('Expiry date in YYYY-MM-DD format'),
];
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace Core\Commerce\Mcp\Tools;
use Core\Commerce\Models\Subscription;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class GetBillingStatus extends Tool
{
protected string $description = 'Get billing status for a workspace including subscription, current plan, and billing period';
public function handle(Request $request): Response
{
$workspaceId = $request->input('workspace_id');
$workspace = Workspace::find($workspaceId);
if (! $workspace) {
return Response::text(json_encode(['error' => 'Workspace not found']));
}
// Get active subscription
$subscription = Subscription::with('workspacePackage.package')
->where('workspace_id', $workspaceId)
->whereIn('status', ['active', 'trialing', 'past_due'])
->first();
// Get workspace packages
$packages = $workspace->workspacePackages()
->with('package')
->whereIn('status', ['active', 'trial'])
->get();
$status = [
'workspace' => [
'id' => $workspace->id,
'name' => $workspace->name,
],
'subscription' => $subscription ? [
'id' => $subscription->id,
'status' => $subscription->status,
'gateway' => $subscription->gateway,
'billing_cycle' => $subscription->billing_cycle,
'current_period_start' => $subscription->current_period_start?->toIso8601String(),
'current_period_end' => $subscription->current_period_end?->toIso8601String(),
'days_until_renewal' => $subscription->daysUntilRenewal(),
'cancel_at_period_end' => $subscription->cancel_at_period_end,
'on_trial' => $subscription->onTrial(),
'trial_ends_at' => $subscription->trial_ends_at?->toIso8601String(),
] : null,
'packages' => $packages->map(fn ($wp) => [
'code' => $wp->package?->code,
'name' => $wp->package?->name,
'status' => $wp->status,
'expires_at' => $wp->expires_at?->toIso8601String(),
])->values()->all(),
];
return Response::text(json_encode($status, JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [
'workspace_id' => $schema->integer('The workspace ID to get billing status for')->required(),
];
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace Core\Commerce\Mcp\Tools;
use Core\Commerce\Models\Invoice;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListInvoices extends Tool
{
protected string $description = 'List invoices for a workspace with optional status filter';
public function handle(Request $request): Response
{
$workspaceId = $request->input('workspace_id');
$status = $request->input('status'); // paid, pending, overdue, void
$limit = min($request->input('limit', 10), 50);
$query = Invoice::with('order')
->where('workspace_id', $workspaceId)
->latest();
if ($status) {
$query->where('status', $status);
}
$invoices = $query->limit($limit)->get();
$result = [
'workspace_id' => $workspaceId,
'count' => $invoices->count(),
'invoices' => $invoices->map(fn ($invoice) => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'status' => $invoice->status,
'subtotal' => (float) $invoice->subtotal,
'discount_amount' => (float) $invoice->discount_amount,
'tax_amount' => (float) $invoice->tax_amount,
'total' => (float) $invoice->total,
'amount_paid' => (float) $invoice->amount_paid,
'amount_due' => (float) $invoice->amount_due,
'currency' => $invoice->currency,
'issue_date' => $invoice->issue_date?->toDateString(),
'due_date' => $invoice->due_date?->toDateString(),
'paid_at' => $invoice->paid_at?->toIso8601String(),
'is_overdue' => $invoice->isOverdue(),
'order_number' => $invoice->order?->order_number,
])->all(),
];
return Response::text(json_encode($result, JSON_PRETTY_PRINT));
}
public function schema(JsonSchema $schema): array
{
return [
'workspace_id' => $schema->integer('The workspace ID to list invoices for')->required(),
'status' => $schema->string('Filter by status: paid, pending, overdue, void'),
'limit' => $schema->integer('Maximum number of invoices to return (default 10, max 50)'),
];
}
}

114
Mcp/Tools/UpgradePlan.php Normal file
View file

@ -0,0 +1,114 @@
<?php
namespace Core\Commerce\Mcp\Tools;
use Core\Commerce\Models\Subscription;
use Core\Commerce\Services\SubscriptionService;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Models\Package;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class UpgradePlan extends Tool
{
protected string $description = 'Preview or execute a plan upgrade/downgrade for a workspace subscription';
public function handle(Request $request): Response
{
$workspaceId = $request->input('workspace_id');
$newPackageCode = $request->input('package_code');
$preview = $request->input('preview', true);
$immediate = $request->input('immediate', true);
$workspace = Workspace::find($workspaceId);
if (! $workspace) {
return Response::text(json_encode(['error' => 'Workspace not found']));
}
$newPackage = Package::where('code', $newPackageCode)->first();
if (! $newPackage) {
return Response::text(json_encode([
'error' => 'Package not found',
'available_packages' => Package::where('is_active', true)
->where('is_public', true)
->pluck('code')
->all(),
]));
}
// Get active subscription
$subscription = Subscription::with('workspacePackage.package')
->where('workspace_id', $workspaceId)
->whereIn('status', ['active', 'trialing'])
->first();
if (! $subscription) {
return Response::text(json_encode([
'error' => 'No active subscription found for this workspace',
]));
}
$subscriptionService = app(SubscriptionService::class);
try {
if ($preview) {
// Preview the proration
$proration = $subscriptionService->previewPlanChange($subscription, $newPackage);
return Response::text(json_encode([
'preview' => true,
'current_package' => $subscription->workspacePackage?->package?->code,
'new_package' => $newPackage->code,
'proration' => [
'is_upgrade' => $proration->isUpgrade(),
'is_downgrade' => $proration->isDowngrade(),
'current_plan_price' => $proration->currentPlanPrice,
'new_plan_price' => $proration->newPlanPrice,
'credit_amount' => $proration->creditAmount,
'prorated_new_cost' => $proration->proratedNewPlanCost,
'net_amount' => $proration->netAmount,
'requires_payment' => $proration->requiresPayment(),
'days_remaining' => $proration->daysRemaining,
'currency' => $proration->currency,
],
], JSON_PRETTY_PRINT));
}
// Execute the plan change
$result = $subscriptionService->changePlan(
$subscription,
$newPackage,
prorate: true,
immediate: $immediate
);
return Response::text(json_encode([
'success' => true,
'immediate' => $result['immediate'],
'current_package' => $subscription->workspacePackage?->package?->code,
'new_package' => $newPackage->code,
'proration' => $result['proration']?->toArray(),
'subscription_status' => $result['subscription']->status,
], JSON_PRETTY_PRINT));
} catch (\Exception $e) {
return Response::text(json_encode([
'error' => $e->getMessage(),
]));
}
}
public function schema(JsonSchema $schema): array
{
return [
'workspace_id' => $schema->integer('The workspace ID to upgrade/downgrade')->required(),
'package_code' => $schema->string('The code of the new package (e.g., agency, enterprise)')->required(),
'preview' => $schema->boolean('If true, only preview the change without executing (default: true)'),
'immediate' => $schema->boolean('If true, apply change immediately; if false, schedule for period end (default: true)'),
];
}
}

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Authenticate Commerce Provisioning API requests using Bearer token.
*
* The token is compared against the configured Commerce API secret.
* Used for internal service provisioning and entitlement management endpoints.
*/
class CommerceApiAuth
{
public function handle(Request $request, Closure $next): Response
{
$token = $request->bearerToken();
if (! $token) {
return $this->unauthorized('API token required. Use Authorization: Bearer <token>');
}
$expectedToken = config('services.commerce.api_secret');
if (! $expectedToken) {
return response()->json([
'error' => 'configuration_error',
'message' => 'Commerce API not configured',
], 500);
}
if (! hash_equals($expectedToken, $token)) {
return $this->unauthorized('Invalid API token');
}
$request->attributes->set('auth_type', 'commerce_api');
return $next($request);
}
/**
* Return 401 Unauthorized response.
*/
protected function unauthorized(string $message): Response
{
return response()->json([
'error' => 'unauthorized',
'message' => $message,
], 401);
}
}

View file

@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Middleware;
use Core\Commerce\Models\Entity;
use Core\Commerce\Services\PermissionMatrixService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* Commerce Matrix Gate - enforces permissions on every request.
*
* Every request through commerce routes is gated:
* - Can THIS REQUEST from THIS ENTITY do THIS ACTION on THIS RESOURCE?
*
* Training mode shows a UI to approve undefined permissions.
* Production mode denies undefined permissions.
*/
class CommerceMatrixGate
{
public function __construct(
protected PermissionMatrixService $matrix
) {}
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next, ?string $action = null): Response
{
$entity = $this->resolveEntity($request);
$action = $action ?? $this->resolveAction($request);
// If no entity or action, skip matrix check
if (! $entity || ! $action) {
return $next($request);
}
$result = $this->matrix->gateRequest($request, $entity, $action);
if ($result->isDenied()) {
if ($request->wantsJson()) {
return response()->json([
'error' => 'permission_denied',
'message' => $result->reason,
'key' => $action,
], 403);
}
abort(403, $result->reason ?? 'Permission denied');
}
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->trainingUrl,
'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);
}
/**
* Resolve the commerce entity from the request.
*/
protected function resolveEntity(Request $request): ?Entity
{
// Option 1: Explicit entity from route parameter
if ($entityId = $request->route('entity')) {
return Entity::find($entityId);
}
// Option 2: Entity header (for API requests)
if ($entityCode = $request->header('X-Commerce-Entity')) {
return Entity::where('code', $entityCode)->first();
}
// Option 3: Domain-based entity resolution
$host = $request->getHost();
if ($entity = Entity::where('domain', $host)->first()) {
return $entity;
}
// Option 4: Workspace-based entity (from authenticated user)
if ($workspace = $this->getCurrentWorkspace($request)) {
return Entity::where('workspace_id', $workspace->id)->first();
}
// Option 5: Session-stored entity
if ($entityId = session('commerce_entity_id')) {
return Entity::find($entityId);
}
return null;
}
/**
* Resolve the action from the request.
*/
protected function resolveAction(Request $request): ?string
{
$route = $request->route();
if (! $route) {
return null;
}
// Option 1: Explicit matrix_action on route
if ($action = $route->getAction('matrix_action')) {
return $action;
}
// Option 2: Controller@method convention
$controller = $route->getControllerClass();
$method = $route->getActionMethod();
if ($controller && $method) {
// Convert ProductController@store → product.store
$resource = Str::snake(
str_replace(['Controller', 'App\\Http\\Controllers\\Commerce\\'], '', class_basename($controller))
);
return "{$resource}.{$method}";
}
// Option 3: REST convention from route name
if ($routeName = $route->getName()) {
// commerce.products.store → product.store
$parts = explode('.', $routeName);
if (count($parts) >= 2) {
$resource = Str::singular($parts[count($parts) - 2]);
$action = $parts[count($parts) - 1];
return "{$resource}.{$action}";
}
}
// Option 4: HTTP method + resource convention
$method = $request->method();
$segment = $request->segment(2); // /commerce/products → products
if ($segment) {
$resource = Str::singular($segment);
return match ($method) {
'GET' => "{$resource}.view",
'POST' => "{$resource}.create",
'PUT', 'PATCH' => "{$resource}.update",
'DELETE' => "{$resource}.delete",
default => null,
};
}
return null;
}
/**
* Get current workspace from request context.
*/
protected function getCurrentWorkspace(Request $request)
{
$user = $request->user();
if (! $user || ! method_exists($user, 'defaultHostWorkspace')) {
return null;
}
return $user->defaultHostWorkspace();
}
}

View file

@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Commerce module tables.
*
* Entity Hierarchy (M1 M2 M3):
* - M1: Master Company (source of truth, owns product catalog)
* - M2: Facades/Storefronts (select from M1, can override content)
* - M3: Dropshippers (full inheritance, no management responsibility)
*/
public function up(): void
{
Schema::disableForeignKeyConstraints();
// 1. Commerce Entities
Schema::create('commerce_entities', function (Blueprint $table) {
$table->id();
$table->string('code', 32)->unique();
$table->string('name');
$table->string('type');
$table->foreignId('parent_id')
->nullable()
->constrained('commerce_entities')
->nullOnDelete();
$table->string('path')->index();
$table->integer('depth')->default(0);
$table->foreignId('workspace_id')
->nullable()
->constrained('workspaces')
->nullOnDelete();
$table->json('settings')->nullable();
$table->string('domain')->nullable();
$table->string('currency', 3)->default('GBP');
$table->string('timezone')->default('Europe/London');
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
$table->index(['type', 'is_active']);
$table->index(['workspace_id', 'is_active']);
});
// 2. Permission Matrix
Schema::create('permission_matrix', function (Blueprint $table) {
$table->id();
$table->foreignId('entity_id')->constrained('commerce_entities')->cascadeOnDelete();
$table->string('key');
$table->string('scope')->nullable();
$table->boolean('allowed')->default(false);
$table->boolean('locked')->default(false);
$table->string('source');
$table->foreignId('set_by_entity_id')
->nullable()
->constrained('commerce_entities')
->nullOnDelete();
$table->timestamp('trained_at')->nullable();
$table->string('trained_route')->nullable();
$table->timestamps();
$table->unique(['entity_id', 'key', 'scope'], 'permission_matrix_unique');
$table->index(['key', 'scope']);
$table->index(['entity_id', 'allowed']);
});
// 3. Permission Requests
Schema::create('permission_requests', function (Blueprint $table) {
$table->id();
$table->foreignId('entity_id')->constrained('commerce_entities')->cascadeOnDelete();
$table->string('method');
$table->string('route');
$table->string('action');
$table->string('scope')->nullable();
$table->json('request_data')->nullable();
$table->string('user_agent')->nullable();
$table->string('ip_address', 45)->nullable();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('status');
$table->boolean('was_trained')->default(false);
$table->timestamp('trained_at')->nullable();
$table->timestamps();
$table->index(['entity_id', 'action', 'status']);
$table->index(['status', 'created_at']);
$table->index(['action', 'was_trained']);
});
// 4. Commerce Products
Schema::create('commerce_products', function (Blueprint $table) {
$table->id();
$table->foreignId('entity_id')->constrained('commerce_entities')->cascadeOnDelete();
$table->string('sku', 64)->unique();
$table->string('name');
$table->text('description')->nullable();
$table->string('type', 32)->default('simple');
$table->string('status', 32)->default('active');
$table->decimal('base_price', 10, 2)->nullable();
$table->string('currency', 3)->default('GBP');
$table->decimal('cost_price', 10, 2)->nullable();
$table->decimal('weight', 8, 3)->nullable();
$table->string('weight_unit', 8)->default('kg');
$table->json('dimensions')->nullable();
$table->json('attributes')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['entity_id', 'status']);
$table->index(['type', 'status']);
});
// 5. Commerce Product Assignments
Schema::create('commerce_product_assignments', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete();
$table->foreignId('entity_id')->constrained('commerce_entities')->cascadeOnDelete();
$table->boolean('is_visible')->default(true);
$table->decimal('price_override', 10, 2)->nullable();
$table->json('content_overrides')->nullable();
$table->json('inventory_override')->nullable();
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->unique(['product_id', 'entity_id']);
$table->index(['entity_id', 'is_visible']);
});
// 6. Commerce Warehouses
Schema::create('commerce_warehouses', function (Blueprint $table) {
$table->id();
$table->foreignId('entity_id')->constrained('commerce_entities')->cascadeOnDelete();
$table->string('code', 32);
$table->string('name');
$table->string('address_line1')->nullable();
$table->string('address_line2')->nullable();
$table->string('city')->nullable();
$table->string('region')->nullable();
$table->string('postal_code')->nullable();
$table->string('country', 2)->nullable();
$table->boolean('is_active')->default(true);
$table->boolean('is_default')->default(false);
$table->json('settings')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['entity_id', 'code']);
$table->index(['entity_id', 'is_active']);
});
// 7. Commerce Inventory
Schema::create('commerce_inventory', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete();
$table->foreignId('warehouse_id')->constrained('commerce_warehouses')->cascadeOnDelete();
$table->integer('quantity')->default(0);
$table->integer('reserved')->default(0);
$table->integer('low_stock_threshold')->nullable();
$table->boolean('track_inventory')->default(true);
$table->boolean('allow_backorder')->default(false);
$table->timestamp('last_counted_at')->nullable();
$table->timestamps();
$table->unique(['product_id', 'warehouse_id']);
$table->index(['warehouse_id', 'quantity']);
});
// 8. Commerce Content Overrides
Schema::create('commerce_content_overrides', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete();
$table->foreignId('entity_id')->constrained('commerce_entities')->cascadeOnDelete();
$table->string('field');
$table->text('value')->nullable();
$table->string('source', 32)->default('manual');
$table->timestamp('approved_at')->nullable();
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['product_id', 'entity_id', 'field'], 'content_override_unique');
$table->index(['entity_id', 'field']);
});
// 9. Commerce Bundle Hashes
Schema::create('commerce_bundle_hashes', function (Blueprint $table) {
$table->id();
$table->foreignId('entity_id')->constrained('commerce_entities')->cascadeOnDelete();
$table->string('bundle_type', 64);
$table->string('hash', 64);
$table->timestamp('generated_at');
$table->timestamps();
$table->unique(['entity_id', 'bundle_type']);
});
// 10. Webhook Events (Commerce)
Schema::create('commerce_webhook_events', function (Blueprint $table) {
$table->id();
$table->foreignId('entity_id')->constrained('commerce_entities')->cascadeOnDelete();
$table->string('event_type', 64);
$table->string('idempotency_key', 64)->unique();
$table->json('payload');
$table->string('status', 32)->default('pending');
$table->unsignedTinyInteger('attempts')->default(0);
$table->timestamp('last_attempt_at')->nullable();
$table->text('last_error')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
$table->index(['entity_id', 'event_type', 'status']);
$table->index(['status', 'created_at']);
});
Schema::enableForeignKeyConstraints();
}
public function down(): void
{
Schema::disableForeignKeyConstraints();
Schema::dropIfExists('commerce_webhook_events');
Schema::dropIfExists('commerce_bundle_hashes');
Schema::dropIfExists('commerce_content_overrides');
Schema::dropIfExists('commerce_inventory');
Schema::dropIfExists('commerce_warehouses');
Schema::dropIfExists('commerce_product_assignments');
Schema::dropIfExists('commerce_products');
Schema::dropIfExists('permission_requests');
Schema::dropIfExists('permission_matrix');
Schema::dropIfExists('commerce_entities');
Schema::enableForeignKeyConstraints();
}
};

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Credit Notes table for tracking credits issued to users.
*
* Credit notes can be:
* - General credits (goodwill, promotional)
* - Partial refunds issued as store credit instead of cash
* - Applied to future orders to reduce payment amount
*/
public function up(): void
{
Schema::create('credit_notes', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
// Source references (optional - not all credits come from orders/refunds)
$table->foreignId('order_id')->nullable()->constrained('orders')->nullOnDelete();
$table->foreignId('refund_id')->nullable()->constrained('refunds')->nullOnDelete();
// Credit details
$table->string('reference_number', 32)->unique();
$table->decimal('amount', 10, 2);
$table->string('currency', 3)->default('GBP');
$table->string('reason');
$table->text('description')->nullable();
// Status: draft, issued, applied, partially_applied, void
$table->string('status', 32)->default('draft');
// Tracking
$table->decimal('amount_used', 10, 2)->default(0);
$table->foreignId('applied_to_order_id')->nullable()->constrained('orders')->nullOnDelete();
$table->timestamp('issued_at')->nullable();
$table->timestamp('applied_at')->nullable();
$table->timestamp('voided_at')->nullable();
$table->foreignId('issued_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('voided_by')->nullable()->constrained('users')->nullOnDelete();
// Flexible storage
$table->json('metadata')->nullable();
$table->timestamps();
// Indexes
$table->index(['workspace_id', 'status']);
$table->index(['user_id', 'status']);
$table->index(['status', 'created_at']);
$table->index('reference_number');
});
}
public function down(): void
{
Schema::dropIfExists('credit_notes');
}
};

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Payment methods table for storing saved payment methods (cards, bank accounts, etc).
*/
public function up(): void
{
Schema::create('payment_methods', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
// Gateway identifiers
$table->string('gateway', 32); // stripe, btcpay, etc.
$table->string('gateway_payment_method_id')->nullable(); // pm_xxx for Stripe
$table->string('gateway_customer_id')->nullable(); // cus_xxx for Stripe
// Payment method details
$table->string('type', 32)->default('card'); // card, bank_account, crypto_wallet
$table->string('brand', 32)->nullable(); // visa, mastercard, amex, etc.
$table->string('last_four', 4)->nullable(); // Last 4 digits of card
$table->unsignedTinyInteger('exp_month')->nullable(); // 1-12
$table->unsignedSmallInteger('exp_year')->nullable(); // 2024, 2025, etc.
// Status
$table->boolean('is_default')->default(false);
$table->boolean('is_active')->default(true);
// Metadata
$table->json('metadata')->nullable();
$table->timestamps();
// Indexes
$table->index(['workspace_id', 'is_active', 'is_default']);
$table->index(['gateway', 'gateway_payment_method_id']);
$table->unique(['workspace_id', 'gateway', 'gateway_payment_method_id'], 'payment_method_unique');
});
}
public function down(): void
{
Schema::dropIfExists('payment_methods');
}
};

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Usage-based billing tables for metered billing support.
*
* Integrates with Stripe metered billing API for usage tracking
* and invoice line item generation.
*/
public function up(): void
{
// 1. Usage Meters - defines what can be metered
Schema::create('commerce_usage_meters', function (Blueprint $table) {
$table->id();
$table->string('code', 64)->unique();
$table->string('name');
$table->text('description')->nullable();
// Stripe meter configuration
$table->string('stripe_meter_id')->nullable();
$table->string('stripe_price_id')->nullable();
// Pricing configuration
$table->string('aggregation_type', 32)->default('sum'); // sum, max, last_value
$table->decimal('unit_price', 10, 4)->default(0);
$table->string('currency', 3)->default('GBP');
$table->string('unit_label', 32)->default('units'); // e.g., 'API calls', 'GB', 'emails'
// Tiers for graduated pricing (optional)
$table->json('pricing_tiers')->nullable();
// Feature code link (optional - for entitlement integration)
$table->string('feature_code')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index('stripe_meter_id');
$table->index('feature_code');
});
// 2. Subscription Usage Records - tracks usage per subscription per meter
Schema::create('commerce_subscription_usage', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('subscription_id');
$table->foreignId('meter_id')->constrained('commerce_usage_meters')->cascadeOnDelete();
// Usage tracking
$table->unsignedBigInteger('quantity')->default(0);
$table->timestamp('period_start');
$table->timestamp('period_end');
// Stripe sync tracking
$table->string('stripe_usage_record_id')->nullable();
$table->timestamp('synced_at')->nullable();
// Billing status
$table->boolean('billed')->default(false);
$table->foreignId('invoice_item_id')->nullable()
->constrained('invoice_items')->nullOnDelete();
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['subscription_id', 'meter_id', 'period_start'], 'sub_meter_period_unique');
$table->index(['subscription_id', 'period_start', 'period_end']);
$table->index(['billed', 'period_end']);
// Add foreign key if subscriptions table exists
if (Schema::hasTable('subscriptions')) {
$table->foreign('subscription_id')
->references('id')
->on('subscriptions')
->cascadeOnDelete();
}
});
// 3. Usage Events - individual usage events before aggregation
Schema::create('commerce_usage_events', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('subscription_id');
$table->foreignId('meter_id')->constrained('commerce_usage_meters')->cascadeOnDelete();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
// Event details
$table->unsignedBigInteger('quantity')->default(1);
$table->timestamp('event_at');
$table->string('idempotency_key', 64)->nullable();
// Optional context
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('action', 64)->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique('idempotency_key');
$table->index(['subscription_id', 'meter_id', 'event_at']);
$table->index(['workspace_id', 'meter_id', 'event_at']);
$table->index('event_at');
// Add foreign key if subscriptions table exists
if (Schema::hasTable('subscriptions')) {
$table->foreign('subscription_id')
->references('id')
->on('subscriptions')
->cascadeOnDelete();
}
});
}
public function down(): void
{
Schema::dropIfExists('commerce_usage_events');
Schema::dropIfExists('commerce_subscription_usage');
Schema::dropIfExists('commerce_usage_meters');
}
};

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Exchange rates table for multi-currency support.
*/
public function up(): void
{
Schema::create('commerce_exchange_rates', function (Blueprint $table) {
$table->id();
$table->string('base_currency', 3);
$table->string('target_currency', 3);
$table->decimal('rate', 16, 8);
$table->string('source', 32)->default('ecb'); // ecb, stripe, openexchangerates, manual
$table->timestamp('fetched_at');
$table->timestamps();
$table->unique(['base_currency', 'target_currency', 'source'], 'exchange_rate_unique');
$table->index(['base_currency', 'target_currency']);
$table->index('fetched_at');
});
// Product prices in multiple currencies
Schema::create('commerce_product_prices', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete();
$table->string('currency', 3);
$table->integer('amount'); // Price in smallest unit (cents/pence)
$table->boolean('is_manual')->default(false); // Manual override vs auto-converted
$table->decimal('exchange_rate_used', 16, 8)->nullable(); // Rate used for auto-conversion
$table->timestamps();
$table->unique(['product_id', 'currency']);
$table->index(['currency', 'is_manual']);
});
// Add currency fields to orders table (if it exists)
if (Schema::hasTable('orders') && ! Schema::hasColumn('orders', 'display_currency')) {
Schema::table('orders', function (Blueprint $table) {
$table->string('display_currency', 3)->after('currency')->nullable();
$table->decimal('exchange_rate_used', 16, 8)->after('display_currency')->nullable();
$table->decimal('base_currency_total', 12, 2)->after('exchange_rate_used')->nullable();
});
}
// Add currency fields to invoices table (if it exists)
if (Schema::hasTable('invoices') && ! Schema::hasColumn('invoices', 'display_currency')) {
Schema::table('invoices', function (Blueprint $table) {
$table->string('display_currency', 3)->after('currency')->nullable();
$table->decimal('exchange_rate_used', 16, 8)->after('display_currency')->nullable();
$table->decimal('base_currency_total', 12, 2)->after('exchange_rate_used')->nullable();
});
}
}
public function down(): void
{
if (Schema::hasTable('invoices') && Schema::hasColumn('invoices', 'display_currency')) {
Schema::table('invoices', function (Blueprint $table) {
$table->dropColumn(['display_currency', 'exchange_rate_used', 'base_currency_total']);
});
}
if (Schema::hasTable('orders') && Schema::hasColumn('orders', 'display_currency')) {
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn(['display_currency', 'exchange_rate_used', 'base_currency_total']);
});
}
Schema::dropIfExists('commerce_product_prices');
Schema::dropIfExists('commerce_exchange_rates');
}
};

View file

@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Referral tracking tables for affiliate/referral programme.
*
* Tracks:
* - Referral relationships (referrer -> referee)
* - Referral codes (user-specific or campaign codes)
* - Conversions (when referrals convert to paid customers)
* - Commissions (earnings from referrals)
* - Payouts (commission withdrawals)
*/
public function up(): void
{
Schema::disableForeignKeyConstraints();
// 1. Referrals - tracks individual referral relationships
Schema::create('commerce_referrals', function (Blueprint $table) {
$table->id();
// Referrer (the user who shared the code)
$table->foreignId('referrer_id')
->constrained('users')
->cascadeOnDelete();
// Referee (the user who signed up via referral)
$table->foreignId('referee_id')
->nullable()
->constrained('users')
->nullOnDelete();
// The code used (either user's namespace or a campaign code)
$table->string('code', 64)->index();
// Status: pending (clicked), converted (signed up), qualified (made purchase), disqualified
$table->string('status', 32)->default('pending');
// Attribution tracking
$table->string('source_url', 2048)->nullable();
$table->string('landing_page', 2048)->nullable();
$table->string('utm_source', 128)->nullable();
$table->string('utm_medium', 128)->nullable();
$table->string('utm_campaign', 128)->nullable();
$table->string('ip_address', 45)->nullable();
$table->string('user_agent', 512)->nullable();
// Cookie/session tracking
$table->string('tracking_id', 64)->nullable()->unique();
// Conversion timestamps
$table->timestamp('clicked_at')->nullable();
$table->timestamp('signed_up_at')->nullable();
$table->timestamp('first_purchase_at')->nullable();
$table->timestamp('qualified_at')->nullable();
$table->timestamp('disqualified_at')->nullable();
$table->string('disqualification_reason', 255)->nullable();
// Maturation (when commission becomes withdrawable)
$table->timestamp('matured_at')->nullable();
$table->timestamps();
// Indexes
$table->index(['referrer_id', 'status']);
$table->index(['referee_id', 'status']);
$table->index(['code', 'status']);
$table->index(['status', 'created_at']);
});
// 2. Referral Payouts - tracks commission withdrawals (created first as commissions reference it)
Schema::create('commerce_referral_payouts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete();
$table->string('payout_number', 32)->unique();
// Payout method: btc, account_credit
$table->string('method', 32);
// For BTC payouts
$table->string('btc_address', 128)->nullable();
$table->string('btc_txid', 128)->nullable();
// Amount
$table->decimal('amount', 10, 2);
$table->string('currency', 3)->default('GBP');
// For BTC: actual BTC amount at time of payout
$table->decimal('btc_amount', 18, 8)->nullable();
$table->decimal('btc_rate', 18, 8)->nullable();
// Status: requested, processing, completed, failed, cancelled
$table->string('status', 32)->default('requested');
$table->timestamp('requested_at')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('failed_at')->nullable();
$table->text('notes')->nullable();
$table->text('failure_reason')->nullable();
// Admin who processed
$table->foreignId('processed_by')
->nullable()
->constrained('users')
->nullOnDelete();
$table->timestamps();
// Indexes
$table->index(['user_id', 'status']);
$table->index(['status', 'requested_at']);
});
// 3. Referral Commissions - tracks earnings from each referral order
Schema::create('commerce_referral_commissions', function (Blueprint $table) {
$table->id();
$table->foreignId('referral_id')
->constrained('commerce_referrals')
->cascadeOnDelete();
$table->foreignId('referrer_id')
->constrained('users')
->cascadeOnDelete();
$table->foreignId('order_id')
->nullable()
->constrained('orders')
->nullOnDelete();
$table->foreignId('invoice_id')
->nullable()
->constrained('invoices')
->nullOnDelete();
// Commission calculation
$table->decimal('order_amount', 10, 2); // Net order amount (after tax/discounts)
$table->decimal('commission_rate', 5, 2)->default(10.00); // Percentage (10.00 = 10%)
$table->decimal('commission_amount', 10, 2); // Calculated commission
$table->string('currency', 3)->default('GBP');
// Status: pending, matured, paid, cancelled
$table->string('status', 32)->default('pending');
// Maturation - commission becomes withdrawable after refund/chargeback period
$table->timestamp('matures_at')->nullable();
$table->timestamp('matured_at')->nullable();
// Payout tracking
$table->foreignId('payout_id')
->nullable()
->constrained('commerce_referral_payouts')
->nullOnDelete();
$table->timestamp('paid_at')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
// Indexes
$table->index(['referrer_id', 'status']);
$table->index(['referral_id', 'status']);
$table->index(['status', 'matures_at']);
$table->index(['payout_id']);
});
// 4. Referral Codes - for campaign/custom codes (beyond user namespaces)
Schema::create('commerce_referral_codes', function (Blueprint $table) {
$table->id();
$table->string('code', 64)->unique();
// Owner - can be null for system/campaign codes
$table->foreignId('user_id')
->nullable()
->constrained('users')
->nullOnDelete();
// Code type: user (auto-generated from namespace), campaign, custom
$table->string('type', 32)->default('custom');
// Custom commission rate (null = use default)
$table->decimal('commission_rate', 5, 2)->nullable();
// Attribution cookie duration (days)
$table->integer('cookie_days')->default(90);
// Limits
$table->integer('max_uses')->nullable();
$table->integer('uses_count')->default(0);
// Validity
$table->timestamp('valid_from')->nullable();
$table->timestamp('valid_until')->nullable();
$table->boolean('is_active')->default(true);
// Metadata for campaign tracking
$table->string('campaign_name', 128)->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
// Indexes
$table->index(['user_id', 'is_active']);
$table->index(['type', 'is_active']);
});
Schema::enableForeignKeyConstraints();
}
public function down(): void
{
Schema::disableForeignKeyConstraints();
Schema::dropIfExists('commerce_referral_codes');
Schema::dropIfExists('commerce_referral_commissions');
Schema::dropIfExists('commerce_referral_payouts');
Schema::dropIfExists('commerce_referrals');
Schema::enableForeignKeyConstraints();
}
};

220
Models/BundleHash.php Normal file
View file

@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Bundle discount lookup by hash.
*
* When a compound SKU contains pipe-separated items (a bundle), we:
* 1. Strip the options to get base SKUs
* 2. Sort and hash them
* 3. Look up the hash to find any applicable discount
*
* This allows "LAPTOP|MOUSE|PAD" to automatically trigger a bundle deal
* regardless of what options (ram~16gb, color~black) the customer chose.
*/
class BundleHash extends Model
{
protected $table = 'commerce_bundle_hashes';
protected $fillable = [
'hash',
'base_skus',
'coupon_code',
'fixed_price',
'discount_percent',
'discount_amount',
'entity_id',
'assignment_id',
'name',
'description',
'min_quantity',
'max_uses',
'valid_from',
'valid_until',
'active',
];
protected $casts = [
'fixed_price' => 'decimal:2',
'discount_percent' => 'decimal:2',
'discount_amount' => 'decimal:2',
'min_quantity' => 'integer',
'max_uses' => 'integer',
'valid_from' => 'datetime',
'valid_until' => 'datetime',
'active' => 'boolean',
];
// Relationships
public function entity(): BelongsTo
{
return $this->belongsTo(Entity::class);
}
public function assignment(): BelongsTo
{
return $this->belongsTo(ProductAssignment::class, 'assignment_id');
}
// Scopes
public function scopeActive($query)
{
return $query->where('active', true);
}
public function scopeForEntity($query, Entity|int $entity)
{
$entityId = $entity instanceof Entity ? $entity->id : $entity;
return $query->where('entity_id', $entityId);
}
public function scopeValid($query)
{
return $query
->where(function ($q) {
$q->whereNull('valid_from')
->orWhere('valid_from', '<=', now());
})
->where(function ($q) {
$q->whereNull('valid_until')
->orWhere('valid_until', '>=', now());
});
}
public function scopeByHash($query, string $hash)
{
return $query->where('hash', $hash);
}
// Lookup methods
/**
* Find bundle discount by hash for an entity.
*/
public static function findByHash(string $hash, Entity|int $entity): ?self
{
return static::byHash($hash)
->forEntity($entity)
->active()
->valid()
->first();
}
/**
* Find bundle discount with entity hierarchy fallback.
*
* Checks entity first, then walks up to parent entities.
*/
public static function findWithHierarchy(string $hash, Entity $entity): ?self
{
// Check this entity first
$bundle = static::findByHash($hash, $entity);
if ($bundle) {
return $bundle;
}
// Walk up the hierarchy
$parent = $entity->parent;
while ($parent) {
$bundle = static::findByHash($hash, $parent);
if ($bundle) {
return $bundle;
}
$parent = $parent->parent;
}
return null;
}
// Discount calculation
/**
* Check if this bundle discount is currently valid.
*/
public function isValid(): bool
{
if (! $this->active) {
return false;
}
if ($this->valid_from && $this->valid_from->isFuture()) {
return false;
}
if ($this->valid_until && $this->valid_until->isPast()) {
return false;
}
return true;
}
/**
* Calculate discount for a given subtotal.
*/
public function calculateDiscount(float $subtotal): float
{
if ($this->fixed_price !== null) {
// Fixed price means discount is difference from subtotal
return max(0, $subtotal - (float) $this->fixed_price);
}
if ($this->discount_amount !== null) {
return min($subtotal, (float) $this->discount_amount);
}
if ($this->discount_percent !== null) {
return $subtotal * ((float) $this->discount_percent / 100);
}
return 0;
}
/**
* Get the final price after bundle discount.
*/
public function getFinalPrice(float $subtotal): float
{
if ($this->fixed_price !== null) {
return (float) $this->fixed_price;
}
return $subtotal - $this->calculateDiscount($subtotal);
}
// Factory methods
/**
* Create a bundle hash from base SKUs.
*
* @param array<string> $baseSkus
*/
public static function createFromSkus(array $baseSkus, Entity|int $entity, array $attributes = []): self
{
$sorted = collect($baseSkus)
->map(fn (string $sku) => strtoupper($sku))
->sort()
->values();
$hash = hash('sha256', $sorted->implode('|'));
$entityId = $entity instanceof Entity ? $entity->id : $entity;
return static::create(array_merge([
'hash' => $hash,
'base_skus' => $sorted->implode('|'),
'entity_id' => $entityId,
], $attributes));
}
}

214
Models/ContentOverride.php Normal file
View file

@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* Content Override - Sparse override entry for white-label commerce.
*
* Stores a single field override for a specific entity + model combination.
* Only stores what's different from the parent/original.
*
* @property int $id
* @property int $entity_id
* @property string $overrideable_type
* @property int $overrideable_id
* @property string $field
* @property string|null $value
* @property string $value_type
* @property int|null $created_by
* @property int|null $updated_by
*/
class ContentOverride extends Model
{
// Value types
public const TYPE_STRING = 'string';
public const TYPE_JSON = 'json';
public const TYPE_HTML = 'html';
public const TYPE_INTEGER = 'integer';
public const TYPE_DECIMAL = 'decimal';
public const TYPE_BOOLEAN = 'boolean';
protected $table = 'commerce_content_overrides';
protected $fillable = [
'entity_id',
'overrideable_type',
'overrideable_id',
'field',
'value',
'value_type',
'created_by',
'updated_by',
];
protected $casts = [
'overrideable_id' => 'integer',
];
// Relationships
public function entity(): BelongsTo
{
return $this->belongsTo(Entity::class);
}
public function overrideable(): MorphTo
{
return $this->morphTo();
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
// Value casting
/**
* Get the value cast to its appropriate type.
*/
public function getCastedValue(): mixed
{
if ($this->value === null) {
return null;
}
return match ($this->value_type) {
self::TYPE_JSON => json_decode($this->value, true),
self::TYPE_INTEGER => (int) $this->value,
self::TYPE_DECIMAL => (float) $this->value,
self::TYPE_BOOLEAN => filter_var($this->value, FILTER_VALIDATE_BOOLEAN),
default => $this->value, // string, html
};
}
/**
* Set the value with automatic type detection.
*/
public function setValueWithType(mixed $value): self
{
if ($value === null) {
$this->value = null;
$this->value_type = self::TYPE_STRING;
return $this;
}
if (is_bool($value)) {
$this->value = $value ? '1' : '0';
$this->value_type = self::TYPE_BOOLEAN;
} elseif (is_int($value)) {
$this->value = (string) $value;
$this->value_type = self::TYPE_INTEGER;
} elseif (is_float($value)) {
$this->value = (string) $value;
$this->value_type = self::TYPE_DECIMAL;
} elseif (is_array($value)) {
$this->value = json_encode($value);
$this->value_type = self::TYPE_JSON;
} elseif (is_string($value) && $this->looksLikeHtml($value)) {
$this->value = $value;
$this->value_type = self::TYPE_HTML;
} else {
$this->value = (string) $value;
$this->value_type = self::TYPE_STRING;
}
return $this;
}
/**
* Check if a string looks like HTML content.
*/
protected function looksLikeHtml(string $value): bool
{
return preg_match('/<[a-z][\s\S]*>/i', $value) === 1;
}
// Scopes
public function scopeForEntity($query, int $entityId)
{
return $query->where('entity_id', $entityId);
}
public function scopeForModel($query, string $type, int $id)
{
return $query->where('overrideable_type', $type)
->where('overrideable_id', $id);
}
public function scopeForField($query, string $field)
{
return $query->where('field', $field);
}
public function scopeForEntities($query, array $entityIds)
{
return $query->whereIn('entity_id', $entityIds);
}
// Factory helpers
/**
* Create or update an override.
*/
public static function setOverride(
Entity $entity,
Model $model,
string $field,
mixed $value,
?int $userId = null
): self {
$override = static::firstOrNew([
'entity_id' => $entity->id,
'overrideable_type' => $model->getMorphClass(),
'overrideable_id' => $model->getKey(),
'field' => $field,
]);
$override->setValueWithType($value);
if ($override->exists) {
$override->updated_by = $userId ?? auth()->id();
} else {
$override->created_by = $userId ?? auth()->id();
}
$override->save();
return $override;
}
/**
* Remove an override.
*/
public static function clearOverride(
Entity $entity,
Model $model,
string $field
): bool {
return static::where('entity_id', $entity->id)
->where('overrideable_type', $model->getMorphClass())
->where('overrideable_id', $model->getKey())
->where('field', $field)
->delete() > 0;
}
}

281
Models/Coupon.php Normal file
View file

@ -0,0 +1,281 @@
<?php
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Core\Commerce\Contracts\Orderable;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Coupon model for discount codes.
*
* @property int $id
* @property string $code
* @property string $name
* @property string|null $description
* @property string $type
* @property float $value
* @property float|null $min_amount
* @property float|null $max_discount
* @property string $applies_to
* @property array|null $package_ids
* @property int|null $max_uses
* @property int $max_uses_per_workspace
* @property int $used_count
* @property string $duration
* @property int|null $duration_months
* @property \Carbon\Carbon|null $valid_from
* @property \Carbon\Carbon|null $valid_until
* @property bool $is_active
*/
class Coupon extends Model
{
use HasFactory;
use LogsActivity;
protected static function newFactory(): \Core\Commerce\Database\Factories\CouponFactory
{
return \Core\Commerce\Database\Factories\CouponFactory::new();
}
protected $fillable = [
'code',
'name',
'description',
'type',
'value',
'min_amount',
'max_discount',
'applies_to',
'package_ids',
'max_uses',
'max_uses_per_workspace',
'used_count',
'duration',
'duration_months',
'valid_from',
'valid_until',
'is_active',
'stripe_coupon_id',
'btcpay_coupon_id',
];
protected $casts = [
'value' => 'decimal:2',
'min_amount' => 'decimal:2',
'max_discount' => 'decimal:2',
'package_ids' => 'array',
'max_uses' => 'integer',
'max_uses_per_workspace' => 'integer',
'used_count' => 'integer',
'duration_months' => 'integer',
'valid_from' => 'datetime',
'valid_until' => 'datetime',
'is_active' => 'boolean',
];
// Relationships
public function usages(): HasMany
{
return $this->hasMany(CouponUsage::class);
}
// Type helpers
public function isPercentage(): bool
{
return $this->type === 'percentage';
}
public function isFixedAmount(): bool
{
return $this->type === 'fixed_amount';
}
// Duration helpers
public function isOnce(): bool
{
return $this->duration === 'once';
}
public function isRepeating(): bool
{
return $this->duration === 'repeating';
}
public function isForever(): bool
{
return $this->duration === 'forever';
}
// Validation
public function isValid(): bool
{
if (! $this->is_active) {
return false;
}
if ($this->valid_from && $this->valid_from->isFuture()) {
return false;
}
if ($this->valid_until && $this->valid_until->isPast()) {
return false;
}
if ($this->max_uses && $this->used_count >= $this->max_uses) {
return false;
}
return true;
}
public function canBeUsedByWorkspace(int $workspaceId): bool
{
if (! $this->isValid()) {
return false;
}
$workspaceUsageCount = $this->usages()
->where('workspace_id', $workspaceId)
->count();
return $workspaceUsageCount < $this->max_uses_per_workspace;
}
/**
* Check if an Orderable entity can use this coupon.
*
* Uses the order's orderable relationship to check usage limits.
*/
public function canBeUsedByOrderable(Orderable&Model $orderable): bool
{
if (! $this->isValid()) {
return false;
}
// Check usage via orders linked to this orderable
$usageCount = $this->usages()
->whereHas('order', function ($query) use ($orderable) {
$query->where('orderable_type', get_class($orderable))
->where('orderable_id', $orderable->id);
})
->count();
return $usageCount < $this->max_uses_per_workspace;
}
/**
* Check if coupon has reached its maximum usage limit.
*/
public function hasReachedMaxUses(): bool
{
if ($this->max_uses === null) {
return false;
}
return $this->used_count >= $this->max_uses;
}
/**
* Check if coupon is restricted to a specific package.
*
* Returns true if the package is in the allowed list.
* Returns false if no restrictions (applies to all) or package not in list.
*/
public function isRestrictedToPackage(string $packageCode): bool
{
if (empty($this->package_ids)) {
return false;
}
return in_array($packageCode, $this->package_ids);
}
public function appliesToPackage(int $packageId): bool
{
if ($this->applies_to === 'all') {
return true;
}
if ($this->applies_to !== 'packages') {
return false;
}
return in_array($packageId, $this->package_ids ?? []);
}
// Calculation
public function calculateDiscount(float $amount): float
{
if ($this->min_amount && $amount < $this->min_amount) {
return 0;
}
if ($this->isPercentage()) {
$discount = $amount * ($this->value / 100);
} else {
$discount = $this->value;
}
// Cap at max_discount if set
if ($this->max_discount && $discount > $this->max_discount) {
$discount = $this->max_discount;
}
// Cap at order amount
return min($discount, $amount);
}
// Actions
public function incrementUsage(): void
{
$this->increment('used_count');
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeValid($query)
{
return $query->active()
->where(function ($q) {
$q->whereNull('valid_from')
->orWhere('valid_from', '<=', now());
})
->where(function ($q) {
$q->whereNull('valid_until')
->orWhere('valid_until', '>=', now());
})
->where(function ($q) {
$q->whereNull('max_uses')
->orWhereRaw('used_count < max_uses');
});
}
public function scopeByCode($query, string $code)
{
return $query->where('code', strtoupper($code));
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['code', 'name', 'is_active', 'value', 'type'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "Coupon {$eventName}");
}
}

62
Models/CouponUsage.php Normal file
View file

@ -0,0 +1,62 @@
<?php
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* CouponUsage model for tracking coupon redemptions.
*
* @property int $id
* @property int $coupon_id
* @property int $workspace_id
* @property int $order_id
* @property float $discount_amount
* @property \Carbon\Carbon $created_at
*/
class CouponUsage extends Model
{
public $timestamps = false;
protected $fillable = [
'coupon_id',
'workspace_id',
'order_id',
'discount_amount',
];
protected $casts = [
'discount_amount' => 'decimal:2',
'created_at' => 'datetime',
];
// Relationships
public function coupon(): BelongsTo
{
return $this->belongsTo(Coupon::class);
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
// Boot
protected static function boot()
{
parent::boot();
static::creating(function ($usage) {
$usage->created_at = now();
});
}
}

256
Models/CreditNote.php Normal file
View file

@ -0,0 +1,256 @@
<?php
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Credit Note model for tracking credits issued to users.
*
* Credit notes can be issued as:
* - General credits (goodwill, promotional)
* - Partial refunds as store credit
* - Applied to orders to reduce payment amount
*
* @property int $id
* @property int $workspace_id
* @property int $user_id
* @property int|null $order_id
* @property int|null $refund_id
* @property string $reference_number
* @property float $amount
* @property string $currency
* @property string $reason
* @property string|null $description
* @property string $status
* @property float $amount_used
* @property int|null $applied_to_order_id
* @property \Carbon\Carbon|null $issued_at
* @property \Carbon\Carbon|null $applied_at
* @property \Carbon\Carbon|null $voided_at
* @property int|null $issued_by
* @property int|null $voided_by
* @property array|null $metadata
*/
class CreditNote extends Model
{
use HasFactory;
use LogsActivity;
protected $fillable = [
'workspace_id',
'user_id',
'order_id',
'refund_id',
'reference_number',
'amount',
'currency',
'reason',
'description',
'status',
'amount_used',
'applied_to_order_id',
'issued_at',
'applied_at',
'voided_at',
'issued_by',
'voided_by',
'metadata',
];
protected $casts = [
'amount' => 'decimal:2',
'amount_used' => 'decimal:2',
'metadata' => 'array',
'issued_at' => 'datetime',
'applied_at' => 'datetime',
'voided_at' => 'datetime',
];
// Relationships
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function refund(): BelongsTo
{
return $this->belongsTo(Refund::class);
}
public function appliedToOrder(): BelongsTo
{
return $this->belongsTo(Order::class, 'applied_to_order_id');
}
public function issuedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'issued_by');
}
public function voidedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'voided_by');
}
// Status helpers
public function isDraft(): bool
{
return $this->status === 'draft';
}
public function isIssued(): bool
{
return $this->status === 'issued';
}
public function isApplied(): bool
{
return $this->status === 'applied';
}
public function isPartiallyApplied(): bool
{
return $this->status === 'partially_applied';
}
public function isVoid(): bool
{
return $this->status === 'void';
}
public function isUsable(): bool
{
return in_array($this->status, ['issued', 'partially_applied']);
}
// Amount helpers
public function getRemainingAmount(): float
{
return max(0, $this->amount - $this->amount_used);
}
public function isFullyUsed(): bool
{
return $this->amount_used >= $this->amount;
}
// Actions
public function issue(?User $issuedBy = null): void
{
$this->update([
'status' => 'issued',
'issued_at' => now(),
'issued_by' => $issuedBy?->id,
]);
}
public function recordUsage(float $amount, ?Order $order = null): void
{
$newUsed = $this->amount_used + $amount;
$status = $newUsed >= $this->amount ? 'applied' : 'partially_applied';
$this->update([
'amount_used' => $newUsed,
'status' => $status,
'applied_to_order_id' => $order?->id ?? $this->applied_to_order_id,
'applied_at' => $status === 'applied' ? now() : $this->applied_at,
]);
}
public function void(?User $voidedBy = null): void
{
$this->update([
'status' => 'void',
'voided_at' => now(),
'voided_by' => $voidedBy?->id,
]);
}
// Scopes
public function scopeDraft($query)
{
return $query->where('status', 'draft');
}
public function scopeIssued($query)
{
return $query->where('status', 'issued');
}
public function scopeUsable($query)
{
return $query->whereIn('status', ['issued', 'partially_applied']);
}
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
}
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
// Reference number generation
public static function generateReferenceNumber(): string
{
$prefix = 'CN';
$date = now()->format('Ymd');
$random = strtoupper(substr(md5(uniqid()), 0, 4));
return "{$prefix}-{$date}-{$random}";
}
// Reason helpers
public static function reasons(): array
{
return [
'partial_refund' => 'Partial refund as store credit',
'goodwill' => 'Goodwill gesture',
'service_issue' => 'Service issue compensation',
'promotional' => 'Promotional credit',
'billing_adjustment' => 'Billing adjustment',
'cancellation' => 'Subscription cancellation credit',
'other' => 'Other',
];
}
public function getReasonLabel(): string
{
return self::reasons()[$this->reason] ?? ucfirst(str_replace('_', ' ', $this->reason));
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status', 'amount', 'amount_used', 'reason'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "Credit note {$eventName}");
}
}

344
Models/Entity.php Normal file
View file

@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* Commerce Entity - Multi-entity hierarchical commerce.
*
* Entity types:
* - M1: Master Company (source of truth, owns product catalog)
* - M2: Facades/Storefronts (select from M1, can override content)
* - M3: Dropshippers (full inheritance, no management responsibility)
*
* @property int $id
* @property string $code
* @property string $name
* @property string $type
* @property int|null $parent_id
* @property string $path
* @property int $depth
* @property int|null $workspace_id
* @property array|null $settings
* @property string|null $domain
* @property string $currency
* @property string $timezone
* @property bool $is_active
*/
class Entity extends Model
{
use HasFactory;
use SoftDeletes;
// Entity types
public const TYPE_M1_MASTER = 'm1';
public const TYPE_M2_FACADE = 'm2';
public const TYPE_M3_DROPSHIP = 'm3';
protected $table = 'commerce_entities';
protected $fillable = [
'code',
'name',
'type',
'parent_id',
'path',
'depth',
'workspace_id',
'settings',
'domain',
'currency',
'timezone',
'is_active',
];
protected $casts = [
'settings' => 'array',
'is_active' => 'boolean',
'depth' => 'integer',
];
// Relationships
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function permissions(): HasMany
{
return $this->hasMany(PermissionMatrix::class, 'entity_id');
}
public function permissionRequests(): HasMany
{
return $this->hasMany(PermissionRequest::class, 'entity_id');
}
// Type helpers
public function isMaster(): bool
{
return $this->type === self::TYPE_M1_MASTER;
}
public function isFacade(): bool
{
return $this->type === self::TYPE_M2_FACADE;
}
public function isDropshipper(): bool
{
return $this->type === self::TYPE_M3_DROPSHIP;
}
// Hierarchy methods
/**
* Get ancestors from root to parent (not including self).
*/
public function getAncestors(): Collection
{
if (! $this->path || $this->depth === 0) {
return collect();
}
$pathCodes = explode('/', trim($this->path, '/'));
array_pop($pathCodes); // Remove self
if (empty($pathCodes)) {
return collect();
}
return static::whereIn('code', $pathCodes)
->orderBy('depth')
->get();
}
/**
* Get hierarchy from root to this entity (including self).
*/
public function getHierarchy(): Collection
{
$ancestors = $this->getAncestors();
$ancestors->push($this);
return $ancestors;
}
/**
* Get all descendants of this entity.
*/
public function getDescendants(): Collection
{
return static::where('path', 'like', $this->path.'/%')->get();
}
/**
* Get the root M1 entity for this hierarchy.
*/
public function getRoot(): self
{
if ($this->depth === 0) {
return $this;
}
$rootCode = explode('/', trim($this->path, '/'))[0];
return static::where('code', $rootCode)->firstOrFail();
}
// SKU methods
/**
* Generate SKU prefix for this entity.
* Format: M1-M2-SKU or M1-M2-M3-SKU
*/
public function getSkuPrefix(): string
{
$pathCodes = explode('/', trim($this->path, '/'));
return implode('-', $pathCodes);
}
/**
* Build a full SKU with entity lineage.
*/
public function buildSku(string $baseSku): string
{
return $this->getSkuPrefix().'-'.$baseSku;
}
// Factory methods
/**
* Create a new M1 master entity.
*/
public static function createMaster(string $code, string $name, array $attributes = []): self
{
$code = Str::upper($code);
return static::create(array_merge([
'code' => $code,
'name' => $name,
'type' => self::TYPE_M1_MASTER,
'path' => $code,
'depth' => 0,
], $attributes));
}
/**
* Create a child entity under this one.
*/
public function createChild(string $code, string $name, string $type, array $attributes = []): self
{
$code = Str::upper($code);
return static::create(array_merge([
'code' => $code,
'name' => $name,
'type' => $type,
'parent_id' => $this->id,
'path' => $this->path.'/'.$code,
'depth' => $this->depth + 1,
], $attributes));
}
/**
* Create an M2 facade under this entity.
*/
public function createFacade(string $code, string $name, array $attributes = []): self
{
return $this->createChild($code, $name, self::TYPE_M2_FACADE, $attributes);
}
/**
* Create an M3 dropshipper under this entity.
*/
public function createDropshipper(string $code, string $name, array $attributes = []): self
{
return $this->createChild($code, $name, self::TYPE_M3_DROPSHIP, $attributes);
}
// Type alias helpers
public function isM1(): bool
{
return $this->isMaster();
}
public function isM2(): bool
{
return $this->isFacade();
}
public function isM3(): bool
{
return $this->isDropshipper();
}
// Boot
protected static function boot(): void
{
parent::boot();
static::creating(function (self $entity) {
// Uppercase the code
$entity->code = Str::upper($entity->code);
// Compute path and depth if not set
if (! $entity->path) {
if ($entity->parent_id) {
$parent = static::find($entity->parent_id);
if ($parent) {
$entity->path = $parent->path.'/'.$entity->code;
$entity->depth = $parent->depth + 1;
}
} else {
$entity->path = $entity->code;
$entity->depth = 0;
}
}
// Auto-determine type based on parent if not set
if (! $entity->type) {
if (! $entity->parent_id) {
$entity->type = self::TYPE_M1_MASTER;
} else {
$parent = static::find($entity->parent_id);
$entity->type = match ($parent?->type) {
self::TYPE_M1_MASTER => self::TYPE_M2_FACADE,
self::TYPE_M2_FACADE => self::TYPE_M3_DROPSHIP,
default => self::TYPE_M3_DROPSHIP,
};
}
}
});
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeMasters($query)
{
return $query->where('type', self::TYPE_M1_MASTER);
}
public function scopeFacades($query)
{
return $query->where('type', self::TYPE_M2_FACADE);
}
public function scopeDropshippers($query)
{
return $query->where('type', self::TYPE_M3_DROPSHIP);
}
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
}
// Settings helpers
public function getSetting(string $key, $default = null)
{
return data_get($this->settings, $key, $default);
}
public function setSetting(string $key, $value): self
{
$settings = $this->settings ?? [];
data_set($settings, $key, $value);
$this->settings = $settings;
return $this;
}
}

224
Models/ExchangeRate.php Normal file
View file

@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
/**
* Exchange rate for currency conversion.
*
* @property int $id
* @property string $base_currency
* @property string $target_currency
* @property float $rate
* @property string $source
* @property \Carbon\Carbon $fetched_at
*/
class ExchangeRate extends Model
{
protected $table = 'commerce_exchange_rates';
protected $fillable = [
'base_currency',
'target_currency',
'rate',
'source',
'fetched_at',
];
protected $casts = [
'rate' => 'decimal:8',
'fetched_at' => 'datetime',
];
/**
* Get the exchange rate between two currencies.
*/
public static function getRate(string $from, string $to, ?string $source = null): ?float
{
$from = strtoupper($from);
$to = strtoupper($to);
// Same currency = 1:1
if ($from === $to) {
return 1.0;
}
$cacheKey = "exchange_rate:{$from}:{$to}";
if ($source) {
$cacheKey .= ":{$source}";
}
return Cache::remember($cacheKey, config('commerce.currencies.exchange_rates.cache_ttl', 60) * 60, function () use ($from, $to, $source) {
$query = static::query()
->where('base_currency', $from)
->where('target_currency', $to)
->orderByDesc('fetched_at');
if ($source) {
$query->where('source', $source);
}
$rate = $query->first();
if ($rate) {
return (float) $rate->rate;
}
// Try inverse rate
$inverseQuery = static::query()
->where('base_currency', $to)
->where('target_currency', $from)
->orderByDesc('fetched_at');
if ($source) {
$inverseQuery->where('source', $source);
}
$inverseRate = $inverseQuery->first();
if ($inverseRate && $inverseRate->rate > 0) {
return 1.0 / (float) $inverseRate->rate;
}
// Fall back to fixed rates from config
$fixedRates = config('commerce.currencies.exchange_rates.fixed', []);
$directKey = "{$from}_{$to}";
$inverseKey = "{$to}_{$from}";
if (isset($fixedRates[$directKey])) {
return (float) $fixedRates[$directKey];
}
if (isset($fixedRates[$inverseKey]) && $fixedRates[$inverseKey] > 0) {
return 1.0 / (float) $fixedRates[$inverseKey];
}
return null;
});
}
/**
* Convert an amount between currencies.
*/
public static function convert(float $amount, string $from, string $to, ?string $source = null): ?float
{
$rate = static::getRate($from, $to, $source);
if ($rate === null) {
return null;
}
return $amount * $rate;
}
/**
* Convert an integer amount (cents/pence) between currencies.
*/
public static function convertCents(int $amount, string $from, string $to, ?string $source = null): ?int
{
$rate = static::getRate($from, $to, $source);
if ($rate === null) {
return null;
}
return (int) round($amount * $rate);
}
/**
* Store or update an exchange rate.
*/
public static function storeRate(string $from, string $to, float $rate, string $source = 'manual'): self
{
$from = strtoupper($from);
$to = strtoupper($to);
$exchangeRate = static::updateOrCreate(
[
'base_currency' => $from,
'target_currency' => $to,
'source' => $source,
],
[
'rate' => $rate,
'fetched_at' => now(),
]
);
// Clear cache
Cache::forget("exchange_rate:{$from}:{$to}");
Cache::forget("exchange_rate:{$from}:{$to}:{$source}");
return $exchangeRate;
}
/**
* Get all current rates from a base currency.
*
* @return array<string, float>
*/
public static function getRatesFrom(string $baseCurrency, ?string $source = null): array
{
$baseCurrency = strtoupper($baseCurrency);
$query = static::query()
->where('base_currency', $baseCurrency)
->orderByDesc('fetched_at');
if ($source) {
$query->where('source', $source);
}
$rates = $query->get()
->unique('target_currency')
->pluck('rate', 'target_currency')
->toArray();
return array_map('floatval', $rates);
}
/**
* Scope for rates from a specific source.
*/
public function scopeFromSource($query, string $source)
{
return $query->where('source', $source);
}
/**
* Scope for current rates (most recent).
*/
public function scopeCurrent($query)
{
return $query->orderByDesc('fetched_at');
}
/**
* Scope for rates fetched within a time window.
*/
public function scopeFresh($query, int $minutes = 60)
{
return $query->where('fetched_at', '>=', now()->subMinutes($minutes));
}
/**
* Check if rates need refreshing.
*/
public static function needsRefresh(?string $source = null): bool
{
$cacheTtl = config('commerce.currencies.exchange_rates.cache_ttl', 60);
$query = static::query()
->where('fetched_at', '>=', now()->subMinutes($cacheTtl));
if ($source) {
$query->where('source', $source);
}
return ! $query->exists();
}
}

216
Models/Inventory.php Normal file
View file

@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Commerce Inventory - Stock level at a warehouse.
*
* @property int $id
* @property int $product_id
* @property int $warehouse_id
* @property int $quantity
* @property int $reserved_quantity
* @property int $incoming_quantity
* @property int|null $low_stock_threshold
* @property string|null $bin_location
* @property string|null $zone
* @property \Carbon\Carbon|null $last_counted_at
* @property \Carbon\Carbon|null $last_restocked_at
* @property int|null $unit_cost
* @property array|null $metadata
*/
class Inventory extends Model
{
protected $table = 'commerce_inventory';
protected $fillable = [
'product_id',
'warehouse_id',
'quantity',
'reserved_quantity',
'incoming_quantity',
'low_stock_threshold',
'bin_location',
'zone',
'last_counted_at',
'last_restocked_at',
'unit_cost',
'metadata',
];
protected $casts = [
'quantity' => 'integer',
'reserved_quantity' => 'integer',
'incoming_quantity' => 'integer',
'low_stock_threshold' => 'integer',
'unit_cost' => 'integer',
'last_counted_at' => 'datetime',
'last_restocked_at' => 'datetime',
'metadata' => 'array',
];
// Relationships
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function movements(): HasMany
{
return $this->hasMany(InventoryMovement::class, 'inventory_id');
}
// Quantity helpers
/**
* Get available quantity (not reserved).
*/
public function getAvailableQuantity(): int
{
return max(0, $this->quantity - $this->reserved_quantity);
}
/**
* Get total expected quantity (including incoming).
*/
public function getTotalExpectedQuantity(): int
{
return $this->quantity + $this->incoming_quantity;
}
/**
* Check if low on stock.
*/
public function isLowStock(): bool
{
$threshold = $this->low_stock_threshold
?? $this->product?->low_stock_threshold
?? 5;
return $this->getAvailableQuantity() <= $threshold;
}
/**
* Check if out of stock.
*/
public function isOutOfStock(): bool
{
return $this->getAvailableQuantity() <= 0;
}
// Stock operations
/**
* Reserve stock for an order.
*/
public function reserve(int $quantity): bool
{
if ($this->getAvailableQuantity() < $quantity) {
return false;
}
$this->increment('reserved_quantity', $quantity);
return true;
}
/**
* Release reserved stock.
*/
public function release(int $quantity): void
{
$this->decrement('reserved_quantity', min($quantity, $this->reserved_quantity));
}
/**
* Fulfill reserved stock (convert to sale).
*/
public function fulfill(int $quantity): bool
{
if ($this->reserved_quantity < $quantity) {
return false;
}
$this->decrement('quantity', $quantity);
$this->decrement('reserved_quantity', $quantity);
return true;
}
/**
* Add stock.
*/
public function addStock(int $quantity): void
{
$this->increment('quantity', $quantity);
$this->last_restocked_at = now();
$this->save();
}
/**
* Remove stock.
*/
public function removeStock(int $quantity): bool
{
if ($this->getAvailableQuantity() < $quantity) {
return false;
}
$this->decrement('quantity', $quantity);
return true;
}
/**
* Set stock count (for physical count).
*/
public function setCount(int $quantity): int
{
$difference = $quantity - $this->quantity;
$this->quantity = $quantity;
$this->last_counted_at = now();
$this->save();
return $difference;
}
// Scopes
public function scopeLowStock($query)
{
// Uses a subquery to compare against threshold
return $query->whereRaw('(quantity - reserved_quantity) <= COALESCE(low_stock_threshold, 5)');
}
public function scopeOutOfStock($query)
{
return $query->whereRaw('(quantity - reserved_quantity) <= 0');
}
public function scopeInStock($query)
{
return $query->whereRaw('(quantity - reserved_quantity) > 0');
}
public function scopeForWarehouse($query, int $warehouseId)
{
return $query->where('warehouse_id', $warehouseId);
}
public function scopeForProduct($query, int $productId)
{
return $query->where('product_id', $productId);
}
}

View file

@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Inventory Movement - Tracks all stock changes.
*
* Provides audit trail for inventory operations.
*
* @property int $id
* @property int|null $inventory_id
* @property int $product_id
* @property int $warehouse_id
* @property string $type
* @property int $quantity
* @property int $balance_after
* @property string|null $reference
* @property string|null $notes
* @property int|null $user_id
* @property int|null $unit_cost
* @property \Carbon\Carbon $created_at
*/
class InventoryMovement extends Model
{
public $timestamps = false;
protected $table = 'commerce_inventory_movements';
// Movement types
public const TYPE_PURCHASE = 'purchase';
public const TYPE_SALE = 'sale';
public const TYPE_TRANSFER_IN = 'transfer_in';
public const TYPE_TRANSFER_OUT = 'transfer_out';
public const TYPE_ADJUSTMENT = 'adjustment';
public const TYPE_RETURN = 'return';
public const TYPE_DAMAGED = 'damaged';
public const TYPE_RESERVED = 'reserved';
public const TYPE_RELEASED = 'released';
public const TYPE_COUNT = 'count';
protected $fillable = [
'inventory_id',
'product_id',
'warehouse_id',
'type',
'quantity',
'balance_after',
'reference',
'notes',
'user_id',
'unit_cost',
];
protected $casts = [
'quantity' => 'integer',
'balance_after' => 'integer',
'unit_cost' => 'integer',
'created_at' => 'datetime',
];
// Relationships
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Helpers
/**
* Check if this is an inbound movement.
*/
public function isInbound(): bool
{
return $this->quantity > 0;
}
/**
* Check if this is an outbound movement.
*/
public function isOutbound(): bool
{
return $this->quantity < 0;
}
/**
* Get absolute quantity.
*/
public function getAbsoluteQuantity(): int
{
return abs($this->quantity);
}
/**
* Get human-readable type.
*/
public function getTypeLabel(): string
{
return match ($this->type) {
self::TYPE_PURCHASE => 'Purchase',
self::TYPE_SALE => 'Sale',
self::TYPE_TRANSFER_IN => 'Transfer In',
self::TYPE_TRANSFER_OUT => 'Transfer Out',
self::TYPE_ADJUSTMENT => 'Adjustment',
self::TYPE_RETURN => 'Return',
self::TYPE_DAMAGED => 'Damaged',
self::TYPE_RESERVED => 'Reserved',
self::TYPE_RELEASED => 'Released',
self::TYPE_COUNT => 'Stock Count',
default => ucfirst($this->type),
};
}
// Factory methods
/**
* Record a movement.
*/
public static function record(
Inventory $inventory,
string $type,
int $quantity,
?string $reference = null,
?string $notes = null,
?int $userId = null,
?int $unitCost = null
): self {
return static::create([
'inventory_id' => $inventory->id,
'product_id' => $inventory->product_id,
'warehouse_id' => $inventory->warehouse_id,
'type' => $type,
'quantity' => $quantity,
'balance_after' => $inventory->quantity,
'reference' => $reference,
'notes' => $notes,
'user_id' => $userId ?? auth()->id(),
'unit_cost' => $unitCost,
'created_at' => now(),
]);
}
// Scopes
public function scopeOfType($query, string $type)
{
return $query->where('type', $type);
}
public function scopeInbound($query)
{
return $query->where('quantity', '>', 0);
}
public function scopeOutbound($query)
{
return $query->where('quantity', '<', 0);
}
public function scopeForProduct($query, int $productId)
{
return $query->where('product_id', $productId);
}
public function scopeForWarehouse($query, int $warehouseId)
{
return $query->where('warehouse_id', $warehouseId);
}
public function scopeWithReference($query, string $reference)
{
return $query->where('reference', $reference);
}
// Boot
protected static function boot(): void
{
parent::boot();
static::creating(function (self $movement) {
if (! $movement->created_at) {
$movement->created_at = now();
}
});
}
}

243
Models/Invoice.php Normal file
View file

@ -0,0 +1,243 @@
<?php
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Invoice model representing a billing document.
*
* @property int $id
* @property int $workspace_id
* @property int|null $order_id
* @property string $invoice_number
* @property string $status
* @property string $currency
* @property float $subtotal
* @property float $tax_amount
* @property float $discount_amount
* @property float $total
* @property float $amount_paid
* @property float $amount_due
* @property \Carbon\Carbon $issue_date
* @property \Carbon\Carbon $due_date
* @property \Carbon\Carbon|null $paid_at
* @property string|null $billing_name
* @property array|null $billing_address
* @property string|null $tax_id
* @property string|null $pdf_path
*/
class Invoice extends Model
{
use HasFactory;
protected static function newFactory(): \Core\Commerce\Database\Factories\InvoiceFactory
{
return \Core\Commerce\Database\Factories\InvoiceFactory::new();
}
protected $fillable = [
'workspace_id',
'order_id',
'payment_id',
'invoice_number',
'status',
'currency',
'subtotal',
'tax_amount',
'tax_rate',
'tax_country',
'discount_amount',
'total',
'amount_paid',
'amount_due',
'issue_date',
'due_date',
'paid_at',
'billing_name',
'billing_email',
'billing_address',
'tax_id',
'pdf_path',
'auto_charge',
'charge_attempts',
'last_charge_attempt',
'next_charge_attempt',
'metadata',
];
protected $casts = [
'subtotal' => 'decimal:2',
'tax_amount' => 'decimal:2',
'discount_amount' => 'decimal:2',
'total' => 'decimal:2',
'amount_paid' => 'decimal:2',
'amount_due' => 'decimal:2',
'issue_date' => 'date',
'due_date' => 'date',
'paid_at' => 'datetime',
'billing_address' => 'array',
'auto_charge' => 'boolean',
'charge_attempts' => 'integer',
'last_charge_attempt' => 'datetime',
'next_charge_attempt' => 'datetime',
'metadata' => 'array',
];
// Accessors for compatibility
/**
* Get the issued_at attribute (alias for issue_date).
*/
public function getIssuedAtAttribute(): ?\Carbon\Carbon
{
return $this->issue_date;
}
// Relationships
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function items(): HasMany
{
return $this->hasMany(InvoiceItem::class);
}
public function payment(): BelongsTo
{
return $this->belongsTo(Payment::class);
}
public function payments(): HasMany
{
return $this->hasMany(Payment::class);
}
// Status helpers
public function isDraft(): bool
{
return $this->status === 'draft';
}
public function isSent(): bool
{
return $this->status === 'sent';
}
public function isPaid(): bool
{
return $this->status === 'paid';
}
public function isPending(): bool
{
return in_array($this->status, ['draft', 'sent', 'pending']);
}
public function isOverdue(): bool
{
return $this->status === 'overdue' ||
($this->isPending() && $this->due_date && $this->due_date->isPast());
}
public function isVoid(): bool
{
return $this->status === 'void';
}
// Actions
public function markAsPaid(?Payment $payment = null): void
{
$data = [
'status' => 'paid',
'paid_at' => now(),
'amount_paid' => $this->total,
'amount_due' => 0,
];
if ($payment) {
$data['payment_id'] = $payment->id;
}
$this->update($data);
}
public function markAsVoid(): void
{
$this->update(['status' => 'void']);
}
public function send(): void
{
$this->update(['status' => 'sent']);
}
// Scopes
public function scopePaid($query)
{
return $query->where('status', 'paid');
}
public function scopeUnpaid($query)
{
return $query->whereIn('status', ['draft', 'sent', 'pending', 'overdue']);
}
public function scopePending($query)
{
return $query->whereIn('status', ['draft', 'sent', 'pending']);
}
public function scopeOverdue($query)
{
return $query->where(function ($q) {
$q->where('status', 'overdue')
->orWhere(function ($q2) {
$q2->whereIn('status', ['draft', 'sent', 'pending'])
->where('due_date', '<', now());
});
});
}
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
}
// Invoice number generation
public static function generateInvoiceNumber(): string
{
$prefix = config('commerce.billing.invoice_prefix', 'INV-');
$year = now()->format('Y');
// Get the last invoice number for this year
$lastInvoice = static::where('invoice_number', 'like', "{$prefix}{$year}-%")
->orderByDesc('id')
->first();
if ($lastInvoice) {
$lastNumber = (int) substr($lastInvoice->invoice_number, -4);
$nextNumber = $lastNumber + 1;
} else {
$nextNumber = config('commerce.billing.invoice_start_number', 1000);
}
return sprintf('%s%s-%04d', $prefix, $year, $nextNumber);
}
}

72
Models/InvoiceItem.php Normal file
View file

@ -0,0 +1,72 @@
<?php
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* InvoiceItem model representing a line item on an invoice.
*
* @property int $id
* @property int $invoice_id
* @property int|null $order_item_id
* @property string $description
* @property int $quantity
* @property float $unit_price
* @property float $line_total
* @property bool $taxable
* @property float $tax_rate
* @property float $tax_amount
* @property array|null $metadata
*/
class InvoiceItem extends Model
{
public $timestamps = false;
protected $fillable = [
'invoice_id',
'order_item_id',
'description',
'quantity',
'unit_price',
'line_total',
'taxable',
'tax_rate',
'tax_amount',
'metadata',
];
protected $casts = [
'quantity' => 'integer',
'unit_price' => 'decimal:2',
'line_total' => 'decimal:2',
'taxable' => 'boolean',
'tax_rate' => 'decimal:2',
'tax_amount' => 'decimal:2',
'metadata' => 'array',
'created_at' => 'datetime',
];
// Relationships
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
public function orderItem(): BelongsTo
{
return $this->belongsTo(OrderItem::class);
}
// Helpers
public function calculateTax(float $rate): void
{
$this->tax_rate = $rate;
$this->tax_amount = $this->taxable
? round($this->line_total * ($rate / 100), 2)
: 0;
}
}

391
Models/Order.php Normal file
View file

@ -0,0 +1,391 @@
<?php
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Core\Commerce\Contracts\Orderable;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Order model representing a checkout transaction.
*
* @property int $id
* @property string|null $orderable_type
* @property int|null $orderable_id
* @property int $user_id
* @property string $order_number
* @property string $status
* @property string $type
* @property string $currency
* @property string|null $display_currency Customer-facing currency
* @property float|null $exchange_rate_used Exchange rate at time of order
* @property float|null $base_currency_total Total in base currency for reporting
* @property float $subtotal
* @property float $tax_amount
* @property float $discount_amount
* @property float $total
* @property string|null $payment_method
* @property string|null $payment_gateway
* @property string|null $gateway_order_id
* @property int|null $coupon_id
* @property array|null $billing_address
* @property array|null $metadata
* @property \Carbon\Carbon|null $paid_at
* @property-read Orderable|null $orderable
*/
class Order extends Model
{
use HasFactory;
use LogsActivity;
protected static function newFactory(): \Core\Commerce\Database\Factories\OrderFactory
{
return \Core\Commerce\Database\Factories\OrderFactory::new();
}
protected $fillable = [
'orderable_type',
'orderable_id',
'user_id',
'order_number',
'status',
'type',
'billing_cycle',
'currency',
'display_currency',
'exchange_rate_used',
'base_currency_total',
'subtotal',
'tax_amount',
'discount_amount',
'total',
'payment_method',
'payment_gateway',
'gateway_order_id',
'coupon_id',
'billing_name',
'billing_email',
'tax_rate',
'tax_country',
'billing_address',
'metadata',
'idempotency_key',
'paid_at',
];
protected $casts = [
'subtotal' => 'decimal:2',
'tax_amount' => 'decimal:2',
'discount_amount' => 'decimal:2',
'total' => 'decimal:2',
'tax_rate' => 'decimal:2',
'exchange_rate_used' => 'decimal:8',
'base_currency_total' => 'decimal:2',
'billing_address' => 'array',
'metadata' => 'array',
'paid_at' => 'datetime',
];
// Relationships
/**
* The orderable entity (User or Workspace).
*/
public function orderable(): MorphTo
{
return $this->morphTo();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function coupon(): BelongsTo
{
return $this->belongsTo(Coupon::class);
}
public function invoice(): HasOne
{
return $this->hasOne(Invoice::class);
}
public function payments(): HasMany
{
return $this->hasMany(Payment::class, 'invoice_id', 'id')
->whereHas('invoice', fn ($q) => $q->where('order_id', $this->id));
}
/**
* Credit notes that originated from this order.
*/
public function creditNotes(): HasMany
{
return $this->hasMany(CreditNote::class);
}
/**
* Credit notes that were applied to this order.
*/
public function appliedCreditNotes(): HasMany
{
return $this->hasMany(CreditNote::class, 'applied_to_order_id');
}
// Status helpers
public function isPending(): bool
{
return $this->status === 'pending';
}
public function isProcessing(): bool
{
return $this->status === 'processing';
}
public function isPaid(): bool
{
return $this->status === 'paid';
}
public function isFailed(): bool
{
return $this->status === 'failed';
}
public function isRefunded(): bool
{
return $this->status === 'refunded';
}
public function isCancelled(): bool
{
return $this->status === 'cancelled';
}
// Actions
public function markAsPaid(): void
{
$this->update([
'status' => 'paid',
'paid_at' => now(),
]);
}
public function markAsFailed(?string $reason = null): void
{
$this->update([
'status' => 'failed',
'metadata' => array_merge($this->metadata ?? [], [
'failure_reason' => $reason,
'failed_at' => now()->toIso8601String(),
]),
]);
}
public function cancel(): void
{
$this->update(['status' => 'cancelled']);
}
// Scopes
public function scopePending($query)
{
return $query->where('status', 'pending');
}
public function scopePaid($query)
{
return $query->where('status', 'paid');
}
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('orderable_type', Workspace::class)
->where('orderable_id', $workspaceId);
}
// Workspace resolution
/**
* Get the workspace ID for this order.
*
* Handles polymorphic orderables: if the orderable is a Workspace,
* returns its ID directly. If it's a User, returns their default
* workspace ID.
*/
public function getWorkspaceIdAttribute(): ?int
{
if ($this->orderable_type === Workspace::class) {
return $this->orderable_id;
}
if ($this->orderable_type === User::class) {
$user = $this->orderable;
return $user?->defaultHostWorkspace()?->id;
}
return null;
}
/**
* Get the workspace for this order.
*
* Returns the workspace directly if orderable is Workspace,
* or the user's default workspace if orderable is User.
*/
public function getResolvedWorkspace(): ?Workspace
{
if ($this->orderable_type === Workspace::class) {
return $this->orderable;
}
if ($this->orderable_type === User::class) {
return $this->orderable?->defaultHostWorkspace();
}
return null;
}
// Order number generation
public static function generateOrderNumber(): string
{
$prefix = 'ORD';
$date = now()->format('Ymd');
$random = strtoupper(substr(md5(uniqid()), 0, 6));
return "{$prefix}-{$date}-{$random}";
}
// Currency helpers
/**
* Get the display currency (customer-facing).
*/
public function getDisplayCurrencyAttribute($value): string
{
return $value ?? $this->currency ?? config('commerce.currency', 'GBP');
}
/**
* Get formatted total in display currency.
*/
public function getFormattedTotalAttribute(): string
{
$currencyService = app(\Core\Commerce\Services\CurrencyService::class);
return $currencyService->format($this->total, $this->display_currency);
}
/**
* Get formatted subtotal in display currency.
*/
public function getFormattedSubtotalAttribute(): string
{
$currencyService = app(\Core\Commerce\Services\CurrencyService::class);
return $currencyService->format($this->subtotal, $this->display_currency);
}
/**
* Get formatted tax amount in display currency.
*/
public function getFormattedTaxAmountAttribute(): string
{
$currencyService = app(\Core\Commerce\Services\CurrencyService::class);
return $currencyService->format($this->tax_amount, $this->display_currency);
}
/**
* Get formatted discount amount in display currency.
*/
public function getFormattedDiscountAmountAttribute(): string
{
$currencyService = app(\Core\Commerce\Services\CurrencyService::class);
return $currencyService->format($this->discount_amount, $this->display_currency);
}
/**
* Convert an amount from display currency to base currency.
*/
public function toBaseCurrency(float $amount): float
{
if ($this->exchange_rate_used && $this->exchange_rate_used > 0) {
return $amount / $this->exchange_rate_used;
}
$baseCurrency = config('commerce.currencies.base', 'GBP');
if ($this->display_currency === $baseCurrency) {
return $amount;
}
return \Core\Commerce\Models\ExchangeRate::convert(
$amount,
$this->display_currency,
$baseCurrency
) ?? $amount;
}
/**
* Convert an amount from base currency to display currency.
*/
public function toDisplayCurrency(float $amount): float
{
if ($this->exchange_rate_used) {
return $amount * $this->exchange_rate_used;
}
$baseCurrency = config('commerce.currencies.base', 'GBP');
if ($this->display_currency === $baseCurrency) {
return $amount;
}
return \Core\Commerce\Models\ExchangeRate::convert(
$amount,
$baseCurrency,
$this->display_currency
) ?? $amount;
}
/**
* Check if order uses a different display currency than base.
*/
public function hasMultiCurrency(): bool
{
$baseCurrency = config('commerce.currencies.base', 'GBP');
return $this->display_currency !== $baseCurrency;
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status', 'paid_at'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "Order {$eventName}");
}
}

93
Models/OrderItem.php Normal file
View file

@ -0,0 +1,93 @@
<?php
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\Package;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* OrderItem model representing a line item in an order.
*
* @property int $id
* @property int $order_id
* @property string $item_type
* @property int|null $item_id
* @property string|null $item_code
* @property string $description
* @property int $quantity
* @property float $unit_price
* @property float $line_total
* @property string $billing_cycle
* @property array|null $metadata
*/
class OrderItem extends Model
{
public $timestamps = false;
protected $fillable = [
'order_id',
'item_type',
'item_id',
'item_code',
'description',
'quantity',
'unit_price',
'line_total',
'billing_cycle',
'metadata',
];
protected $casts = [
'quantity' => 'integer',
'unit_price' => 'decimal:2',
'line_total' => 'decimal:2',
'metadata' => 'array',
'created_at' => 'datetime',
];
// Relationships
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function package(): BelongsTo
{
return $this->belongsTo(Package::class, 'item_id')
->where('item_type', 'package');
}
// Helpers
public function isPackage(): bool
{
return $this->item_type === 'package';
}
public function isAddon(): bool
{
return $this->item_type === 'addon';
}
public function isBoost(): bool
{
return $this->item_type === 'boost';
}
public function isMonthly(): bool
{
return $this->billing_cycle === 'monthly';
}
public function isYearly(): bool
{
return $this->billing_cycle === 'yearly';
}
public function isOneTime(): bool
{
return $this->billing_cycle === 'onetime';
}
}

179
Models/Payment.php Normal file
View file

@ -0,0 +1,179 @@
<?php
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Payment model representing money received.
*
* @property int $id
* @property int $workspace_id
* @property int|null $invoice_id
* @property string $gateway
* @property string|null $gateway_payment_id
* @property string|null $gateway_customer_id
* @property string $currency
* @property float $amount
* @property float $fee
* @property float $net_amount
* @property string $status
* @property string|null $failure_reason
* @property string|null $payment_method_type
* @property string|null $payment_method_last4
* @property string|null $payment_method_brand
* @property array|null $gateway_response
* @property float $refunded_amount
*/
class Payment extends Model
{
use HasFactory;
protected static function newFactory(): \Core\Commerce\Database\Factories\PaymentFactory
{
return \Core\Commerce\Database\Factories\PaymentFactory::new();
}
protected $fillable = [
'workspace_id',
'invoice_id',
'order_id',
'gateway',
'gateway_payment_id',
'gateway_customer_id',
'currency',
'amount',
'fee',
'net_amount',
'status',
'failure_reason',
'payment_method_type',
'payment_method_last4',
'payment_method_brand',
'gateway_response',
'refunded_amount',
'paid_at',
];
protected $casts = [
'amount' => 'decimal:2',
'fee' => 'decimal:2',
'net_amount' => 'decimal:2',
'refunded_amount' => 'decimal:2',
'gateway_response' => 'array',
'paid_at' => 'datetime',
];
// Relationships
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
public function refunds(): HasMany
{
return $this->hasMany(Refund::class);
}
// Status helpers
public function isPending(): bool
{
return $this->status === 'pending';
}
public function isProcessing(): bool
{
return $this->status === 'processing';
}
public function isSucceeded(): bool
{
return $this->status === 'succeeded';
}
public function isFailed(): bool
{
return $this->status === 'failed';
}
public function isRefunded(): bool
{
return $this->status === 'refunded';
}
public function isPartiallyRefunded(): bool
{
return $this->status === 'partially_refunded';
}
public function canRefund(): bool
{
return $this->isSucceeded() || $this->isPartiallyRefunded();
}
public function isFullyRefunded(): bool
{
return $this->refunded_amount >= $this->amount;
}
public function getRefundableAmount(): float
{
return $this->amount - $this->refunded_amount;
}
// Actions
public function markAsSucceeded(): void
{
$this->update(['status' => 'succeeded']);
}
public function markAsFailed(?string $reason = null): void
{
$this->update([
'status' => 'failed',
'failure_reason' => $reason,
]);
}
public function recordRefund(float $amount): void
{
$newRefundedAmount = $this->refunded_amount + $amount;
$status = $newRefundedAmount >= $this->amount
? 'refunded'
: 'partially_refunded';
$this->update([
'refunded_amount' => $newRefundedAmount,
'status' => $status,
]);
}
// Scopes
public function scopeSucceeded($query)
{
return $query->where('status', 'succeeded');
}
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
}
public function scopeForGateway($query, string $gateway)
{
return $query->where('gateway', $gateway);
}
}

142
Models/PaymentMethod.php Normal file
View file

@ -0,0 +1,142 @@
<?php
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* PaymentMethod model representing saved payment methods.
*
* @property int $id
* @property int $workspace_id
* @property int $user_id
* @property string $gateway
* @property string $gateway_payment_method_id
* @property string $gateway_customer_id
* @property string $type
* @property string|null $brand
* @property string|null $last_four
* @property int|null $exp_month
* @property int|null $exp_year
* @property bool $is_default
* @property bool $is_active
*/
class PaymentMethod extends Model
{
use HasFactory;
protected $fillable = [
'workspace_id',
'user_id',
'gateway',
'gateway_payment_method_id',
'gateway_customer_id',
'type',
'brand',
'last_four',
'exp_month',
'exp_year',
'is_default',
'is_active',
'metadata',
];
protected $casts = [
'exp_month' => 'integer',
'exp_year' => 'integer',
'is_default' => 'boolean',
'is_active' => 'boolean',
'metadata' => 'array',
];
// Relationships
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Helpers
public function isCard(): bool
{
return $this->type === 'card';
}
public function isCrypto(): bool
{
return $this->type === 'crypto_wallet';
}
public function isBankAccount(): bool
{
return $this->type === 'bank_account';
}
public function isExpired(): bool
{
if (! $this->exp_month || ! $this->exp_year) {
return false;
}
$expiry = \Carbon\Carbon::createFromDate($this->exp_year, $this->exp_month)->endOfMonth();
return $expiry->isPast();
}
public function getDisplayName(): string
{
if ($this->isCard()) {
return sprintf('%s **** %s', ucfirst($this->brand ?? 'Card'), $this->last_four);
}
if ($this->isCrypto()) {
return 'Crypto Wallet';
}
return 'Bank Account';
}
// Actions
public function setAsDefault(): void
{
// Remove default from other methods
static::where('workspace_id', $this->workspace_id)
->where('id', '!=', $this->id)
->update(['is_default' => false]);
$this->update(['is_default' => true]);
}
public function deactivate(): void
{
$this->update(['is_active' => false]);
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeDefault($query)
{
return $query->where('is_default', true);
}
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
}
}

140
Models/PermissionMatrix.php Normal file
View file

@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Permission Matrix entry - defines what an entity can do.
*
* Top-down immutable rules:
* - If M1 says "NO" Everything below is "NO"
* - If M1 says "YES" M2 can say "NO" for itself
* - Permissions cascade DOWN, restrictions are IMMUTABLE from above
*
* @property int $id
* @property int $entity_id
* @property string $key
* @property string|null $scope
* @property bool $allowed
* @property bool $locked
* @property string $source
* @property int|null $set_by_entity_id
* @property \Carbon\Carbon|null $trained_at
* @property string|null $trained_route
*/
class PermissionMatrix extends Model
{
// Source types
public const SOURCE_INHERITED = 'inherited';
public const SOURCE_EXPLICIT = 'explicit';
public const SOURCE_TRAINED = 'trained';
protected $table = 'permission_matrix';
protected $fillable = [
'entity_id',
'key',
'scope',
'allowed',
'locked',
'source',
'set_by_entity_id',
'trained_at',
'trained_route',
];
protected $casts = [
'allowed' => 'boolean',
'locked' => 'boolean',
'trained_at' => 'datetime',
];
// Relationships
public function entity(): BelongsTo
{
return $this->belongsTo(Entity::class, 'entity_id');
}
public function setByEntity(): BelongsTo
{
return $this->belongsTo(Entity::class, 'set_by_entity_id');
}
// Status helpers
public function isAllowed(): bool
{
return $this->allowed;
}
public function isDenied(): bool
{
return ! $this->allowed;
}
public function isLocked(): bool
{
return $this->locked;
}
public function isTrained(): bool
{
return $this->source === self::SOURCE_TRAINED;
}
public function isInherited(): bool
{
return $this->source === self::SOURCE_INHERITED;
}
public function isExplicit(): bool
{
return $this->source === self::SOURCE_EXPLICIT;
}
// Scopes
public function scopeForEntity($query, int $entityId)
{
return $query->where('entity_id', $entityId);
}
public function scopeForKey($query, string $key)
{
return $query->where('key', $key);
}
public function scopeForScope($query, ?string $scope)
{
return $query->where(function ($q) use ($scope) {
$q->whereNull('scope')->orWhere('scope', $scope);
});
}
public function scopeAllowed($query)
{
return $query->where('allowed', true);
}
public function scopeDenied($query)
{
return $query->where('allowed', false);
}
public function scopeLocked($query)
{
return $query->where('locked', true);
}
public function scopeTrained($query)
{
return $query->where('source', self::SOURCE_TRAINED);
}
}

View file

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Permission Request - training data for the Permission Matrix.
*
* In training mode, undefined permissions create entries here
* for approval. This builds a complete map of every action
* in the system through actual usage.
*
* @property int $id
* @property int $entity_id
* @property string $method
* @property string $route
* @property string $action
* @property string|null $scope
* @property array|null $request_data
* @property string|null $user_agent
* @property string|null $ip_address
* @property int|null $user_id
* @property string $status
* @property bool $was_trained
* @property \Carbon\Carbon|null $trained_at
*/
class PermissionRequest extends Model
{
// Status values
public const STATUS_ALLOWED = 'allowed';
public const STATUS_DENIED = 'denied';
public const STATUS_PENDING = 'pending';
protected $table = 'permission_requests';
protected $fillable = [
'entity_id',
'method',
'route',
'action',
'scope',
'request_data',
'user_agent',
'ip_address',
'user_id',
'status',
'was_trained',
'trained_at',
];
protected $casts = [
'request_data' => 'array',
'was_trained' => 'boolean',
'trained_at' => 'datetime',
];
// Relationships
public function entity(): BelongsTo
{
return $this->belongsTo(Entity::class, 'entity_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Status helpers
public function isAllowed(): bool
{
return $this->status === self::STATUS_ALLOWED;
}
public function isDenied(): bool
{
return $this->status === self::STATUS_DENIED;
}
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function wasTrained(): bool
{
return $this->was_trained;
}
// Factory methods
/**
* Create a request log entry from an HTTP request.
*/
public static function fromRequest(
Entity $entity,
string $action,
string $status,
?string $scope = null
): self {
$request = request();
return static::create([
'entity_id' => $entity->id,
'method' => $request->method(),
'route' => $request->path(),
'action' => $action,
'scope' => $scope,
'request_data' => self::sanitiseRequestData($request->all()),
'user_agent' => $request->userAgent(),
'ip_address' => $request->ip(),
'user_id' => auth()->id(),
'status' => $status,
]);
}
/**
* Sanitise request data for storage (remove sensitive fields).
*/
protected static function sanitiseRequestData(array $data): array
{
$sensitiveKeys = [
'password',
'password_confirmation',
'token',
'api_key',
'secret',
'credit_card',
'card_number',
'cvv',
'ssn',
];
foreach ($sensitiveKeys as $key) {
unset($data[$key]);
}
// Limit size
$json = json_encode($data);
if (strlen($json) > 10000) {
return ['_truncated' => true, '_size' => strlen($json)];
}
return $data;
}
// Scopes
public function scopeForEntity($query, int $entityId)
{
return $query->where('entity_id', $entityId);
}
public function scopeForAction($query, string $action)
{
return $query->where('action', $action);
}
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
public function scopeUntrained($query)
{
return $query->where('was_trained', false);
}
public function scopeRecent($query, int $days = 7)
{
return $query->where('created_at', '>=', now()->subDays($days));
}
}

526
Models/Product.php Normal file
View file

@ -0,0 +1,526 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Core\Commerce\Concerns\HasContentOverrides;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Commerce Product - Master catalog entry.
*
* Products are owned exclusively by M1 (Master) entities.
* M2/M3 entities access products through ProductAssignment.
*
* @property int $id
* @property string $sku
* @property int $owner_entity_id
* @property string $name
* @property string|null $description
* @property string|null $short_description
* @property string|null $category
* @property string|null $subcategory
* @property array|null $tags
* @property int $price
* @property int|null $cost_price
* @property int|null $rrp
* @property string $currency
* @property array|null $price_tiers
* @property string $tax_class
* @property bool $tax_inclusive
* @property float|null $weight
* @property float|null $length
* @property float|null $width
* @property float|null $height
* @property bool $track_stock
* @property int $stock_quantity
* @property int $low_stock_threshold
* @property string $stock_status
* @property bool $allow_backorder
* @property string $type
* @property int|null $parent_id
* @property array|null $variant_attributes
* @property string|null $image_url
* @property array|null $gallery_urls
* @property string|null $slug
* @property bool $is_active
* @property bool $is_featured
* @property bool $is_visible
* @property array|null $metadata
*/
class Product extends Model
{
use HasContentOverrides;
use HasFactory;
use LogsActivity;
use SoftDeletes;
// Product types
public const TYPE_SIMPLE = 'simple';
public const TYPE_VARIABLE = 'variable';
public const TYPE_BUNDLE = 'bundle';
public const TYPE_VIRTUAL = 'virtual';
public const TYPE_SUBSCRIPTION = 'subscription';
// Stock statuses
public const STOCK_IN_STOCK = 'in_stock';
public const STOCK_LOW = 'low_stock';
public const STOCK_OUT = 'out_of_stock';
public const STOCK_BACKORDER = 'backorder';
public const STOCK_DISCONTINUED = 'discontinued';
// Tax classes
public const TAX_STANDARD = 'standard';
public const TAX_REDUCED = 'reduced';
public const TAX_ZERO = 'zero';
public const TAX_EXEMPT = 'exempt';
protected $table = 'commerce_products';
protected $fillable = [
'sku',
'owner_entity_id',
'name',
'description',
'short_description',
'category',
'subcategory',
'tags',
'price',
'cost_price',
'rrp',
'currency',
'price_tiers',
'tax_class',
'tax_inclusive',
'weight',
'length',
'width',
'height',
'track_stock',
'stock_quantity',
'low_stock_threshold',
'stock_status',
'allow_backorder',
'type',
'parent_id',
'variant_attributes',
'image_url',
'gallery_urls',
'slug',
'meta_title',
'meta_description',
'is_active',
'is_featured',
'is_visible',
'available_from',
'available_until',
'sort_order',
'metadata',
];
protected $casts = [
'tags' => 'array',
'price' => 'integer',
'cost_price' => 'integer',
'rrp' => 'integer',
'price_tiers' => 'array',
'tax_inclusive' => 'boolean',
'weight' => 'decimal:3',
'length' => 'decimal:2',
'width' => 'decimal:2',
'height' => 'decimal:2',
'track_stock' => 'boolean',
'stock_quantity' => 'integer',
'low_stock_threshold' => 'integer',
'allow_backorder' => 'boolean',
'variant_attributes' => 'array',
'gallery_urls' => 'array',
'is_active' => 'boolean',
'is_featured' => 'boolean',
'is_visible' => 'boolean',
'available_from' => 'datetime',
'available_until' => 'datetime',
'sort_order' => 'integer',
'metadata' => 'array',
];
// Relationships
public function ownerEntity(): BelongsTo
{
return $this->belongsTo(Entity::class, 'owner_entity_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function variants(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
public function assignments(): HasMany
{
return $this->hasMany(ProductAssignment::class, 'product_id');
}
public function prices(): HasMany
{
return $this->hasMany(ProductPrice::class, 'product_id');
}
// Type helpers
public function isSimple(): bool
{
return $this->type === self::TYPE_SIMPLE;
}
public function isVariable(): bool
{
return $this->type === self::TYPE_VARIABLE;
}
public function isBundle(): bool
{
return $this->type === self::TYPE_BUNDLE;
}
public function isVirtual(): bool
{
return $this->type === self::TYPE_VIRTUAL;
}
public function isSubscription(): bool
{
return $this->type === self::TYPE_SUBSCRIPTION;
}
public function isVariant(): bool
{
return $this->parent_id !== null;
}
// Stock helpers
public function isInStock(): bool
{
if (! $this->track_stock) {
return true;
}
return $this->stock_quantity > 0 || $this->allow_backorder;
}
public function isLowStock(): bool
{
return $this->track_stock && $this->stock_quantity <= $this->low_stock_threshold;
}
public function updateStockStatus(): self
{
if (! $this->track_stock) {
$this->stock_status = self::STOCK_IN_STOCK;
} elseif ($this->stock_quantity <= 0) {
$this->stock_status = $this->allow_backorder ? self::STOCK_BACKORDER : self::STOCK_OUT;
} elseif ($this->stock_quantity <= $this->low_stock_threshold) {
$this->stock_status = self::STOCK_LOW;
} else {
$this->stock_status = self::STOCK_IN_STOCK;
}
return $this;
}
public function adjustStock(int $quantity, string $reason = ''): self
{
$this->stock_quantity += $quantity;
$this->updateStockStatus();
$this->save();
return $this;
}
// Price helpers
/**
* Get formatted price.
*/
public function getFormattedPriceAttribute(): string
{
return $this->formatPrice($this->price);
}
/**
* Get price for a specific tier.
*/
public function getTierPrice(string $tier): ?int
{
return $this->price_tiers[$tier] ?? null;
}
/**
* Format a price value.
*/
public function formatPrice(int $amount, ?string $currency = null): string
{
$currency = $currency ?? $this->currency;
$currencyService = app(\Core\Commerce\Services\CurrencyService::class);
return $currencyService->format($amount, $currency, isCents: true);
}
/**
* Get price in a specific currency.
*
* Returns explicit price if set, otherwise auto-converts from base price.
*/
public function getPriceInCurrency(string $currency): ?int
{
$currency = strtoupper($currency);
// Check for explicit price
$price = $this->prices()->where('currency', $currency)->first();
if ($price) {
return $price->amount;
}
// Auto-convert if enabled
if (! config('commerce.currencies.auto_convert', true)) {
return null;
}
// Same currency as base
if ($currency === $this->currency) {
return $this->price;
}
// Convert from base currency
$rate = ExchangeRate::getRate($this->currency, $currency);
if ($rate === null) {
return null;
}
return (int) round($this->price * $rate);
}
/**
* Get formatted price in a specific currency.
*/
public function getFormattedPriceInCurrency(string $currency): ?string
{
$amount = $this->getPriceInCurrency($currency);
if ($amount === null) {
return null;
}
return $this->formatPrice($amount, $currency);
}
/**
* Set an explicit price for a currency.
*/
public function setPriceForCurrency(string $currency, int $amount): ProductPrice
{
return $this->prices()->updateOrCreate(
['currency' => strtoupper($currency)],
[
'amount' => $amount,
'is_manual' => true,
'exchange_rate_used' => null,
]
);
}
/**
* Remove explicit price for a currency (will fall back to conversion).
*/
public function removePriceForCurrency(string $currency): bool
{
return $this->prices()
->where('currency', strtoupper($currency))
->delete() > 0;
}
/**
* Refresh all auto-converted prices from exchange rates.
*/
public function refreshConvertedPrices(): void
{
ProductPrice::refreshAutoConverted($this);
}
/**
* Calculate margin percentage.
*/
public function getMarginPercentAttribute(): ?float
{
if (! $this->cost_price || $this->cost_price === 0) {
return null;
}
return round((($this->price - $this->cost_price) / $this->price) * 100, 2);
}
// SKU helpers
/**
* Build full SKU with entity lineage.
*/
public function buildFullSku(Entity $entity): string
{
return $entity->buildSku($this->sku);
}
/**
* Generate a unique SKU.
*/
public static function generateSku(string $prefix = ''): string
{
$random = strtoupper(Str::random(8));
return $prefix ? "{$prefix}-{$random}" : $random;
}
// Availability helpers
public function isAvailable(): bool
{
if (! $this->is_active || ! $this->is_visible) {
return false;
}
$now = now();
if ($this->available_from && $now->lt($this->available_from)) {
return false;
}
if ($this->available_until && $now->gt($this->available_until)) {
return false;
}
return true;
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeVisible($query)
{
return $query->where('is_visible', true);
}
public function scopeFeatured($query)
{
return $query->where('is_featured', true);
}
public function scopeInStock($query)
{
return $query->where(function ($q) {
$q->where('track_stock', false)
->orWhere('stock_quantity', '>', 0)
->orWhere('allow_backorder', true);
});
}
public function scopeForOwner($query, int $entityId)
{
return $query->where('owner_entity_id', $entityId);
}
public function scopeInCategory($query, string $category)
{
return $query->where('category', $category);
}
public function scopeOfType($query, string $type)
{
return $query->where('type', $type);
}
public function scopeParentsOnly($query)
{
return $query->whereNull('parent_id');
}
// Content override support
/**
* Get the fields that can be overridden by M2/M3 entities.
*/
public function getOverrideableFields(): array
{
return [
'name',
'description',
'short_description',
'image_url',
'gallery_urls',
'meta_title',
'meta_description',
'slug',
];
}
// Boot
protected static function boot(): void
{
parent::boot();
static::creating(function (self $product) {
// Generate slug if not set
if (! $product->slug) {
$product->slug = Str::slug($product->name);
}
// Uppercase SKU
$product->sku = strtoupper($product->sku);
});
static::saving(function (self $product) {
// Update stock status
$product->updateStockStatus();
});
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['name', 'sku', 'price', 'is_active', 'stock_status'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "Product {$eventName}");
}
}

View file

@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Core\Commerce\Concerns\HasContentOverrides;
/**
* Product Assignment - Links products to M2/M3 entities.
*
* Allows entities to sell products with optional overrides
* for price, content, and visibility.
*
* @property int $id
* @property int $entity_id
* @property int $product_id
* @property string|null $sku_suffix
* @property int|null $price_override
* @property array|null $price_tier_overrides
* @property float|null $margin_percent
* @property int|null $fixed_margin
* @property string|null $name_override
* @property string|null $description_override
* @property string|null $image_override
* @property bool $is_active
* @property bool $is_featured
* @property int $sort_order
* @property int|null $allocated_stock
* @property bool $can_discount
* @property int|null $min_price
* @property int|null $max_price
* @property array|null $metadata
*/
class ProductAssignment extends Model
{
use HasContentOverrides;
protected $table = 'commerce_product_assignments';
protected $fillable = [
'entity_id',
'product_id',
'sku_suffix',
'price_override',
'price_tier_overrides',
'margin_percent',
'fixed_margin',
'name_override',
'description_override',
'image_override',
'is_active',
'is_featured',
'sort_order',
'allocated_stock',
'can_discount',
'min_price',
'max_price',
'metadata',
];
protected $casts = [
'price_override' => 'integer',
'price_tier_overrides' => 'array',
'margin_percent' => 'decimal:2',
'fixed_margin' => 'integer',
'is_active' => 'boolean',
'is_featured' => 'boolean',
'sort_order' => 'integer',
'allocated_stock' => 'integer',
'can_discount' => 'boolean',
'min_price' => 'integer',
'max_price' => 'integer',
'metadata' => 'array',
];
// Relationships
public function entity(): BelongsTo
{
return $this->belongsTo(Entity::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
// Effective value getters (use override if set, else fall back to product)
/**
* Get effective price for this assignment.
*/
public function getEffectivePrice(): int
{
return $this->price_override ?? $this->product->price;
}
/**
* Get effective name.
*/
public function getEffectiveName(): string
{
return $this->name_override ?? $this->product->name;
}
/**
* Get effective description.
*/
public function getEffectiveDescription(): ?string
{
return $this->description_override ?? $this->product->description;
}
/**
* Get effective image URL.
*/
public function getEffectiveImage(): ?string
{
return $this->image_override ?? $this->product->image_url;
}
/**
* Get effective tier price.
*/
public function getEffectiveTierPrice(string $tier): ?int
{
if ($this->price_tier_overrides && isset($this->price_tier_overrides[$tier])) {
return $this->price_tier_overrides[$tier];
}
return $this->product->getTierPrice($tier);
}
// SKU helpers
/**
* Build full SKU for this entity's product.
* Format: OWNER-ENTITY-BASEKU or OWNER-ENTITY-SUFFIX
*/
public function getFullSku(): string
{
$baseSku = $this->sku_suffix ?? $this->product->sku;
return $this->entity->buildSku($baseSku);
}
/**
* Get SKU without entity prefix (just the product part).
*/
public function getBaseSku(): string
{
return $this->sku_suffix ?? $this->product->sku;
}
// Price validation
/**
* Check if a price is within allowed range.
*/
public function isPriceAllowed(int $price): bool
{
if ($this->min_price !== null && $price < $this->min_price) {
return false;
}
if ($this->max_price !== null && $price > $this->max_price) {
return false;
}
return true;
}
/**
* Clamp price to allowed range.
*/
public function clampPrice(int $price): int
{
if ($this->min_price !== null && $price < $this->min_price) {
return $this->min_price;
}
if ($this->max_price !== null && $price > $this->max_price) {
return $this->max_price;
}
return $price;
}
// Margin calculation
/**
* Calculate entity's margin on this product.
*/
public function calculateMargin(?int $salePrice = null): int
{
$salePrice ??= $this->getEffectivePrice();
$basePrice = $this->product->price;
if ($this->fixed_margin !== null) {
return $this->fixed_margin;
}
if ($this->margin_percent !== null) {
return (int) round($salePrice * ($this->margin_percent / 100));
}
// Default: difference between sale and base price
return $salePrice - $basePrice;
}
// Stock helpers
/**
* Get available stock for this entity.
*/
public function getAvailableStock(): int
{
// If entity has allocated stock, use that
if ($this->allocated_stock !== null) {
return $this->allocated_stock;
}
// Otherwise use master product stock
return $this->product->stock_quantity;
}
/**
* Check if product is available for this entity.
*/
public function isAvailable(): bool
{
return $this->is_active && $this->product->isAvailable();
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeFeatured($query)
{
return $query->where('is_featured', true);
}
public function scopeForEntity($query, int $entityId)
{
return $query->where('entity_id', $entityId);
}
public function scopeForProduct($query, int $productId)
{
return $query->where('product_id', $productId);
}
public function scopeWithActiveProducts($query)
{
return $query->whereHas('product', fn ($q) => $q->active()->visible());
}
}

221
Models/ProductPrice.php Normal file
View file

@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Product price in a specific currency.
*
* Allows products to have explicit prices in multiple currencies,
* with fallback to auto-conversion from the base price.
*
* @property int $id
* @property int $product_id
* @property string $currency
* @property int $amount Price in smallest unit (cents/pence)
* @property bool $is_manual Whether this is a manual override
* @property float|null $exchange_rate_used Rate used for auto-conversion
*/
class ProductPrice extends Model
{
protected $table = 'commerce_product_prices';
protected $fillable = [
'product_id',
'currency',
'amount',
'is_manual',
'exchange_rate_used',
];
protected $casts = [
'amount' => 'integer',
'is_manual' => 'boolean',
'exchange_rate_used' => 'decimal:8',
];
/**
* Get the product.
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
/**
* Get the formatted price.
*/
public function getFormattedAttribute(): string
{
return $this->format();
}
/**
* Format the price for display.
*/
public function format(): string
{
$config = config("commerce.currencies.supported.{$this->currency}", []);
$symbol = $config['symbol'] ?? $this->currency;
$position = $config['symbol_position'] ?? 'before';
$decimals = $config['decimal_places'] ?? 2;
$thousandsSep = $config['thousands_separator'] ?? ',';
$decimalSep = $config['decimal_separator'] ?? '.';
$value = number_format(
$this->amount / 100,
$decimals,
$decimalSep,
$thousandsSep
);
return $position === 'before'
? "{$symbol}{$value}"
: "{$value}{$symbol}";
}
/**
* Get price as decimal (not cents).
*/
public function getDecimalAmount(): float
{
return $this->amount / 100;
}
/**
* Set price from decimal amount.
*/
public function setDecimalAmount(float $amount): self
{
$this->amount = (int) round($amount * 100);
return $this;
}
/**
* Get or create a price for a product in a currency.
*
* If no explicit price exists and auto-convert is enabled,
* creates an auto-converted price.
*/
public static function getOrCreate(Product $product, string $currency): ?self
{
$currency = strtoupper($currency);
// Check for existing price
$price = static::where('product_id', $product->id)
->where('currency', $currency)
->first();
if ($price) {
return $price;
}
// Check if auto-conversion is enabled
if (! config('commerce.currencies.auto_convert', true)) {
return null;
}
// Get base price and convert
$baseCurrency = $product->currency ?? config('commerce.currencies.base', 'GBP');
if ($baseCurrency === $currency) {
// Create with base price
return static::create([
'product_id' => $product->id,
'currency' => $currency,
'amount' => $product->price,
'is_manual' => false,
'exchange_rate_used' => 1.0,
]);
}
$rate = ExchangeRate::getRate($baseCurrency, $currency);
if ($rate === null) {
return null;
}
$convertedAmount = (int) round($product->price * $rate);
return static::create([
'product_id' => $product->id,
'currency' => $currency,
'amount' => $convertedAmount,
'is_manual' => false,
'exchange_rate_used' => $rate,
]);
}
/**
* Update all auto-converted prices for a product.
*/
public static function refreshAutoConverted(Product $product): void
{
$baseCurrency = $product->currency ?? config('commerce.currencies.base', 'GBP');
$supportedCurrencies = array_keys(config('commerce.currencies.supported', []));
foreach ($supportedCurrencies as $currency) {
if ($currency === $baseCurrency) {
continue;
}
$existing = static::where('product_id', $product->id)
->where('currency', $currency)
->first();
// Skip manual prices
if ($existing && $existing->is_manual) {
continue;
}
$rate = ExchangeRate::getRate($baseCurrency, $currency);
if ($rate === null) {
continue;
}
$convertedAmount = (int) round($product->price * $rate);
static::updateOrCreate(
[
'product_id' => $product->id,
'currency' => $currency,
],
[
'amount' => $convertedAmount,
'is_manual' => false,
'exchange_rate_used' => $rate,
]
);
}
}
/**
* Scope for manual prices only.
*/
public function scopeManual($query)
{
return $query->where('is_manual', true);
}
/**
* Scope for auto-converted prices.
*/
public function scopeAutoConverted($query)
{
return $query->where('is_manual', false);
}
/**
* Scope for a specific currency.
*/
public function scopeForCurrency($query, string $currency)
{
return $query->where('currency', strtoupper($currency));
}
}

266
Models/Referral.php Normal file
View file

@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Referral model for tracking referral relationships.
*
* Tracks the relationship between referrer and referee, including
* attribution data and conversion status.
*
* @property int $id
* @property int $referrer_id
* @property int|null $referee_id
* @property string $code
* @property string $status
* @property string|null $source_url
* @property string|null $landing_page
* @property string|null $utm_source
* @property string|null $utm_medium
* @property string|null $utm_campaign
* @property string|null $ip_address
* @property string|null $user_agent
* @property string|null $tracking_id
* @property \Carbon\Carbon|null $clicked_at
* @property \Carbon\Carbon|null $signed_up_at
* @property \Carbon\Carbon|null $first_purchase_at
* @property \Carbon\Carbon|null $qualified_at
* @property \Carbon\Carbon|null $disqualified_at
* @property string|null $disqualification_reason
* @property \Carbon\Carbon|null $matured_at
*/
class Referral extends Model
{
use LogsActivity;
protected $table = 'commerce_referrals';
// Status constants
public const STATUS_PENDING = 'pending'; // Link clicked, waiting for signup
public const STATUS_CONVERTED = 'converted'; // User signed up
public const STATUS_QUALIFIED = 'qualified'; // User made a purchase
public const STATUS_DISQUALIFIED = 'disqualified'; // Referral invalidated
protected $fillable = [
'referrer_id',
'referee_id',
'code',
'status',
'source_url',
'landing_page',
'utm_source',
'utm_medium',
'utm_campaign',
'ip_address',
'user_agent',
'tracking_id',
'clicked_at',
'signed_up_at',
'first_purchase_at',
'qualified_at',
'disqualified_at',
'disqualification_reason',
'matured_at',
];
protected $casts = [
'clicked_at' => 'datetime',
'signed_up_at' => 'datetime',
'first_purchase_at' => 'datetime',
'qualified_at' => 'datetime',
'disqualified_at' => 'datetime',
'matured_at' => 'datetime',
];
// Relationships
/**
* The user who referred (affiliate).
*/
public function referrer(): BelongsTo
{
return $this->belongsTo(User::class, 'referrer_id');
}
/**
* The user who was referred.
*/
public function referee(): BelongsTo
{
return $this->belongsTo(User::class, 'referee_id');
}
/**
* Commissions earned from this referral.
*/
public function commissions(): HasMany
{
return $this->hasMany(ReferralCommission::class);
}
// Status helpers
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isConverted(): bool
{
return $this->status === self::STATUS_CONVERTED;
}
public function isQualified(): bool
{
return $this->status === self::STATUS_QUALIFIED;
}
public function isDisqualified(): bool
{
return $this->status === self::STATUS_DISQUALIFIED;
}
public function isActive(): bool
{
return ! $this->isDisqualified();
}
public function hasMatured(): bool
{
return $this->matured_at !== null;
}
// Actions
/**
* Mark as converted when referee signs up.
*/
public function markConverted(User $referee): void
{
$this->update([
'referee_id' => $referee->id,
'status' => self::STATUS_CONVERTED,
'signed_up_at' => now(),
]);
}
/**
* Mark as qualified when referee makes first purchase.
*/
public function markQualified(): void
{
$this->update([
'status' => self::STATUS_QUALIFIED,
'first_purchase_at' => $this->first_purchase_at ?? now(),
'qualified_at' => now(),
]);
}
/**
* Disqualify this referral.
*/
public function disqualify(string $reason): void
{
$this->update([
'status' => self::STATUS_DISQUALIFIED,
'disqualified_at' => now(),
'disqualification_reason' => $reason,
]);
}
/**
* Mark as matured (commissions can be withdrawn).
*/
public function markMatured(): void
{
$this->update(['matured_at' => now()]);
}
// Calculations
/**
* Get total commission amount from this referral.
*/
public function getTotalCommissionAttribute(): float
{
return (float) $this->commissions()->sum('commission_amount');
}
/**
* Get matured (withdrawable) commission amount.
*/
public function getMaturedCommissionAttribute(): float
{
return (float) $this->commissions()
->where('status', ReferralCommission::STATUS_MATURED)
->sum('commission_amount');
}
/**
* Get pending commission amount.
*/
public function getPendingCommissionAttribute(): float
{
return (float) $this->commissions()
->where('status', ReferralCommission::STATUS_PENDING)
->sum('commission_amount');
}
// Scopes
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
public function scopeConverted($query)
{
return $query->where('status', self::STATUS_CONVERTED);
}
public function scopeQualified($query)
{
return $query->where('status', self::STATUS_QUALIFIED);
}
public function scopeActive($query)
{
return $query->where('status', '!=', self::STATUS_DISQUALIFIED);
}
public function scopeForReferrer($query, int $userId)
{
return $query->where('referrer_id', $userId);
}
public function scopeForReferee($query, int $userId)
{
return $query->where('referee_id', $userId);
}
public function scopeWithCode($query, string $code)
{
return $query->where('code', $code);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status', 'qualified_at', 'disqualified_at', 'matured_at'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "Referral {$eventName}");
}
}

216
Models/ReferralCode.php Normal file
View file

@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* ReferralCode model for tracking referral/affiliate codes.
*
* Codes can be user-specific (from their namespace), campaign codes,
* or custom promotional codes with special commission rates.
*
* @property int $id
* @property string $code
* @property int|null $user_id
* @property string $type
* @property float|null $commission_rate
* @property int $cookie_days
* @property int|null $max_uses
* @property int $uses_count
* @property \Carbon\Carbon|null $valid_from
* @property \Carbon\Carbon|null $valid_until
* @property bool $is_active
* @property string|null $campaign_name
* @property array|null $metadata
*/
class ReferralCode extends Model
{
use LogsActivity;
protected $table = 'commerce_referral_codes';
// Code types
public const TYPE_USER = 'user'; // Auto-generated from user namespace
public const TYPE_CAMPAIGN = 'campaign'; // Marketing campaign codes
public const TYPE_CUSTOM = 'custom'; // Custom promotional codes
// Default attribution cookie duration (days)
public const DEFAULT_COOKIE_DAYS = 90;
protected $fillable = [
'code',
'user_id',
'type',
'commission_rate',
'cookie_days',
'max_uses',
'uses_count',
'valid_from',
'valid_until',
'is_active',
'campaign_name',
'metadata',
];
protected $casts = [
'commission_rate' => 'decimal:2',
'cookie_days' => 'integer',
'max_uses' => 'integer',
'uses_count' => 'integer',
'valid_from' => 'datetime',
'valid_until' => 'datetime',
'is_active' => 'boolean',
'metadata' => 'array',
];
// Relationships
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Validation
/**
* Check if code is currently valid for use.
*/
public function isValid(): bool
{
if (! $this->is_active) {
return false;
}
if ($this->valid_from && $this->valid_from->isFuture()) {
return false;
}
if ($this->valid_until && $this->valid_until->isPast()) {
return false;
}
if ($this->max_uses && $this->uses_count >= $this->max_uses) {
return false;
}
return true;
}
/**
* Check if code has reached max uses.
*/
public function hasReachedMaxUses(): bool
{
if ($this->max_uses === null) {
return false;
}
return $this->uses_count >= $this->max_uses;
}
// Getters
/**
* Get effective commission rate (own or default).
*/
public function getEffectiveCommissionRate(): float
{
return $this->commission_rate ?? ReferralCommission::DEFAULT_COMMISSION_RATE;
}
/**
* Get effective cookie duration in days.
*/
public function getEffectiveCookieDays(): int
{
return $this->cookie_days ?? self::DEFAULT_COOKIE_DAYS;
}
// Actions
/**
* Increment usage count.
*/
public function incrementUsage(): void
{
$this->increment('uses_count');
}
/**
* Activate code.
*/
public function activate(): void
{
$this->update(['is_active' => true]);
}
/**
* Deactivate code.
*/
public function deactivate(): void
{
$this->update(['is_active' => false]);
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeValid($query)
{
return $query->active()
->where(function ($q) {
$q->whereNull('valid_from')
->orWhere('valid_from', '<=', now());
})
->where(function ($q) {
$q->whereNull('valid_until')
->orWhere('valid_until', '>=', now());
})
->where(function ($q) {
$q->whereNull('max_uses')
->orWhereRaw('uses_count < max_uses');
});
}
public function scopeByCode($query, string $code)
{
return $query->where('code', $code);
}
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
public function scopeByType($query, string $type)
{
return $query->where('type', $type);
}
public function scopeCampaign($query)
{
return $query->where('type', self::TYPE_CAMPAIGN);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['code', 'is_active', 'commission_rate', 'max_uses'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "Referral code {$eventName}");
}
}

View file

@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* ReferralCommission model for tracking commission earnings.
*
* Each commission is linked to an order and tracks its maturation
* and payout status.
*
* @property int $id
* @property int $referral_id
* @property int $referrer_id
* @property int|null $order_id
* @property int|null $invoice_id
* @property float $order_amount
* @property float $commission_rate
* @property float $commission_amount
* @property string $currency
* @property string $status
* @property \Carbon\Carbon|null $matures_at
* @property \Carbon\Carbon|null $matured_at
* @property int|null $payout_id
* @property \Carbon\Carbon|null $paid_at
* @property string|null $notes
*/
class ReferralCommission extends Model
{
use LogsActivity;
protected $table = 'commerce_referral_commissions';
// Status constants
public const STATUS_PENDING = 'pending'; // Waiting to mature
public const STATUS_MATURED = 'matured'; // Can be withdrawn
public const STATUS_PAID = 'paid'; // Included in a payout
public const STATUS_CANCELLED = 'cancelled'; // Refunded/chargedback
// Default commission rate (percentage)
public const DEFAULT_COMMISSION_RATE = 10.00;
// Maturation periods (days after order)
public const MATURATION_CRYPTO = 14; // Crypto: 14 days (refund period)
public const MATURATION_CARD = 90; // Card: 90 days (chargeback period)
protected $fillable = [
'referral_id',
'referrer_id',
'order_id',
'invoice_id',
'order_amount',
'commission_rate',
'commission_amount',
'currency',
'status',
'matures_at',
'matured_at',
'payout_id',
'paid_at',
'notes',
];
protected $casts = [
'order_amount' => 'decimal:2',
'commission_rate' => 'decimal:2',
'commission_amount' => 'decimal:2',
'matures_at' => 'datetime',
'matured_at' => 'datetime',
'paid_at' => 'datetime',
];
// Relationships
public function referral(): BelongsTo
{
return $this->belongsTo(Referral::class);
}
public function referrer(): BelongsTo
{
return $this->belongsTo(User::class, 'referrer_id');
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
public function payout(): BelongsTo
{
return $this->belongsTo(ReferralPayout::class);
}
// Status helpers
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isMatured(): bool
{
return $this->status === self::STATUS_MATURED;
}
public function isPaid(): bool
{
return $this->status === self::STATUS_PAID;
}
public function isCancelled(): bool
{
return $this->status === self::STATUS_CANCELLED;
}
public function canMature(): bool
{
return $this->isPending() && $this->matures_at && $this->matures_at->isPast();
}
// Actions
/**
* Mark commission as matured.
*/
public function markMatured(): void
{
$this->update([
'status' => self::STATUS_MATURED,
'matured_at' => now(),
]);
}
/**
* Mark commission as paid (included in payout).
*/
public function markPaid(ReferralPayout $payout): void
{
$this->update([
'status' => self::STATUS_PAID,
'payout_id' => $payout->id,
'paid_at' => now(),
]);
}
/**
* Cancel commission (refund/chargeback).
*/
public function cancel(?string $reason = null): void
{
$this->update([
'status' => self::STATUS_CANCELLED,
'notes' => $reason,
]);
}
// Static factory
/**
* Calculate commission for an order.
*/
public static function calculateForOrder(
Referral $referral,
Order $order,
?float $commissionRate = null
): array {
$commissionRate = $commissionRate ?? self::DEFAULT_COMMISSION_RATE;
// Calculate commission on net order amount (after discount, before tax)
$netAmount = $order->subtotal - $order->discount_amount;
$commissionAmount = round($netAmount * ($commissionRate / 100), 2);
// Determine maturation date based on payment method
$gateway = $order->gateway ?? 'stripe';
$maturationDays = in_array($gateway, ['btcpay', 'bitcoin', 'crypto'])
? self::MATURATION_CRYPTO
: self::MATURATION_CARD;
return [
'referral_id' => $referral->id,
'referrer_id' => $referral->referrer_id,
'order_id' => $order->id,
'invoice_id' => $order->invoice?->id,
'order_amount' => $netAmount,
'commission_rate' => $commissionRate,
'commission_amount' => $commissionAmount,
'currency' => $order->currency,
'status' => self::STATUS_PENDING,
'matures_at' => now()->addDays($maturationDays),
];
}
// Scopes
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
public function scopeMatured($query)
{
return $query->where('status', self::STATUS_MATURED);
}
public function scopePaid($query)
{
return $query->where('status', self::STATUS_PAID);
}
public function scopeWithdrawable($query)
{
return $query->where('status', self::STATUS_MATURED);
}
public function scopeReadyToMature($query)
{
return $query->pending()->where('matures_at', '<=', now());
}
public function scopeForReferrer($query, int $userId)
{
return $query->where('referrer_id', $userId);
}
public function scopeUnpaid($query)
{
return $query->whereNull('payout_id');
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status', 'matured_at', 'payout_id', 'paid_at'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "Commission {$eventName}");
}
}

298
Models/ReferralPayout.php Normal file
View file

@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* ReferralPayout model for tracking commission withdrawals.
*
* Supports BTC payouts and account credit application.
*
* @property int $id
* @property int $user_id
* @property string $payout_number
* @property string $method
* @property string|null $btc_address
* @property string|null $btc_txid
* @property float $amount
* @property string $currency
* @property float|null $btc_amount
* @property float|null $btc_rate
* @property string $status
* @property \Carbon\Carbon|null $requested_at
* @property \Carbon\Carbon|null $processed_at
* @property \Carbon\Carbon|null $completed_at
* @property \Carbon\Carbon|null $failed_at
* @property string|null $notes
* @property string|null $failure_reason
* @property int|null $processed_by
*/
class ReferralPayout extends Model
{
use LogsActivity;
protected $table = 'commerce_referral_payouts';
// Status constants
public const STATUS_REQUESTED = 'requested';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELLED = 'cancelled';
// Payout methods
public const METHOD_BTC = 'btc';
public const METHOD_ACCOUNT_CREDIT = 'account_credit';
// Minimum payout amounts (in GBP)
public const MINIMUM_BTC_PAYOUT = 10.00;
public const MINIMUM_CREDIT_PAYOUT = 0.01; // No minimum for account credit
protected $fillable = [
'user_id',
'payout_number',
'method',
'btc_address',
'btc_txid',
'amount',
'currency',
'btc_amount',
'btc_rate',
'status',
'requested_at',
'processed_at',
'completed_at',
'failed_at',
'notes',
'failure_reason',
'processed_by',
];
protected $casts = [
'amount' => 'decimal:2',
'btc_amount' => 'decimal:8',
'btc_rate' => 'decimal:8',
'requested_at' => 'datetime',
'processed_at' => 'datetime',
'completed_at' => 'datetime',
'failed_at' => 'datetime',
];
// Relationships
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function commissions(): HasMany
{
return $this->hasMany(ReferralCommission::class, 'payout_id');
}
public function processor(): BelongsTo
{
return $this->belongsTo(User::class, 'processed_by');
}
// Status helpers
public function isRequested(): bool
{
return $this->status === self::STATUS_REQUESTED;
}
public function isProcessing(): bool
{
return $this->status === self::STATUS_PROCESSING;
}
public function isCompleted(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
public function isFailed(): bool
{
return $this->status === self::STATUS_FAILED;
}
public function isCancelled(): bool
{
return $this->status === self::STATUS_CANCELLED;
}
public function isPending(): bool
{
return in_array($this->status, [self::STATUS_REQUESTED, self::STATUS_PROCESSING]);
}
// Method helpers
public function isBtcPayout(): bool
{
return $this->method === self::METHOD_BTC;
}
public function isAccountCredit(): bool
{
return $this->method === self::METHOD_ACCOUNT_CREDIT;
}
// Actions
/**
* Mark as processing.
*/
public function markProcessing(User $admin): void
{
$this->update([
'status' => self::STATUS_PROCESSING,
'processed_at' => now(),
'processed_by' => $admin->id,
]);
}
/**
* Mark as completed.
*/
public function markCompleted(?string $btcTxid = null, ?float $btcAmount = null, ?float $btcRate = null): void
{
$updates = [
'status' => self::STATUS_COMPLETED,
'completed_at' => now(),
];
if ($btcTxid) {
$updates['btc_txid'] = $btcTxid;
}
if ($btcAmount) {
$updates['btc_amount'] = $btcAmount;
$updates['btc_rate'] = $btcRate;
}
$this->update($updates);
// Mark all commissions as paid
$this->commissions()->update([
'status' => ReferralCommission::STATUS_PAID,
'paid_at' => now(),
]);
}
/**
* Mark as failed.
*/
public function markFailed(string $reason): void
{
$this->update([
'status' => self::STATUS_FAILED,
'failed_at' => now(),
'failure_reason' => $reason,
]);
// Return commissions to matured status
$this->commissions()->update([
'status' => ReferralCommission::STATUS_MATURED,
'payout_id' => null,
]);
}
/**
* Cancel payout request.
*/
public function cancel(?string $reason = null): void
{
$this->update([
'status' => self::STATUS_CANCELLED,
'notes' => $reason ?? $this->notes,
]);
// Return commissions to matured status
$this->commissions()->update([
'status' => ReferralCommission::STATUS_MATURED,
'payout_id' => null,
]);
}
// Static helpers
/**
* Generate a unique payout number.
*/
public static function generatePayoutNumber(): string
{
$prefix = 'PAY';
$date = now()->format('Ymd');
$random = strtoupper(substr(md5(uniqid()), 0, 6));
return "{$prefix}-{$date}-{$random}";
}
/**
* Get minimum payout amount for a method.
*/
public static function getMinimumPayout(string $method): float
{
return match ($method) {
self::METHOD_BTC => self::MINIMUM_BTC_PAYOUT,
self::METHOD_ACCOUNT_CREDIT => self::MINIMUM_CREDIT_PAYOUT,
default => self::MINIMUM_BTC_PAYOUT,
};
}
// Scopes
public function scopeRequested($query)
{
return $query->where('status', self::STATUS_REQUESTED);
}
public function scopeProcessing($query)
{
return $query->where('status', self::STATUS_PROCESSING);
}
public function scopeCompleted($query)
{
return $query->where('status', self::STATUS_COMPLETED);
}
public function scopePending($query)
{
return $query->whereIn('status', [self::STATUS_REQUESTED, self::STATUS_PROCESSING]);
}
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
public function scopeByMethod($query, string $method)
{
return $query->where('method', $method);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status', 'processed_at', 'completed_at', 'failed_at', 'btc_txid'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "Payout {$eventName}");
}
}

147
Models/Refund.php Normal file
View file

@ -0,0 +1,147 @@
<?php
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Refund model for tracking payment refunds.
*
* @property int $id
* @property int $payment_id
* @property string|null $gateway_refund_id
* @property float $amount
* @property string $currency
* @property string $status
* @property string|null $reason
* @property string|null $notes
* @property int|null $initiated_by
* @property array|null $gateway_response
*/
class Refund extends Model
{
use HasFactory;
use LogsActivity;
protected $fillable = [
'payment_id',
'gateway_refund_id',
'amount',
'currency',
'status',
'reason',
'notes',
'initiated_by',
'gateway_response',
];
protected $casts = [
'amount' => 'decimal:2',
'gateway_response' => 'array',
];
// Relationships
public function payment(): BelongsTo
{
return $this->belongsTo(Payment::class);
}
public function initiator(): BelongsTo
{
return $this->belongsTo(User::class, 'initiated_by');
}
public function creditNote(): HasOne
{
return $this->hasOne(CreditNote::class);
}
// Status helpers
public function isPending(): bool
{
return $this->status === 'pending';
}
public function isSucceeded(): bool
{
return $this->status === 'succeeded';
}
public function isFailed(): bool
{
return $this->status === 'failed';
}
public function isCancelled(): bool
{
return $this->status === 'cancelled';
}
// Actions
public function markAsSucceeded(?string $gatewayRefundId = null): void
{
$this->update([
'status' => 'succeeded',
'gateway_refund_id' => $gatewayRefundId ?? $this->gateway_refund_id,
]);
// Update payment refunded amount
$this->payment->recordRefund($this->amount);
}
public function markAsFailed(?array $response = null): void
{
$this->update([
'status' => 'failed',
'gateway_response' => $response,
]);
}
public function cancel(): void
{
$this->update(['status' => 'cancelled']);
}
// Scopes
public function scopePending($query)
{
return $query->where('status', 'pending');
}
public function scopeSucceeded($query)
{
return $query->where('status', 'succeeded');
}
// Reason helpers
public function getReasonLabel(): string
{
return match ($this->reason) {
'duplicate' => 'Duplicate payment',
'fraudulent' => 'Fraudulent transaction',
'requested_by_customer' => 'Customer request',
'other' => 'Other',
default => 'Unknown',
};
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status', 'amount', 'reason'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "Refund {$eventName}");
}
}

279
Models/Subscription.php Normal file
View file

@ -0,0 +1,279 @@
<?php
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Models\WorkspacePackage;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Core\Commerce\Events\SubscriptionCreated;
use Core\Commerce\Events\SubscriptionUpdated;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Subscription model for recurring billing state.
*
* Links gateway subscriptions (Stripe, BTCPay) to workspace packages.
*
* @property int $id
* @property int $workspace_id
* @property int $workspace_package_id
* @property string $gateway
* @property string $gateway_subscription_id
* @property string $gateway_customer_id
* @property string|null $gateway_price_id
* @property string $status
* @property \Carbon\Carbon $current_period_start
* @property \Carbon\Carbon $current_period_end
* @property \Carbon\Carbon|null $trial_ends_at
* @property bool $cancel_at_period_end
* @property \Carbon\Carbon|null $cancelled_at
* @property \Carbon\Carbon|null $ended_at
* @property array|null $metadata
*/
class Subscription extends Model
{
use HasFactory;
use LogsActivity;
protected static function newFactory(): \Core\Commerce\Database\Factories\SubscriptionFactory
{
return \Core\Commerce\Database\Factories\SubscriptionFactory::new();
}
/**
* The event map for the model.
*/
protected $dispatchesEvents = [
'created' => SubscriptionCreated::class,
'updated' => SubscriptionUpdated::class,
];
protected $fillable = [
'workspace_id',
'workspace_package_id',
'gateway',
'gateway_subscription_id',
'gateway_customer_id',
'gateway_price_id',
'status',
'billing_cycle',
'current_period_start',
'current_period_end',
'trial_ends_at',
'cancel_at_period_end',
'cancelled_at',
'cancellation_reason',
'ended_at',
'paused_at',
'pause_count',
'metadata',
];
protected $casts = [
'current_period_start' => 'datetime',
'current_period_end' => 'datetime',
'trial_ends_at' => 'datetime',
'cancel_at_period_end' => 'boolean',
'cancelled_at' => 'datetime',
'ended_at' => 'datetime',
'paused_at' => 'datetime',
'pause_count' => 'integer',
'metadata' => 'array',
];
// Relationships
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function workspacePackage(): BelongsTo
{
return $this->belongsTo(WorkspacePackage::class);
}
public function usageRecords(): HasMany
{
return $this->hasMany(SubscriptionUsage::class);
}
public function usageEvents(): HasMany
{
return $this->hasMany(UsageEvent::class);
}
// Status helpers
public function isActive(): bool
{
return $this->status === 'active';
}
public function isTrialing(): bool
{
return $this->status === 'trialing';
}
public function isPastDue(): bool
{
return $this->status === 'past_due';
}
public function isPaused(): bool
{
return $this->status === 'paused';
}
public function isCancelled(): bool
{
return $this->status === 'cancelled';
}
public function isIncomplete(): bool
{
return $this->status === 'incomplete';
}
/**
* Check if the subscription can be paused (hasn't exceeded max pause cycles).
*/
public function canPause(): bool
{
if (! config('commerce.subscriptions.allow_pause', true)) {
return false;
}
$maxPauseCycles = config('commerce.subscriptions.max_pause_cycles', 3);
return ($this->pause_count ?? 0) < $maxPauseCycles;
}
/**
* Get the number of remaining pause cycles.
*/
public function remainingPauseCycles(): int
{
$maxPauseCycles = config('commerce.subscriptions.max_pause_cycles', 3);
return max(0, $maxPauseCycles - ($this->pause_count ?? 0));
}
public function isValid(): bool
{
return in_array($this->status, ['active', 'trialing', 'past_due']);
}
public function onTrial(): bool
{
return $this->trial_ends_at && $this->trial_ends_at->isFuture();
}
public function onGracePeriod(): bool
{
return $this->cancel_at_period_end && $this->current_period_end->isFuture();
}
public function hasEnded(): bool
{
return $this->ended_at !== null;
}
// Period helpers
public function daysUntilRenewal(): int
{
return max(0, now()->diffInDays($this->current_period_end, false));
}
public function isRenewingSoon(int $days = 7): bool
{
return $this->daysUntilRenewal() <= $days;
}
// Actions
public function cancel(bool $immediately = false): void
{
if ($immediately) {
$this->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'ended_at' => now(),
]);
} else {
$this->update([
'cancel_at_period_end' => true,
'cancelled_at' => now(),
]);
}
}
public function resume(): void
{
$this->update([
'cancel_at_period_end' => false,
'cancelled_at' => null,
]);
}
public function pause(): void
{
$this->update(['status' => 'paused']);
}
public function markPastDue(): void
{
$this->update(['status' => 'past_due']);
}
public function renew(\Carbon\Carbon $periodStart, \Carbon\Carbon $periodEnd): void
{
$this->update([
'status' => 'active',
'current_period_start' => $periodStart,
'current_period_end' => $periodEnd,
]);
}
// Scopes
public function scopeActive($query)
{
return $query->whereIn('status', ['active', 'trialing']);
}
public function scopeValid($query)
{
return $query->whereIn('status', ['active', 'trialing', 'past_due']);
}
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
}
public function scopeForGateway($query, string $gateway)
{
return $query->where('gateway', $gateway);
}
public function scopeExpiringSoon($query, int $days = 7)
{
return $query->where('current_period_end', '<=', now()->addDays($days))
->where('current_period_end', '>', now());
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status', 'cancel_at_period_end', 'cancelled_at', 'paused_at'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "Subscription {$eventName}");
}
}

View file

@ -0,0 +1,177 @@
<?php
namespace Core\Commerce\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* SubscriptionUsage model - aggregated usage per subscription per billing period.
*
* @property int $id
* @property int $subscription_id
* @property int $meter_id
* @property int $quantity
* @property \Carbon\Carbon $period_start
* @property \Carbon\Carbon $period_end
* @property string|null $stripe_usage_record_id
* @property \Carbon\Carbon|null $synced_at
* @property bool $billed
* @property int|null $invoice_item_id
* @property array|null $metadata
*/
class SubscriptionUsage extends Model
{
protected $table = 'commerce_subscription_usage';
protected $fillable = [
'subscription_id',
'meter_id',
'quantity',
'period_start',
'period_end',
'stripe_usage_record_id',
'synced_at',
'billed',
'invoice_item_id',
'metadata',
];
protected $casts = [
'quantity' => 'integer',
'period_start' => 'datetime',
'period_end' => 'datetime',
'synced_at' => 'datetime',
'billed' => 'boolean',
'metadata' => 'array',
];
// Relationships
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
public function meter(): BelongsTo
{
return $this->belongsTo(UsageMeter::class, 'meter_id');
}
public function invoiceItem(): BelongsTo
{
return $this->belongsTo(InvoiceItem::class);
}
// Scopes
public function scopeForSubscription($query, int $subscriptionId)
{
return $query->where('subscription_id', $subscriptionId);
}
public function scopeForMeter($query, int $meterId)
{
return $query->where('meter_id', $meterId);
}
public function scopeInPeriod($query, Carbon $start, Carbon $end)
{
return $query->where('period_start', '>=', $start)
->where('period_end', '<=', $end);
}
public function scopeCurrentPeriod($query, Subscription $subscription)
{
return $query->where('period_start', '>=', $subscription->current_period_start)
->where('period_end', '<=', $subscription->current_period_end);
}
public function scopeUnbilled($query)
{
return $query->where('billed', false);
}
public function scopeUnsynced($query)
{
return $query->whereNull('synced_at');
}
// Helpers
/**
* Check if this usage record is in the current billing period.
*/
public function isCurrentPeriod(): bool
{
$now = now();
return $now->between($this->period_start, $this->period_end);
}
/**
* Calculate the charge for this usage.
*/
public function calculateCharge(): float
{
return $this->meter->calculateCharge($this->quantity);
}
/**
* Add quantity to this usage record.
*/
public function addQuantity(int $quantity): self
{
$this->increment('quantity', $quantity);
return $this->fresh();
}
/**
* Mark as synced with Stripe.
*/
public function markSynced(?string $stripeUsageRecordId = null): void
{
$this->update([
'synced_at' => now(),
'stripe_usage_record_id' => $stripeUsageRecordId,
]);
}
/**
* Mark as billed.
*/
public function markBilled(?int $invoiceItemId = null): void
{
$this->update([
'billed' => true,
'invoice_item_id' => $invoiceItemId,
]);
}
/**
* Get or create usage record for current period.
*/
public static function getOrCreateForCurrentPeriod(
Subscription $subscription,
UsageMeter $meter
): self {
$record = static::where('subscription_id', $subscription->id)
->where('meter_id', $meter->id)
->where('period_start', $subscription->current_period_start)
->first();
if (! $record) {
$record = static::create([
'subscription_id' => $subscription->id,
'meter_id' => $meter->id,
'quantity' => 0,
'period_start' => $subscription->current_period_start,
'period_end' => $subscription->current_period_end,
]);
}
return $record;
}
}

149
Models/TaxRate.php Normal file
View file

@ -0,0 +1,149 @@
<?php
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* TaxRate model for VAT/GST/sales tax rates.
*
* Supports UK VAT, EU OSS, US state taxes, and Australian GST.
*
* @property int $id
* @property string $country_code
* @property string|null $state_code
* @property string $name
* @property string $type
* @property float $rate
* @property bool $is_digital_services
* @property \Carbon\Carbon $effective_from
* @property \Carbon\Carbon|null $effective_until
* @property bool $is_active
* @property string|null $stripe_tax_rate_id
*/
class TaxRate extends Model
{
use HasFactory;
protected $fillable = [
'country_code',
'state_code',
'name',
'type',
'rate',
'is_digital_services',
'effective_from',
'effective_until',
'is_active',
'stripe_tax_rate_id',
];
protected $casts = [
'rate' => 'decimal:2',
'is_digital_services' => 'boolean',
'effective_from' => 'date',
'effective_until' => 'date',
'is_active' => 'boolean',
];
// Type helpers
public function isVat(): bool
{
return $this->type === 'vat';
}
public function isSalesTax(): bool
{
return $this->type === 'sales_tax';
}
public function isGst(): bool
{
return $this->type === 'gst';
}
// Validation
public function isEffective(): bool
{
if (! $this->is_active) {
return false;
}
$now = now()->toDateString();
if ($this->effective_from > $now) {
return false;
}
if ($this->effective_until && $this->effective_until < $now) {
return false;
}
return true;
}
// Calculation
public function calculateTax(float $amount): float
{
return round($amount * ($this->rate / 100), 2);
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeEffective($query)
{
$now = now()->toDateString();
return $query->active()
->where('effective_from', '<=', $now)
->where(function ($q) use ($now) {
$q->whereNull('effective_until')
->orWhere('effective_until', '>=', $now);
});
}
public function scopeForCountry($query, string $countryCode)
{
return $query->where('country_code', strtoupper($countryCode));
}
public function scopeForState($query, string $countryCode, string $stateCode)
{
return $query->where('country_code', strtoupper($countryCode))
->where('state_code', strtoupper($stateCode));
}
public function scopeDigitalServices($query)
{
return $query->where('is_digital_services', true);
}
// Static helpers
public static function findForLocation(string $countryCode, ?string $stateCode = null): ?self
{
$query = static::effective()
->digitalServices()
->forCountry($countryCode);
// Try state-specific first (for US)
if ($stateCode) {
$stateRate = (clone $query)->where('state_code', strtoupper($stateCode))->first();
if ($stateRate) {
return $stateRate;
}
}
// Fall back to country-level
return $query->whereNull('state_code')->first();
}
}

144
Models/UsageEvent.php Normal file
View file

@ -0,0 +1,144 @@
<?php
namespace Core\Commerce\Models;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* UsageEvent model - individual usage event before aggregation.
*
* @property int $id
* @property int $subscription_id
* @property int $meter_id
* @property int $workspace_id
* @property int $quantity
* @property \Carbon\Carbon $event_at
* @property string|null $idempotency_key
* @property int|null $user_id
* @property string|null $action
* @property array|null $metadata
*/
class UsageEvent extends Model
{
protected $table = 'commerce_usage_events';
protected $fillable = [
'subscription_id',
'meter_id',
'workspace_id',
'quantity',
'event_at',
'idempotency_key',
'user_id',
'action',
'metadata',
];
protected $casts = [
'quantity' => 'integer',
'event_at' => 'datetime',
'metadata' => 'array',
];
// Relationships
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
public function meter(): BelongsTo
{
return $this->belongsTo(UsageMeter::class, 'meter_id');
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Scopes
public function scopeForSubscription($query, int $subscriptionId)
{
return $query->where('subscription_id', $subscriptionId);
}
public function scopeForMeter($query, int $meterId)
{
return $query->where('meter_id', $meterId);
}
public function scopeForWorkspace($query, int $workspaceId)
{
return $query->where('workspace_id', $workspaceId);
}
public function scopeSince($query, $date)
{
return $query->where('event_at', '>=', $date);
}
public function scopeBetween($query, $start, $end)
{
return $query->whereBetween('event_at', [$start, $end]);
}
// Helpers
/**
* Generate a unique idempotency key.
*/
public static function generateIdempotencyKey(): string
{
return Str::uuid()->toString();
}
/**
* Check if an event with this idempotency key already exists.
*/
public static function existsByIdempotencyKey(string $key): bool
{
return static::where('idempotency_key', $key)->exists();
}
/**
* Create event with idempotency protection.
*
* Returns null if duplicate idempotency key.
*/
public static function createWithIdempotency(array $attributes): ?self
{
$key = $attributes['idempotency_key'] ?? null;
if ($key && static::existsByIdempotencyKey($key)) {
return null;
}
return static::create($attributes);
}
/**
* Get total quantity for a subscription + meter in a period.
*/
public static function getTotalQuantity(
int $subscriptionId,
int $meterId,
$periodStart,
$periodEnd
): int {
return (int) static::where('subscription_id', $subscriptionId)
->where('meter_id', $meterId)
->whereBetween('event_at', [$periodStart, $periodEnd])
->sum('quantity');
}
}

171
Models/UsageMeter.php Normal file
View file

@ -0,0 +1,171 @@
<?php
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* UsageMeter model - defines a metered billing product.
*
* @property int $id
* @property string $code
* @property string $name
* @property string|null $description
* @property string|null $stripe_meter_id
* @property string|null $stripe_price_id
* @property string $aggregation_type
* @property float $unit_price
* @property string $currency
* @property string $unit_label
* @property array|null $pricing_tiers
* @property string|null $feature_code
* @property bool $is_active
*/
class UsageMeter extends Model
{
protected $table = 'commerce_usage_meters';
protected $fillable = [
'code',
'name',
'description',
'stripe_meter_id',
'stripe_price_id',
'aggregation_type',
'unit_price',
'currency',
'unit_label',
'pricing_tiers',
'feature_code',
'is_active',
];
protected $casts = [
'unit_price' => 'decimal:4',
'pricing_tiers' => 'array',
'is_active' => 'boolean',
];
// Aggregation types
public const AGGREGATION_SUM = 'sum';
public const AGGREGATION_MAX = 'max';
public const AGGREGATION_LAST = 'last_value';
// Relationships
public function subscriptionUsage(): HasMany
{
return $this->hasMany(SubscriptionUsage::class, 'meter_id');
}
public function usageEvents(): HasMany
{
return $this->hasMany(UsageEvent::class, 'meter_id');
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeByCode($query, string $code)
{
return $query->where('code', $code);
}
public function scopeForFeature($query, string $featureCode)
{
return $query->where('feature_code', $featureCode);
}
// Helpers
/**
* Check if this meter has tiered pricing.
*/
public function hasTieredPricing(): bool
{
return ! empty($this->pricing_tiers);
}
/**
* Calculate charge for a given quantity.
*/
public function calculateCharge(int $quantity): float
{
if ($this->hasTieredPricing()) {
return $this->calculateTieredCharge($quantity);
}
return round($quantity * $this->unit_price, 2);
}
/**
* Calculate charge using tiered pricing.
*
* Tiers format:
* [
* ['up_to' => 100, 'unit_price' => 0.10],
* ['up_to' => 1000, 'unit_price' => 0.05],
* ['up_to' => null, 'unit_price' => 0.01], // unlimited
* ]
*/
protected function calculateTieredCharge(int $quantity): float
{
$tiers = $this->pricing_tiers ?? [];
$remaining = $quantity;
$total = 0.0;
$previousLimit = 0;
foreach ($tiers as $tier) {
$upTo = $tier['up_to'] ?? PHP_INT_MAX;
$tierQuantity = min($remaining, $upTo - $previousLimit);
if ($tierQuantity <= 0) {
break;
}
$total += $tierQuantity * ($tier['unit_price'] ?? 0);
$remaining -= $tierQuantity;
$previousLimit = $upTo;
if ($remaining <= 0) {
break;
}
}
return round($total, 2);
}
/**
* Get pricing description for display.
*/
public function getPricingDescription(): string
{
if ($this->hasTieredPricing()) {
return 'Tiered pricing';
}
$symbol = match ($this->currency) {
'GBP' => '£',
'USD' => '$',
'EUR' => '€',
default => $this->currency.' ',
};
return "{$symbol}{$this->unit_price} per {$this->unit_label}";
}
/**
* Find meter by code.
*/
public static function findByCode(string $code): ?self
{
return static::where('code', $code)->first();
}
}

202
Models/Warehouse.php Normal file
View file

@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Commerce Warehouse - Fulfillment location.
*
* @property int $id
* @property string $code
* @property int $entity_id
* @property string $name
* @property string|null $description
* @property string|null $address_line1
* @property string|null $address_line2
* @property string|null $city
* @property string|null $county
* @property string|null $postcode
* @property string $country
* @property string|null $contact_name
* @property string|null $contact_email
* @property string|null $contact_phone
* @property string $type
* @property bool $can_ship
* @property bool $can_pickup
* @property bool $is_primary
* @property array|null $operating_hours
* @property array|null $settings
* @property bool $is_active
*/
class Warehouse extends Model
{
use HasFactory;
use SoftDeletes;
// Warehouse types
public const TYPE_OWNED = 'owned';
public const TYPE_THIRD_PARTY = 'third_party';
public const TYPE_DROPSHIP = 'dropship';
public const TYPE_VIRTUAL = 'virtual';
protected $table = 'commerce_warehouses';
protected $fillable = [
'code',
'entity_id',
'name',
'description',
'address_line1',
'address_line2',
'city',
'county',
'postcode',
'country',
'contact_name',
'contact_email',
'contact_phone',
'type',
'can_ship',
'can_pickup',
'is_primary',
'operating_hours',
'settings',
'is_active',
];
protected $casts = [
'operating_hours' => 'array',
'settings' => 'array',
'can_ship' => 'boolean',
'can_pickup' => 'boolean',
'is_primary' => 'boolean',
'is_active' => 'boolean',
];
// Relationships
public function entity(): BelongsTo
{
return $this->belongsTo(Entity::class);
}
public function inventory(): HasMany
{
return $this->hasMany(Inventory::class, 'warehouse_id');
}
public function movements(): HasMany
{
return $this->hasMany(InventoryMovement::class, 'warehouse_id');
}
// Address helpers
public function getFullAddressAttribute(): string
{
$parts = array_filter([
$this->address_line1,
$this->address_line2,
$this->city,
$this->county,
$this->postcode,
$this->country,
]);
return implode(', ', $parts);
}
// Stock helpers
/**
* Get stock for a specific product.
*/
public function getStock(Product $product): ?Inventory
{
return $this->inventory()
->where('product_id', $product->id)
->first();
}
/**
* Get available stock (quantity - reserved).
*/
public function getAvailableStock(Product $product): int
{
$inventory = $this->getStock($product);
if (! $inventory) {
return 0;
}
return $inventory->getAvailableQuantity();
}
/**
* Check if product is in stock at this warehouse.
*/
public function hasStock(Product $product, int $quantity = 1): bool
{
return $this->getAvailableStock($product) >= $quantity;
}
// Operating hours
/**
* Check if warehouse is open at a given time.
*/
public function isOpenAt(\DateTimeInterface $dateTime): bool
{
if (! $this->operating_hours) {
return true; // No hours defined = always open
}
$dayOfWeek = strtolower($dateTime->format('D'));
$time = $dateTime->format('H:i');
$hours = $this->operating_hours[$dayOfWeek] ?? null;
if (! $hours || ! isset($hours['open'], $hours['close'])) {
return false;
}
return $time >= $hours['open'] && $time <= $hours['close'];
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopePrimary($query)
{
return $query->where('is_primary', true);
}
public function scopeCanShip($query)
{
return $query->where('can_ship', true);
}
public function scopeForEntity($query, int $entityId)
{
return $query->where('entity_id', $entityId);
}
public function scopeOfType($query, string $type)
{
return $query->where('type', $type);
}
}

280
Models/WebhookEvent.php Normal file
View file

@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Webhook Event - Audit trail for incoming payment webhooks.
*
* @property int $id
* @property string $gateway
* @property string|null $event_id
* @property string $event_type
* @property string $payload
* @property array|null $headers
* @property string $status
* @property string|null $error_message
* @property int|null $http_status_code
* @property int|null $order_id
* @property int|null $subscription_id
* @property \Carbon\Carbon $received_at
* @property \Carbon\Carbon|null $processed_at
*/
class WebhookEvent extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSED = 'processed';
public const STATUS_FAILED = 'failed';
public const STATUS_SKIPPED = 'skipped';
protected $table = 'webhook_events';
protected $fillable = [
'gateway',
'event_id',
'event_type',
'payload',
'headers',
'status',
'error_message',
'http_status_code',
'order_id',
'subscription_id',
'received_at',
'processed_at',
];
protected $casts = [
'headers' => 'array',
'http_status_code' => 'integer',
'received_at' => 'datetime',
'processed_at' => 'datetime',
];
/**
* Headers that contain sensitive data and should be redacted.
*/
protected const SENSITIVE_HEADERS = [
'stripe-signature',
'authorization',
'api-key',
'x-api-key',
'btcpay-sig',
'btcpay-signature',
'x-webhook-secret',
'x-auth-token',
];
/**
* Mutator to redact sensitive headers before storing.
*
* @param array<string, string>|null $value
*/
protected function setHeadersAttribute(?array $value): void
{
if ($value === null) {
$this->attributes['headers'] = null;
return;
}
$redacted = [];
foreach ($value as $key => $headerValue) {
$lowerKey = strtolower($key);
// Check if this is a sensitive header
$isSensitive = false;
foreach (self::SENSITIVE_HEADERS as $sensitiveHeader) {
if ($lowerKey === $sensitiveHeader || str_contains($lowerKey, 'signature') || str_contains($lowerKey, 'secret')) {
$isSensitive = true;
break;
}
}
if ($isSensitive && $headerValue) {
// Keep a truncated version for debugging (first 20 chars)
$redacted[$key] = substr($headerValue, 0, 20).'...[REDACTED]';
} else {
$redacted[$key] = $headerValue;
}
}
$this->attributes['headers'] = json_encode($redacted);
}
// Relationships
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
// Status helpers
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isProcessed(): bool
{
return $this->status === self::STATUS_PROCESSED;
}
public function isFailed(): bool
{
return $this->status === self::STATUS_FAILED;
}
public function isSkipped(): bool
{
return $this->status === self::STATUS_SKIPPED;
}
// Actions
/**
* Mark as successfully processed.
*/
public function markProcessed(int $httpStatusCode = 200): self
{
$this->update([
'status' => self::STATUS_PROCESSED,
'http_status_code' => $httpStatusCode,
'processed_at' => now(),
]);
return $this;
}
/**
* Mark as failed with error message.
*/
public function markFailed(string $error, int $httpStatusCode = 500): self
{
$this->update([
'status' => self::STATUS_FAILED,
'error_message' => $error,
'http_status_code' => $httpStatusCode,
'processed_at' => now(),
]);
return $this;
}
/**
* Mark as skipped (e.g., duplicate or unhandled event type).
*/
public function markSkipped(string $reason, int $httpStatusCode = 200): self
{
$this->update([
'status' => self::STATUS_SKIPPED,
'error_message' => $reason,
'http_status_code' => $httpStatusCode,
'processed_at' => now(),
]);
return $this;
}
/**
* Link to an order.
*/
public function linkOrder(Order $order): self
{
$this->update(['order_id' => $order->id]);
return $this;
}
/**
* Link to a subscription.
*/
public function linkSubscription(Subscription $subscription): self
{
$this->update(['subscription_id' => $subscription->id]);
return $this;
}
/**
* Get decoded payload.
*/
public function getDecodedPayload(): array
{
return json_decode($this->payload, true) ?? [];
}
// Factory methods
/**
* Create a webhook event record.
*/
public static function record(
string $gateway,
string $eventType,
string $payload,
?string $eventId = null,
?array $headers = null
): self {
return static::create([
'gateway' => $gateway,
'event_type' => $eventType,
'event_id' => $eventId,
'payload' => $payload,
'headers' => $headers,
'status' => self::STATUS_PENDING,
'received_at' => now(),
]);
}
/**
* Check if an event has already been processed (deduplication).
*/
public static function hasBeenProcessed(string $gateway, string $eventId): bool
{
return static::where('gateway', $gateway)
->where('event_id', $eventId)
->whereIn('status', [self::STATUS_PROCESSED, self::STATUS_SKIPPED])
->exists();
}
// Scopes
public function scopeForGateway($query, string $gateway)
{
return $query->where('gateway', $gateway);
}
public function scopeOfType($query, string $eventType)
{
return $query->where('event_type', $eventType);
}
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
public function scopeFailed($query)
{
return $query->where('status', self::STATUS_FAILED);
}
public function scopeRecent($query, int $days = 7)
{
return $query->where('received_at', '>=', now()->subDays($days));
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace Core\Commerce\Notifications;
use Core\Commerce\Models\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class AccountSuspended extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Subscription $subscription
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$cancelDays = config('commerce.dunning.cancel_after_days', 30) - config('commerce.dunning.suspend_after_days', 14);
return (new MailMessage)
->subject('Account suspended - immediate action required')
->greeting('Your account has been suspended')
->line('Due to repeated payment failures, your account access has been temporarily suspended.')
->line('Your data is safe. To restore access, please update your payment method and clear your outstanding balance.')
->line('If payment is not received within '.$cancelDays.' days, your subscription will be cancelled and your account downgraded.')
->action('Restore Account', route('hub.billing.index'))
->line('Need help? Contact our support team and we\'ll work with you to resolve this.')
->salutation('Host UK');
}
public function toArray(object $notifiable): array
{
return [
'subscription_id' => $this->subscription->id,
'workspace_id' => $this->subscription->workspace_id,
'suspended_at' => now()->toISOString(),
];
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Core\Commerce\Notifications;
use Core\Commerce\Models\Order;
use Core\Commerce\Services\CommerceService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class OrderConfirmation extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Order $order
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$commerce = app(CommerceService::class);
$items = $this->order->items;
$firstItem = $items->first();
return (new MailMessage)
->subject('Order confirmation - '.$this->order->order_number)
->greeting('Thank you for your order')
->line('Your order has been confirmed and your account has been activated.')
->line('**Order Details**')
->line('Order Number: '.$this->order->order_number)
->line('Plan: '.($firstItem?->name ?? 'Subscription'))
->line('Total: '.$commerce->formatMoney($this->order->total, $this->order->currency))
->action('View Dashboard', route('hub.dashboard'))
->line('If you have any questions, please contact our support team.')
->salutation('Host UK');
}
public function toArray(object $notifiable): array
{
return [
'order_id' => $this->order->id,
'order_number' => $this->order->order_number,
'total' => $this->order->total,
'currency' => $this->order->currency,
];
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace Core\Commerce\Notifications;
use Core\Commerce\Models\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class PaymentFailed extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Subscription $subscription
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Payment failed - action required')
->greeting('We couldn\'t process your payment')
->line('We attempted to charge your payment method for your subscription renewal, but the payment was declined.')
->line('Please update your payment details to avoid service interruption.')
->action('Update Payment Method', route('hub.dashboard'))
->line('If you believe this is an error, please contact our support team.')
->line('We\'ll automatically retry the payment in a few days.')
->salutation('Host UK');
}
public function toArray(object $notifiable): array
{
return [
'subscription_id' => $this->subscription->id,
'workspace_id' => $this->subscription->workspace_id,
];
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace Core\Commerce\Notifications;
use Core\Commerce\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class PaymentRetry extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Invoice $invoice,
public int $attemptNumber,
public int $maxAttempts
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$remainingAttempts = $this->maxAttempts - $this->attemptNumber;
return (new MailMessage)
->subject('Payment retry scheduled - action required')
->greeting('Payment attempt '.$this->attemptNumber.' failed')
->line('We attempted to charge your payment method for invoice '.$this->invoice->invoice_number.', but the payment was declined.')
->line('We will automatically retry the payment in a few days.')
->when($remainingAttempts > 0, function ($message) use ($remainingAttempts) {
return $message->line('You have '.$remainingAttempts.' automatic retry attempts remaining.');
})
->when($remainingAttempts === 0, function ($message) {
return $message->line('This was our final automatic retry. Please update your payment method to avoid service interruption.');
})
->action('Update Payment Method', route('hub.billing.payment-methods'))
->line('If you believe this is an error, please contact our support team.')
->salutation('Host UK');
}
public function toArray(object $notifiable): array
{
return [
'invoice_id' => $this->invoice->id,
'invoice_number' => $this->invoice->invoice_number,
'attempt_number' => $this->attemptNumber,
'max_attempts' => $this->maxAttempts,
];
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Core\Commerce\Notifications;
use Core\Commerce\Models\Refund;
use Core\Commerce\Services\CommerceService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class RefundProcessed extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Refund $refund
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$commerce = app(CommerceService::class);
$amount = $commerce->formatMoney($this->refund->amount, $this->refund->currency);
return (new MailMessage)
->subject('Refund processed - '.$amount)
->greeting('Your refund has been processed')
->line('We have processed a refund of '.$amount.' to your original payment method.')
->line('**Refund details:**')
->line('Amount: '.$amount)
->line('Reason: '.$this->refund->getReasonLabel())
->line('Depending on your payment method and bank, the refund may take 5-10 business days to appear in your account.')
->action('View Billing', route('hub.billing.index'))
->line('If you have any questions about this refund, please contact our support team.')
->salutation('Host UK');
}
public function toArray(object $notifiable): array
{
return [
'refund_id' => $this->refund->id,
'payment_id' => $this->refund->payment_id,
'amount' => $this->refund->amount,
'currency' => $this->refund->currency,
'reason' => $this->refund->reason,
];
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace Core\Commerce\Notifications;
use Core\Commerce\Models\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class SubscriptionCancelled extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Subscription $subscription
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Subscription cancelled')
->greeting('Your subscription has ended')
->line('Your subscription has been cancelled and your account has been downgraded.')
->line('You can continue using free features, but premium features are no longer available.')
->line('We\'d love to have you back. You can resubscribe at any time to restore full access.')
->action('View Plans', route('pricing'))
->line('Thank you for being a Host UK customer.')
->salutation('Host UK');
}
public function toArray(object $notifiable): array
{
return [
'subscription_id' => $this->subscription->id,
'workspace_id' => $this->subscription->workspace_id,
'cancelled_at' => $this->subscription->cancelled_at,
];
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace Core\Commerce\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Core\Commerce\Models\Subscription;
class SubscriptionPaused extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Subscription $subscription
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$suspendDays = config('commerce.dunning.suspend_after_days', 14);
return (new MailMessage)
->subject('Subscription paused - payment required')
->greeting('Your subscription has been paused')
->line('We were unable to process your payment after multiple attempts. Your subscription has been paused to prevent further charge attempts.')
->line('Your account is still accessible, but some features may be limited.')
->line('To resume your subscription, please update your payment method and pay the outstanding balance.')
->line("If payment is not received within {$suspendDays} days, your account will be suspended.")
->action('Update Payment Method', route('hub.billing.payment-methods'))
->line('Need help? Our support team is here to assist you.')
->salutation('Host UK');
}
public function toArray(object $notifiable): array
{
return [
'subscription_id' => $this->subscription->id,
'workspace_id' => $this->subscription->workspace_id,
'paused_at' => now()->toISOString(),
];
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace Core\Commerce\Notifications;
use Core\Commerce\Models\Subscription;
use Core\Commerce\Services\CommerceService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class UpcomingRenewal extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Subscription $subscription,
public float $amount,
public string $currency = 'GBP'
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$commerce = app(CommerceService::class);
$packageName = $this->subscription->workspacePackage?->package?->name ?? 'Subscription';
$renewalDate = $this->subscription->current_period_end?->format('j F Y');
return (new MailMessage)
->subject('Upcoming renewal - '.$packageName)
->greeting('Your subscription renews soon')
->line('Your '.$packageName.' subscription will automatically renew on '.$renewalDate.'.')
->line('**Renewal amount:** '.$commerce->formatMoney($this->amount, $this->currency))
->line('No action is required. Your payment method on file will be charged automatically.')
->action('Manage Subscription', route('hub.billing.subscription'))
->line('Want to make changes? You can upgrade, downgrade, or cancel your subscription at any time before the renewal date.')
->salutation('Host UK');
}
public function toArray(object $notifiable): array
{
return [
'subscription_id' => $this->subscription->id,
'workspace_id' => $this->subscription->workspace_id,
'renewal_date' => $this->subscription->current_period_end?->toISOString(),
'amount' => $this->amount,
'currency' => $this->currency,
];
}
}

View file

@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Services;
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
/**
* Rate limiter for checkout and coupon validation attempts.
*
* Prevents abuse by limiting checkout creation and coupon validation
* per customer/IP combination. Uses a sliding window approach.
*/
class CheckoutRateLimiter
{
/**
* Maximum checkout attempts per window.
*/
private const MAX_ATTEMPTS = 5;
/**
* Window duration in seconds (15 minutes).
*/
private const DECAY_SECONDS = 900;
/**
* Maximum coupon validation attempts per window.
*
* More aggressive than checkout to prevent brute-forcing codes.
*/
private const MAX_COUPON_ATTEMPTS = 10;
/**
* Coupon window duration in seconds (5 minutes).
*/
private const COUPON_DECAY_SECONDS = 300;
public function __construct(
protected readonly RateLimiter $limiter
) {}
/**
* Check if the customer/IP has exceeded checkout rate limits.
*/
public function tooManyAttempts(?int $workspaceId, ?int $userId, Request $request): bool
{
$key = $this->throttleKey($workspaceId, $userId, $request);
return $this->limiter->tooManyAttempts($key, self::MAX_ATTEMPTS);
}
/**
* Increment the checkout attempt counter.
*/
public function increment(?int $workspaceId, ?int $userId, Request $request): void
{
$key = $this->throttleKey($workspaceId, $userId, $request);
$this->limiter->hit($key, self::DECAY_SECONDS);
}
/**
* Get the number of attempts made.
*/
public function attempts(?int $workspaceId, ?int $userId, Request $request): int
{
return $this->limiter->attempts($this->throttleKey($workspaceId, $userId, $request));
}
/**
* Get seconds until rate limit resets.
*/
public function availableIn(?int $workspaceId, ?int $userId, Request $request): int
{
return $this->limiter->availableIn($this->throttleKey($workspaceId, $userId, $request));
}
/**
* Clear rate limit (e.g., after successful checkout).
*/
public function clear(?int $workspaceId, ?int $userId, Request $request): void
{
$this->limiter->clear($this->throttleKey($workspaceId, $userId, $request));
}
/**
* Check if customer/IP has exceeded coupon validation rate limits.
*/
public function tooManyCouponAttempts(?int $workspaceId, ?int $userId, Request $request): bool
{
$key = $this->couponThrottleKey($workspaceId, $userId, $request);
return $this->limiter->tooManyAttempts($key, self::MAX_COUPON_ATTEMPTS);
}
/**
* Increment the coupon validation attempt counter.
*/
public function incrementCoupon(?int $workspaceId, ?int $userId, Request $request): void
{
$key = $this->couponThrottleKey($workspaceId, $userId, $request);
$this->limiter->hit($key, self::COUPON_DECAY_SECONDS);
}
/**
* Get seconds until coupon rate limit resets.
*/
public function couponAvailableIn(?int $workspaceId, ?int $userId, Request $request): int
{
return $this->limiter->availableIn($this->couponThrottleKey($workspaceId, $userId, $request));
}
/**
* Generate throttle key for coupon validation.
*/
protected function couponThrottleKey(?int $workspaceId, ?int $userId, Request $request): string
{
if ($workspaceId) {
return "coupon:workspace:{$workspaceId}";
}
if ($userId) {
return "coupon:user:{$userId}";
}
$ip = $request->ip() ?? 'unknown';
return "coupon:ip:{$ip}";
}
/**
* Generate throttle key from workspace/user/IP.
*
* Rate limiting hierarchy:
* - Authenticated user with workspace: workspace_id
* - Authenticated user without workspace: user_id
* - Guest: IP address
*/
protected function throttleKey(?int $workspaceId, ?int $userId, Request $request): string
{
if ($workspaceId) {
return "checkout:workspace:{$workspaceId}";
}
if ($userId) {
return "checkout:user:{$userId}";
}
$ip = $request->ip() ?? 'unknown';
return "checkout:ip:{$ip}";
}
}

View file

@ -0,0 +1,628 @@
<?php
namespace Core\Commerce\Services;
use Core\Mod\Tenant\Models\Package;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\EntitlementService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Core\Commerce\Contracts\Orderable;
use Core\Commerce\Models\Coupon;
use Core\Commerce\Models\Invoice;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\OrderItem;
use Core\Commerce\Models\Payment;
use Core\Commerce\Models\Subscription;
use Core\Commerce\Services\PaymentGateway\PaymentGatewayContract;
/**
* Main commerce orchestration service.
*
* Handles order creation, checkout flow, and payment processing.
*/
class CommerceService
{
public function __construct(
protected EntitlementService $entitlements,
protected TaxService $taxService,
protected CouponService $couponService,
protected InvoiceService $invoiceService,
protected CurrencyService $currencyService,
) {}
/**
* Get the active payment gateway.
*/
public function gateway(?string $name = null): PaymentGatewayContract
{
$name = $name ?? $this->getDefaultGateway();
return app("commerce.gateway.{$name}");
}
/**
* Get the default gateway name.
*/
public function getDefaultGateway(): string
{
// BTCPay is primary, Stripe is fallback
if (config('commerce.gateways.btcpay.enabled')) {
return 'btcpay';
}
return 'stripe';
}
/**
* Get all enabled gateways.
*
* @return array<string, PaymentGatewayContract>
*/
public function getEnabledGateways(): array
{
$gateways = [];
foreach (config('commerce.gateways') as $name => $config) {
if ($config['enabled'] ?? false) {
$gateways[$name] = $this->gateway($name);
}
}
return $gateways;
}
// Order Creation
/**
* Create an order for a package purchase.
*
* @param string|null $idempotencyKey Optional idempotency key to prevent duplicate orders
*/
public function createOrder(
Orderable&Model $orderable,
Package $package,
string $billingCycle = 'monthly',
?Coupon $coupon = null,
array $metadata = [],
?string $idempotencyKey = null
): Order {
// Check for existing order with same idempotency key
if ($idempotencyKey) {
$existingOrder = Order::where('idempotency_key', $idempotencyKey)->first();
if ($existingOrder) {
return $existingOrder;
}
}
return DB::transaction(function () use ($orderable, $package, $billingCycle, $coupon, $metadata, $idempotencyKey) {
// Calculate pricing
$subtotal = $package->getPrice($billingCycle);
$setupFee = $package->setup_fee ?? 0;
// Apply coupon if valid
$discountAmount = 0;
if ($coupon && $this->couponService->validateForOrderable($coupon, $orderable, $package)) {
$discountAmount = $coupon->calculateDiscount($subtotal);
}
// Calculate tax
$taxableAmount = $subtotal - $discountAmount + $setupFee;
$taxResult = $this->taxService->calculateForOrderable($orderable, $taxableAmount);
// Create order
$order = Order::create([
'orderable_type' => get_class($orderable),
'orderable_id' => $orderable->id,
'user_id' => $orderable instanceof \Core\Mod\Tenant\Models\User ? $orderable->id : null,
'order_number' => Order::generateOrderNumber(),
'status' => 'pending',
'billing_cycle' => $billingCycle,
'subtotal' => $subtotal + $setupFee,
'discount_amount' => $discountAmount,
'tax_amount' => $taxResult->taxAmount,
'tax_rate' => $taxResult->taxRate,
'tax_country' => $taxResult->jurisdiction,
'total' => $subtotal - $discountAmount + $setupFee + $taxResult->taxAmount,
'currency' => config('commerce.currency', 'GBP'),
'coupon_id' => $coupon?->id,
'billing_name' => $orderable->getBillingName(),
'billing_email' => $orderable->getBillingEmail(),
'billing_address' => $orderable->getBillingAddress(),
'metadata' => $metadata,
'idempotency_key' => $idempotencyKey,
]);
// Create line items
$lineTotal = $subtotal - $discountAmount;
OrderItem::create([
'order_id' => $order->id,
'item_type' => 'package',
'item_id' => $package->id,
'item_code' => $package->code,
'description' => "{$package->name} - ".ucfirst($billingCycle),
'quantity' => 1,
'unit_price' => $subtotal,
'line_total' => $lineTotal,
'billing_cycle' => $billingCycle,
]);
// Add setup fee as separate line item if applicable
if ($setupFee > 0) {
OrderItem::create([
'order_id' => $order->id,
'item_type' => 'setup_fee',
'item_id' => $package->id,
'item_code' => 'setup-fee',
'description' => "One-time setup fee for {$package->name}",
'quantity' => 1,
'unit_price' => $setupFee,
'line_total' => $setupFee,
'billing_cycle' => 'onetime',
]);
}
return $order;
});
}
/**
* Create a checkout session for an order.
*
* @return array{order: Order, session_id: string, checkout_url: string}
*/
public function createCheckout(
Order $order,
?string $gateway = null,
?string $successUrl = null,
?string $cancelUrl = null
): array {
$gateway = $gateway ?? $this->getDefaultGateway();
$successUrl = $successUrl ?? route('checkout.success', ['order' => $order->order_number]);
$cancelUrl = $cancelUrl ?? route('checkout.cancel', ['order' => $order->order_number]);
// Ensure customer exists in gateway (only for Workspace orderables)
if ($order->orderable instanceof Workspace) {
$this->ensureCustomer($order->orderable, $gateway);
}
// Update order with gateway info
$order->update([
'gateway' => $gateway,
'status' => 'processing',
]);
// Create checkout session
$session = $this->gateway($gateway)->createCheckoutSession($order, $successUrl, $cancelUrl);
$order->update([
'gateway_session_id' => $session['session_id'],
]);
return [
'order' => $order->fresh(),
'session_id' => $session['session_id'],
'checkout_url' => $session['checkout_url'],
];
}
/**
* Ensure workspace has a customer ID in the gateway.
*/
public function ensureCustomer(Workspace $workspace, string $gateway): string
{
$field = "{$gateway}_customer_id";
if ($workspace->{$field}) {
return $workspace->{$field};
}
$customerId = $this->gateway($gateway)->createCustomer($workspace);
$workspace->update([$field => $customerId]);
return $customerId;
}
/**
* Create an order for a one-time boost purchase.
*/
public function createBoostOrder(
Orderable&Model $orderable,
string $boostCode,
string $boostName,
int $price,
?Coupon $coupon = null,
array $metadata = []
): Order {
return DB::transaction(function () use ($orderable, $boostCode, $boostName, $price, $coupon, $metadata) {
// Calculate pricing
$subtotal = $price;
// Apply coupon if valid
$discountAmount = 0;
if ($coupon) {
$discountAmount = $coupon->calculateDiscount($subtotal);
}
// Calculate tax
$taxableAmount = $subtotal - $discountAmount;
$taxResult = $this->taxService->calculateForOrderable($orderable, $taxableAmount);
// Create order
$order = Order::create([
'orderable_type' => get_class($orderable),
'orderable_id' => $orderable->id,
'user_id' => $orderable instanceof \Core\Mod\Tenant\Models\User ? $orderable->id : null,
'order_number' => Order::generateOrderNumber(),
'status' => 'pending',
'billing_cycle' => 'onetime',
'subtotal' => $subtotal,
'discount_amount' => $discountAmount,
'tax_amount' => $taxResult->taxAmount,
'tax_rate' => $taxResult->taxRate,
'tax_country' => $taxResult->jurisdiction,
'total' => $subtotal - $discountAmount + $taxResult->taxAmount,
'currency' => config('commerce.currency', 'GBP'),
'coupon_id' => $coupon?->id,
'billing_name' => $orderable->getBillingName(),
'billing_email' => $orderable->getBillingEmail(),
'billing_address' => $orderable->getBillingAddress(),
'metadata' => array_merge($metadata, ['boost_code' => $boostCode]),
]);
// Create line item
OrderItem::create([
'order_id' => $order->id,
'item_type' => 'boost',
'item_id' => null,
'item_code' => $boostCode,
'description' => $boostName,
'quantity' => 1,
'unit_price' => $subtotal,
'line_total' => $subtotal - $discountAmount,
'billing_cycle' => 'onetime',
]);
return $order;
});
}
// Order Fulfilment
/**
* Process a successful payment and provision entitlements.
*/
public function fulfillOrder(Order $order, Payment $payment): void
{
DB::transaction(function () use ($order, $payment) {
// Mark order as paid
$order->markAsPaid();
// Create invoice
$invoice = $this->invoiceService->createFromOrder($order, $payment);
// Record coupon usage if applicable
if ($order->coupon_id && $order->orderable) {
$this->couponService->recordUsageForOrderable(
$order->coupon,
$order->orderable,
$order,
$order->discount_amount
);
}
// Provision entitlements for each package item (only for Workspace orderables)
if ($order->orderable instanceof Workspace) {
foreach ($order->items as $item) {
if ($item->item_type === 'package' && $item->item_id) {
$this->entitlements->provisionPackage(
$order->orderable,
$item->package->code,
[
'order_id' => $order->id,
'source' => $order->gateway,
]
);
}
}
}
// Provision boosts for user-level orders
if ($order->orderable instanceof \Core\Mod\Tenant\Models\User) {
foreach ($order->items as $item) {
if ($item->item_type === 'boost') {
$quantity = $item->metadata['quantity'] ?? $item->quantity ?? 1;
$this->provisionBoostForUser($order->orderable, $item->item_code, $quantity, [
'order_id' => $order->id,
'source' => $order->gateway,
]);
}
}
}
// Dispatch OrderPaid event for referral tracking and other listeners
event(new \Core\Commerce\Events\OrderPaid($order, $payment));
});
}
/**
* Provision a boost for a user.
*/
public function provisionBoostForUser(\Core\Mod\Tenant\Models\User $user, string $featureCode, int $quantity = 1, array $metadata = []): \Core\Mod\Tenant\Models\Boost
{
// Use ADD_LIMIT for quantity-based boosts, ENABLE for boolean boosts
$boostType = $quantity > 1 || $this->isQuantityBasedFeature($featureCode)
? \Core\Mod\Tenant\Models\Boost::BOOST_TYPE_ADD_LIMIT
: \Core\Mod\Tenant\Models\Boost::BOOST_TYPE_ENABLE;
return \Core\Mod\Tenant\Models\Boost::create([
'user_id' => $user->id,
'workspace_id' => null,
'feature_code' => $featureCode,
'boost_type' => $boostType,
'duration_type' => \Core\Mod\Tenant\Models\Boost::DURATION_PERMANENT,
'limit_value' => $boostType === \Core\Mod\Tenant\Models\Boost::BOOST_TYPE_ADD_LIMIT ? $quantity : null,
'status' => \Core\Mod\Tenant\Models\Boost::STATUS_ACTIVE,
'starts_at' => now(),
'metadata' => $metadata,
]);
}
/**
* Check if a feature code is quantity-based (needs ADD_LIMIT).
*/
protected function isQuantityBasedFeature(string $featureCode): bool
{
return in_array($featureCode, [
'bio.pages',
'bio.blocks',
'bio.shortened_links',
'bio.qr_codes',
'bio.file_downloads',
'bio.events',
'bio.vcard',
'bio.splash_pages',
'bio.pixels',
'bio.static_sites',
'bio.custom_domains',
'bio.web3_domains',
'ai.credits',
'webpage.sub_pages',
]);
}
/**
* Handle a failed order.
*/
public function failOrder(Order $order, ?string $reason = null): void
{
$order->markAsFailed($reason);
}
// Subscription Management
/**
* Create a subscription for a workspace.
*/
public function createSubscription(
Workspace $workspace,
Package $package,
string $billingCycle = 'monthly',
?string $gateway = null
): Subscription {
$gateway = $gateway ?? $this->getDefaultGateway();
$priceId = $package->getGatewayPriceId($gateway, $billingCycle);
if (! $priceId) {
throw new \InvalidArgumentException(
"Package {$package->code} has no {$gateway} price ID for {$billingCycle} billing"
);
}
// Ensure customer exists
$this->ensureCustomer($workspace, $gateway);
// Create subscription in gateway
$subscription = $this->gateway($gateway)->createSubscription($workspace, $priceId, [
'trial_days' => $package->trial_days,
]);
return $subscription;
}
/**
* Upgrade or downgrade a subscription.
*/
public function changeSubscription(
Subscription $subscription,
Package $newPackage,
?string $billingCycle = null
): Subscription {
$billingCycle = $billingCycle ?? $this->guessBillingCycleFromSubscription($subscription);
$priceId = $newPackage->getGatewayPriceId($subscription->gateway, $billingCycle);
if (! $priceId) {
throw new \InvalidArgumentException(
"Package {$newPackage->code} has no {$subscription->gateway} price ID"
);
}
return $this->gateway($subscription->gateway)->updateSubscription($subscription, [
'price_id' => $priceId,
'prorate' => config('commerce.subscriptions.allow_proration', true),
]);
}
/**
* Cancel a subscription.
*/
public function cancelSubscription(Subscription $subscription, bool $immediately = false): void
{
$this->gateway($subscription->gateway)->cancelSubscription($subscription, $immediately);
if ($immediately) {
// Revoke entitlements immediately
$workspacePackage = $subscription->workspacePackage;
if ($workspacePackage) {
$this->entitlements->revokePackage($subscription->workspace, $workspacePackage->package->code);
}
}
}
/**
* Resume a cancelled subscription.
*/
public function resumeSubscription(Subscription $subscription): void
{
if (! $subscription->onGracePeriod()) {
throw new \InvalidArgumentException('Cannot resume subscription outside grace period');
}
$this->gateway($subscription->gateway)->resumeSubscription($subscription);
}
// Refunds
/**
* Process a refund.
*/
public function refund(
Payment $payment,
?float $amount = null,
?string $reason = null
): \Core\Commerce\Models\Refund {
$amountCents = $amount
? (int) ($amount * 100)
: (int) (($payment->amount - $payment->amount_refunded) * 100);
return $this->gateway($payment->gateway)->refund($payment, $amountCents, $reason);
}
// Invoice Retries
/**
* Retry payment for an invoice.
*/
public function retryInvoicePayment(Invoice $invoice): bool
{
if ($invoice->isPaid()) {
return true; // Already paid
}
$workspace = $invoice->workspace;
if (! $workspace) {
return false;
}
// Get default payment method
$paymentMethod = $workspace->paymentMethods()
->where('is_active', true)
->where('is_default', true)
->first();
if (! $paymentMethod) {
return false;
}
try {
$gateway = $this->gateway($paymentMethod->gateway);
// Convert total to cents and charge via gateway
$amountCents = (int) ($invoice->total * 100);
$payment = $gateway->chargePaymentMethod(
$paymentMethod,
$amountCents,
$invoice->currency,
[
'description' => "Invoice {$invoice->invoice_number}",
'invoice_id' => $invoice->id,
]
);
// Gateway returns a Payment model - check if it succeeded
if ($payment->status === 'succeeded') {
// Link payment to invoice
$payment->update(['invoice_id' => $invoice->id]);
$invoice->markAsPaid($payment);
return true;
}
// For BTCPay, payment will be 'pending' as it requires customer action
// This is expected - automatic retry won't work for crypto payments
if ($payment->status === 'pending' && $paymentMethod->gateway === 'btcpay') {
\Illuminate\Support\Facades\Log::info('BTCPay invoice created for retry - requires customer payment', [
'invoice_id' => $invoice->id,
'payment_id' => $payment->id,
]);
}
return false;
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Invoice payment retry failed', [
'invoice_id' => $invoice->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get gateway by name (alias for gateway method).
*/
public function getGateway(string $name): PaymentGatewayContract
{
return $this->gateway($name);
}
// Helpers
/**
* Guess billing cycle from subscription metadata.
*/
protected function guessBillingCycleFromSubscription(Subscription $subscription): string
{
// Try to determine from current period length
$periodDays = $subscription->current_period_start->diffInDays($subscription->current_period_end);
return $periodDays > 32 ? 'yearly' : 'monthly';
}
/**
* Get currency symbol.
*/
public function getCurrencySymbol(?string $currency = null): string
{
$currency = $currency ?? config('commerce.currency', 'GBP');
return $this->currencyService->getSymbol($currency);
}
/**
* Format money for display.
*/
public function formatMoney(float $amount, ?string $currency = null): string
{
$currency = $currency ?? config('commerce.currency', 'GBP');
return $this->currencyService->format($amount, $currency);
}
/**
* Get the currency service.
*/
public function getCurrencyService(): CurrencyService
{
return $this->currencyService;
}
/**
* Convert an amount between currencies.
*/
public function convertCurrency(float $amount, string $from, string $to): ?float
{
return $this->currencyService->convert($amount, $from, $to);
}
}

View file

@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Services;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Core\Commerce\Models\ContentOverride;
use Core\Commerce\Models\Entity;
/**
* Content Override Service - Sparse override resolution for white-label commerce.
*
* Resolution chain: entity parent parent M1 (original)
* Only stores what's different. Returns merged view at runtime.
*/
class ContentOverrideService
{
/**
* Get a single field value, resolved through the entity hierarchy.
*
* Checks from the entity up to root, returns first override found.
* If no override, returns the original model value.
*/
public function get(Entity $entity, Model $model, string $field): mixed
{
$hierarchy = $this->getHierarchyBottomUp($entity);
$morphType = $model->getMorphClass();
$modelId = $model->getKey();
// Check from this entity up to root
foreach ($hierarchy as $ancestor) {
$override = ContentOverride::where('entity_id', $ancestor->id)
->where('overrideable_type', $morphType)
->where('overrideable_id', $modelId)
->where('field', $field)
->first();
if ($override) {
return $override->getCastedValue();
}
}
// No override found - return original model value
return $model->getAttribute($field);
}
/**
* Set an override for an entity.
*/
public function set(Entity $entity, Model $model, string $field, mixed $value): ContentOverride
{
return ContentOverride::setOverride($entity, $model, $field, $value);
}
/**
* Clear (remove) an override for an entity.
*
* After clearing, the entity will inherit from parent or original.
*/
public function clear(Entity $entity, Model $model, string $field): bool
{
return ContentOverride::clearOverride($entity, $model, $field);
}
/**
* Get all resolved fields for a model within an entity context.
*
* Returns the model's attributes with all applicable overrides applied.
*/
public function getEffective(Entity $entity, Model $model, ?array $fields = null): array
{
// Start with original model data
$resolved = $model->toArray();
// If specific fields requested, filter to just those
if ($fields !== null) {
$resolved = array_intersect_key($resolved, array_flip($fields));
}
// Get hierarchy from M1 down to this entity
$hierarchy = $this->getHierarchyTopDown($entity);
$morphType = $model->getMorphClass();
$modelId = $model->getKey();
// Apply overrides in order (M1 first, then M2, then M3, etc.)
// Later overrides win, so entity's own overrides take precedence
foreach ($hierarchy as $ancestor) {
$overrides = ContentOverride::where('entity_id', $ancestor->id)
->where('overrideable_type', $morphType)
->where('overrideable_id', $modelId)
->when($fields !== null, fn ($q) => $q->whereIn('field', $fields))
->get();
foreach ($overrides as $override) {
$resolved[$override->field] = $override->getCastedValue();
}
}
return $resolved;
}
/**
* Get override status for all fields of a model.
*
* Returns information about what's overridden vs inherited.
*/
public function getOverrideStatus(Entity $entity, Model $model, array $fields): array
{
$morphType = $model->getMorphClass();
$modelId = $model->getKey();
$hierarchy = $this->getHierarchyBottomUp($entity);
$hierarchyIds = $hierarchy->pluck('id')->toArray();
$status = [];
foreach ($fields as $field) {
// Find the override for this field (if any)
$override = ContentOverride::where('overrideable_type', $morphType)
->where('overrideable_id', $modelId)
->where('field', $field)
->whereIn('entity_id', $hierarchyIds)
->orderByRaw('FIELD(entity_id, '.implode(',', $hierarchyIds).')')
->with('entity')
->first();
$resolvedValue = $override
? $override->getCastedValue()
: $model->getAttribute($field);
$status[$field] = [
'value' => $resolvedValue,
'original' => $model->getAttribute($field),
'source' => $override ? $override->entity->name : 'original',
'source_type' => $override ? $override->entity->type : null,
'is_overridden' => $override && $override->entity_id === $entity->id,
'inherited_from' => $override && $override->entity_id !== $entity->id
? $override->entity->name
: null,
'can_override' => true, // Could add permission check here
];
}
return $status;
}
/**
* Get all overrides for an entity (for admin UI).
*/
public function getEntityOverrides(Entity $entity): Collection
{
return ContentOverride::where('entity_id', $entity->id)
->orderBy('overrideable_type')
->orderBy('overrideable_id')
->orderBy('field')
->get();
}
/**
* Get overrides grouped by model (for admin UI).
*/
public function getEntityOverridesGrouped(Entity $entity): Collection
{
return $this->getEntityOverrides($entity)
->groupBy(['overrideable_type', 'overrideable_id']);
}
/**
* Bulk set overrides for a model.
*/
public function setBulk(Entity $entity, Model $model, array $overrides): array
{
$results = [];
foreach ($overrides as $field => $value) {
$results[$field] = $this->set($entity, $model, $field, $value);
}
return $results;
}
/**
* Clear all overrides for a model within an entity.
*/
public function clearAll(Entity $entity, Model $model): int
{
return ContentOverride::where('entity_id', $entity->id)
->where('overrideable_type', $model->getMorphClass())
->where('overrideable_id', $model->getKey())
->delete();
}
/**
* Copy overrides from one entity to another.
*
* Useful when creating child entities that should start with parent's customisations.
*/
public function copyOverrides(Entity $source, Entity $target, ?Model $model = null): int
{
$query = ContentOverride::where('entity_id', $source->id);
if ($model) {
$query->where('overrideable_type', $model->getMorphClass())
->where('overrideable_id', $model->getKey());
}
$overrides = $query->get();
$count = 0;
foreach ($overrides as $override) {
ContentOverride::updateOrCreate(
[
'entity_id' => $target->id,
'overrideable_type' => $override->overrideable_type,
'overrideable_id' => $override->overrideable_id,
'field' => $override->field,
],
[
'value' => $override->value,
'value_type' => $override->value_type,
'created_by' => auth()->id(),
]
);
$count++;
}
return $count;
}
/**
* Check if an entity has any overrides for a model.
*/
public function hasOverrides(Entity $entity, Model $model): bool
{
return ContentOverride::where('entity_id', $entity->id)
->where('overrideable_type', $model->getMorphClass())
->where('overrideable_id', $model->getKey())
->exists();
}
/**
* Get which fields are overridden by an entity.
*/
public function getOverriddenFields(Entity $entity, Model $model): array
{
return ContentOverride::where('entity_id', $entity->id)
->where('overrideable_type', $model->getMorphClass())
->where('overrideable_id', $model->getKey())
->pluck('field')
->toArray();
}
/**
* Get hierarchy from this entity up to root (including self).
*/
protected function getHierarchyBottomUp(Entity $entity): Collection
{
$hierarchy = $entity->getHierarchy(); // Includes self
return $hierarchy->reverse()->values();
}
/**
* Get hierarchy from root down to this entity (including self).
*/
protected function getHierarchyTopDown(Entity $entity): Collection
{
return $entity->getHierarchy(); // Already ordered root to self
}
}

226
Services/CouponService.php Normal file
View file

@ -0,0 +1,226 @@
<?php
namespace Core\Commerce\Services;
use Core\Mod\Tenant\Models\Package;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Model;
use Core\Commerce\Contracts\Orderable;
use Core\Commerce\Data\CouponValidationResult;
use Core\Commerce\Models\Coupon;
use Core\Commerce\Models\CouponUsage;
use Core\Commerce\Models\Order;
/**
* Coupon validation and application service.
*/
class CouponService
{
/**
* Find a coupon by code.
*/
public function findByCode(string $code): ?Coupon
{
return Coupon::byCode($code)->first();
}
/**
* Validate a coupon for a workspace and package.
*/
public function validate(Coupon $coupon, Workspace $workspace, ?Package $package = null): CouponValidationResult
{
// Check if coupon is valid (active, within dates, not maxed out)
if (! $coupon->isValid()) {
return CouponValidationResult::invalid('This coupon is no longer valid');
}
// Check workspace usage limit
if (! $coupon->canBeUsedByWorkspace($workspace->id)) {
return CouponValidationResult::invalid('You have already used this coupon');
}
// Check if coupon applies to the package
if ($package && ! $coupon->appliesToPackage($package->id)) {
return CouponValidationResult::invalid('This coupon does not apply to the selected plan');
}
return CouponValidationResult::valid($coupon);
}
/**
* Validate a coupon for any Orderable entity (User or Workspace).
*
* Returns boolean for use in CommerceService order creation.
*/
public function validateForOrderable(Coupon $coupon, Orderable&Model $orderable, ?Package $package = null): bool
{
// Check if coupon is valid (active, within dates, not maxed out)
if (! $coupon->isValid()) {
return false;
}
// Check orderable usage limit
if (! $coupon->canBeUsedByOrderable($orderable)) {
return false;
}
// Check if coupon applies to the package
if ($package && ! $coupon->appliesToPackage($package->id)) {
return false;
}
return true;
}
/**
* Validate a coupon by code.
*/
public function validateByCode(string $code, Workspace $workspace, ?Package $package = null): CouponValidationResult
{
$coupon = $this->findByCode($code);
if (! $coupon) {
return CouponValidationResult::invalid('Invalid coupon code');
}
return $this->validate($coupon, $workspace, $package);
}
/**
* Calculate discount for an amount.
*/
public function calculateDiscount(Coupon $coupon, float $amount): float
{
return $coupon->calculateDiscount($amount);
}
/**
* Record coupon usage after successful payment.
*/
public function recordUsage(Coupon $coupon, Workspace $workspace, Order $order, float $discountAmount): CouponUsage
{
$usage = CouponUsage::create([
'coupon_id' => $coupon->id,
'workspace_id' => $workspace->id,
'order_id' => $order->id,
'discount_amount' => $discountAmount,
]);
// Increment global usage count
$coupon->incrementUsage();
return $usage;
}
/**
* Record coupon usage for any Orderable entity.
*/
public function recordUsageForOrderable(Coupon $coupon, Orderable&Model $orderable, Order $order, float $discountAmount): CouponUsage
{
$workspaceId = $orderable instanceof Workspace ? $orderable->id : null;
$usage = CouponUsage::create([
'coupon_id' => $coupon->id,
'workspace_id' => $workspaceId,
'order_id' => $order->id,
'discount_amount' => $discountAmount,
]);
// Increment global usage count
$coupon->incrementUsage();
return $usage;
}
/**
* Get usage history for a coupon.
*/
public function getUsageHistory(Coupon $coupon, int $limit = 50): \Illuminate\Database\Eloquent\Collection
{
return $coupon->usages()
->with(['workspace', 'order'])
->latest()
->limit($limit)
->get();
}
/**
* Get usage count for a workspace.
*/
public function getWorkspaceUsageCount(Coupon $coupon, Workspace $workspace): int
{
return $coupon->usages()
->where('workspace_id', $workspace->id)
->count();
}
/**
* Get total discount amount for a coupon.
*/
public function getTotalDiscountAmount(Coupon $coupon): float
{
return $coupon->usages()->sum('discount_amount');
}
/**
* Create a new coupon.
*/
public function create(array $data): Coupon
{
// Normalise code to uppercase
$data['code'] = strtoupper($data['code']);
return Coupon::create($data);
}
/**
* Deactivate a coupon.
*/
public function deactivate(Coupon $coupon): void
{
$coupon->update(['is_active' => false]);
}
/**
* Generate a random coupon code.
*/
public function generateCode(int $length = 8): string
{
$characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= $characters[random_int(0, strlen($characters) - 1)];
}
// Ensure uniqueness
while (Coupon::where('code', $code)->exists()) {
$code = $this->generateCode($length);
}
return $code;
}
/**
* Generate multiple coupons with unique codes.
*
* @param int $count Number of coupons to generate (1-100)
* @param array $baseData Base coupon data (shared settings for all coupons)
* @return array<Coupon> Array of created coupons
*/
public function generateBulk(int $count, array $baseData): array
{
$count = min(max($count, 1), 100);
$coupons = [];
$prefix = $baseData['code_prefix'] ?? '';
unset($baseData['code_prefix']);
for ($i = 0; $i < $count; $i++) {
$code = $prefix ? $prefix.'-'.$this->generateCode(6) : $this->generateCode(8);
$data = array_merge($baseData, ['code' => $code]);
$coupons[] = $this->create($data);
}
return $coupons;
}
}

View file

@ -0,0 +1,286 @@
<?php
namespace Core\Commerce\Services;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Core\Commerce\Models\CreditNote;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\Refund;
class CreditNoteService
{
/**
* Create a new credit note.
*/
public function create(
Workspace $workspace,
User $user,
float $amount,
string $reason,
?string $description = null,
string $currency = 'GBP',
?User $issuedBy = null,
bool $issueImmediately = true
): CreditNote {
if ($amount <= 0) {
throw new \InvalidArgumentException('Credit note amount must be greater than zero');
}
return DB::transaction(function () use ($workspace, $user, $amount, $reason, $description, $currency, $issuedBy, $issueImmediately) {
$creditNote = CreditNote::create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'reference_number' => CreditNote::generateReferenceNumber(),
'amount' => $amount,
'currency' => $currency,
'reason' => $reason,
'description' => $description,
'status' => 'draft',
]);
if ($issueImmediately) {
$creditNote->issue($issuedBy);
}
Log::info('Credit note created', [
'credit_note_id' => $creditNote->id,
'reference' => $creditNote->reference_number,
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'amount' => $amount,
'reason' => $reason,
]);
return $creditNote;
});
}
/**
* Create a credit note from a refund (partial refund as store credit).
*/
public function createFromRefund(
Refund $refund,
float $amount,
?string $description = null,
?User $issuedBy = null
): CreditNote {
if ($amount <= 0) {
throw new \InvalidArgumentException('Credit note amount must be greater than zero');
}
if ($amount > $refund->amount) {
throw new \InvalidArgumentException('Credit note amount cannot exceed refund amount');
}
$payment = $refund->payment;
$workspace = $payment->workspace;
// Get user from the payment's workspace owner
$user = $workspace->owner();
if (! $user) {
throw new \InvalidArgumentException('Cannot create credit note: no workspace owner found');
}
return DB::transaction(function () use ($workspace, $user, $refund, $amount, $description, $issuedBy, $payment) {
// Get order from payment if available
$orderId = $payment->invoice?->order_id;
$creditNote = CreditNote::create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'order_id' => $orderId,
'refund_id' => $refund->id,
'reference_number' => CreditNote::generateReferenceNumber(),
'amount' => $amount,
'currency' => $refund->currency,
'reason' => 'partial_refund',
'description' => $description ?? "Credit from refund #{$refund->id}",
'status' => 'draft',
]);
$creditNote->issue($issuedBy);
Log::info('Credit note created from refund', [
'credit_note_id' => $creditNote->id,
'refund_id' => $refund->id,
'amount' => $amount,
]);
return $creditNote;
});
}
/**
* Apply a credit note to an order.
*
* @return float Amount applied
*/
public function apply(CreditNote $creditNote, Order $order, ?float $amount = null): float
{
if (! $creditNote->isUsable()) {
throw new \InvalidArgumentException('Credit note is not usable (status: '.$creditNote->status.')');
}
$available = $creditNote->getRemainingAmount();
if ($available <= 0) {
throw new \InvalidArgumentException('Credit note has no remaining balance');
}
// If no amount specified, use the full remaining amount
$applyAmount = $amount ?? $available;
// Cap at available amount
if ($applyAmount > $available) {
$applyAmount = $available;
}
// Cap at order total
if ($applyAmount > $order->total) {
$applyAmount = $order->total;
}
return DB::transaction(function () use ($creditNote, $order, $applyAmount) {
$creditNote->recordUsage($applyAmount, $order);
// Update order metadata to track credit applied
$order->update([
'metadata' => array_merge($order->metadata ?? [], [
'credits_applied' => array_merge(
$order->metadata['credits_applied'] ?? [],
[[
'credit_note_id' => $creditNote->id,
'reference' => $creditNote->reference_number,
'amount' => $applyAmount,
'applied_at' => now()->toIso8601String(),
]]
),
]),
]);
Log::info('Credit note applied to order', [
'credit_note_id' => $creditNote->id,
'order_id' => $order->id,
'amount_applied' => $applyAmount,
]);
return $applyAmount;
});
}
/**
* Void a credit note.
*/
public function void(CreditNote $creditNote, ?User $voidedBy = null): void
{
if ($creditNote->isVoid()) {
throw new \InvalidArgumentException('Credit note is already void');
}
if ($creditNote->amount_used > 0) {
throw new \InvalidArgumentException('Cannot void a credit note that has been partially or fully used');
}
$creditNote->void($voidedBy);
Log::info('Credit note voided', [
'credit_note_id' => $creditNote->id,
'reference' => $creditNote->reference_number,
'voided_by' => $voidedBy?->id,
]);
}
/**
* Get available (usable) credits for a user in a workspace.
*/
public function getAvailableCredits(User $user, Workspace $workspace): Collection
{
return CreditNote::query()
->forWorkspace($workspace->id)
->forUser($user->id)
->usable()
->where('amount_used', '<', DB::raw('amount'))
->orderBy('created_at', 'asc') // FIFO - oldest credits first
->get();
}
/**
* Get total available credit amount for a user in a workspace.
*/
public function getTotalCredit(User $user, Workspace $workspace): float
{
return (float) CreditNote::query()
->forWorkspace($workspace->id)
->forUser($user->id)
->usable()
->selectRaw('SUM(amount - amount_used) as total')
->value('total') ?? 0;
}
/**
* Get total available credit for a workspace (all users).
*/
public function getTotalCreditForWorkspace(Workspace $workspace): float
{
return (float) CreditNote::query()
->forWorkspace($workspace->id)
->usable()
->selectRaw('SUM(amount - amount_used) as total')
->value('total') ?? 0;
}
/**
* Get all credit notes for a workspace.
*/
public function getCreditNotesForWorkspace(int $workspaceId): Collection
{
return CreditNote::query()
->forWorkspace($workspaceId)
->with(['user', 'order', 'refund', 'issuedByUser'])
->latest()
->get();
}
/**
* Get all credit notes for a user.
*/
public function getCreditNotesForUser(int $userId, ?int $workspaceId = null): Collection
{
return CreditNote::query()
->forUser($userId)
->when($workspaceId, fn ($q) => $q->forWorkspace($workspaceId))
->with(['workspace', 'order', 'refund'])
->latest()
->get();
}
/**
* Auto-apply available credits to an order.
*
* @return float Total amount applied
*/
public function autoApplyCredits(Order $order, User $user, Workspace $workspace): float
{
$availableCredits = $this->getAvailableCredits($user, $workspace);
$totalApplied = 0;
$remainingTotal = $order->total;
foreach ($availableCredits as $creditNote) {
if ($remainingTotal <= 0) {
break;
}
$applyAmount = min($creditNote->getRemainingAmount(), $remainingTotal);
$applied = $this->apply($creditNote, $order, $applyAmount);
$totalApplied += $applied;
$remainingTotal -= $applied;
}
return $totalApplied;
}
}

View file

@ -0,0 +1,468 @@
<?php
declare(strict_types=1);
namespace Core\Commerce\Services;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
use Core\Commerce\Models\ExchangeRate;
/**
* Currency service for multi-currency support.
*
* Handles currency conversion, formatting, exchange rate fetching,
* and currency detection based on location/preferences.
*/
class CurrencyService
{
/**
* Session key for storing selected currency.
*/
protected const SESSION_KEY = 'commerce_currency';
/**
* Get the base currency.
*/
public function getBaseCurrency(): string
{
return config('commerce.currencies.base', 'GBP');
}
/**
* Get all supported currencies.
*
* @return array<string, array>
*/
public function getSupportedCurrencies(): array
{
return config('commerce.currencies.supported', []);
}
/**
* Check if a currency is supported.
*/
public function isSupported(string $currency): bool
{
return array_key_exists(
strtoupper($currency),
$this->getSupportedCurrencies()
);
}
/**
* Get currency configuration.
*/
public function getCurrencyConfig(string $currency): ?array
{
return config("commerce.currencies.supported.{$currency}");
}
/**
* Get the current currency for the session.
*/
public function getCurrentCurrency(): string
{
// Check session first
if (Session::has(self::SESSION_KEY)) {
$currency = Session::get(self::SESSION_KEY);
if ($this->isSupported($currency)) {
return $currency;
}
}
// Detect currency
return $this->detectCurrency();
}
/**
* Set the current currency for the session.
*/
public function setCurrentCurrency(string $currency): void
{
$currency = strtoupper($currency);
if ($this->isSupported($currency)) {
Session::put(self::SESSION_KEY, $currency);
}
}
/**
* Detect the best currency for a request.
*/
public function detectCurrency(?Request $request = null): string
{
$request = $request ?? request();
$detectionOrder = config('commerce.currencies.detection_order', ['geolocation', 'browser', 'default']);
foreach ($detectionOrder as $method) {
$currency = match ($method) {
'geolocation' => $this->detectFromGeolocation($request),
'browser' => $this->detectFromBrowser($request),
'default' => $this->getBaseCurrency(),
default => null,
};
if ($currency && $this->isSupported($currency)) {
return $currency;
}
}
return $this->getBaseCurrency();
}
/**
* Detect currency from geolocation (country).
*/
protected function detectFromGeolocation(Request $request): ?string
{
// Check for country header (set by CDN/load balancer)
$country = $request->header('CF-IPCountry') // Cloudflare
?? $request->header('X-Country-Code') // Generic
?? $request->header('X-Geo-Country'); // Bunny CDN
if (! $country) {
return null;
}
$country = strtoupper($country);
$countryCurrencies = config('commerce.currencies.country_currencies', []);
return $countryCurrencies[$country] ?? null;
}
/**
* Detect currency from browser Accept-Language header.
*/
protected function detectFromBrowser(Request $request): ?string
{
$acceptLanguage = $request->header('Accept-Language');
if (! $acceptLanguage) {
return null;
}
// Parse primary locale (e.g., "en-GB,en;q=0.9" -> "en-GB")
$primaryLocale = explode(',', $acceptLanguage)[0];
$parts = explode('-', str_replace('_', '-', $primaryLocale));
if (count($parts) >= 2) {
$country = strtoupper($parts[1]);
$countryCurrencies = config('commerce.currencies.country_currencies', []);
return $countryCurrencies[$country] ?? null;
}
return null;
}
/**
* Format an amount for display.
*
* @param float|int $amount Amount in decimal or cents
* @param bool $isCents Whether amount is in cents
*/
public function format(float|int $amount, string $currency, bool $isCents = false): string
{
$currency = strtoupper($currency);
$config = $this->getCurrencyConfig($currency);
if (! $config) {
// Fallback formatting
return $currency.' '.number_format($isCents ? $amount / 100 : $amount, 2);
}
$symbol = $config['symbol'] ?? $currency;
$position = $config['symbol_position'] ?? 'before';
$decimals = $config['decimal_places'] ?? 2;
$thousandsSep = $config['thousands_separator'] ?? ',';
$decimalSep = $config['decimal_separator'] ?? '.';
$value = $isCents ? $amount / 100 : $amount;
$formatted = number_format($value, $decimals, $decimalSep, $thousandsSep);
return $position === 'before'
? "{$symbol}{$formatted}"
: "{$formatted}{$symbol}";
}
/**
* Format an amount in the current session currency.
*/
public function formatCurrent(float|int $amount, bool $isCents = false): string
{
return $this->format($amount, $this->getCurrentCurrency(), $isCents);
}
/**
* Get the currency symbol.
*/
public function getSymbol(string $currency): string
{
$config = $this->getCurrencyConfig($currency);
return $config['symbol'] ?? $currency;
}
/**
* Convert an amount between currencies.
*/
public function convert(float $amount, string $from, string $to): ?float
{
return ExchangeRate::convert($amount, $from, $to);
}
/**
* Convert cents between currencies.
*/
public function convertCents(int $amount, string $from, string $to): ?int
{
return ExchangeRate::convertCents($amount, $from, $to);
}
/**
* Get the exchange rate between currencies.
*/
public function getExchangeRate(string $from, string $to): ?float
{
return ExchangeRate::getRate($from, $to);
}
/**
* Refresh exchange rates from the configured provider.
*/
public function refreshExchangeRates(): array
{
$provider = config('commerce.currencies.exchange_rates.provider', 'ecb');
$baseCurrency = $this->getBaseCurrency();
$supportedCurrencies = array_keys($this->getSupportedCurrencies());
return match ($provider) {
'ecb' => $this->fetchFromEcb($baseCurrency, $supportedCurrencies),
'stripe' => $this->fetchFromStripe($baseCurrency, $supportedCurrencies),
'openexchangerates' => $this->fetchFromOpenExchangeRates($baseCurrency, $supportedCurrencies),
'fixed' => $this->loadFixedRates($baseCurrency, $supportedCurrencies),
default => [],
};
}
/**
* Fetch rates from European Central Bank (free, no API key).
*/
protected function fetchFromEcb(string $baseCurrency, array $targetCurrencies): array
{
try {
// ECB provides rates in EUR
$response = Http::timeout(10)
->get('https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml');
if (! $response->successful()) {
Log::warning('ECB exchange rate fetch failed', ['status' => $response->status()]);
return [];
}
$xml = simplexml_load_string($response->body());
$rates = ['EUR' => 1.0];
foreach ($xml->Cube->Cube->Cube as $rate) {
$rates[(string) $rate['currency']] = (float) $rate['rate'];
}
// Convert to base currency
$stored = [];
$baseInEur = $rates[$baseCurrency] ?? null;
if (! $baseInEur) {
Log::warning("ECB does not have rate for base currency: {$baseCurrency}");
return [];
}
foreach ($targetCurrencies as $currency) {
if ($currency === $baseCurrency) {
continue;
}
$targetInEur = $rates[$currency] ?? null;
if ($targetInEur) {
// Convert: baseCurrency -> EUR -> targetCurrency
$rate = $targetInEur / $baseInEur;
ExchangeRate::storeRate($baseCurrency, $currency, $rate, 'ecb');
$stored[$currency] = $rate;
}
}
Log::info('ECB exchange rates updated', ['count' => count($stored)]);
return $stored;
} catch (\Exception $e) {
Log::error('ECB exchange rate fetch error', ['error' => $e->getMessage()]);
return [];
}
}
/**
* Fetch rates from Stripe's balance transaction API.
* Requires Stripe API key.
*/
protected function fetchFromStripe(string $baseCurrency, array $targetCurrencies): array
{
// Stripe provides real-time rates through their conversion API
// This requires an active Stripe account
$stripeSecret = config('commerce.gateways.stripe.secret');
if (! $stripeSecret) {
Log::warning('Stripe exchange rates requested but no API key configured');
return $this->loadFixedRates($baseCurrency, $targetCurrencies);
}
try {
// Stripe doesn't have a direct exchange rate API, but we can use balance transactions
// For simplicity, fall back to ECB and just log that Stripe was requested
Log::info('Stripe exchange rates: falling back to ECB');
return $this->fetchFromEcb($baseCurrency, $targetCurrencies);
} catch (\Exception $e) {
Log::error('Stripe exchange rate fetch error', ['error' => $e->getMessage()]);
return [];
}
}
/**
* Fetch rates from Open Exchange Rates API.
*/
protected function fetchFromOpenExchangeRates(string $baseCurrency, array $targetCurrencies): array
{
$apiKey = config('commerce.currencies.exchange_rates.api_key');
if (! $apiKey) {
Log::warning('Open Exchange Rates requested but no API key configured');
return $this->loadFixedRates($baseCurrency, $targetCurrencies);
}
try {
$response = Http::timeout(10)
->get('https://openexchangerates.org/api/latest.json', [
'app_id' => $apiKey,
'base' => 'USD', // Free tier only supports USD as base
'symbols' => implode(',', array_merge([$baseCurrency], $targetCurrencies)),
]);
if (! $response->successful()) {
Log::warning('Open Exchange Rates fetch failed', ['status' => $response->status()]);
return [];
}
$data = $response->json();
$rates = $data['rates'] ?? [];
if (empty($rates)) {
return [];
}
// Convert from USD base to our base currency
$stored = [];
$baseInUsd = $rates[$baseCurrency] ?? null;
if (! $baseInUsd) {
Log::warning("Open Exchange Rates does not have rate for: {$baseCurrency}");
return [];
}
foreach ($targetCurrencies as $currency) {
if ($currency === $baseCurrency) {
continue;
}
$targetInUsd = $rates[$currency] ?? null;
if ($targetInUsd) {
$rate = $targetInUsd / $baseInUsd;
ExchangeRate::storeRate($baseCurrency, $currency, $rate, 'openexchangerates');
$stored[$currency] = $rate;
}
}
Log::info('Open Exchange Rates updated', ['count' => count($stored)]);
return $stored;
} catch (\Exception $e) {
Log::error('Open Exchange Rates fetch error', ['error' => $e->getMessage()]);
return [];
}
}
/**
* Load fixed rates from configuration.
*/
protected function loadFixedRates(string $baseCurrency, array $targetCurrencies): array
{
$fixedRates = config('commerce.currencies.exchange_rates.fixed', []);
$stored = [];
foreach ($targetCurrencies as $currency) {
if ($currency === $baseCurrency) {
continue;
}
$directKey = "{$baseCurrency}_{$currency}";
$inverseKey = "{$currency}_{$baseCurrency}";
if (isset($fixedRates[$directKey])) {
$rate = (float) $fixedRates[$directKey];
ExchangeRate::storeRate($baseCurrency, $currency, $rate, 'fixed');
$stored[$currency] = $rate;
} elseif (isset($fixedRates[$inverseKey]) && $fixedRates[$inverseKey] > 0) {
$rate = 1.0 / (float) $fixedRates[$inverseKey];
ExchangeRate::storeRate($baseCurrency, $currency, $rate, 'fixed');
$stored[$currency] = $rate;
}
}
return $stored;
}
/**
* Get currency data for JavaScript/frontend.
*
* @return array<string, array>
*/
public function getJsData(): array
{
$currencies = [];
$baseCurrency = $this->getBaseCurrency();
foreach ($this->getSupportedCurrencies() as $code => $config) {
$rate = $code === $baseCurrency ? 1.0 : ExchangeRate::getRate($baseCurrency, $code);
$currencies[$code] = [
'code' => $code,
'name' => $config['name'],
'symbol' => $config['symbol'],
'symbolPosition' => $config['symbol_position'],
'decimalPlaces' => $config['decimal_places'],
'thousandsSeparator' => $config['thousands_separator'],
'decimalSeparator' => $config['decimal_separator'],
'flag' => $config['flag'],
'rate' => $rate,
];
}
return [
'base' => $baseCurrency,
'current' => $this->getCurrentCurrency(),
'currencies' => $currencies,
];
}
}

426
Services/DunningService.php Normal file
View file

@ -0,0 +1,426 @@
<?php
namespace Core\Commerce\Services;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Core\Commerce\Models\Invoice;
use Core\Commerce\Models\Subscription;
use Core\Commerce\Notifications\AccountSuspended;
use Core\Commerce\Notifications\PaymentFailed;
use Core\Commerce\Notifications\PaymentRetry;
use Core\Commerce\Notifications\SubscriptionCancelled;
use Core\Commerce\Notifications\SubscriptionPaused;
use Core\Mod\Tenant\Services\EntitlementService;
/**
* Dunning service for failed payment recovery.
*
* Flow:
* 1. Payment fails invoice marked as overdue, subscription marked past_due
* 2. Retry attempts scheduled (exponential backoff: 1, 3, 7 days by default)
* 3. After max retries subscription paused, notification sent
* 4. After suspend_after_days workspace suspended via EntitlementService
* 5. After cancel_after_days subscription cancelled, workspace downgraded
*/
class DunningService
{
public function __construct(
protected CommerceService $commerce,
protected SubscriptionService $subscriptions,
protected EntitlementService $entitlements,
) {}
/**
* Handle a failed payment for an invoice.
*
* Called by payment gateways when a charge fails.
*/
public function handlePaymentFailure(Invoice $invoice, ?Subscription $subscription = null): void
{
$currentAttempts = $invoice->charge_attempts ?? 0;
$isFirstFailure = $currentAttempts === 0;
// For first failure, apply initial grace period before scheduling retry
$nextRetry = $isFirstFailure
? $this->calculateInitialRetry()
: $this->calculateNextRetry($currentAttempts);
$invoice->update([
'status' => 'overdue',
'charge_attempts' => $currentAttempts + 1,
'last_charge_attempt' => now(),
'next_charge_attempt' => $nextRetry,
]);
// Mark subscription as past due if provided
if ($subscription && $subscription->isActive()) {
$subscription->markPastDue();
}
// Send initial failure notification
if (config('commerce.dunning.send_notifications', true)) {
$owner = $invoice->workspace?->owner();
if ($owner && $subscription) {
$owner->notify(new PaymentFailed($subscription));
}
}
Log::info('Payment failure handled', [
'invoice_id' => $invoice->id,
'subscription_id' => $subscription?->id,
'attempt' => $invoice->charge_attempts,
'next_retry' => $invoice->next_charge_attempt,
]);
}
/**
* Handle a successful payment recovery.
*
* Called when a retry succeeds or customer manually pays.
*/
public function handlePaymentRecovery(Invoice $invoice, ?Subscription $subscription = null): void
{
// Clear dunning state from invoice
$invoice->update([
'next_charge_attempt' => null,
]);
// Reactivate subscription if it was paused
if ($subscription && $subscription->isPaused()) {
$this->subscriptions->unpause($subscription);
// Reactivate workspace entitlements
$this->entitlements->reactivateWorkspace($subscription->workspace, 'dunning_recovery');
} elseif ($subscription && $subscription->isPastDue()) {
$subscription->update(['status' => 'active']);
}
Log::info('Payment recovery successful', [
'invoice_id' => $invoice->id,
'subscription_id' => $subscription?->id,
]);
}
/**
* Get invoices due for retry.
*/
public function getInvoicesDueForRetry(): Collection
{
return Invoice::query()
->whereIn('status', ['sent', 'overdue'])
->where('auto_charge', true)
->whereNotNull('next_charge_attempt')
->where('next_charge_attempt', '<=', now())
->with('workspace')
->get();
}
/**
* Attempt to retry payment for an invoice.
*
* @return bool True if payment succeeded
*/
public function retryPayment(Invoice $invoice): bool
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
$maxRetries = count($retryDays);
try {
$success = $this->commerce->retryInvoicePayment($invoice);
if ($success) {
// Find associated subscription and recover
$subscription = $this->findSubscriptionForInvoice($invoice);
$this->handlePaymentRecovery($invoice, $subscription);
return true;
}
// Payment failed - schedule next retry or escalate
$attempts = ($invoice->charge_attempts ?? 0) + 1;
$nextRetry = $this->calculateNextRetry($attempts);
$invoice->update([
'charge_attempts' => $attempts,
'last_charge_attempt' => now(),
'next_charge_attempt' => $nextRetry,
]);
// Send retry notification
if (config('commerce.dunning.send_notifications', true)) {
$owner = $invoice->workspace?->owner();
if ($owner) {
$owner->notify(new PaymentRetry($invoice, $attempts, $maxRetries));
}
}
Log::info('Payment retry failed', [
'invoice_id' => $invoice->id,
'attempt' => $attempts,
'next_retry' => $nextRetry,
]);
return false;
} catch (\Exception $e) {
Log::error('Payment retry exception', [
'invoice_id' => $invoice->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get subscriptions that should be paused (past retry threshold).
*/
public function getSubscriptionsForPause(): Collection
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
$pauseAfterDays = array_sum($retryDays) + 1; // Day after last retry
return Subscription::query()
->where('status', 'past_due')
->whereHas('workspace.invoices', function ($query) use ($pauseAfterDays) {
$query->whereIn('status', ['sent', 'overdue'])
->where('auto_charge', true)
->where('last_charge_attempt', '<=', now()->subDays($pauseAfterDays))
->whereNull('next_charge_attempt'); // No more retries scheduled
})
->with('workspace')
->get();
}
/**
* Pause a subscription due to payment failure.
*
* Uses force=true to bypass the pause limit check for dunning,
* as payment failures must always result in a pause regardless
* of how many times the subscription has been paused before.
*/
public function pauseSubscription(Subscription $subscription): void
{
$this->subscriptions->pause($subscription, force: true);
if (config('commerce.dunning.send_notifications', true)) {
$owner = $subscription->workspace?->owner();
if ($owner) {
$owner->notify(new SubscriptionPaused($subscription));
}
}
Log::info('Subscription paused due to dunning', [
'subscription_id' => $subscription->id,
'workspace_id' => $subscription->workspace_id,
]);
}
/**
* Get subscriptions that should have their workspace suspended.
*/
public function getSubscriptionsForSuspension(): Collection
{
$suspendAfterDays = config('commerce.dunning.suspend_after_days', 14);
return Subscription::query()
->where('status', 'paused')
->where('paused_at', '<=', now()->subDays($suspendAfterDays))
->whereDoesntHave('workspace.workspacePackages', function ($query) {
$query->where('status', 'suspended');
})
->with('workspace')
->get();
}
/**
* Suspend a workspace due to prolonged payment failure.
*/
public function suspendWorkspace(Subscription $subscription): void
{
$workspace = $subscription->workspace;
if (! $workspace) {
return;
}
// Use EntitlementService to suspend workspace
$this->entitlements->suspendWorkspace($workspace, 'dunning');
if (config('commerce.dunning.send_notifications', true)) {
$owner = $workspace->owner();
if ($owner) {
$owner->notify(new AccountSuspended($subscription));
}
}
Log::info('Workspace suspended due to dunning', [
'subscription_id' => $subscription->id,
'workspace_id' => $workspace->id,
]);
}
/**
* Get subscriptions that should be cancelled.
*/
public function getSubscriptionsForCancellation(): Collection
{
$cancelAfterDays = config('commerce.dunning.cancel_after_days', 30);
return Subscription::query()
->where('status', 'paused')
->where('paused_at', '<=', now()->subDays($cancelAfterDays))
->with('workspace')
->get();
}
/**
* Cancel a subscription due to non-payment.
*/
public function cancelSubscription(Subscription $subscription): void
{
$workspace = $subscription->workspace;
$this->subscriptions->cancel($subscription, 'Non-payment');
$this->subscriptions->expire($subscription);
// Send cancellation notification
if (config('commerce.dunning.send_notifications', true)) {
$owner = $workspace?->owner();
if ($owner) {
$owner->notify(new SubscriptionCancelled($subscription));
}
}
Log::info('Subscription cancelled due to non-payment', [
'subscription_id' => $subscription->id,
'workspace_id' => $subscription->workspace_id,
]);
}
/**
* Calculate the initial retry date (after first failure).
*
* Respects the initial_grace_hours config to give customers
* time to fix their payment method before automated retries.
*/
public function calculateInitialRetry(): Carbon
{
$graceHours = config('commerce.dunning.initial_grace_hours', 24);
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
// Use the larger of: grace period OR first retry interval
$firstRetryDays = $retryDays[0] ?? 1;
$graceInDays = $graceHours / 24;
$daysUntilRetry = max($graceInDays, $firstRetryDays);
return now()->addHours((int) ($daysUntilRetry * 24));
}
/**
* Calculate the next retry date based on attempt count.
*
* Uses exponential backoff from config.
*/
public function calculateNextRetry(int $currentAttempts): ?Carbon
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
// Account for the initial attempt (attempt 0 used grace period)
$retryIndex = $currentAttempts;
if ($retryIndex >= count($retryDays)) {
return null; // No more retries
}
$daysUntilNext = $retryDays[$retryIndex] ?? null;
return $daysUntilNext ? now()->addDays($daysUntilNext) : null;
}
/**
* Get the dunning status for a subscription.
*
* @return array{stage: string, days_overdue: int, next_action: string, next_action_date: ?Carbon}
*/
public function getDunningStatus(Subscription $subscription): array
{
$workspace = $subscription->workspace;
$overdueInvoice = $workspace?->invoices()
->whereIn('status', ['sent', 'overdue'])
->where('auto_charge', true)
->orderBy('due_date')
->first();
if (! $overdueInvoice) {
return [
'stage' => 'none',
'days_overdue' => 0,
'next_action' => 'none',
'next_action_date' => null,
];
}
$daysOverdue = $overdueInvoice->due_date
? (int) $overdueInvoice->due_date->diffInDays(now(), false)
: 0;
$suspendDays = config('commerce.dunning.suspend_after_days', 14);
$cancelDays = config('commerce.dunning.cancel_after_days', 30);
if ($subscription->status === 'active' || $subscription->status === 'past_due') {
return [
'stage' => 'retry',
'days_overdue' => max(0, $daysOverdue),
'next_action' => $overdueInvoice->next_charge_attempt ? 'retry' : 'pause',
'next_action_date' => $overdueInvoice->next_charge_attempt,
];
}
if ($subscription->status === 'paused') {
$pausedDays = $subscription->paused_at
? (int) $subscription->paused_at->diffInDays(now(), false)
: 0;
if ($pausedDays < $suspendDays) {
return [
'stage' => 'paused',
'days_overdue' => max(0, $daysOverdue),
'next_action' => 'suspend',
'next_action_date' => $subscription->paused_at?->addDays($suspendDays),
];
}
return [
'stage' => 'suspended',
'days_overdue' => max(0, $daysOverdue),
'next_action' => 'cancel',
'next_action_date' => $subscription->paused_at?->addDays($cancelDays),
];
}
return [
'stage' => 'cancelled',
'days_overdue' => max(0, $daysOverdue),
'next_action' => 'none',
'next_action_date' => null,
];
}
/**
* Find the subscription associated with an invoice.
*/
protected function findSubscriptionForInvoice(Invoice $invoice): ?Subscription
{
if (! $invoice->workspace_id) {
return null;
}
return Subscription::query()
->where('workspace_id', $invoice->workspace_id)
->whereIn('status', ['active', 'past_due', 'paused'])
->first();
}
}

251
Services/InvoiceService.php Normal file
View file

@ -0,0 +1,251 @@
<?php
namespace Core\Commerce\Services;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Core\Commerce\Mail\InvoiceGenerated;
use Core\Commerce\Models\Invoice;
use Core\Commerce\Models\InvoiceItem;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\Payment;
use Core\Mod\Tenant\Models\Workspace;
/**
* Invoice generation and management service.
*/
class InvoiceService
{
public function __construct(
protected TaxService $taxService,
) {}
/**
* Create an invoice from an order.
*/
public function createFromOrder(Order $order, ?Payment $payment = null): Invoice
{
$amountDue = $payment ? 0 : $order->total;
// Resolve workspace ID from polymorphic orderable (Workspace or User)
$workspaceId = $order->workspace_id;
$invoice = Invoice::create([
'workspace_id' => $workspaceId,
'order_id' => $order->id,
'payment_id' => $payment?->id,
'invoice_number' => Invoice::generateInvoiceNumber(),
'status' => $payment ? 'paid' : 'pending',
'subtotal' => $order->subtotal,
'discount_amount' => $order->discount_amount ?? 0,
'tax_amount' => $order->tax_amount ?? 0,
'tax_rate' => $order->tax_rate ?? 0,
'tax_country' => $order->tax_country,
'total' => $order->total,
'amount_paid' => $payment ? $order->total : 0,
'amount_due' => $amountDue,
'currency' => $order->currency,
'billing_name' => $order->billing_name,
'billing_email' => $order->billing_email,
'billing_address' => $order->billing_address,
'issue_date' => now(),
'due_date' => now()->addDays(config('commerce.billing.invoice_due_days', 14)),
'paid_at' => $payment ? now() : null,
]);
// Copy line items from order
foreach ($order->items as $orderItem) {
InvoiceItem::create([
'invoice_id' => $invoice->id,
'order_item_id' => $orderItem->id,
'description' => $orderItem->description,
'quantity' => $orderItem->quantity,
'unit_price' => $orderItem->unit_price,
'line_total' => $orderItem->line_total,
'tax_rate' => $order->tax_rate ?? 0,
'tax_amount' => ($orderItem->line_total - $orderItem->unit_price * $orderItem->quantity),
]);
}
return $invoice;
}
/**
* Create an invoice for a subscription renewal.
*/
public function createForRenewal(
Workspace $workspace,
float $amount,
string $description,
?Payment $payment = null
): Invoice {
$taxResult = $this->taxService->calculate($workspace, $amount);
$total = $amount + $taxResult->taxAmount;
$amountDue = $payment ? 0 : $total;
$invoice = Invoice::create([
'workspace_id' => $workspace->id,
'invoice_number' => Invoice::generateInvoiceNumber(),
'status' => $payment ? 'paid' : 'pending',
'subtotal' => $amount,
'discount_amount' => 0,
'tax_amount' => $taxResult->taxAmount,
'tax_rate' => $taxResult->taxRate,
'tax_country' => $taxResult->jurisdiction,
'total' => $total,
'amount_paid' => $payment ? $total : 0,
'amount_due' => $amountDue,
'currency' => config('commerce.currency', 'GBP'),
'billing_name' => $workspace->billing_name,
'billing_email' => $workspace->billing_email,
'billing_address' => $workspace->getBillingAddress(),
'issue_date' => now(),
'due_date' => now()->addDays(config('commerce.billing.invoice_due_days', 14)),
'paid_at' => $payment ? now() : null,
'payment_id' => $payment?->id,
]);
InvoiceItem::create([
'invoice_id' => $invoice->id,
'description' => $description,
'quantity' => 1,
'unit_price' => $amount,
'line_total' => $total,
'tax_rate' => $taxResult->taxRate,
'tax_amount' => $taxResult->taxAmount,
]);
return $invoice;
}
/**
* Mark invoice as paid.
*/
public function markAsPaid(Invoice $invoice, Payment $payment): void
{
$invoice->markAsPaid($payment);
}
/**
* Mark invoice as void.
*/
public function void(Invoice $invoice): void
{
$invoice->void();
}
/**
* Generate PDF for an invoice.
*/
public function generatePdf(Invoice $invoice): string
{
$invoice->load(['workspace', 'items']);
$pdf = Pdf::loadView('commerce::pdf.invoice', [
'invoice' => $invoice,
'business' => config('commerce.tax.business'),
]);
$filename = $this->getPdfPath($invoice);
Storage::disk(config('commerce.pdf.storage_disk', 'local'))
->put($filename, $pdf->output());
// Update invoice with PDF path
$invoice->update(['pdf_path' => $filename]);
return $filename;
}
/**
* Get or generate PDF for invoice.
*/
public function getPdf(Invoice $invoice): string
{
if ($invoice->pdf_path && Storage::disk(config('commerce.pdf.storage_disk', 'local'))->exists($invoice->pdf_path)) {
return $invoice->pdf_path;
}
return $this->generatePdf($invoice);
}
/**
* Get PDF download response.
*/
public function downloadPdf(Invoice $invoice): \Symfony\Component\HttpFoundation\StreamedResponse
{
$path = $this->getPdf($invoice);
return Storage::disk(config('commerce.pdf.storage_disk', 'local'))
->download($path, "invoice-{$invoice->invoice_number}.pdf");
}
/**
* Get PDF path for an invoice.
*/
protected function getPdfPath(Invoice $invoice): string
{
$basePath = config('commerce.pdf.storage_path', 'invoices');
return "{$basePath}/{$invoice->workspace_id}/{$invoice->invoice_number}.pdf";
}
/**
* Send invoice email.
*/
public function sendEmail(Invoice $invoice): void
{
if (! config('commerce.billing.send_invoice_emails', true)) {
return;
}
// Generate PDF if not exists
$this->getPdf($invoice);
// Determine recipient email
$recipientEmail = $invoice->billing_email
?? $invoice->workspace?->billing_email
?? $invoice->workspace?->owner()?->email;
if (! $recipientEmail) {
return;
}
Mail::to($recipientEmail)->queue(new InvoiceGenerated($invoice));
}
/**
* Get invoices for a workspace.
*/
public function getForWorkspace(Workspace $workspace, int $limit = 25): \Illuminate\Pagination\LengthAwarePaginator
{
return $workspace->invoices()
->with('items')
->latest()
->paginate($limit);
}
/**
* Get unpaid invoices for a workspace.
*/
public function getUnpaidForWorkspace(Workspace $workspace): \Illuminate\Database\Eloquent\Collection
{
return $workspace->invoices()
->pending()
->where('due_date', '>=', now())
->get();
}
/**
* Get overdue invoices for a workspace.
*/
public function getOverdueForWorkspace(Workspace $workspace): \Illuminate\Database\Eloquent\Collection
{
return $workspace->invoices()
->pending()
->where('due_date', '<', now())
->get();
}
}

View file

@ -0,0 +1,488 @@
<?php
namespace Core\Commerce\Services\PaymentGateway;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\Payment;
use Core\Commerce\Models\PaymentMethod;
use Core\Commerce\Models\Subscription;
use Core\Mod\Tenant\Models\Workspace;
/**
* BTCPay Server payment gateway implementation.
*
* This is the primary payment gateway for Host UK, supporting
* Bitcoin, Litecoin, and Monero payments.
*/
class BTCPayGateway implements PaymentGatewayContract
{
protected string $baseUrl;
protected string $storeId;
protected string $apiKey;
protected string $webhookSecret;
public function __construct()
{
$this->baseUrl = rtrim(config('commerce.gateways.btcpay.url') ?? '', '/');
$this->storeId = config('commerce.gateways.btcpay.store_id') ?? '';
$this->apiKey = config('commerce.gateways.btcpay.api_key') ?? '';
$this->webhookSecret = config('commerce.gateways.btcpay.webhook_secret') ?? '';
}
public function getIdentifier(): string
{
return 'btcpay';
}
public function isEnabled(): bool
{
return config('commerce.gateways.btcpay.enabled', false)
&& $this->storeId
&& $this->apiKey;
}
// Customer Management
public function createCustomer(Workspace $workspace): string
{
// BTCPay doesn't have a customer concept like Stripe
// We generate a unique identifier for the workspace
$customerId = 'btc_cus_'.Str::ulid();
$workspace->update(['btcpay_customer_id' => $customerId]);
return $customerId;
}
public function updateCustomer(Workspace $workspace): void
{
// BTCPay doesn't store customer details
// No-op but could sync to external systems
}
// Checkout
public function createCheckoutSession(Order $order, string $successUrl, string $cancelUrl): array
{
try {
$response = $this->request('POST', "/api/v1/stores/{$this->storeId}/invoices", [
'amount' => (string) $order->total,
'currency' => $order->currency,
'metadata' => [
'order_id' => $order->id,
'order_number' => $order->order_number,
'workspace_id' => $order->workspace_id,
],
'checkout' => [
'redirectURL' => $successUrl,
'redirectAutomatically' => true,
'requiresRefundEmail' => true,
],
'receipt' => [
'enabled' => true,
'showQr' => true,
],
]);
if (empty($response['id'])) {
Log::error('BTCPay checkout: Invalid response - missing invoice ID', [
'order_id' => $order->id,
]);
throw new \RuntimeException('Invalid response from payment service.');
}
$invoiceId = $response['id'];
$checkoutUrl = "{$this->baseUrl}/i/{$invoiceId}";
// Store the BTCPay invoice ID in the order
$order->update([
'gateway_session_id' => $invoiceId,
]);
return [
'session_id' => $invoiceId,
'checkout_url' => $checkoutUrl,
];
} catch (\RuntimeException $e) {
// Re-throw RuntimeExceptions (already logged/handled)
throw $e;
} catch (\Exception $e) {
Log::error('BTCPay checkout failed', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
throw new \RuntimeException('Unable to create checkout session. Please try again or contact support.', 0, $e);
}
}
public function getCheckoutSession(string $sessionId): array
{
$response = $this->request('GET', "/api/v1/stores/{$this->storeId}/invoices/{$sessionId}");
return [
'id' => $response['id'],
'status' => $this->mapInvoiceStatus($response['status']),
'amount' => $response['amount'],
'currency' => $response['currency'],
'paid_at' => $response['status'] === 'Settled' ? now() : null,
'metadata' => $response['metadata'] ?? [],
'raw' => $response,
];
}
// Payments
public function charge(Workspace $workspace, int $amountCents, string $currency, array $metadata = []): Payment
{
// BTCPay requires creating an invoice - customer pays by visiting checkout
// This creates a "pending" invoice that awaits payment
$response = $this->request('POST', "/api/v1/stores/{$this->storeId}/invoices", [
'amount' => (string) ($amountCents / 100),
'currency' => $currency,
'metadata' => array_merge($metadata, [
'workspace_id' => $workspace->id,
]),
]);
return Payment::create([
'workspace_id' => $workspace->id,
'gateway' => 'btcpay',
'gateway_payment_id' => $response['id'],
'amount' => $amountCents / 100,
'currency' => $currency,
'status' => 'pending',
'gateway_response' => $response,
]);
}
public function chargePaymentMethod(PaymentMethod $paymentMethod, int $amountCents, string $currency, array $metadata = []): Payment
{
// BTCPay doesn't support automatic recurring charges like traditional payment processors.
// Each payment requires customer action (visiting checkout URL and sending crypto).
//
// For subscription renewals, we create a pending invoice that requires manual payment.
// The dunning system will notify the customer, but auto-retry won't work for crypto.
//
// This returns a 'pending' payment - the webhook will update it when payment arrives.
return $this->charge($paymentMethod->workspace, $amountCents, $currency, $metadata);
}
// Subscriptions - BTCPay doesn't natively support subscriptions
// We implement a manual recurring billing approach
public function createSubscription(Workspace $workspace, string $priceId, array $options = []): Subscription
{
// BTCPay doesn't have native subscription support
// We create a local subscription record and manage billing manually
$subscription = Subscription::create([
'workspace_id' => $workspace->id,
'gateway' => 'btcpay',
'gateway_subscription_id' => 'btcsub_'.Str::ulid(),
'gateway_customer_id' => $workspace->btcpay_customer_id,
'gateway_price_id' => $priceId,
'status' => 'active',
'current_period_start' => now(),
'current_period_end' => now()->addMonth(), // Default to monthly
'trial_ends_at' => isset($options['trial_days']) && $options['trial_days'] > 0
? now()->addDays($options['trial_days'])
: null,
]);
return $subscription;
}
public function updateSubscription(Subscription $subscription, array $options): Subscription
{
// Update local subscription record
$updates = [];
if (isset($options['price_id'])) {
$updates['gateway_price_id'] = $options['price_id'];
}
if (! empty($updates)) {
$subscription->update($updates);
}
return $subscription->fresh();
}
public function cancelSubscription(Subscription $subscription, bool $immediately = false): void
{
$subscription->cancel($immediately);
}
public function resumeSubscription(Subscription $subscription): void
{
$subscription->resume();
}
public function pauseSubscription(Subscription $subscription): void
{
$subscription->pause();
}
// Payment Methods - BTCPay doesn't support saved payment methods
public function createSetupSession(Workspace $workspace, string $returnUrl): array
{
// BTCPay doesn't support saving payment methods
// Return a no-op response
return [
'session_id' => null,
'setup_url' => $returnUrl,
];
}
public function attachPaymentMethod(Workspace $workspace, string $gatewayPaymentMethodId): PaymentMethod
{
// Create a placeholder payment method for crypto
return PaymentMethod::create([
'workspace_id' => $workspace->id,
'gateway' => 'btcpay',
'gateway_payment_method_id' => $gatewayPaymentMethodId,
'type' => 'crypto',
'is_default' => true,
]);
}
public function detachPaymentMethod(PaymentMethod $paymentMethod): void
{
$paymentMethod->delete();
}
public function setDefaultPaymentMethod(PaymentMethod $paymentMethod): void
{
// Unset other defaults
PaymentMethod::where('workspace_id', $paymentMethod->workspace_id)
->where('id', '!=', $paymentMethod->id)
->update(['is_default' => false]);
$paymentMethod->update(['is_default' => true]);
}
// Refunds
public function refund(Payment $payment, float $amount, ?string $reason = null): array
{
// BTCPay refunds require manual processing via the API
try {
$response = $this->request('POST', "/api/v1/stores/{$this->storeId}/invoices/{$payment->gateway_payment_id}/refund", [
'refundVariant' => 'Custom',
'customAmount' => $amount,
'customCurrency' => $payment->currency,
'description' => $reason ?? 'Refund requested',
]);
return [
'success' => true,
'refund_id' => $response['id'] ?? null,
'gateway_response' => $response,
];
} catch (\Exception $e) {
Log::warning('BTCPay refund creation failed', [
'payment_id' => $payment->id,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
// Invoices
public function getInvoice(string $gatewayInvoiceId): array
{
return $this->request('GET', "/api/v1/stores/{$this->storeId}/invoices/{$gatewayInvoiceId}");
}
public function getInvoicePdfUrl(string $gatewayInvoiceId): ?string
{
// BTCPay doesn't provide invoice PDFs - we generate our own
return null;
}
// Webhooks
public function verifyWebhookSignature(string $payload, string $signature): bool
{
if (! $this->webhookSecret) {
Log::warning('BTCPay webhook: No webhook secret configured');
return false;
}
if (empty($signature)) {
Log::warning('BTCPay webhook: Empty signature provided');
return false;
}
// BTCPay may send signature with 'sha256=' prefix
$providedSignature = $signature;
if (str_starts_with($signature, 'sha256=')) {
$providedSignature = substr($signature, 7);
}
$expectedSignature = hash_hmac('sha256', $payload, $this->webhookSecret);
if (! hash_equals($expectedSignature, $providedSignature)) {
Log::warning('BTCPay webhook: Signature mismatch');
return false;
}
return true;
}
public function parseWebhookEvent(string $payload): array
{
$data = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::warning('BTCPay webhook: Invalid JSON payload', [
'error' => json_last_error_msg(),
]);
return [
'type' => 'unknown',
'id' => null,
'status' => 'unknown',
'metadata' => [],
'raw' => [],
];
}
$type = $data['type'] ?? 'unknown';
$invoiceId = $data['invoiceId'] ?? $data['id'] ?? null;
return [
'type' => $this->mapWebhookEventType($type),
'id' => $invoiceId,
'status' => $this->mapInvoiceStatus($data['status'] ?? $data['afterExpiration'] ?? 'unknown'),
'metadata' => $data['metadata'] ?? [],
'raw' => $data,
];
}
// Tax
public function createTaxRate(string $name, float $percentage, string $country, bool $inclusive = false): string
{
// BTCPay doesn't have tax rate management - handled locally
return 'local_'.Str::slug($name);
}
// Portal
public function getPortalUrl(Workspace $workspace, string $returnUrl): ?string
{
// BTCPay doesn't have a customer portal
return null;
}
// Helper Methods
protected function request(string $method, string $endpoint, array $data = []): array
{
if (! $this->baseUrl || ! $this->apiKey) {
throw new \RuntimeException('BTCPay is not configured. Please check BTCPAY_URL and BTCPAY_API_KEY.');
}
$url = $this->baseUrl.$endpoint;
$http = Http::withHeaders([
'Authorization' => "token {$this->apiKey}",
'Content-Type' => 'application/json',
])->timeout(30);
$response = match (strtoupper($method)) {
'GET' => $http->get($url, $data),
'POST' => $http->post($url, $data),
'PUT' => $http->put($url, $data),
'DELETE' => $http->delete($url, $data),
default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"),
};
if ($response->failed()) {
// Sanitise error logging - don't expose full response body which may contain sensitive data
$errorMessage = $this->sanitiseErrorMessage($response);
Log::error('BTCPay API request failed', [
'method' => $method,
'endpoint' => $endpoint,
'status' => $response->status(),
'error' => $errorMessage,
]);
throw new \RuntimeException("BTCPay API request failed ({$response->status()}): {$errorMessage}");
}
return $response->json() ?? [];
}
/**
* Extract a safe error message from a failed response.
*/
protected function sanitiseErrorMessage(\Illuminate\Http\Client\Response $response): string
{
$json = $response->json();
// BTCPay returns structured errors
if (isset($json['message'])) {
return $json['message'];
}
if (isset($json['error'])) {
return is_string($json['error']) ? $json['error'] : 'Unknown error';
}
// Map common HTTP status codes
return match ($response->status()) {
400 => 'Bad request',
401 => 'Unauthorised - check API key',
403 => 'Forbidden - insufficient permissions',
404 => 'Resource not found',
422 => 'Validation failed',
429 => 'Rate limited',
500, 502, 503, 504 => 'Server error',
default => 'Request failed',
};
}
protected function mapInvoiceStatus(string $status): string
{
return match (strtolower($status)) {
'new' => 'pending',
'processing' => 'processing',
'expired' => 'expired',
'invalid' => 'failed',
'settled' => 'succeeded',
'complete', 'confirmed' => 'succeeded',
default => 'pending',
};
}
protected function mapWebhookEventType(string $type): string
{
return match ($type) {
'InvoiceCreated' => 'invoice.created',
'InvoiceReceivedPayment' => 'invoice.payment_received',
'InvoiceProcessing' => 'invoice.processing',
'InvoiceExpired' => 'invoice.expired',
'InvoiceSettled' => 'invoice.paid',
'InvoiceInvalid' => 'invoice.failed',
'InvoicePaymentSettled' => 'payment.settled',
default => $type,
};
}
}

View file

@ -0,0 +1,164 @@
<?php
namespace Core\Commerce\Services\PaymentGateway;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\Payment;
use Core\Commerce\Models\PaymentMethod;
use Core\Commerce\Models\Refund;
use Core\Commerce\Models\Subscription;
use Core\Mod\Tenant\Models\Workspace;
/**
* Contract for payment gateway implementations.
*
* Implemented by BTCPayGateway (primary) and StripeGateway (secondary).
*/
interface PaymentGatewayContract
{
/**
* Get the gateway identifier.
*/
public function getIdentifier(): string;
/**
* Check if the gateway is enabled.
*/
public function isEnabled(): bool;
// Customer Management
/**
* Create or retrieve a customer in the gateway.
*/
public function createCustomer(Workspace $workspace): string;
/**
* Update customer details in the gateway.
*/
public function updateCustomer(Workspace $workspace): void;
// Checkout
/**
* Create a checkout session for an order.
*
* @return array{session_id: string, checkout_url: string}
*/
public function createCheckoutSession(Order $order, string $successUrl, string $cancelUrl): array;
/**
* Retrieve checkout session status.
*/
public function getCheckoutSession(string $sessionId): array;
// Payments
/**
* Create a one-time payment charge.
*/
public function charge(Workspace $workspace, int $amountCents, string $currency, array $metadata = []): Payment;
/**
* Charge using a saved payment method.
*/
public function chargePaymentMethod(PaymentMethod $paymentMethod, int $amountCents, string $currency, array $metadata = []): Payment;
// Subscriptions
/**
* Create a subscription.
*/
public function createSubscription(Workspace $workspace, string $priceId, array $options = []): Subscription;
/**
* Update a subscription (change plan, quantity, etc.).
*/
public function updateSubscription(Subscription $subscription, array $options): Subscription;
/**
* Cancel a subscription.
*/
public function cancelSubscription(Subscription $subscription, bool $immediately = false): void;
/**
* Resume a cancelled subscription (if still in grace period).
*/
public function resumeSubscription(Subscription $subscription): void;
/**
* Pause a subscription.
*/
public function pauseSubscription(Subscription $subscription): void;
// Payment Methods
/**
* Create a setup session for adding a payment method.
*
* @return array{session_id: string, setup_url: string}
*/
public function createSetupSession(Workspace $workspace, string $returnUrl): array;
/**
* Attach a payment method to a customer.
*/
public function attachPaymentMethod(Workspace $workspace, string $gatewayPaymentMethodId): PaymentMethod;
/**
* Detach/delete a payment method.
*/
public function detachPaymentMethod(PaymentMethod $paymentMethod): void;
/**
* Set a payment method as default.
*/
public function setDefaultPaymentMethod(PaymentMethod $paymentMethod): void;
// Refunds
/**
* Process a refund through the gateway.
*
* @return array{success: bool, refund_id?: string, error?: string}
*/
public function refund(Payment $payment, float $amount, ?string $reason = null): array;
// Invoices
/**
* Retrieve an invoice from the gateway.
*/
public function getInvoice(string $gatewayInvoiceId): array;
/**
* Get invoice PDF URL.
*/
public function getInvoicePdfUrl(string $gatewayInvoiceId): ?string;
// Webhooks
/**
* Verify webhook signature.
*/
public function verifyWebhookSignature(string $payload, string $signature): bool;
/**
* Parse webhook event.
*/
public function parseWebhookEvent(string $payload): array;
// Tax
/**
* Create a tax rate in the gateway.
*/
public function createTaxRate(string $name, float $percentage, string $country, bool $inclusive = false): string;
// Portal
/**
* Get customer portal URL (if supported).
*/
public function getPortalUrl(Workspace $workspace, string $returnUrl): ?string;
}

View file

@ -0,0 +1,656 @@
<?php
namespace Core\Commerce\Services\PaymentGateway;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Log;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\Payment;
use Core\Commerce\Models\PaymentMethod;
use Core\Commerce\Models\Refund;
use Core\Commerce\Models\Subscription;
use Stripe\StripeClient;
/**
* Stripe payment gateway implementation.
*
* Secondary gateway - implemented but not exposed to users initially.
*/
class StripeGateway implements PaymentGatewayContract
{
protected ?StripeClient $stripe = null;
protected string $webhookSecret;
public function __construct()
{
$secret = config('commerce.gateways.stripe.secret');
if ($secret) {
$this->stripe = new StripeClient($secret);
}
$this->webhookSecret = config('commerce.gateways.stripe.webhook_secret') ?? '';
}
/**
* Get the Stripe client instance.
*
* @throws \RuntimeException If Stripe is not configured.
*/
protected function getStripe(): StripeClient
{
if (! $this->stripe) {
throw new \RuntimeException('Stripe is not configured. Please set STRIPE_SECRET in your environment.');
}
return $this->stripe;
}
public function getIdentifier(): string
{
return 'stripe';
}
public function isEnabled(): bool
{
return config('commerce.gateways.stripe.enabled', false)
&& $this->stripe !== null;
}
// Customer Management
public function createCustomer(Workspace $workspace): string
{
$customer = $this->getStripe()->customers->create([
'name' => $workspace->billing_name ?? $workspace->name,
'email' => $workspace->billing_email,
'address' => [
'line1' => $workspace->billing_address_line1,
'line2' => $workspace->billing_address_line2,
'city' => $workspace->billing_city,
'state' => $workspace->billing_state,
'postal_code' => $workspace->billing_postal_code,
'country' => $workspace->billing_country,
],
'metadata' => [
'workspace_id' => $workspace->id,
'workspace_slug' => $workspace->slug,
],
]);
$workspace->update(['stripe_customer_id' => $customer->id]);
return $customer->id;
}
public function updateCustomer(Workspace $workspace): void
{
if (! $workspace->stripe_customer_id) {
return;
}
$this->getStripe()->customers->update($workspace->stripe_customer_id, [
'name' => $workspace->billing_name ?? $workspace->name,
'email' => $workspace->billing_email,
'address' => [
'line1' => $workspace->billing_address_line1,
'line2' => $workspace->billing_address_line2,
'city' => $workspace->billing_city,
'state' => $workspace->billing_state,
'postal_code' => $workspace->billing_postal_code,
'country' => $workspace->billing_country,
],
]);
}
// Checkout
public function createCheckoutSession(Order $order, string $successUrl, string $cancelUrl): array
{
try {
$lineItems = $this->buildLineItems($order);
// Ensure customer exists
$customerId = $order->workspace->stripe_customer_id;
if (! $customerId) {
$customerId = $this->createCustomer($order->workspace);
}
$sessionParams = [
'customer' => $customerId,
'line_items' => $lineItems,
'mode' => $this->hasRecurringItems($order) ? 'subscription' : 'payment',
'success_url' => $successUrl.'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => $cancelUrl,
'metadata' => [
'order_id' => $order->id,
'order_number' => $order->order_number,
'workspace_id' => $order->workspace_id,
],
'automatic_tax' => ['enabled' => false], // We handle tax ourselves
'allow_promotion_codes' => false, // We handle coupons ourselves
];
// Add discount if applicable
if ($order->discount_amount > 0 && $order->coupon) {
$sessionParams['discounts'] = [['coupon' => $this->createOrderCoupon($order)]];
}
$session = $this->getStripe()->checkout->sessions->create($sessionParams);
$order->update(['gateway_session_id' => $session->id]);
return [
'session_id' => $session->id,
'checkout_url' => $session->url,
];
} catch (\Stripe\Exception\CardException $e) {
Log::warning('Stripe checkout failed: card error', [
'order_id' => $order->id,
'error' => $e->getMessage(),
'code' => $e->getStripeCode(),
]);
throw new \RuntimeException('Payment card error: '.$e->getMessage(), 0, $e);
} catch (\Stripe\Exception\RateLimitException $e) {
Log::error('Stripe checkout failed: rate limit', [
'order_id' => $order->id,
]);
throw new \RuntimeException('Payment service temporarily unavailable. Please try again.', 0, $e);
} catch (\Stripe\Exception\InvalidRequestException $e) {
Log::error('Stripe checkout failed: invalid request', [
'order_id' => $order->id,
'error' => $e->getMessage(),
'param' => $e->getStripeParam(),
]);
throw new \RuntimeException('Unable to create checkout session. Please contact support.', 0, $e);
} catch (\Stripe\Exception\AuthenticationException $e) {
Log::critical('Stripe authentication failed - check API keys', [
'order_id' => $order->id,
]);
throw new \RuntimeException('Payment service configuration error. Please contact support.', 0, $e);
} catch (\Stripe\Exception\ApiConnectionException $e) {
Log::error('Stripe checkout failed: connection error', [
'order_id' => $order->id,
]);
throw new \RuntimeException('Unable to connect to payment service. Please try again.', 0, $e);
} catch (\Stripe\Exception\ApiErrorException $e) {
Log::error('Stripe checkout failed: API error', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
throw new \RuntimeException('Payment service error. Please try again or contact support.', 0, $e);
}
}
/**
* Build line items array for Stripe checkout session.
*/
protected function buildLineItems(Order $order): array
{
$lineItems = [];
foreach ($order->items as $item) {
$lineItem = [
'price_data' => [
'currency' => strtolower($order->currency),
'product_data' => [
'name' => $item->name,
],
'unit_amount' => (int) round($item->unit_price * 100),
],
'quantity' => $item->quantity,
];
// Only add description if present (Stripe rejects empty strings)
if (! empty($item->description)) {
$lineItem['price_data']['product_data']['description'] = $item->description;
}
// Add recurring config if applicable
if ($item->billing_cycle) {
$lineItem['price_data']['recurring'] = [
'interval' => $item->billing_cycle === 'yearly' ? 'year' : 'month',
];
}
$lineItems[] = $lineItem;
}
return $lineItems;
}
/**
* Create a one-time Stripe coupon for an order discount.
*/
protected function createOrderCoupon(Order $order): string
{
$stripeCoupon = $this->getStripe()->coupons->create([
'amount_off' => (int) round($order->discount_amount * 100),
'currency' => strtolower($order->currency),
'duration' => 'once',
'name' => $order->coupon->code,
]);
return $stripeCoupon->id;
}
public function getCheckoutSession(string $sessionId): array
{
$session = $this->getStripe()->checkout->sessions->retrieve($sessionId, [
'expand' => ['payment_intent', 'subscription'],
]);
return [
'id' => $session->id,
'status' => $this->mapSessionStatus($session->status),
'amount' => $session->amount_total / 100,
'currency' => strtoupper($session->currency),
'paid_at' => $session->payment_status === 'paid' ? now() : null,
'subscription_id' => $session->subscription?->id,
'payment_intent_id' => $session->payment_intent?->id,
'metadata' => (array) $session->metadata,
'raw' => $session,
];
}
// Payments
public function charge(Workspace $workspace, int $amountCents, string $currency, array $metadata = []): Payment
{
$customerId = $workspace->stripe_customer_id;
if (! $customerId) {
$customerId = $this->createCustomer($workspace);
}
$paymentIntent = $this->getStripe()->paymentIntents->create([
'amount' => $amountCents,
'currency' => strtolower($currency),
'customer' => $customerId,
'metadata' => array_merge($metadata, ['workspace_id' => $workspace->id]),
'automatic_payment_methods' => ['enabled' => true],
]);
return Payment::create([
'workspace_id' => $workspace->id,
'gateway' => 'stripe',
'gateway_payment_id' => $paymentIntent->id,
'amount' => $amountCents / 100,
'currency' => strtoupper($currency),
'status' => $this->mapPaymentIntentStatus($paymentIntent->status),
'gateway_response' => $paymentIntent->toArray(),
]);
}
public function chargePaymentMethod(PaymentMethod $paymentMethod, int $amountCents, string $currency, array $metadata = []): Payment
{
$workspace = $paymentMethod->workspace;
$paymentIntent = $this->getStripe()->paymentIntents->create([
'amount' => $amountCents,
'currency' => strtolower($currency),
'customer' => $workspace->stripe_customer_id,
'payment_method' => $paymentMethod->gateway_payment_method_id,
'off_session' => true,
'confirm' => true,
'metadata' => array_merge($metadata, ['workspace_id' => $workspace->id]),
]);
return Payment::create([
'workspace_id' => $workspace->id,
'gateway' => 'stripe',
'gateway_payment_id' => $paymentIntent->id,
'payment_method_id' => $paymentMethod->id,
'amount' => $amountCents / 100,
'currency' => strtoupper($currency),
'status' => $this->mapPaymentIntentStatus($paymentIntent->status),
'gateway_response' => $paymentIntent->toArray(),
]);
}
// Subscriptions
public function createSubscription(Workspace $workspace, string $priceId, array $options = []): Subscription
{
$customerId = $workspace->stripe_customer_id;
if (! $customerId) {
$customerId = $this->createCustomer($workspace);
}
$params = [
'customer' => $customerId,
'items' => [['price' => $priceId]],
'metadata' => ['workspace_id' => $workspace->id],
];
if (isset($options['trial_days']) && $options['trial_days'] > 0) {
$params['trial_period_days'] = $options['trial_days'];
}
if (isset($options['coupon'])) {
$params['coupon'] = $options['coupon'];
}
$stripeSubscription = $this->getStripe()->subscriptions->create($params);
return Subscription::create([
'workspace_id' => $workspace->id,
'gateway' => 'stripe',
'gateway_subscription_id' => $stripeSubscription->id,
'gateway_customer_id' => $customerId,
'gateway_price_id' => $priceId,
'status' => $this->mapSubscriptionStatus($stripeSubscription->status),
'current_period_start' => \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_start),
'current_period_end' => \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_end),
'trial_ends_at' => $stripeSubscription->trial_end
? \Carbon\Carbon::createFromTimestamp($stripeSubscription->trial_end)
: null,
'metadata' => ['stripe_subscription' => $stripeSubscription->toArray()],
]);
}
public function updateSubscription(Subscription $subscription, array $options): Subscription
{
$params = [];
if (isset($options['price_id'])) {
$params['items'] = [
[
'id' => $this->getSubscriptionItemId($subscription),
'price' => $options['price_id'],
],
];
$params['proration_behavior'] = ($options['prorate'] ?? true)
? 'create_prorations'
: 'none';
}
if (isset($options['cancel_at_period_end'])) {
$params['cancel_at_period_end'] = $options['cancel_at_period_end'];
}
$stripeSubscription = $this->getStripe()->subscriptions->update(
$subscription->gateway_subscription_id,
$params
);
$subscription->update([
'gateway_price_id' => $options['price_id'] ?? $subscription->gateway_price_id,
'status' => $this->mapSubscriptionStatus($stripeSubscription->status),
'cancel_at_period_end' => $stripeSubscription->cancel_at_period_end,
'current_period_start' => \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_start),
'current_period_end' => \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_end),
]);
return $subscription->fresh();
}
public function cancelSubscription(Subscription $subscription, bool $immediately = false): void
{
if ($immediately) {
$this->getStripe()->subscriptions->cancel($subscription->gateway_subscription_id);
$subscription->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'ended_at' => now(),
]);
} else {
$this->getStripe()->subscriptions->update($subscription->gateway_subscription_id, [
'cancel_at_period_end' => true,
]);
$subscription->update([
'cancel_at_period_end' => true,
'cancelled_at' => now(),
]);
}
}
public function resumeSubscription(Subscription $subscription): void
{
$this->getStripe()->subscriptions->update($subscription->gateway_subscription_id, [
'cancel_at_period_end' => false,
]);
$subscription->resume();
}
public function pauseSubscription(Subscription $subscription): void
{
$this->getStripe()->subscriptions->update($subscription->gateway_subscription_id, [
'pause_collection' => ['behavior' => 'void'],
]);
$subscription->pause();
}
// Payment Methods
public function createSetupSession(Workspace $workspace, string $returnUrl): array
{
$customerId = $workspace->stripe_customer_id;
if (! $customerId) {
$customerId = $this->createCustomer($workspace);
}
$session = $this->getStripe()->checkout->sessions->create([
'customer' => $customerId,
'mode' => 'setup',
'success_url' => $returnUrl.'?setup_intent={SETUP_INTENT}',
'cancel_url' => $returnUrl,
]);
return [
'session_id' => $session->id,
'setup_url' => $session->url,
];
}
public function attachPaymentMethod(Workspace $workspace, string $gatewayPaymentMethodId): PaymentMethod
{
$stripePaymentMethod = $this->getStripe()->paymentMethods->attach($gatewayPaymentMethodId, [
'customer' => $workspace->stripe_customer_id,
]);
return PaymentMethod::create([
'workspace_id' => $workspace->id,
'gateway' => 'stripe',
'gateway_payment_method_id' => $stripePaymentMethod->id,
'type' => $stripePaymentMethod->type,
'last_four' => $stripePaymentMethod->card?->last4,
'brand' => $stripePaymentMethod->card?->brand,
'exp_month' => $stripePaymentMethod->card?->exp_month,
'exp_year' => $stripePaymentMethod->card?->exp_year,
'is_default' => false,
]);
}
public function detachPaymentMethod(PaymentMethod $paymentMethod): void
{
$this->getStripe()->paymentMethods->detach($paymentMethod->gateway_payment_method_id);
// Don't delete - the PaymentMethodService handles deactivation
}
public function setDefaultPaymentMethod(PaymentMethod $paymentMethod): void
{
$this->getStripe()->customers->update($paymentMethod->workspace->stripe_customer_id, [
'invoice_settings' => [
'default_payment_method' => $paymentMethod->gateway_payment_method_id,
],
]);
// Update local records
PaymentMethod::where('workspace_id', $paymentMethod->workspace_id)
->where('id', '!=', $paymentMethod->id)
->update(['is_default' => false]);
$paymentMethod->update(['is_default' => true]);
}
// Refunds
public function refund(Payment $payment, float $amount, ?string $reason = null): array
{
$amountCents = (int) round($amount * 100);
try {
$stripeRefund = $this->getStripe()->refunds->create([
'payment_intent' => $payment->gateway_payment_id,
'amount' => $amountCents,
'reason' => $this->mapRefundReason($reason),
]);
$refund = Refund::create([
'payment_id' => $payment->id,
'gateway_refund_id' => $stripeRefund->id,
'amount' => $amount,
'currency' => $payment->currency,
'status' => $stripeRefund->status === 'succeeded' ? 'succeeded' : 'pending',
'reason' => $reason,
'gateway_response' => $stripeRefund->toArray(),
]);
if ($stripeRefund->status === 'succeeded') {
$refund->markAsSucceeded($stripeRefund->id);
}
return [
'success' => true,
'refund_id' => $stripeRefund->id,
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
// Invoices
public function getInvoice(string $gatewayInvoiceId): array
{
$invoice = $this->getStripe()->invoices->retrieve($gatewayInvoiceId);
return $invoice->toArray();
}
public function getInvoicePdfUrl(string $gatewayInvoiceId): ?string
{
$invoice = $this->getStripe()->invoices->retrieve($gatewayInvoiceId);
return $invoice->invoice_pdf;
}
// Webhooks
public function verifyWebhookSignature(string $payload, string $signature): bool
{
try {
\Stripe\Webhook::constructEvent($payload, $signature, $this->webhookSecret);
return true;
} catch (\Exception $e) {
Log::warning('Stripe webhook signature verification failed', ['error' => $e->getMessage()]);
return false;
}
}
public function parseWebhookEvent(string $payload): array
{
$event = json_decode($payload, true);
return [
'type' => $event['type'] ?? 'unknown',
'id' => $event['data']['object']['id'] ?? null,
'object_type' => $event['data']['object']['object'] ?? null,
'metadata' => $event['data']['object']['metadata'] ?? [],
'raw' => $event,
];
}
// Tax
public function createTaxRate(string $name, float $percentage, string $country, bool $inclusive = false): string
{
$taxRate = $this->getStripe()->taxRates->create([
'display_name' => $name,
'percentage' => $percentage,
'country' => $country,
'inclusive' => $inclusive,
]);
return $taxRate->id;
}
// Portal
public function getPortalUrl(Workspace $workspace, string $returnUrl): ?string
{
if (! $workspace->stripe_customer_id) {
return null;
}
$session = $this->getStripe()->billingPortal->sessions->create([
'customer' => $workspace->stripe_customer_id,
'return_url' => $returnUrl,
]);
return $session->url;
}
// Helper Methods
protected function hasRecurringItems(Order $order): bool
{
return $order->items->contains(fn ($item) => $item->billing_cycle !== null);
}
protected function getSubscriptionItemId(Subscription $subscription): string
{
$stripeSubscription = $this->getStripe()->subscriptions->retrieve($subscription->gateway_subscription_id);
return $stripeSubscription->items->data[0]->id;
}
protected function mapSessionStatus(string $status): string
{
return match ($status) {
'complete' => 'succeeded',
'expired' => 'expired',
'open' => 'pending',
default => 'pending',
};
}
protected function mapPaymentIntentStatus(string $status): string
{
return match ($status) {
'succeeded' => 'succeeded',
'processing' => 'processing',
'requires_payment_method', 'requires_confirmation', 'requires_action' => 'pending',
'canceled' => 'failed',
default => 'pending',
};
}
protected function mapSubscriptionStatus(string $status): string
{
return match ($status) {
'active' => 'active',
'trialing' => 'trialing',
'past_due' => 'past_due',
'paused' => 'paused',
'canceled' => 'cancelled',
'incomplete', 'incomplete_expired' => 'incomplete',
default => 'active',
};
}
protected function mapRefundReason(?string $reason): string
{
return match ($reason) {
'duplicate' => 'duplicate',
'fraudulent' => 'fraudulent',
default => 'requested_by_customer',
};
}
}

Some files were not shown because too many files have changed in this diff Show more