monorepo sepration
This commit is contained in:
parent
aa53f48850
commit
a74a02f406
211 changed files with 38322 additions and 616 deletions
76
.env.example
76
.env.example
|
|
@ -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=
|
||||
62
.github/package-workflows/README.md
vendored
62
.github/package-workflows/README.md
vendored
|
|
@ -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
|
||||
[](https://github.com/host-uk/{package}/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/host-uk/{package})
|
||||
[](https://packagist.org/packages/host-uk/{package})
|
||||
[](https://packagist.org/packages/host-uk/{package})
|
||||
[](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"
|
||||
}
|
||||
}
|
||||
```
|
||||
55
.github/package-workflows/ci.yml
vendored
55
.github/package-workflows/ci.yml
vendored
|
|
@ -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 }}
|
||||
40
.github/package-workflows/release.yml
vendored
40
.github/package-workflows/release.yml
vendored
|
|
@ -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
160
Boot.php
Normal 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);
|
||||
}
|
||||
}
|
||||
179
Concerns/HasContentOverrides.php
Normal file
179
Concerns/HasContentOverrides.php
Normal 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);
|
||||
}
|
||||
}
|
||||
97
Console/CleanupExpiredOrders.php
Normal file
97
Console/CleanupExpiredOrders.php
Normal 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;
|
||||
}
|
||||
}
|
||||
29
Console/MatureReferralCommissions.php
Normal file
29
Console/MatureReferralCommissions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
205
Console/PlantSubscriberTrees.php
Normal file
205
Console/PlantSubscriberTrees.php
Normal 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
288
Console/ProcessDunning.php
Normal 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;
|
||||
}
|
||||
}
|
||||
58
Console/RefreshExchangeRates.php
Normal file
58
Console/RefreshExchangeRates.php
Normal 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;
|
||||
}
|
||||
}
|
||||
123
Console/SendRenewalReminders.php
Normal file
123
Console/SendRenewalReminders.php
Normal 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';
|
||||
}
|
||||
}
|
||||
122
Console/SyncUsageToStripe.php
Normal file
122
Console/SyncUsageToStripe.php
Normal 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
33
Contracts/Orderable.php
Normal 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;
|
||||
}
|
||||
484
Controllers/Api/CommerceController.php
Normal file
484
Controllers/Api/CommerceController.php
Normal 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(),
|
||||
]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
77
Controllers/InvoiceController.php
Normal file
77
Controllers/InvoiceController.php
Normal 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"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
154
Controllers/MatrixTrainingController.php
Normal file
154
Controllers/MatrixTrainingController.php
Normal 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");
|
||||
}
|
||||
}
|
||||
220
Controllers/Webhooks/BTCPayWebhookController.php
Normal file
220
Controllers/Webhooks/BTCPayWebhookController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
495
Controllers/Webhooks/StripeWebhookController.php
Normal file
495
Controllers/Webhooks/StripeWebhookController.php
Normal 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
77
Data/BundleItem.php
Normal 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();
|
||||
}
|
||||
}
|
||||
41
Data/CouponValidationResult.php
Normal file
41
Data/CouponValidationResult.php
Normal 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
65
Data/ParsedItem.php
Normal 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
38
Data/SkuOption.php
Normal 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
138
Data/SkuParseResult.php
Normal 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
24
Events/OrderPaid.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
17
Events/SubscriptionCancelled.php
Normal file
17
Events/SubscriptionCancelled.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
16
Events/SubscriptionCreated.php
Normal file
16
Events/SubscriptionCreated.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
17
Events/SubscriptionRenewed.php
Normal file
17
Events/SubscriptionRenewed.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
17
Events/SubscriptionUpdated.php
Normal file
17
Events/SubscriptionUpdated.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
27
Exceptions/PauseLimitExceededException.php
Normal file
27
Exceptions/PauseLimitExceededException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
99
Jobs/ProcessSubscriptionRenewal.php
Normal file
99
Jobs/ProcessSubscriptionRenewal.php
Normal 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
452
Lang/en_GB/commerce.php
Normal 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.',
|
||||
],
|
||||
];
|
||||
58
Listeners/CreateReferralCommission.php
Normal file
58
Listeners/CreateReferralCommission.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
296
Listeners/ProvisionSocialHostSubscription.php
Normal file
296
Listeners/ProvisionSocialHostSubscription.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
27
Listeners/ResetUsageOnRenewal.php
Normal file
27
Listeners/ResetUsageOnRenewal.php
Normal 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);
|
||||
}
|
||||
}
|
||||
91
Listeners/RewardAgentReferralOnSubscription.php
Normal file
91
Listeners/RewardAgentReferralOnSubscription.php
Normal 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
89
Mail/InvoiceGenerated.php
Normal 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
100
Mcp/Tools/CreateCoupon.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
72
Mcp/Tools/GetBillingStatus.php
Normal file
72
Mcp/Tools/GetBillingStatus.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
64
Mcp/Tools/ListInvoices.php
Normal file
64
Mcp/Tools/ListInvoices.php
Normal 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
114
Mcp/Tools/UpgradePlan.php
Normal 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)'),
|
||||
];
|
||||
}
|
||||
}
|
||||
55
Middleware/CommerceApiAuth.php
Normal file
55
Middleware/CommerceApiAuth.php
Normal 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);
|
||||
}
|
||||
}
|
||||
185
Middleware/CommerceMatrixGate.php
Normal file
185
Middleware/CommerceMatrixGate.php
Normal 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();
|
||||
}
|
||||
}
|
||||
243
Migrations/0001_01_01_000001_create_commerce_tables.php
Normal file
243
Migrations/0001_01_01_000001_create_commerce_tables.php
Normal 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();
|
||||
}
|
||||
};
|
||||
66
Migrations/0001_01_01_000002_create_credit_notes_table.php
Normal file
66
Migrations/0001_01_01_000002_create_credit_notes_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
125
Migrations/2026_01_26_000000_create_usage_billing_tables.php
Normal file
125
Migrations/2026_01_26_000000_create_usage_billing_tables.php
Normal 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');
|
||||
}
|
||||
};
|
||||
80
Migrations/2026_01_26_000001_create_exchange_rates_table.php
Normal file
80
Migrations/2026_01_26_000001_create_exchange_rates_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
233
Migrations/2026_01_26_000001_create_referral_tables.php
Normal file
233
Migrations/2026_01_26_000001_create_referral_tables.php
Normal 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
220
Models/BundleHash.php
Normal 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
214
Models/ContentOverride.php
Normal 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
281
Models/Coupon.php
Normal 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
62
Models/CouponUsage.php
Normal 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
256
Models/CreditNote.php
Normal 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
344
Models/Entity.php
Normal 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
224
Models/ExchangeRate.php
Normal 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
216
Models/Inventory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
217
Models/InventoryMovement.php
Normal file
217
Models/InventoryMovement.php
Normal 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
243
Models/Invoice.php
Normal 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
72
Models/InvoiceItem.php
Normal 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
391
Models/Order.php
Normal 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
93
Models/OrderItem.php
Normal 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
179
Models/Payment.php
Normal 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
142
Models/PaymentMethod.php
Normal 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
140
Models/PermissionMatrix.php
Normal 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);
|
||||
}
|
||||
}
|
||||
181
Models/PermissionRequest.php
Normal file
181
Models/PermissionRequest.php
Normal 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
526
Models/Product.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
264
Models/ProductAssignment.php
Normal file
264
Models/ProductAssignment.php
Normal 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
221
Models/ProductPrice.php
Normal 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
266
Models/Referral.php
Normal 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
216
Models/ReferralCode.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
255
Models/ReferralCommission.php
Normal file
255
Models/ReferralCommission.php
Normal 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
298
Models/ReferralPayout.php
Normal 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
147
Models/Refund.php
Normal 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
279
Models/Subscription.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
177
Models/SubscriptionUsage.php
Normal file
177
Models/SubscriptionUsage.php
Normal 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
149
Models/TaxRate.php
Normal 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
144
Models/UsageEvent.php
Normal 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
171
Models/UsageMeter.php
Normal 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
202
Models/Warehouse.php
Normal 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
280
Models/WebhookEvent.php
Normal 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));
|
||||
}
|
||||
}
|
||||
47
Notifications/AccountSuspended.php
Normal file
47
Notifications/AccountSuspended.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
53
Notifications/OrderConfirmation.php
Normal file
53
Notifications/OrderConfirmation.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
44
Notifications/PaymentFailed.php
Normal file
44
Notifications/PaymentFailed.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
55
Notifications/PaymentRetry.php
Normal file
55
Notifications/PaymentRetry.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
53
Notifications/RefundProcessed.php
Normal file
53
Notifications/RefundProcessed.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
45
Notifications/SubscriptionCancelled.php
Normal file
45
Notifications/SubscriptionCancelled.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
48
Notifications/SubscriptionPaused.php
Normal file
48
Notifications/SubscriptionPaused.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
54
Notifications/UpcomingRenewal.php
Normal file
54
Notifications/UpcomingRenewal.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
156
Services/CheckoutRateLimiter.php
Normal file
156
Services/CheckoutRateLimiter.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
628
Services/CommerceService.php
Normal file
628
Services/CommerceService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
271
Services/ContentOverrideService.php
Normal file
271
Services/ContentOverrideService.php
Normal 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
226
Services/CouponService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
286
Services/CreditNoteService.php
Normal file
286
Services/CreditNoteService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
468
Services/CurrencyService.php
Normal file
468
Services/CurrencyService.php
Normal 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
426
Services/DunningService.php
Normal 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
251
Services/InvoiceService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
488
Services/PaymentGateway/BTCPayGateway.php
Normal file
488
Services/PaymentGateway/BTCPayGateway.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
164
Services/PaymentGateway/PaymentGatewayContract.php
Normal file
164
Services/PaymentGateway/PaymentGatewayContract.php
Normal 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;
|
||||
}
|
||||
656
Services/PaymentGateway/StripeGateway.php
Normal file
656
Services/PaymentGateway/StripeGateway.php
Normal 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
Loading…
Add table
Reference in a new issue