From a74a02f4060dce3098f41a3021798389dd1e9544 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 27 Jan 2026 00:24:22 +0000 Subject: [PATCH] monorepo sepration --- .env.example | 76 -- .github/package-workflows/README.md | 62 - .github/package-workflows/ci.yml | 55 - .github/package-workflows/release.yml | 40 - Boot.php | 160 +++ Concerns/HasContentOverrides.php | 179 +++ Console/CleanupExpiredOrders.php | 97 ++ Console/MatureReferralCommissions.php | 29 + Console/PlantSubscriberTrees.php | 205 ++++ Console/ProcessDunning.php | 288 +++++ Console/RefreshExchangeRates.php | 58 + Console/SendRenewalReminders.php | 123 ++ Console/SyncUsageToStripe.php | 122 ++ Contracts/Orderable.php | 33 + Controllers/Api/CommerceController.php | 484 ++++++++ Controllers/InvoiceController.php | 77 ++ Controllers/MatrixTrainingController.php | 154 +++ .../Webhooks/BTCPayWebhookController.php | 220 ++++ .../Webhooks/StripeWebhookController.php | 495 ++++++++ Data/BundleItem.php | 77 ++ Data/CouponValidationResult.php | 41 + Data/ParsedItem.php | 65 ++ Data/SkuOption.php | 38 + Data/SkuParseResult.php | 138 +++ Events/OrderPaid.php | 24 + Events/SubscriptionCancelled.php | 17 + Events/SubscriptionCreated.php | 16 + Events/SubscriptionRenewed.php | 17 + Events/SubscriptionUpdated.php | 17 + Exceptions/PauseLimitExceededException.php | 27 + Jobs/ProcessSubscriptionRenewal.php | 99 ++ Lang/en_GB/commerce.php | 452 ++++++++ Listeners/CreateReferralCommission.php | 58 + Listeners/ProvisionSocialHostSubscription.php | 296 +++++ Listeners/ResetUsageOnRenewal.php | 27 + .../RewardAgentReferralOnSubscription.php | 91 ++ Mail/InvoiceGenerated.php | 89 ++ Mcp/Tools/CreateCoupon.php | 100 ++ Mcp/Tools/GetBillingStatus.php | 72 ++ Mcp/Tools/ListInvoices.php | 64 ++ Mcp/Tools/UpgradePlan.php | 114 ++ Middleware/CommerceApiAuth.php | 55 + Middleware/CommerceMatrixGate.php | 185 +++ ...01_01_01_000001_create_commerce_tables.php | 243 ++++ ...01_01_000002_create_credit_notes_table.php | 66 ++ ...01_000003_create_payment_methods_table.php | 53 + ..._26_000000_create_usage_billing_tables.php | 125 ++ ..._26_000001_create_exchange_rates_table.php | 80 ++ ...26_01_26_000001_create_referral_tables.php | 233 ++++ Models/BundleHash.php | 220 ++++ Models/ContentOverride.php | 214 ++++ Models/Coupon.php | 281 +++++ Models/CouponUsage.php | 62 + Models/CreditNote.php | 256 +++++ Models/Entity.php | 344 ++++++ Models/ExchangeRate.php | 224 ++++ Models/Inventory.php | 216 ++++ Models/InventoryMovement.php | 217 ++++ Models/Invoice.php | 243 ++++ Models/InvoiceItem.php | 72 ++ Models/Order.php | 391 +++++++ Models/OrderItem.php | 93 ++ Models/Payment.php | 179 +++ Models/PaymentMethod.php | 142 +++ Models/PermissionMatrix.php | 140 +++ Models/PermissionRequest.php | 181 +++ Models/Product.php | 526 +++++++++ Models/ProductAssignment.php | 264 +++++ Models/ProductPrice.php | 221 ++++ Models/Referral.php | 266 +++++ Models/ReferralCode.php | 216 ++++ Models/ReferralCommission.php | 255 +++++ Models/ReferralPayout.php | 298 +++++ Models/Refund.php | 147 +++ Models/Subscription.php | 279 +++++ Models/SubscriptionUsage.php | 177 +++ Models/TaxRate.php | 149 +++ Models/UsageEvent.php | 144 +++ Models/UsageMeter.php | 171 +++ Models/Warehouse.php | 202 ++++ Models/WebhookEvent.php | 280 +++++ Notifications/AccountSuspended.php | 47 + Notifications/OrderConfirmation.php | 53 + Notifications/PaymentFailed.php | 44 + Notifications/PaymentRetry.php | 55 + Notifications/RefundProcessed.php | 53 + Notifications/SubscriptionCancelled.php | 45 + Notifications/SubscriptionPaused.php | 48 + Notifications/UpcomingRenewal.php | 54 + Services/CheckoutRateLimiter.php | 156 +++ Services/CommerceService.php | 628 +++++++++++ Services/ContentOverrideService.php | 271 +++++ Services/CouponService.php | 226 ++++ Services/CreditNoteService.php | 286 +++++ Services/CurrencyService.php | 468 ++++++++ Services/DunningService.php | 426 +++++++ Services/InvoiceService.php | 251 +++++ Services/PaymentGateway/BTCPayGateway.php | 488 ++++++++ .../PaymentGateway/PaymentGatewayContract.php | 164 +++ Services/PaymentGateway/StripeGateway.php | 656 +++++++++++ Services/PaymentMethodService.php | 335 ++++++ Services/PermissionLockedException.php | 12 + Services/PermissionMatrixService.php | 415 +++++++ Services/PermissionResult.php | 107 ++ Services/ProductCatalogService.php | 397 +++++++ Services/ProrationResult.php | 110 ++ Services/ReferralService.php | 580 ++++++++++ Services/RefundService.php | 181 +++ Services/SkuBuilderService.php | 182 +++ Services/SkuLineageService.php | 284 +++++ Services/SkuParserService.php | 192 ++++ Services/SubscriptionService.php | 456 ++++++++ Services/TaxService.php | 386 +++++++ Services/UsageBillingService.php | 520 +++++++++ Services/WarehouseService.php | 391 +++++++ Services/WebhookLogger.php | 322 ++++++ View/Blade/admin/coupon-manager.blade.php | 252 +++++ .../Blade/admin/credit-note-manager.blade.php | 234 ++++ View/Blade/admin/dashboard.blade.php | 44 + View/Blade/admin/entity-manager.blade.php | 299 +++++ View/Blade/admin/order-manager.blade.php | 188 ++++ .../Blade/admin/partials/entity-row.blade.php | 87 ++ .../admin/permission-matrix-manager.blade.php | 396 +++++++ View/Blade/admin/product-manager.blade.php | 125 ++ View/Blade/admin/referral-manager.blade.php | 453 ++++++++ .../admin/subscription-manager.blade.php | 270 +++++ View/Blade/emails/invoice-generated.blade.php | 59 + View/Blade/pdf/invoice.blade.php | 479 ++++++++ View/Blade/web/change-plan.blade.php | 215 ++++ .../web/checkout/checkout-cancel.blade.php | 46 + .../web/checkout/checkout-page.blade.php | 481 ++++++++ .../web/checkout/checkout-success.blade.php | 217 ++++ .../components/currency-selector.blade.php | 116 ++ View/Blade/web/dashboard.blade.php | 261 +++++ View/Blade/web/invoices.blade.php | 162 +++ View/Blade/web/matrix/pending.blade.php | 101 ++ View/Blade/web/matrix/train-prompt.blade.php | 125 ++ View/Blade/web/payment-methods.blade.php | 172 +++ .../payment_platforms/btcpayserver.blade.php | 17 + View/Blade/web/referral-dashboard.blade.php | 358 ++++++ View/Blade/web/subscription.blade.php | 231 ++++ View/Blade/web/usage-dashboard.blade.php | 216 ++++ View/Modal/Admin/CouponManager.php | 608 ++++++++++ View/Modal/Admin/CreditNoteManager.php | 427 +++++++ View/Modal/Admin/Dashboard.php | 121 ++ View/Modal/Admin/EntityManager.php | 316 ++++++ View/Modal/Admin/OrderManager.php | 376 +++++++ View/Modal/Admin/PermissionMatrixManager.php | 270 +++++ View/Modal/Admin/ProductManager.php | 423 +++++++ View/Modal/Admin/ReferralManager.php | 415 +++++++ View/Modal/Admin/SubscriptionManager.php | 425 +++++++ View/Modal/Web/ChangePlan.php | 213 ++++ View/Modal/Web/CheckoutCancel.php | 55 + View/Modal/Web/CheckoutPage.php | 630 +++++++++++ View/Modal/Web/CheckoutSuccess.php | 194 ++++ View/Modal/Web/CurrencySelector.php | 110 ++ View/Modal/Web/Dashboard.php | 112 ++ View/Modal/Web/Invoices.php | 67 ++ View/Modal/Web/PaymentMethods.php | 239 ++++ View/Modal/Web/ReferralDashboard.php | 180 +++ View/Modal/Web/Subscription.php | 177 +++ View/Modal/Web/UsageDashboard.php | 160 +++ app/Http/Controllers/.gitkeep | 0 app/Mod/.gitkeep | 0 app/Models/.gitkeep | 0 app/Providers/AppServiceProvider.php | 24 - artisan | 15 - bootstrap/app.php | 26 - bootstrap/cache/.gitignore | 2 - bootstrap/providers.php | 5 - composer.json | 68 +- config.php | 454 ++++++++ config/core.php | 24 - database/factories/.gitkeep | 0 database/seeders/DatabaseSeeder.php | 16 - package.json | 16 - phpunit.xml | 33 - postcss.config.js | 6 - public/.htaccess | 21 - public/index.php | 17 - public/robots.txt | 2 - resources/css/app.css | 3 - resources/js/app.js | 1 - resources/js/bootstrap.js | 3 - resources/views/welcome.blade.php | 65 -- routes/admin.php | 34 + routes/api.php | 88 +- routes/web.php | 33 +- storage/app/.gitignore | 3 - storage/app/public/.gitignore | 2 - storage/framework/.gitignore | 9 - storage/framework/cache/.gitignore | 3 - storage/framework/cache/data/.gitignore | 2 - storage/framework/sessions/.gitignore | 2 - storage/framework/testing/.gitignore | 2 - storage/framework/views/.gitignore | 2 - storage/logs/.gitignore | 2 - tailwind.config.js | 11 - tests/Feature/CheckoutFlowTest.php | 340 ++++++ tests/Feature/CompoundSkuTest.php | 236 ++++ tests/Feature/ContentOverrideServiceTest.php | 319 ++++++ tests/Feature/CouponServiceTest.php | 361 ++++++ tests/Feature/CurrencyServiceTest.php | 197 ++++ tests/Feature/DunningServiceTest.php | 561 +++++++++ .../ProcessSubscriptionRenewalTest.php | 224 ++++ tests/Feature/RefundServiceTest.php | 278 +++++ tests/Feature/SubscriptionServiceTest.php | 551 +++++++++ tests/Feature/TaxServiceTest.php | 239 ++++ tests/Feature/WebhookTest.php | 1001 +++++++++++++++++ tests/UseCase/AdminCrudBasic.php | 209 ++++ vite.config.js | 11 - 211 files changed, 38322 insertions(+), 616 deletions(-) delete mode 100644 .env.example delete mode 100644 .github/package-workflows/README.md delete mode 100644 .github/package-workflows/ci.yml delete mode 100644 .github/package-workflows/release.yml create mode 100644 Boot.php create mode 100644 Concerns/HasContentOverrides.php create mode 100644 Console/CleanupExpiredOrders.php create mode 100644 Console/MatureReferralCommissions.php create mode 100644 Console/PlantSubscriberTrees.php create mode 100644 Console/ProcessDunning.php create mode 100644 Console/RefreshExchangeRates.php create mode 100644 Console/SendRenewalReminders.php create mode 100644 Console/SyncUsageToStripe.php create mode 100644 Contracts/Orderable.php create mode 100644 Controllers/Api/CommerceController.php create mode 100644 Controllers/InvoiceController.php create mode 100644 Controllers/MatrixTrainingController.php create mode 100644 Controllers/Webhooks/BTCPayWebhookController.php create mode 100644 Controllers/Webhooks/StripeWebhookController.php create mode 100644 Data/BundleItem.php create mode 100644 Data/CouponValidationResult.php create mode 100644 Data/ParsedItem.php create mode 100644 Data/SkuOption.php create mode 100644 Data/SkuParseResult.php create mode 100644 Events/OrderPaid.php create mode 100644 Events/SubscriptionCancelled.php create mode 100644 Events/SubscriptionCreated.php create mode 100644 Events/SubscriptionRenewed.php create mode 100644 Events/SubscriptionUpdated.php create mode 100644 Exceptions/PauseLimitExceededException.php create mode 100644 Jobs/ProcessSubscriptionRenewal.php create mode 100644 Lang/en_GB/commerce.php create mode 100644 Listeners/CreateReferralCommission.php create mode 100644 Listeners/ProvisionSocialHostSubscription.php create mode 100644 Listeners/ResetUsageOnRenewal.php create mode 100644 Listeners/RewardAgentReferralOnSubscription.php create mode 100644 Mail/InvoiceGenerated.php create mode 100644 Mcp/Tools/CreateCoupon.php create mode 100644 Mcp/Tools/GetBillingStatus.php create mode 100644 Mcp/Tools/ListInvoices.php create mode 100644 Mcp/Tools/UpgradePlan.php create mode 100644 Middleware/CommerceApiAuth.php create mode 100644 Middleware/CommerceMatrixGate.php create mode 100644 Migrations/0001_01_01_000001_create_commerce_tables.php create mode 100644 Migrations/0001_01_01_000002_create_credit_notes_table.php create mode 100644 Migrations/0001_01_01_000003_create_payment_methods_table.php create mode 100644 Migrations/2026_01_26_000000_create_usage_billing_tables.php create mode 100644 Migrations/2026_01_26_000001_create_exchange_rates_table.php create mode 100644 Migrations/2026_01_26_000001_create_referral_tables.php create mode 100644 Models/BundleHash.php create mode 100644 Models/ContentOverride.php create mode 100644 Models/Coupon.php create mode 100644 Models/CouponUsage.php create mode 100644 Models/CreditNote.php create mode 100644 Models/Entity.php create mode 100644 Models/ExchangeRate.php create mode 100644 Models/Inventory.php create mode 100644 Models/InventoryMovement.php create mode 100644 Models/Invoice.php create mode 100644 Models/InvoiceItem.php create mode 100644 Models/Order.php create mode 100644 Models/OrderItem.php create mode 100644 Models/Payment.php create mode 100644 Models/PaymentMethod.php create mode 100644 Models/PermissionMatrix.php create mode 100644 Models/PermissionRequest.php create mode 100644 Models/Product.php create mode 100644 Models/ProductAssignment.php create mode 100644 Models/ProductPrice.php create mode 100644 Models/Referral.php create mode 100644 Models/ReferralCode.php create mode 100644 Models/ReferralCommission.php create mode 100644 Models/ReferralPayout.php create mode 100644 Models/Refund.php create mode 100644 Models/Subscription.php create mode 100644 Models/SubscriptionUsage.php create mode 100644 Models/TaxRate.php create mode 100644 Models/UsageEvent.php create mode 100644 Models/UsageMeter.php create mode 100644 Models/Warehouse.php create mode 100644 Models/WebhookEvent.php create mode 100644 Notifications/AccountSuspended.php create mode 100644 Notifications/OrderConfirmation.php create mode 100644 Notifications/PaymentFailed.php create mode 100644 Notifications/PaymentRetry.php create mode 100644 Notifications/RefundProcessed.php create mode 100644 Notifications/SubscriptionCancelled.php create mode 100644 Notifications/SubscriptionPaused.php create mode 100644 Notifications/UpcomingRenewal.php create mode 100644 Services/CheckoutRateLimiter.php create mode 100644 Services/CommerceService.php create mode 100644 Services/ContentOverrideService.php create mode 100644 Services/CouponService.php create mode 100644 Services/CreditNoteService.php create mode 100644 Services/CurrencyService.php create mode 100644 Services/DunningService.php create mode 100644 Services/InvoiceService.php create mode 100644 Services/PaymentGateway/BTCPayGateway.php create mode 100644 Services/PaymentGateway/PaymentGatewayContract.php create mode 100644 Services/PaymentGateway/StripeGateway.php create mode 100644 Services/PaymentMethodService.php create mode 100644 Services/PermissionLockedException.php create mode 100644 Services/PermissionMatrixService.php create mode 100644 Services/PermissionResult.php create mode 100644 Services/ProductCatalogService.php create mode 100644 Services/ProrationResult.php create mode 100644 Services/ReferralService.php create mode 100644 Services/RefundService.php create mode 100644 Services/SkuBuilderService.php create mode 100644 Services/SkuLineageService.php create mode 100644 Services/SkuParserService.php create mode 100644 Services/SubscriptionService.php create mode 100644 Services/TaxService.php create mode 100644 Services/UsageBillingService.php create mode 100644 Services/WarehouseService.php create mode 100644 Services/WebhookLogger.php create mode 100644 View/Blade/admin/coupon-manager.blade.php create mode 100644 View/Blade/admin/credit-note-manager.blade.php create mode 100644 View/Blade/admin/dashboard.blade.php create mode 100644 View/Blade/admin/entity-manager.blade.php create mode 100644 View/Blade/admin/order-manager.blade.php create mode 100644 View/Blade/admin/partials/entity-row.blade.php create mode 100644 View/Blade/admin/permission-matrix-manager.blade.php create mode 100644 View/Blade/admin/product-manager.blade.php create mode 100644 View/Blade/admin/referral-manager.blade.php create mode 100644 View/Blade/admin/subscription-manager.blade.php create mode 100644 View/Blade/emails/invoice-generated.blade.php create mode 100644 View/Blade/pdf/invoice.blade.php create mode 100644 View/Blade/web/change-plan.blade.php create mode 100644 View/Blade/web/checkout/checkout-cancel.blade.php create mode 100644 View/Blade/web/checkout/checkout-page.blade.php create mode 100644 View/Blade/web/checkout/checkout-success.blade.php create mode 100644 View/Blade/web/components/currency-selector.blade.php create mode 100644 View/Blade/web/dashboard.blade.php create mode 100644 View/Blade/web/invoices.blade.php create mode 100644 View/Blade/web/matrix/pending.blade.php create mode 100644 View/Blade/web/matrix/train-prompt.blade.php create mode 100644 View/Blade/web/payment-methods.blade.php create mode 100644 View/Blade/web/payment_platforms/btcpayserver.blade.php create mode 100644 View/Blade/web/referral-dashboard.blade.php create mode 100644 View/Blade/web/subscription.blade.php create mode 100644 View/Blade/web/usage-dashboard.blade.php create mode 100644 View/Modal/Admin/CouponManager.php create mode 100644 View/Modal/Admin/CreditNoteManager.php create mode 100644 View/Modal/Admin/Dashboard.php create mode 100644 View/Modal/Admin/EntityManager.php create mode 100644 View/Modal/Admin/OrderManager.php create mode 100644 View/Modal/Admin/PermissionMatrixManager.php create mode 100644 View/Modal/Admin/ProductManager.php create mode 100644 View/Modal/Admin/ReferralManager.php create mode 100644 View/Modal/Admin/SubscriptionManager.php create mode 100644 View/Modal/Web/ChangePlan.php create mode 100644 View/Modal/Web/CheckoutCancel.php create mode 100644 View/Modal/Web/CheckoutPage.php create mode 100644 View/Modal/Web/CheckoutSuccess.php create mode 100644 View/Modal/Web/CurrencySelector.php create mode 100644 View/Modal/Web/Dashboard.php create mode 100644 View/Modal/Web/Invoices.php create mode 100644 View/Modal/Web/PaymentMethods.php create mode 100644 View/Modal/Web/ReferralDashboard.php create mode 100644 View/Modal/Web/Subscription.php create mode 100644 View/Modal/Web/UsageDashboard.php delete mode 100644 app/Http/Controllers/.gitkeep delete mode 100644 app/Mod/.gitkeep delete mode 100644 app/Models/.gitkeep delete mode 100644 app/Providers/AppServiceProvider.php delete mode 100755 artisan delete mode 100644 bootstrap/app.php delete mode 100644 bootstrap/cache/.gitignore delete mode 100644 bootstrap/providers.php create mode 100644 config.php delete mode 100644 config/core.php delete mode 100644 database/factories/.gitkeep delete mode 100644 database/seeders/DatabaseSeeder.php delete mode 100644 package.json delete mode 100644 phpunit.xml delete mode 100644 postcss.config.js delete mode 100644 public/.htaccess delete mode 100644 public/index.php delete mode 100644 public/robots.txt delete mode 100644 resources/css/app.css delete mode 100644 resources/js/app.js delete mode 100644 resources/js/bootstrap.js delete mode 100644 resources/views/welcome.blade.php create mode 100644 routes/admin.php delete mode 100644 storage/app/.gitignore delete mode 100644 storage/app/public/.gitignore delete mode 100644 storage/framework/.gitignore delete mode 100644 storage/framework/cache/.gitignore delete mode 100644 storage/framework/cache/data/.gitignore delete mode 100644 storage/framework/sessions/.gitignore delete mode 100644 storage/framework/testing/.gitignore delete mode 100644 storage/framework/views/.gitignore delete mode 100644 storage/logs/.gitignore delete mode 100644 tailwind.config.js create mode 100644 tests/Feature/CheckoutFlowTest.php create mode 100644 tests/Feature/CompoundSkuTest.php create mode 100644 tests/Feature/ContentOverrideServiceTest.php create mode 100644 tests/Feature/CouponServiceTest.php create mode 100644 tests/Feature/CurrencyServiceTest.php create mode 100644 tests/Feature/DunningServiceTest.php create mode 100644 tests/Feature/ProcessSubscriptionRenewalTest.php create mode 100644 tests/Feature/RefundServiceTest.php create mode 100644 tests/Feature/SubscriptionServiceTest.php create mode 100644 tests/Feature/TaxServiceTest.php create mode 100644 tests/Feature/WebhookTest.php create mode 100644 tests/UseCase/AdminCrudBasic.php delete mode 100644 vite.config.js diff --git a/.env.example b/.env.example deleted file mode 100644 index 01b4da4..0000000 --- a/.env.example +++ /dev/null @@ -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= diff --git a/.github/package-workflows/README.md b/.github/package-workflows/README.md deleted file mode 100644 index 999966f..0000000 --- a/.github/package-workflows/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Package Workflows - -These workflow templates are for **library packages** (host-uk/core, host-uk/core-api, etc.), not application projects. - -## README Badges - -Add these badges to your package README (replace `{package}` with your package name): - -```markdown -[![CI](https://github.com/host-uk/{package}/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/{package}/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/host-uk/{package}/graph/badge.svg)](https://codecov.io/gh/host-uk/{package}) -[![Latest Version](https://img.shields.io/packagist/v/host-uk/{package})](https://packagist.org/packages/host-uk/{package}) -[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/{package})](https://packagist.org/packages/host-uk/{package}) -[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE) -``` - -## Usage - -Copy the relevant workflows to your library's `.github/workflows/` directory: - -```bash -# In your library repo -mkdir -p .github/workflows -cp path/to/core-template/.github/package-workflows/ci.yml .github/workflows/ -cp path/to/core-template/.github/package-workflows/release.yml .github/workflows/ -``` - -## Workflows - -### ci.yml -- Runs on push/PR to main -- Tests against PHP 8.2, 8.3, 8.4 -- Tests against Laravel 11 and 12 -- Runs Pint linting -- Runs Pest tests - -### release.yml -- Triggers on version tags (v*) -- Generates changelog using git-cliff -- Creates GitHub release - -## Requirements - -For these workflows to work, your package needs: - -1. **cliff.toml** - Copy from core-template root -2. **Pest configured** - `composer require pestphp/pest --dev` -3. **Pint configured** - `composer require laravel/pint --dev` -4. **CODECOV_TOKEN** - Add to repo secrets for coverage uploads -5. **FUNDING.yml** - Copy `.github/FUNDING.yml` for sponsor button - -## Recommended composer.json scripts - -```json -{ - "scripts": { - "lint": "pint", - "test": "pest", - "test:coverage": "pest --coverage" - } -} -``` diff --git a/.github/package-workflows/ci.yml b/.github/package-workflows/ci.yml deleted file mode 100644 index 7c5f722..0000000 --- a/.github/package-workflows/ci.yml +++ /dev/null @@ -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 }} diff --git a/.github/package-workflows/release.yml b/.github/package-workflows/release.yml deleted file mode 100644 index 035294e..0000000 --- a/.github/package-workflows/release.yml +++ /dev/null @@ -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 }} diff --git a/Boot.php b/Boot.php new file mode 100644 index 0000000..72230d7 --- /dev/null +++ b/Boot.php @@ -0,0 +1,160 @@ + + */ + 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); + } +} diff --git a/Concerns/HasContentOverrides.php b/Concerns/HasContentOverrides.php new file mode 100644 index 0000000..f492536 --- /dev/null +++ b/Concerns/HasContentOverrides.php @@ -0,0 +1,179 @@ +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); + } +} diff --git a/Console/CleanupExpiredOrders.php b/Console/CleanupExpiredOrders.php new file mode 100644 index 0000000..a541589 --- /dev/null +++ b/Console/CleanupExpiredOrders.php @@ -0,0 +1,97 @@ +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; + } +} diff --git a/Console/MatureReferralCommissions.php b/Console/MatureReferralCommissions.php new file mode 100644 index 0000000..e4d7eb1 --- /dev/null +++ b/Console/MatureReferralCommissions.php @@ -0,0 +1,29 @@ +matureReadyCommissions(); + + $this->info("Matured {$count} commissions."); + + return self::SUCCESS; + } +} diff --git a/Console/PlantSubscriberTrees.php b/Console/PlantSubscriberTrees.php new file mode 100644 index 0000000..080c62e --- /dev/null +++ b/Console/PlantSubscriberTrees.php @@ -0,0 +1,205 @@ +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'; + } +} diff --git a/Console/ProcessDunning.php b/Console/ProcessDunning.php new file mode 100644 index 0000000..43f4b25 --- /dev/null +++ b/Console/ProcessDunning.php @@ -0,0 +1,288 @@ +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; + } +} diff --git a/Console/RefreshExchangeRates.php b/Console/RefreshExchangeRates.php new file mode 100644 index 0000000..c228a1d --- /dev/null +++ b/Console/RefreshExchangeRates.php @@ -0,0 +1,58 @@ +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; + } +} diff --git a/Console/SendRenewalReminders.php b/Console/SendRenewalReminders.php new file mode 100644 index 0000000..93492a5 --- /dev/null +++ b/Console/SendRenewalReminders.php @@ -0,0 +1,123 @@ +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'; + } +} diff --git a/Console/SyncUsageToStripe.php b/Console/SyncUsageToStripe.php new file mode 100644 index 0000000..db1fb35 --- /dev/null +++ b/Console/SyncUsageToStripe.php @@ -0,0 +1,122 @@ +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; + } +} diff --git a/Contracts/Orderable.php b/Contracts/Orderable.php new file mode 100644 index 0000000..c20ade2 --- /dev/null +++ b/Contracts/Orderable.php @@ -0,0 +1,33 @@ +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(), + ]), + ], + ]); + } +} diff --git a/Controllers/InvoiceController.php b/Controllers/InvoiceController.php new file mode 100644 index 0000000..b7c7a52 --- /dev/null +++ b/Controllers/InvoiceController.php @@ -0,0 +1,77 @@ +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"', + ]); + } +} diff --git a/Controllers/MatrixTrainingController.php b/Controllers/MatrixTrainingController.php new file mode 100644 index 0000000..ef9ec0c --- /dev/null +++ b/Controllers/MatrixTrainingController.php @@ -0,0 +1,154 @@ +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"); + } +} diff --git a/Controllers/Webhooks/BTCPayWebhookController.php b/Controllers/Webhooks/BTCPayWebhookController.php new file mode 100644 index 0000000..27a3e3a --- /dev/null +++ b/Controllers/Webhooks/BTCPayWebhookController.php @@ -0,0 +1,220 @@ +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)); + } + } +} diff --git a/Controllers/Webhooks/StripeWebhookController.php b/Controllers/Webhooks/StripeWebhookController.php new file mode 100644 index 0000000..733bae1 --- /dev/null +++ b/Controllers/Webhooks/StripeWebhookController.php @@ -0,0 +1,495 @@ +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)); + } + } +} diff --git a/Data/BundleItem.php b/Data/BundleItem.php new file mode 100644 index 0000000..6b0983a --- /dev/null +++ b/Data/BundleItem.php @@ -0,0 +1,77 @@ + $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(); + } +} diff --git a/Data/CouponValidationResult.php b/Data/CouponValidationResult.php new file mode 100644 index 0000000..5e26891 --- /dev/null +++ b/Data/CouponValidationResult.php @@ -0,0 +1,41 @@ +isValid; + } + + public function getDiscount(float $amount): float + { + if (! $this->isValid || ! $this->coupon) { + return 0; + } + + return $this->coupon->calculateDiscount($amount); + } +} diff --git a/Data/ParsedItem.php b/Data/ParsedItem.php new file mode 100644 index 0000000..968ce38 --- /dev/null +++ b/Data/ParsedItem.php @@ -0,0 +1,65 @@ + $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(); + } +} diff --git a/Data/SkuOption.php b/Data/SkuOption.php new file mode 100644 index 0000000..c36ed6b --- /dev/null +++ b/Data/SkuOption.php @@ -0,0 +1,38 @@ +code}~{$this->value}"; + + if ($this->quantity > 1) { + $str .= "*{$this->quantity}"; + } + + return $str; + } + + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/Data/SkuParseResult.php b/Data/SkuParseResult.php new file mode 100644 index 0000000..ad0c980 --- /dev/null +++ b/Data/SkuParseResult.php @@ -0,0 +1,138 @@ + $items + */ + public function __construct( + public array $items, + ) {} + + /** + * Get all items as a collection. + * + * @return Collection + */ + public function collect(): Collection + { + return collect($this->items); + } + + /** + * Get only single items (not bundles). + * + * @return Collection + */ + public function singles(): Collection + { + return $this->collect()->filter( + fn ($item) => $item instanceof ParsedItem + ); + } + + /** + * Get only bundles. + * + * @return Collection + */ + 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(); + } +} diff --git a/Events/OrderPaid.php b/Events/OrderPaid.php new file mode 100644 index 0000000..f6f77f5 --- /dev/null +++ b/Events/OrderPaid.php @@ -0,0 +1,24 @@ +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)); + } +} diff --git a/Lang/en_GB/commerce.php b/Lang/en_GB/commerce.php new file mode 100644 index 0000000..5bafb7c --- /dev/null +++ b/Lang/en_GB/commerce.php @@ -0,0 +1,452 @@ + [ + '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.', + ], +]; diff --git a/Listeners/CreateReferralCommission.php b/Listeners/CreateReferralCommission.php new file mode 100644 index 0000000..f889752 --- /dev/null +++ b/Listeners/CreateReferralCommission.php @@ -0,0 +1,58 @@ +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(), + ]); + } + } +} diff --git a/Listeners/ProvisionSocialHostSubscription.php b/Listeners/ProvisionSocialHostSubscription.php new file mode 100644 index 0000000..78da5a5 --- /dev/null +++ b/Listeners/ProvisionSocialHostSubscription.php @@ -0,0 +1,296 @@ +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', + ]; + } +} diff --git a/Listeners/ResetUsageOnRenewal.php b/Listeners/ResetUsageOnRenewal.php new file mode 100644 index 0000000..c4326bc --- /dev/null +++ b/Listeners/ResetUsageOnRenewal.php @@ -0,0 +1,27 @@ +usageBilling->onPeriodReset($event->subscription); + } +} diff --git a/Listeners/RewardAgentReferralOnSubscription.php b/Listeners/RewardAgentReferralOnSubscription.php new file mode 100644 index 0000000..b240bb0 --- /dev/null +++ b/Listeners/RewardAgentReferralOnSubscription.php @@ -0,0 +1,91 @@ +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, + ]); + } +} diff --git a/Mail/InvoiceGenerated.php b/Mail/InvoiceGenerated.php new file mode 100644 index 0000000..347207e --- /dev/null +++ b/Mail/InvoiceGenerated.php @@ -0,0 +1,89 @@ +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 + */ + 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'), + ]; + } +} diff --git a/Mcp/Tools/CreateCoupon.php b/Mcp/Tools/CreateCoupon.php new file mode 100644 index 0000000..e51c575 --- /dev/null +++ b/Mcp/Tools/CreateCoupon.php @@ -0,0 +1,100 @@ +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'), + ]; + } +} diff --git a/Mcp/Tools/GetBillingStatus.php b/Mcp/Tools/GetBillingStatus.php new file mode 100644 index 0000000..c69d8ff --- /dev/null +++ b/Mcp/Tools/GetBillingStatus.php @@ -0,0 +1,72 @@ +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(), + ]; + } +} diff --git a/Mcp/Tools/ListInvoices.php b/Mcp/Tools/ListInvoices.php new file mode 100644 index 0000000..be76e9a --- /dev/null +++ b/Mcp/Tools/ListInvoices.php @@ -0,0 +1,64 @@ +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)'), + ]; + } +} diff --git a/Mcp/Tools/UpgradePlan.php b/Mcp/Tools/UpgradePlan.php new file mode 100644 index 0000000..f40508d --- /dev/null +++ b/Mcp/Tools/UpgradePlan.php @@ -0,0 +1,114 @@ +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)'), + ]; + } +} diff --git a/Middleware/CommerceApiAuth.php b/Middleware/CommerceApiAuth.php new file mode 100644 index 0000000..97b8597 --- /dev/null +++ b/Middleware/CommerceApiAuth.php @@ -0,0 +1,55 @@ +bearerToken(); + + if (! $token) { + return $this->unauthorized('API token required. Use Authorization: Bearer '); + } + + $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); + } +} diff --git a/Middleware/CommerceMatrixGate.php b/Middleware/CommerceMatrixGate.php new file mode 100644 index 0000000..7367503 --- /dev/null +++ b/Middleware/CommerceMatrixGate.php @@ -0,0 +1,185 @@ +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(); + } +} diff --git a/Migrations/0001_01_01_000001_create_commerce_tables.php b/Migrations/0001_01_01_000001_create_commerce_tables.php new file mode 100644 index 0000000..95f22a6 --- /dev/null +++ b/Migrations/0001_01_01_000001_create_commerce_tables.php @@ -0,0 +1,243 @@ +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(); + } +}; diff --git a/Migrations/0001_01_01_000002_create_credit_notes_table.php b/Migrations/0001_01_01_000002_create_credit_notes_table.php new file mode 100644 index 0000000..f0823e7 --- /dev/null +++ b/Migrations/0001_01_01_000002_create_credit_notes_table.php @@ -0,0 +1,66 @@ +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'); + } +}; diff --git a/Migrations/0001_01_01_000003_create_payment_methods_table.php b/Migrations/0001_01_01_000003_create_payment_methods_table.php new file mode 100644 index 0000000..5e1c1ce --- /dev/null +++ b/Migrations/0001_01_01_000003_create_payment_methods_table.php @@ -0,0 +1,53 @@ +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'); + } +}; diff --git a/Migrations/2026_01_26_000000_create_usage_billing_tables.php b/Migrations/2026_01_26_000000_create_usage_billing_tables.php new file mode 100644 index 0000000..6fcc55f --- /dev/null +++ b/Migrations/2026_01_26_000000_create_usage_billing_tables.php @@ -0,0 +1,125 @@ +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'); + } +}; diff --git a/Migrations/2026_01_26_000001_create_exchange_rates_table.php b/Migrations/2026_01_26_000001_create_exchange_rates_table.php new file mode 100644 index 0000000..095c8d3 --- /dev/null +++ b/Migrations/2026_01_26_000001_create_exchange_rates_table.php @@ -0,0 +1,80 @@ +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'); + } +}; diff --git a/Migrations/2026_01_26_000001_create_referral_tables.php b/Migrations/2026_01_26_000001_create_referral_tables.php new file mode 100644 index 0000000..32cea49 --- /dev/null +++ b/Migrations/2026_01_26_000001_create_referral_tables.php @@ -0,0 +1,233 @@ + 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(); + } +}; diff --git a/Models/BundleHash.php b/Models/BundleHash.php new file mode 100644 index 0000000..7625d0e --- /dev/null +++ b/Models/BundleHash.php @@ -0,0 +1,220 @@ + '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 $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)); + } +} diff --git a/Models/ContentOverride.php b/Models/ContentOverride.php new file mode 100644 index 0000000..7cc54fb --- /dev/null +++ b/Models/ContentOverride.php @@ -0,0 +1,214 @@ + '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; + } +} diff --git a/Models/Coupon.php b/Models/Coupon.php new file mode 100644 index 0000000..665a4eb --- /dev/null +++ b/Models/Coupon.php @@ -0,0 +1,281 @@ + '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}"); + } +} diff --git a/Models/CouponUsage.php b/Models/CouponUsage.php new file mode 100644 index 0000000..df015a0 --- /dev/null +++ b/Models/CouponUsage.php @@ -0,0 +1,62 @@ + '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(); + }); + } +} diff --git a/Models/CreditNote.php b/Models/CreditNote.php new file mode 100644 index 0000000..a698a2c --- /dev/null +++ b/Models/CreditNote.php @@ -0,0 +1,256 @@ + '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}"); + } +} diff --git a/Models/Entity.php b/Models/Entity.php new file mode 100644 index 0000000..002318e --- /dev/null +++ b/Models/Entity.php @@ -0,0 +1,344 @@ + '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; + } +} diff --git a/Models/ExchangeRate.php b/Models/ExchangeRate.php new file mode 100644 index 0000000..e8cf133 --- /dev/null +++ b/Models/ExchangeRate.php @@ -0,0 +1,224 @@ + '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 + */ + 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(); + } +} diff --git a/Models/Inventory.php b/Models/Inventory.php new file mode 100644 index 0000000..c19c1a4 --- /dev/null +++ b/Models/Inventory.php @@ -0,0 +1,216 @@ + '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); + } +} diff --git a/Models/InventoryMovement.php b/Models/InventoryMovement.php new file mode 100644 index 0000000..9c3ebe4 --- /dev/null +++ b/Models/InventoryMovement.php @@ -0,0 +1,217 @@ + '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(); + } + }); + } +} diff --git a/Models/Invoice.php b/Models/Invoice.php new file mode 100644 index 0000000..0b93813 --- /dev/null +++ b/Models/Invoice.php @@ -0,0 +1,243 @@ + '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); + } +} diff --git a/Models/InvoiceItem.php b/Models/InvoiceItem.php new file mode 100644 index 0000000..9ab7bce --- /dev/null +++ b/Models/InvoiceItem.php @@ -0,0 +1,72 @@ + '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; + } +} diff --git a/Models/Order.php b/Models/Order.php new file mode 100644 index 0000000..cdc5f25 --- /dev/null +++ b/Models/Order.php @@ -0,0 +1,391 @@ + '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}"); + } +} diff --git a/Models/OrderItem.php b/Models/OrderItem.php new file mode 100644 index 0000000..0602d03 --- /dev/null +++ b/Models/OrderItem.php @@ -0,0 +1,93 @@ + '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'; + } +} diff --git a/Models/Payment.php b/Models/Payment.php new file mode 100644 index 0000000..64a097e --- /dev/null +++ b/Models/Payment.php @@ -0,0 +1,179 @@ + '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); + } +} diff --git a/Models/PaymentMethod.php b/Models/PaymentMethod.php new file mode 100644 index 0000000..5857a97 --- /dev/null +++ b/Models/PaymentMethod.php @@ -0,0 +1,142 @@ + '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); + } +} diff --git a/Models/PermissionMatrix.php b/Models/PermissionMatrix.php new file mode 100644 index 0000000..ff5cb82 --- /dev/null +++ b/Models/PermissionMatrix.php @@ -0,0 +1,140 @@ + '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); + } +} diff --git a/Models/PermissionRequest.php b/Models/PermissionRequest.php new file mode 100644 index 0000000..b3c3837 --- /dev/null +++ b/Models/PermissionRequest.php @@ -0,0 +1,181 @@ + '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)); + } +} diff --git a/Models/Product.php b/Models/Product.php new file mode 100644 index 0000000..88bcc04 --- /dev/null +++ b/Models/Product.php @@ -0,0 +1,526 @@ + '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}"); + } +} diff --git a/Models/ProductAssignment.php b/Models/ProductAssignment.php new file mode 100644 index 0000000..16ecf0e --- /dev/null +++ b/Models/ProductAssignment.php @@ -0,0 +1,264 @@ + '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()); + } +} diff --git a/Models/ProductPrice.php b/Models/ProductPrice.php new file mode 100644 index 0000000..4d72725 --- /dev/null +++ b/Models/ProductPrice.php @@ -0,0 +1,221 @@ + '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)); + } +} diff --git a/Models/Referral.php b/Models/Referral.php new file mode 100644 index 0000000..c239248 --- /dev/null +++ b/Models/Referral.php @@ -0,0 +1,266 @@ + '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}"); + } +} diff --git a/Models/ReferralCode.php b/Models/ReferralCode.php new file mode 100644 index 0000000..ec0f1a8 --- /dev/null +++ b/Models/ReferralCode.php @@ -0,0 +1,216 @@ + '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}"); + } +} diff --git a/Models/ReferralCommission.php b/Models/ReferralCommission.php new file mode 100644 index 0000000..8c5ab89 --- /dev/null +++ b/Models/ReferralCommission.php @@ -0,0 +1,255 @@ + '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}"); + } +} diff --git a/Models/ReferralPayout.php b/Models/ReferralPayout.php new file mode 100644 index 0000000..3fc857a --- /dev/null +++ b/Models/ReferralPayout.php @@ -0,0 +1,298 @@ + '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}"); + } +} diff --git a/Models/Refund.php b/Models/Refund.php new file mode 100644 index 0000000..61d33ea --- /dev/null +++ b/Models/Refund.php @@ -0,0 +1,147 @@ + '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}"); + } +} diff --git a/Models/Subscription.php b/Models/Subscription.php new file mode 100644 index 0000000..500fa41 --- /dev/null +++ b/Models/Subscription.php @@ -0,0 +1,279 @@ + 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}"); + } +} diff --git a/Models/SubscriptionUsage.php b/Models/SubscriptionUsage.php new file mode 100644 index 0000000..72fed74 --- /dev/null +++ b/Models/SubscriptionUsage.php @@ -0,0 +1,177 @@ + '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; + } +} diff --git a/Models/TaxRate.php b/Models/TaxRate.php new file mode 100644 index 0000000..6155f92 --- /dev/null +++ b/Models/TaxRate.php @@ -0,0 +1,149 @@ + '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(); + } +} diff --git a/Models/UsageEvent.php b/Models/UsageEvent.php new file mode 100644 index 0000000..8adf232 --- /dev/null +++ b/Models/UsageEvent.php @@ -0,0 +1,144 @@ + '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'); + } +} diff --git a/Models/UsageMeter.php b/Models/UsageMeter.php new file mode 100644 index 0000000..5487d1b --- /dev/null +++ b/Models/UsageMeter.php @@ -0,0 +1,171 @@ + '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(); + } +} diff --git a/Models/Warehouse.php b/Models/Warehouse.php new file mode 100644 index 0000000..d944cf4 --- /dev/null +++ b/Models/Warehouse.php @@ -0,0 +1,202 @@ + '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); + } +} diff --git a/Models/WebhookEvent.php b/Models/WebhookEvent.php new file mode 100644 index 0000000..b2429e8 --- /dev/null +++ b/Models/WebhookEvent.php @@ -0,0 +1,280 @@ + '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|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)); + } +} diff --git a/Notifications/AccountSuspended.php b/Notifications/AccountSuspended.php new file mode 100644 index 0000000..ac717a8 --- /dev/null +++ b/Notifications/AccountSuspended.php @@ -0,0 +1,47 @@ +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(), + ]; + } +} diff --git a/Notifications/OrderConfirmation.php b/Notifications/OrderConfirmation.php new file mode 100644 index 0000000..bdb244b --- /dev/null +++ b/Notifications/OrderConfirmation.php @@ -0,0 +1,53 @@ +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, + ]; + } +} diff --git a/Notifications/PaymentFailed.php b/Notifications/PaymentFailed.php new file mode 100644 index 0000000..06b5762 --- /dev/null +++ b/Notifications/PaymentFailed.php @@ -0,0 +1,44 @@ +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, + ]; + } +} diff --git a/Notifications/PaymentRetry.php b/Notifications/PaymentRetry.php new file mode 100644 index 0000000..56b2b7e --- /dev/null +++ b/Notifications/PaymentRetry.php @@ -0,0 +1,55 @@ +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, + ]; + } +} diff --git a/Notifications/RefundProcessed.php b/Notifications/RefundProcessed.php new file mode 100644 index 0000000..349a905 --- /dev/null +++ b/Notifications/RefundProcessed.php @@ -0,0 +1,53 @@ +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, + ]; + } +} diff --git a/Notifications/SubscriptionCancelled.php b/Notifications/SubscriptionCancelled.php new file mode 100644 index 0000000..1c81b83 --- /dev/null +++ b/Notifications/SubscriptionCancelled.php @@ -0,0 +1,45 @@ +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, + ]; + } +} diff --git a/Notifications/SubscriptionPaused.php b/Notifications/SubscriptionPaused.php new file mode 100644 index 0000000..19caa17 --- /dev/null +++ b/Notifications/SubscriptionPaused.php @@ -0,0 +1,48 @@ +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(), + ]; + } +} diff --git a/Notifications/UpcomingRenewal.php b/Notifications/UpcomingRenewal.php new file mode 100644 index 0000000..256517a --- /dev/null +++ b/Notifications/UpcomingRenewal.php @@ -0,0 +1,54 @@ +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, + ]; + } +} diff --git a/Services/CheckoutRateLimiter.php b/Services/CheckoutRateLimiter.php new file mode 100644 index 0000000..9ea3602 --- /dev/null +++ b/Services/CheckoutRateLimiter.php @@ -0,0 +1,156 @@ +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}"; + } +} diff --git a/Services/CommerceService.php b/Services/CommerceService.php new file mode 100644 index 0000000..7dd8f75 --- /dev/null +++ b/Services/CommerceService.php @@ -0,0 +1,628 @@ +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 + */ + 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); + } +} diff --git a/Services/ContentOverrideService.php b/Services/ContentOverrideService.php new file mode 100644 index 0000000..090f16e --- /dev/null +++ b/Services/ContentOverrideService.php @@ -0,0 +1,271 @@ +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 + } +} diff --git a/Services/CouponService.php b/Services/CouponService.php new file mode 100644 index 0000000..31aaf70 --- /dev/null +++ b/Services/CouponService.php @@ -0,0 +1,226 @@ +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 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; + } +} diff --git a/Services/CreditNoteService.php b/Services/CreditNoteService.php new file mode 100644 index 0000000..59e474e --- /dev/null +++ b/Services/CreditNoteService.php @@ -0,0 +1,286 @@ + $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; + } +} diff --git a/Services/CurrencyService.php b/Services/CurrencyService.php new file mode 100644 index 0000000..a831ddb --- /dev/null +++ b/Services/CurrencyService.php @@ -0,0 +1,468 @@ + + */ + 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 + */ + 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, + ]; + } +} diff --git a/Services/DunningService.php b/Services/DunningService.php new file mode 100644 index 0000000..6864574 --- /dev/null +++ b/Services/DunningService.php @@ -0,0 +1,426 @@ +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(); + } +} diff --git a/Services/InvoiceService.php b/Services/InvoiceService.php new file mode 100644 index 0000000..1c9b19f --- /dev/null +++ b/Services/InvoiceService.php @@ -0,0 +1,251 @@ +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(); + } +} diff --git a/Services/PaymentGateway/BTCPayGateway.php b/Services/PaymentGateway/BTCPayGateway.php new file mode 100644 index 0000000..8ceac3e --- /dev/null +++ b/Services/PaymentGateway/BTCPayGateway.php @@ -0,0 +1,488 @@ +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, + }; + } +} diff --git a/Services/PaymentGateway/PaymentGatewayContract.php b/Services/PaymentGateway/PaymentGatewayContract.php new file mode 100644 index 0000000..37d2dd7 --- /dev/null +++ b/Services/PaymentGateway/PaymentGatewayContract.php @@ -0,0 +1,164 @@ +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', + }; + } +} diff --git a/Services/PaymentMethodService.php b/Services/PaymentMethodService.php new file mode 100644 index 0000000..fa07615 --- /dev/null +++ b/Services/PaymentMethodService.php @@ -0,0 +1,335 @@ +paymentMethods() + ->where('is_active', true) + ->orderByDesc('is_default') + ->orderByDesc('created_at') + ->get(); + } + + /** + * Get the default payment method for a workspace. + */ + public function getDefaultPaymentMethod(Workspace $workspace): ?PaymentMethod + { + return $workspace->paymentMethods() + ->where('is_active', true) + ->where('is_default', true) + ->first(); + } + + /** + * Add a new payment method to a workspace. + * + * @param string $gatewayPaymentMethodId The payment method ID from the gateway (e.g., pm_xxx for Stripe) + */ + public function addPaymentMethod( + Workspace $workspace, + string $gatewayPaymentMethodId, + ?User $user = null, + string $gateway = 'stripe' + ): PaymentMethod { + return DB::transaction(function () use ($workspace, $gatewayPaymentMethodId, $user, $gateway) { + // For Stripe, attach and get details from the gateway + if ($gateway === 'stripe') { + return $this->addStripePaymentMethod($workspace, $gatewayPaymentMethodId, $user); + } + + throw new \InvalidArgumentException("Unsupported payment gateway: {$gateway}"); + }); + } + + /** + * Add a Stripe payment method. + */ + protected function addStripePaymentMethod( + Workspace $workspace, + string $gatewayPaymentMethodId, + ?User $user = null + ): PaymentMethod { + // Check if payment method already exists + $existing = PaymentMethod::where('workspace_id', $workspace->id) + ->where('gateway', 'stripe') + ->where('gateway_payment_method_id', $gatewayPaymentMethodId) + ->first(); + + if ($existing) { + // Reactivate if it was deactivated + if (! $existing->is_active) { + $existing->update(['is_active' => true]); + } + + return $existing; + } + + // Attach to Stripe customer + $paymentMethod = $this->stripeGateway->attachPaymentMethod($workspace, $gatewayPaymentMethodId); + + // Update with user info + if ($user) { + $paymentMethod->update(['user_id' => $user->id]); + } + + // If this is the first payment method, make it the default + $hasOtherMethods = $workspace->paymentMethods() + ->where('is_active', true) + ->where('id', '!=', $paymentMethod->id) + ->exists(); + + if (! $hasOtherMethods) { + $this->setDefaultPaymentMethod($workspace, $paymentMethod); + } + + Log::info('Payment method added', [ + 'workspace_id' => $workspace->id, + 'payment_method_id' => $paymentMethod->id, + 'type' => $paymentMethod->type, + 'brand' => $paymentMethod->brand, + ]); + + return $paymentMethod; + } + + /** + * Remove a payment method from a workspace. + * + * @throws \RuntimeException If the payment method cannot be removed + */ + public function removePaymentMethod(Workspace $workspace, PaymentMethod $paymentMethod): void + { + // Verify ownership + if ($paymentMethod->workspace_id !== $workspace->id) { + throw new \RuntimeException('Payment method does not belong to this workspace.'); + } + + // Check if this is the last active payment method + $activeCount = $workspace->paymentMethods() + ->where('is_active', true) + ->count(); + + if ($activeCount === 1) { + // Check for active subscriptions + $hasActiveSubscription = $workspace->subscriptions() + ->active() + ->exists(); + + if ($hasActiveSubscription) { + throw new \RuntimeException( + 'Cannot remove the last payment method while you have an active subscription.' + ); + } + } + + DB::transaction(function () use ($workspace, $paymentMethod) { + // Detach from gateway (Stripe) + if ($paymentMethod->gateway === 'stripe' && $paymentMethod->gateway_payment_method_id) { + try { + $this->stripeGateway->detachPaymentMethod($paymentMethod); + } catch (\Exception $e) { + Log::warning('Failed to detach payment method from Stripe', [ + 'payment_method_id' => $paymentMethod->id, + 'error' => $e->getMessage(), + ]); + // Continue with local removal even if gateway fails + } + } + + // If this was the default, make another one the default + if ($paymentMethod->is_default) { + $newDefault = $workspace->paymentMethods() + ->where('is_active', true) + ->where('id', '!=', $paymentMethod->id) + ->first(); + + if ($newDefault) { + $this->setDefaultPaymentMethod($workspace, $newDefault); + } + } + + // Soft-delete by marking as inactive + $paymentMethod->update(['is_active' => false]); + + Log::info('Payment method removed', [ + 'workspace_id' => $workspace->id, + 'payment_method_id' => $paymentMethod->id, + ]); + }); + } + + /** + * Set a payment method as the default for a workspace. + */ + public function setDefaultPaymentMethod(Workspace $workspace, PaymentMethod $paymentMethod): void + { + // Verify ownership + if ($paymentMethod->workspace_id !== $workspace->id) { + throw new \RuntimeException('Payment method does not belong to this workspace.'); + } + + DB::transaction(function () use ($workspace, $paymentMethod) { + // Update gateway default (for Stripe) + if ($paymentMethod->gateway === 'stripe') { + try { + $this->stripeGateway->setDefaultPaymentMethod($paymentMethod); + } catch (\Exception $e) { + Log::warning('Failed to set default payment method in Stripe', [ + 'payment_method_id' => $paymentMethod->id, + 'error' => $e->getMessage(), + ]); + // Continue with local update even if gateway fails + } + } + + // Remove default from all other methods + $workspace->paymentMethods() + ->where('id', '!=', $paymentMethod->id) + ->update(['is_default' => false]); + + // Set this one as default + $paymentMethod->update(['is_default' => true]); + + Log::info('Default payment method updated', [ + 'workspace_id' => $workspace->id, + 'payment_method_id' => $paymentMethod->id, + ]); + }); + } + + /** + * Sync payment methods from Stripe to local database. + * + * This is useful when payment methods are added via Stripe's + * hosted checkout or customer portal. + */ + public function syncPaymentMethodsFromStripe(Workspace $workspace): Collection + { + if (! $workspace->stripe_customer_id) { + return collect(); + } + + // This would need the Stripe SDK to list payment methods + // For now, we rely on webhooks to keep data in sync + Log::info('Payment method sync requested', [ + 'workspace_id' => $workspace->id, + 'stripe_customer_id' => $workspace->stripe_customer_id, + ]); + + return $this->getPaymentMethods($workspace); + } + + /** + * Check if a payment method is expiring soon. + */ + public function isExpiringSoon(PaymentMethod $paymentMethod, int $monthsThreshold = 2): bool + { + if (! $paymentMethod->exp_month || ! $paymentMethod->exp_year) { + return false; + } + + $expiry = \Carbon\Carbon::createFromDate( + $paymentMethod->exp_year, + $paymentMethod->exp_month + )->endOfMonth(); + + return $expiry->isBefore(now()->addMonths($monthsThreshold)); + } + + /** + * Get all workspaces with expiring payment methods. + * + * Useful for sending expiry warning notifications. + */ + public function getExpiringPaymentMethods(int $monthsThreshold = 2): Collection + { + $thresholdDate = now()->addMonths($monthsThreshold); + + return PaymentMethod::query() + ->where('is_active', true) + ->where('is_default', true) + ->where('type', 'card') + ->whereNotNull('exp_month') + ->whereNotNull('exp_year') + ->whereRaw("STR_TO_DATE(CONCAT(exp_year, '-', exp_month, '-01'), '%Y-%m-%d') <= ?", [ + $thresholdDate->format('Y-m-d'), + ]) + ->with('workspace') + ->get(); + } + + /** + * Update payment method details from gateway. + * + * Called when card details are updated (e.g., new expiry date from card networks). + */ + public function updateFromGateway(PaymentMethod $paymentMethod, array $gatewayData): PaymentMethod + { + $updates = []; + + if (isset($gatewayData['card'])) { + $card = $gatewayData['card']; + $updates['brand'] = $card['brand'] ?? $paymentMethod->brand; + $updates['last_four'] = $card['last4'] ?? $paymentMethod->last_four; + $updates['exp_month'] = $card['exp_month'] ?? $paymentMethod->exp_month; + $updates['exp_year'] = $card['exp_year'] ?? $paymentMethod->exp_year; + } + + if (! empty($updates)) { + $paymentMethod->update($updates); + } + + return $paymentMethod->fresh(); + } + + /** + * Create a setup session for adding a payment method. + * + * Returns the URL to redirect the user to Stripe's hosted setup page. + */ + public function createSetupSession(Workspace $workspace, string $returnUrl): array + { + if (! $this->stripeGateway->isEnabled()) { + throw new \RuntimeException('Stripe payments are not currently available.'); + } + + return $this->stripeGateway->createSetupSession($workspace, $returnUrl); + } + + /** + * Get the billing portal URL for full payment management. + */ + public function getBillingPortalUrl(Workspace $workspace, string $returnUrl): ?string + { + if (! $this->stripeGateway->isEnabled()) { + return null; + } + + return $this->stripeGateway->getPortalUrl($workspace, $returnUrl); + } +} diff --git a/Services/PermissionLockedException.php b/Services/PermissionLockedException.php new file mode 100644 index 0000000..8ab5b44 --- /dev/null +++ b/Services/PermissionLockedException.php @@ -0,0 +1,12 @@ +trainingMode = config('commerce.matrix.training_mode', false); + $this->strictMode = config('commerce.matrix.strict_mode', true); + $this->logAllChecks = config('commerce.matrix.log_all_checks', false); + $this->logDenials = config('commerce.matrix.log_denials', true); + } + + /** + * Check if an entity can perform an action. + */ + public function can(Entity $entity, string $key, ?string $scope = null): PermissionResult + { + // Build the hierarchy path (M1 → M2 → M3) + $hierarchy = $this->getHierarchy($entity); + + // Check from top down (M1 first, ancestors) + foreach ($hierarchy as $ancestor) { + $permission = PermissionMatrix::where('entity_id', $ancestor->id) + ->where('key', $key) + ->where(function ($q) use ($scope) { + $q->whereNull('scope')->orWhere('scope', $scope); + }) + ->first(); + + if ($permission) { + // If locked and denied at this level, everything below is denied + if ($permission->locked && ! $permission->allowed) { + return PermissionResult::denied( + reason: "Locked by {$ancestor->name}", + lockedBy: $ancestor + ); + } + + // If explicitly denied (not locked), continue checking + if (! $permission->allowed && ! $permission->locked) { + return PermissionResult::denied( + reason: "Denied by {$ancestor->name}" + ); + } + } + } + + // Check the entity itself + $ownPermission = PermissionMatrix::where('entity_id', $entity->id) + ->where('key', $key) + ->where(function ($q) use ($scope) { + $q->whereNull('scope')->orWhere('scope', $scope); + }) + ->first(); + + if ($ownPermission) { + return $ownPermission->allowed + ? PermissionResult::allowed() + : PermissionResult::denied(reason: 'Denied by own policy'); + } + + // No permission found + return PermissionResult::undefined(key: $key, scope: $scope); + } + + /** + * Gate a request through the matrix. + */ + public function gateRequest(Request $request, Entity $entity, string $action): PermissionResult + { + $scope = $this->extractScope($request); + $result = $this->can($entity, $action, $scope); + + // Log the request if configured + if ($this->logAllChecks || ($this->logDenials && $result->isDenied())) { + $this->logRequest($request, $entity, $action, $scope, $result); + } + + // Training mode: undefined permissions become pending for approval + if ($result->isUndefined() && $this->trainingMode) { + // Log as pending + PermissionRequest::fromRequest($entity, $action, PermissionRequest::STATUS_PENDING, $scope); + + return PermissionResult::pending( + key: $action, + scope: $scope, + trainingUrl: route('commerce.matrix.train', [ + 'entity' => $entity->id, + 'key' => $action, + 'scope' => $scope, + ]) + ); + } + + // Production mode (strict): undefined = denied + if ($result->isUndefined() && $this->strictMode) { + return PermissionResult::denied( + reason: "No permission defined for {$action}" + ); + } + + // Non-strict mode with undefined: check default_allow config + if ($result->isUndefined()) { + $defaultAllow = config('commerce.matrix.default_allow', false); + + return $defaultAllow + ? PermissionResult::allowed() + : PermissionResult::denied(reason: "No permission defined for {$action}"); + } + + return $result; + } + + /** + * Train a permission (dev mode). + */ + public function train( + Entity $entity, + string $key, + ?string $scope, + bool $allow, + ?string $route = null + ): PermissionMatrix { + // Check if parent has locked this + $hierarchy = $this->getHierarchy($entity); + + foreach ($hierarchy as $ancestor) { + $parentPerm = PermissionMatrix::where('entity_id', $ancestor->id) + ->where('key', $key) + ->where('locked', true) + ->first(); + + if ($parentPerm && ! $parentPerm->allowed) { + throw new PermissionLockedException( + "Cannot train permission '{$key}' - locked by {$ancestor->name}" + ); + } + } + + return PermissionMatrix::updateOrCreate( + [ + 'entity_id' => $entity->id, + 'key' => $key, + 'scope' => $scope, + ], + [ + 'allowed' => $allow, + 'locked' => false, + 'source' => PermissionMatrix::SOURCE_TRAINED, + 'trained_at' => now(), + 'trained_route' => $route, + ] + ); + } + + /** + * Lock a permission (cascades down). + */ + public function lock(Entity $entity, string $key, bool $allowed, ?string $scope = null): void + { + // Set on this entity + PermissionMatrix::updateOrCreate( + [ + 'entity_id' => $entity->id, + 'key' => $key, + 'scope' => $scope, + ], + [ + 'allowed' => $allowed, + 'locked' => true, + 'source' => PermissionMatrix::SOURCE_EXPLICIT, + 'set_by_entity_id' => $entity->id, + ] + ); + + // Cascade to all descendants + $descendants = Entity::where('path', 'like', $entity->path.'/%')->get(); + + foreach ($descendants as $descendant) { + PermissionMatrix::updateOrCreate( + [ + 'entity_id' => $descendant->id, + 'key' => $key, + 'scope' => $scope, + ], + [ + 'allowed' => $allowed, + 'locked' => true, + 'source' => PermissionMatrix::SOURCE_INHERITED, + 'set_by_entity_id' => $entity->id, + ] + ); + } + } + + /** + * Set an explicit permission (not locked, not trained). + */ + public function setPermission( + Entity $entity, + string $key, + bool $allowed, + ?string $scope = null + ): PermissionMatrix { + // Check if parent has locked this + $hierarchy = $this->getHierarchy($entity); + + foreach ($hierarchy as $ancestor) { + $parentPerm = PermissionMatrix::where('entity_id', $ancestor->id) + ->where('key', $key) + ->where('locked', true) + ->first(); + + if ($parentPerm && ! $parentPerm->allowed && $allowed) { + throw new PermissionLockedException( + "Cannot allow permission '{$key}' - locked as denied by {$ancestor->name}" + ); + } + } + + return PermissionMatrix::updateOrCreate( + [ + 'entity_id' => $entity->id, + 'key' => $key, + 'scope' => $scope, + ], + [ + 'allowed' => $allowed, + 'locked' => false, + 'source' => PermissionMatrix::SOURCE_EXPLICIT, + ] + ); + } + + /** + * Unlock a permission (removes inherited locks from descendants). + */ + public function unlock(Entity $entity, string $key, ?string $scope = null): void + { + // Update this entity's permission to unlocked + PermissionMatrix::where('entity_id', $entity->id) + ->where('key', $key) + ->where('scope', $scope) + ->update(['locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT]); + + // Remove inherited locks from descendants + $descendantIds = Entity::where('path', 'like', $entity->path.'/%') + ->pluck('id'); + + PermissionMatrix::whereIn('entity_id', $descendantIds) + ->where('key', $key) + ->where('scope', $scope) + ->where('set_by_entity_id', $entity->id) + ->delete(); + } + + /** + * Get all permissions for an entity. + */ + public function getPermissions(Entity $entity): Collection + { + return PermissionMatrix::where('entity_id', $entity->id) + ->orderBy('key') + ->get(); + } + + /** + * Get effective permissions for an entity (including inherited). + */ + public function getEffectivePermissions(Entity $entity): Collection + { + $hierarchy = $this->getHierarchy($entity); + $hierarchy->push($entity); + + $entityIds = $hierarchy->pluck('id'); + + return PermissionMatrix::whereIn('entity_id', $entityIds) + ->orderBy('key') + ->get() + ->groupBy('key') + ->map(function ($permissions) use ($entity) { + // For each key, determine the effective permission + foreach ($permissions as $perm) { + if ($perm->locked && ! $perm->allowed) { + return $perm; // Locked denial wins + } + } + + // Return the entity's own permission if exists + return $permissions->firstWhere('entity_id', $entity->id) + ?? $permissions->last(); + }); + } + + /** + * Get pending permission requests for training. + */ + public function getPendingRequests(?Entity $entity = null): Collection + { + $query = PermissionRequest::pending()->untrained(); + + if ($entity) { + $query->forEntity($entity->id); + } + + return $query->orderBy('created_at', 'desc')->get(); + } + + /** + * Mark pending requests as trained. + */ + public function markRequestsTrained(Entity $entity, string $action, ?string $scope = null): int + { + return PermissionRequest::forEntity($entity->id) + ->forAction($action) + ->where('scope', $scope) + ->pending() + ->update([ + 'was_trained' => true, + 'trained_at' => now(), + ]); + } + + /** + * Check if training mode is enabled. + */ + public function isTrainingMode(): bool + { + return $this->trainingMode; + } + + /** + * Check if strict mode is enabled. + */ + public function isStrictMode(): bool + { + return $this->strictMode; + } + + /** + * Get hierarchy from root to parent (not including entity itself). + */ + protected function getHierarchy(Entity $entity): Collection + { + return $entity->getAncestors(); + } + + /** + * Extract scope from request (resource type or ID). + */ + protected function extractScope(Request $request): ?string + { + // Try route parameters + $route = $request->route(); + + if ($route) { + // Look for common resource parameters + foreach (['id', 'product', 'order', 'customer'] as $param) { + if ($value = $route->parameter($param)) { + return is_object($value) ? (string) $value->id : (string) $value; + } + } + } + + return null; + } + + /** + * Log a permission request. + */ + protected function logRequest( + Request $request, + Entity $entity, + string $action, + ?string $scope, + PermissionResult $result + ): void { + PermissionRequest::fromRequest( + $entity, + $action, + match (true) { + $result->isAllowed() => PermissionRequest::STATUS_ALLOWED, + $result->isDenied() => PermissionRequest::STATUS_DENIED, + default => PermissionRequest::STATUS_PENDING, + }, + $scope + ); + } +} diff --git a/Services/PermissionResult.php b/Services/PermissionResult.php new file mode 100644 index 0000000..cb97d64 --- /dev/null +++ b/Services/PermissionResult.php @@ -0,0 +1,107 @@ +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 isUndefined(): bool + { + return $this->status === self::STATUS_UNDEFINED; + } + + public function isLocked(): bool + { + return $this->lockedBy !== null; + } + + // Conversion + + public function toArray(): array + { + return array_filter([ + 'status' => $this->status, + 'reason' => $this->reason, + 'locked_by' => $this->lockedBy?->name, + 'key' => $this->key, + 'scope' => $this->scope, + 'training_url' => $this->trainingUrl, + ], fn ($v) => $v !== null); + } +} diff --git a/Services/ProductCatalogService.php b/Services/ProductCatalogService.php new file mode 100644 index 0000000..c45ef63 --- /dev/null +++ b/Services/ProductCatalogService.php @@ -0,0 +1,397 @@ +isM1()) { + throw new \InvalidArgumentException( + "Only M1 (Master) entities can own products. Entity '{$owner->code}' is {$owner->type}." + ); + } + + $data['owner_entity_id'] = $owner->id; + $data['sku'] = strtoupper($data['sku'] ?? Product::generateSku($owner->code)); + + return Product::create($data); + } + + /** + * Update a product. + */ + public function updateProduct(Product $product, array $data): Product + { + // SKU is immutable after creation + unset($data['sku'], $data['owner_entity_id']); + + $product->update($data); + + return $product->fresh(); + } + + /** + * Delete a product (soft delete). + */ + public function deleteProduct(Product $product): bool + { + return $product->delete(); + } + + /** + * Assign a product to an M2/M3 entity. + * + * @throws \InvalidArgumentException + */ + public function assignProduct( + Entity $entity, + Product $product, + array $overrides = [] + ): ProductAssignment { + if ($entity->isM1()) { + throw new \InvalidArgumentException( + 'M1 entities own products directly. Use createProduct() instead.' + ); + } + + // Check if assignment already exists + $existing = ProductAssignment::where('entity_id', $entity->id) + ->where('product_id', $product->id) + ->first(); + + if ($existing) { + return $this->updateAssignment($existing, $overrides); + } + + return ProductAssignment::create([ + 'entity_id' => $entity->id, + 'product_id' => $product->id, + ...$overrides, + ]); + } + + /** + * Update an assignment's overrides. + */ + public function updateAssignment( + ProductAssignment $assignment, + array $overrides + ): ProductAssignment { + $assignment->update($overrides); + + return $assignment->fresh(); + } + + /** + * Remove a product assignment. + */ + public function removeAssignment(ProductAssignment $assignment): bool + { + return $assignment->delete(); + } + + /** + * Get all products for an entity. + * + * For M1: Returns owned products + * For M2/M3: Returns assigned products + */ + public function getProductsForEntity( + Entity $entity, + bool $activeOnly = true + ): Collection { + if ($entity->isM1()) { + $query = Product::forOwner($entity->id); + if ($activeOnly) { + $query->active()->visible(); + } + + return $query->orderBy('sort_order')->get(); + } + + // M2/M3: Get assigned products + $query = ProductAssignment::forEntity($entity->id) + ->with('product'); + + if ($activeOnly) { + $query->active()->withActiveProducts(); + } + + return $query->orderBy('sort_order')->get(); + } + + /** + * Get a product for an entity with effective values. + * + * Returns array with effective price, name, description, etc. + */ + public function getEffectiveProduct(Entity $entity, Product $product): array + { + if ($entity->isM1()) { + return [ + 'product' => $product, + 'assignment' => null, + 'sku' => $entity->buildSku($product->sku), + 'price' => $product->price, + 'name' => $product->name, + 'description' => $product->description, + 'image' => $product->image_url, + 'available_stock' => $product->stock_quantity, + ]; + } + + $assignment = ProductAssignment::where('entity_id', $entity->id) + ->where('product_id', $product->id) + ->first(); + + if (! $assignment) { + return null; + } + + return [ + 'product' => $product, + 'assignment' => $assignment, + 'sku' => $assignment->getFullSku(), + 'price' => $assignment->getEffectivePrice(), + 'name' => $assignment->getEffectiveName(), + 'description' => $assignment->getEffectiveDescription(), + 'image' => $assignment->getEffectiveImage(), + 'available_stock' => $assignment->getAvailableStock(), + ]; + } + + /** + * Bulk assign products to an entity. + */ + public function bulkAssign( + Entity $entity, + array $productIds, + array $defaultOverrides = [] + ): int { + $count = 0; + + DB::transaction(function () use ($entity, $productIds, $defaultOverrides, &$count) { + foreach ($productIds as $productId) { + $product = Product::find($productId); + if ($product) { + $this->assignProduct($entity, $product, $defaultOverrides); + $count++; + } + } + }); + + return $count; + } + + /** + * Copy assignments from one entity to another. + * + * Useful for setting up new M3 entities with same products as parent M2. + */ + public function copyAssignments( + Entity $source, + Entity $target, + bool $includeOverrides = true + ): int { + $assignments = ProductAssignment::forEntity($source->id)->get(); + $count = 0; + + DB::transaction(function () use ($assignments, $target, $includeOverrides, &$count) { + foreach ($assignments as $assignment) { + $overrides = $includeOverrides ? [ + 'sku_suffix' => $assignment->sku_suffix, + 'price_override' => $assignment->price_override, + 'price_tier_overrides' => $assignment->price_tier_overrides, + 'margin_percent' => $assignment->margin_percent, + 'fixed_margin' => $assignment->fixed_margin, + 'name_override' => $assignment->name_override, + 'description_override' => $assignment->description_override, + 'image_override' => $assignment->image_override, + 'is_featured' => $assignment->is_featured, + 'can_discount' => $assignment->can_discount, + 'min_price' => $assignment->min_price, + 'max_price' => $assignment->max_price, + ] : []; + + $this->assignProduct($target, $assignment->product, $overrides); + $count++; + } + }); + + return $count; + } + + /** + * Search products by name/SKU. + */ + public function searchProducts( + Entity $owner, + string $query, + int $limit = 20 + ): Collection { + return Product::forOwner($owner->id) + ->where(function ($q) use ($query) { + $q->where('name', 'like', "%{$query}%") + ->orWhere('sku', 'like', "%{$query}%"); + }) + ->active() + ->visible() + ->limit($limit) + ->get(); + } + + /** + * Get products by category. + */ + public function getByCategory( + Entity $entity, + string $category, + bool $activeOnly = true + ): Collection { + if ($entity->isM1()) { + $query = Product::forOwner($entity->id) + ->inCategory($category); + + if ($activeOnly) { + $query->active()->visible(); + } + + return $query->orderBy('sort_order')->get(); + } + + $query = ProductAssignment::forEntity($entity->id) + ->with('product') + ->whereHas('product', fn ($q) => $q->inCategory($category)); + + if ($activeOnly) { + $query->active()->withActiveProducts(); + } + + return $query->orderBy('sort_order')->get(); + } + + /** + * Get featured products for an entity. + */ + public function getFeaturedProducts(Entity $entity, int $limit = 10): Collection + { + if ($entity->isM1()) { + return Product::forOwner($entity->id) + ->active() + ->visible() + ->featured() + ->limit($limit) + ->get(); + } + + return ProductAssignment::forEntity($entity->id) + ->with('product') + ->active() + ->featured() + ->withActiveProducts() + ->limit($limit) + ->get(); + } + + /** + * Get product statistics for an entity. + */ + public function getProductStats(Entity $entity): array + { + if ($entity->isM1()) { + $products = Product::forOwner($entity->id); + + return [ + 'total' => $products->count(), + 'active' => $products->clone()->active()->count(), + 'featured' => $products->clone()->featured()->count(), + 'in_stock' => $products->clone()->inStock()->count(), + 'out_of_stock' => $products->clone()->where('stock_status', Product::STOCK_OUT)->count(), + 'low_stock' => $products->clone()->where('stock_status', Product::STOCK_LOW)->count(), + ]; + } + + $assignments = ProductAssignment::forEntity($entity->id); + + return [ + 'total' => $assignments->count(), + 'active' => $assignments->clone()->active()->count(), + 'featured' => $assignments->clone()->featured()->count(), + ]; + } + + /** + * Resolve SKU to product for an entity. + * + * Parses SKU lineage (M1-M2-SKU) and finds the corresponding product. + */ + public function resolveSku(string $fullSku): ?array + { + // Parse SKU format: M1-M2-SKU or M1-M2-M3-SKU + $parts = explode('-', $fullSku); + + if (count($parts) < 2) { + return null; + } + + // Last part is the base SKU + $baseSku = array_pop($parts); + + // Find product by base SKU + $product = Product::where('sku', $baseSku)->first(); + + if (! $product) { + // Try with combined last parts (SKU might have dashes) + for ($i = count($parts) - 1; $i >= 1; $i--) { + $testSku = implode('-', array_slice($parts, $i)).'-'.$baseSku; + $product = Product::where('sku', $testSku)->first(); + if ($product) { + $parts = array_slice($parts, 0, $i); + break; + } + } + } + + if (! $product) { + return null; + } + + // Build entity path from remaining parts + $entityPath = implode('/', $parts); + $entity = Entity::where('path', $entityPath)->first(); + + if (! $entity) { + return null; + } + + return [ + 'product' => $product, + 'entity' => $entity, + 'assignment' => $entity->isM1() + ? null + : ProductAssignment::where('entity_id', $entity->id) + ->where('product_id', $product->id) + ->first(), + ]; + } +} diff --git a/Services/ProrationResult.php b/Services/ProrationResult.php new file mode 100644 index 0000000..cd856ed --- /dev/null +++ b/Services/ProrationResult.php @@ -0,0 +1,110 @@ +netAmount > 0; + } + + /** + * Check if this is a downgrade (customer gets credit). + */ + public function isDowngrade(): bool + { + return $this->newPlanPrice < $this->currentPlanPrice; + } + + /** + * Check if this is an upgrade (new plan costs more). + */ + public function isUpgrade(): bool + { + return $this->newPlanPrice > $this->currentPlanPrice; + } + + /** + * Check if plans are same price (lateral move). + */ + public function isSamePrice(): bool + { + return abs($this->newPlanPrice - $this->currentPlanPrice) < 0.01; + } + + /** + * Get absolute credit amount (always positive). + */ + public function getCreditBalance(): float + { + return $this->netAmount < 0 ? abs($this->netAmount) : 0; + } + + /** + * Get amount due (always positive or zero). + */ + public function getAmountDue(): float + { + return max(0, $this->netAmount); + } + + /** + * Convert to array for storage or display. + */ + public function toArray(): array + { + return [ + 'days_remaining' => $this->daysRemaining, + 'total_period_days' => $this->totalPeriodDays, + 'used_percentage' => round($this->usedPercentage * 100, 2), + 'current_plan_price' => $this->currentPlanPrice, + 'new_plan_price' => $this->newPlanPrice, + 'credit_amount' => $this->creditAmount, + 'prorated_new_plan_cost' => $this->proratedNewPlanCost, + 'net_amount' => $this->netAmount, + 'currency' => $this->currency, + 'is_upgrade' => $this->isUpgrade(), + 'is_downgrade' => $this->isDowngrade(), + 'requires_payment' => $this->requiresPayment(), + ]; + } +} diff --git a/Services/ReferralService.php b/Services/ReferralService.php new file mode 100644 index 0000000..87fe170 --- /dev/null +++ b/Services/ReferralService.php @@ -0,0 +1,580 @@ +resolveReferrerFromCode($code); + + if (! $referrerId) { + return null; + } + + // Generate unique tracking ID + $trackingId = Str::uuid()->toString(); + + return Referral::create([ + 'referrer_id' => $referrerId, + 'code' => $code, + 'status' => Referral::STATUS_PENDING, + 'source_url' => $sourceUrl, + 'landing_page' => $landingPage, + 'ip_address' => $ipAddress, + 'user_agent' => $userAgent ? Str::limit($userAgent, 512) : null, + 'utm_source' => $utmParams['source'] ?? null, + 'utm_medium' => $utmParams['medium'] ?? null, + 'utm_campaign' => $utmParams['campaign'] ?? null, + 'tracking_id' => $trackingId, + 'clicked_at' => now(), + ]); + } + + /** + * Resolve referrer user ID from a code. + * + * Checks: + * 1. Custom referral codes + * 2. User namespaces (bio page URLs) + */ + public function resolveReferrerFromCode(string $code): ?int + { + // Check custom referral codes first + $referralCode = ReferralCode::valid()->byCode($code)->first(); + if ($referralCode && $referralCode->user_id) { + return $referralCode->user_id; + } + + // Check user namespaces (bio pages) + $page = Page::with('user') + ->where('url', $code) + ->first(); + + if ($page && $page->user && $page->user->hasActivatedReferrals()) { + return $page->user_id; + } + + return null; + } + + /** + * Convert a referral when user signs up. + */ + public function convertReferral(User $referee, ?string $trackingId = null, ?int $referrerUserId = null): ?Referral + { + // Find pending referral by tracking ID or referrer + $referral = null; + + if ($trackingId) { + $referral = Referral::pending() + ->where('tracking_id', $trackingId) + ->whereNull('referee_id') + ->first(); + } + + if (! $referral && $referrerUserId) { + // Create new referral if we have referrer ID from session + $referral = Referral::create([ + 'referrer_id' => $referrerUserId, + 'code' => '', // Will be filled from session context + 'status' => Referral::STATUS_PENDING, + 'clicked_at' => now(), + ]); + } + + if (! $referral) { + return null; + } + + // Prevent self-referral + if ($referral->referrer_id === $referee->id) { + Log::info('Self-referral prevented', [ + 'user_id' => $referee->id, + 'referral_id' => $referral->id, + ]); + + return null; + } + + // Mark as converted + $referral->markConverted($referee); + + // Update referrer's referral count + $referrer = $referral->referrer; + if ($referrer) { + $referrer->increment('referral_count'); + } + + // Increment code usage if applicable + $referralCode = ReferralCode::byCode($referral->code)->first(); + if ($referralCode) { + $referralCode->incrementUsage(); + } + + Log::info('Referral converted', [ + 'referral_id' => $referral->id, + 'referrer_id' => $referral->referrer_id, + 'referee_id' => $referee->id, + ]); + + return $referral; + } + + /** + * Get or create referral for a referee user. + */ + public function getReferralForUser(User $referee): ?Referral + { + return Referral::active() + ->forReferee($referee->id) + ->first(); + } + + /** + * Calculate and create commission for an order. + */ + public function createCommissionForOrder(Order $order): ?ReferralCommission + { + // Find the referee (user who made the purchase) + $referee = $order->user; + if (! $referee) { + return null; + } + + // Find active referral for this user + $referral = Referral::active() + ->forReferee($referee->id) + ->first(); + + if (! $referral) { + return null; + } + + // Check if commission already exists for this order + $existingCommission = ReferralCommission::where('order_id', $order->id)->first(); + if ($existingCommission) { + return $existingCommission; + } + + // Get commission rate from referral code or default + $commissionRate = $this->getCommissionRateForReferral($referral); + + // Create commission + $commissionData = ReferralCommission::calculateForOrder($referral, $order, $commissionRate); + $commission = ReferralCommission::create($commissionData); + + // Mark referral as qualified if first purchase + if (! $referral->isQualified()) { + $referral->markQualified(); + } + + Log::info('Referral commission created', [ + 'commission_id' => $commission->id, + 'referral_id' => $referral->id, + 'order_id' => $order->id, + 'amount' => $commission->commission_amount, + ]); + + return $commission; + } + + /** + * Get commission rate for a referral. + */ + public function getCommissionRateForReferral(Referral $referral): float + { + // Check if referral code has custom rate + $referralCode = ReferralCode::valid()->byCode($referral->code)->first(); + + if ($referralCode && $referralCode->commission_rate !== null) { + return $referralCode->commission_rate; + } + + return ReferralCommission::DEFAULT_COMMISSION_RATE; + } + + /** + * Mature commissions that are ready. + */ + public function matureReadyCommissions(): int + { + $commissions = ReferralCommission::readyToMature()->get(); + $count = 0; + + foreach ($commissions as $commission) { + $commission->markMatured(); + $count++; + + // Also mature the referral if this is the first matured commission + $referral = $commission->referral; + if ($referral && ! $referral->hasMatured()) { + $referral->markMatured(); + } + } + + if ($count > 0) { + Log::info('Matured referral commissions', ['count' => $count]); + } + + return $count; + } + + /** + * Cancel commission for a refunded/chargedback order. + */ + public function cancelCommissionForOrder(Order $order, string $reason = 'Order refunded'): void + { + $commission = ReferralCommission::where('order_id', $order->id)->first(); + + if ($commission && ! $commission->isPaid()) { + $commission->cancel($reason); + + Log::info('Referral commission cancelled', [ + 'commission_id' => $commission->id, + 'order_id' => $order->id, + 'reason' => $reason, + ]); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Payout Management + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get user's available balance (matured, unpaid commissions). + */ + public function getAvailableBalance(User $user): float + { + return (float) ReferralCommission::forReferrer($user->id) + ->matured() + ->unpaid() + ->sum('commission_amount'); + } + + /** + * Get user's pending balance (not yet matured). + */ + public function getPendingBalance(User $user): float + { + return (float) ReferralCommission::forReferrer($user->id) + ->pending() + ->sum('commission_amount'); + } + + /** + * Get user's total lifetime earnings. + */ + public function getLifetimeEarnings(User $user): float + { + return (float) ReferralCommission::forReferrer($user->id) + ->whereIn('status', [ + ReferralCommission::STATUS_MATURED, + ReferralCommission::STATUS_PAID, + ]) + ->sum('commission_amount'); + } + + /** + * Get user's total paid out amount. + */ + public function getTotalPaidOut(User $user): float + { + return (float) ReferralPayout::forUser($user->id) + ->completed() + ->sum('amount'); + } + + /** + * Request a payout. + */ + public function requestPayout( + User $user, + string $method, + ?float $amount = null, + ?string $btcAddress = null + ): ReferralPayout { + return DB::transaction(function () use ($user, $method, $amount, $btcAddress) { + // Get available balance + $availableBalance = $this->getAvailableBalance($user); + + // Default to full balance + $amount = $amount ?? $availableBalance; + + // Validate amount + $minimumPayout = ReferralPayout::getMinimumPayout($method); + if ($amount < $minimumPayout) { + throw new \InvalidArgumentException( + "Minimum payout amount is GBP {$minimumPayout} for {$method}" + ); + } + + if ($amount > $availableBalance) { + throw new \InvalidArgumentException( + "Requested amount exceeds available balance of GBP {$availableBalance}" + ); + } + + // Validate BTC address if needed + if ($method === ReferralPayout::METHOD_BTC && ! $btcAddress) { + throw new \InvalidArgumentException('BTC address is required for Bitcoin payouts'); + } + + // Create payout + $payout = ReferralPayout::create([ + 'user_id' => $user->id, + 'payout_number' => ReferralPayout::generatePayoutNumber(), + 'method' => $method, + 'btc_address' => $btcAddress, + 'amount' => $amount, + 'currency' => 'GBP', + 'status' => ReferralPayout::STATUS_REQUESTED, + 'requested_at' => now(), + ]); + + // Assign matured commissions to this payout up to amount + $commissionsToAssign = ReferralCommission::forReferrer($user->id) + ->matured() + ->unpaid() + ->orderBy('matured_at') + ->get(); + + $assignedAmount = 0; + foreach ($commissionsToAssign as $commission) { + if ($assignedAmount >= $amount) { + break; + } + + $commission->update(['payout_id' => $payout->id]); + $assignedAmount += $commission->commission_amount; + } + + Log::info('Payout requested', [ + 'payout_id' => $payout->id, + 'user_id' => $user->id, + 'method' => $method, + 'amount' => $amount, + ]); + + return $payout; + }); + } + + /** + * Process a payout (admin action). + */ + public function processPayout(ReferralPayout $payout, User $admin): void + { + if (! $payout->isRequested()) { + throw new \InvalidArgumentException('Payout is not in requested status'); + } + + $payout->markProcessing($admin); + + Log::info('Payout processing started', [ + 'payout_id' => $payout->id, + 'admin_id' => $admin->id, + ]); + } + + /** + * Complete a payout (admin action). + */ + public function completePayout( + ReferralPayout $payout, + ?string $btcTxid = null, + ?float $btcAmount = null, + ?float $btcRate = null + ): void { + if (! $payout->isProcessing()) { + throw new \InvalidArgumentException('Payout is not in processing status'); + } + + $payout->markCompleted($btcTxid, $btcAmount, $btcRate); + + Log::info('Payout completed', [ + 'payout_id' => $payout->id, + 'btc_txid' => $btcTxid, + ]); + } + + /** + * Fail a payout (admin action). + */ + public function failPayout(ReferralPayout $payout, string $reason): void + { + $payout->markFailed($reason); + + Log::info('Payout failed', [ + 'payout_id' => $payout->id, + 'reason' => $reason, + ]); + } + + // ───────────────────────────────────────────────────────────────────────── + // Referral Code Management + // ───────────────────────────────────────────────────────────────────────── + + /** + * Create a custom referral code. + */ + public function createCode(array $data): ReferralCode + { + return ReferralCode::create(array_merge([ + 'type' => ReferralCode::TYPE_CUSTOM, + 'cookie_days' => ReferralCode::DEFAULT_COOKIE_DAYS, + 'is_active' => true, + ], $data)); + } + + /** + * Create a campaign referral code. + */ + public function createCampaignCode( + string $code, + string $campaignName, + ?int $userId = null, + ?float $commissionRate = null, + array $metadata = [] + ): ReferralCode { + return ReferralCode::create([ + 'code' => strtoupper($code), + 'user_id' => $userId, + 'type' => ReferralCode::TYPE_CAMPAIGN, + 'commission_rate' => $commissionRate, + 'cookie_days' => ReferralCode::DEFAULT_COOKIE_DAYS, + 'is_active' => true, + 'campaign_name' => $campaignName, + 'metadata' => $metadata, + ]); + } + + /** + * Find a referral code by code string. + */ + public function findCode(string $code): ?ReferralCode + { + return ReferralCode::byCode($code)->first(); + } + + /** + * Validate a referral code. + */ + public function validateCode(string $code): bool + { + // Check custom codes + $referralCode = ReferralCode::valid()->byCode($code)->first(); + if ($referralCode) { + return true; + } + + // Check user namespaces + $referrerId = $this->resolveReferrerFromCode($code); + + return $referrerId !== null; + } + + // ───────────────────────────────────────────────────────────────────────── + // Statistics + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get referral statistics for a user. + */ + public function getStatsForUser(User $user): array + { + $referrals = Referral::forReferrer($user->id); + + return [ + 'total_referrals' => $referrals->count(), + 'pending_referrals' => $referrals->clone()->pending()->count(), + 'converted_referrals' => $referrals->clone()->converted()->count(), + 'qualified_referrals' => $referrals->clone()->qualified()->count(), + 'available_balance' => $this->getAvailableBalance($user), + 'pending_balance' => $this->getPendingBalance($user), + 'lifetime_earnings' => $this->getLifetimeEarnings($user), + 'total_paid_out' => $this->getTotalPaidOut($user), + ]; + } + + /** + * Get global referral statistics (admin). + */ + public function getGlobalStats(): array + { + return [ + 'total_referrals' => Referral::count(), + 'active_referrals' => Referral::active()->count(), + 'qualified_referrals' => Referral::qualified()->count(), + 'total_commissions' => ReferralCommission::sum('commission_amount'), + 'pending_commissions' => ReferralCommission::pending()->sum('commission_amount'), + 'matured_commissions' => ReferralCommission::matured()->sum('commission_amount'), + 'paid_commissions' => ReferralCommission::paid()->sum('commission_amount'), + 'pending_payouts' => ReferralPayout::pending()->sum('amount'), + 'completed_payouts' => ReferralPayout::completed()->sum('amount'), + ]; + } + + /** + * Disqualify a referral (admin action). + */ + public function disqualifyReferral(Referral $referral, string $reason): void + { + DB::transaction(function () use ($referral, $reason) { + // Disqualify the referral + $referral->disqualify($reason); + + // Cancel any unpaid commissions + $referral->commissions() + ->whereIn('status', [ + ReferralCommission::STATUS_PENDING, + ReferralCommission::STATUS_MATURED, + ]) + ->each(fn ($c) => $c->cancel('Referral disqualified: '.$reason)); + + // Decrement referrer's referral count if they have one + $referrer = $referral->referrer; + if ($referrer && $referrer->referral_count > 0) { + $referrer->decrement('referral_count'); + } + }); + + Log::info('Referral disqualified', [ + 'referral_id' => $referral->id, + 'reason' => $reason, + ]); + } +} diff --git a/Services/RefundService.php b/Services/RefundService.php new file mode 100644 index 0000000..5c9e3dd --- /dev/null +++ b/Services/RefundService.php @@ -0,0 +1,181 @@ +getMaxRefundableAmount($payment); + + return $this->refund($payment, $refundableAmount, $reason, $notes, $initiatedBy); + } + + /** + * Process a partial refund for a payment. + */ + public function refund( + Payment $payment, + float $amount, + string $reason = 'requested_by_customer', + ?string $notes = null, + ?User $initiatedBy = null + ): Refund { + // Validate refund amount + $maxRefundable = $payment->amount - $payment->refunded_amount; + + if ($amount > $maxRefundable) { + throw new \InvalidArgumentException( + "Refund amount ({$amount}) exceeds maximum refundable amount ({$maxRefundable})" + ); + } + + if ($amount <= 0) { + throw new \InvalidArgumentException('Refund amount must be greater than zero'); + } + + // Can only refund successful or partially refunded payments + if (! in_array($payment->status, ['succeeded', 'partially_refunded'])) { + throw new \InvalidArgumentException('Can only refund successful payments'); + } + + return DB::transaction(function () use ($payment, $amount, $reason, $notes, $initiatedBy) { + // Create refund record + $refund = Refund::create([ + 'payment_id' => $payment->id, + 'amount' => $amount, + 'currency' => $payment->currency, + 'status' => 'pending', + 'reason' => $reason, + 'notes' => $notes, + 'initiated_by' => $initiatedBy?->id, + ]); + + // Process refund through gateway + try { + $gateway = $this->commerce->getGateway($payment->gateway); + $result = $gateway->refund($payment, $amount, $reason); + + if ($result['success']) { + $refund->markAsSucceeded($result['refund_id'] ?? null); + + // Send notification + $this->notifyRefundProcessed($payment, $refund); + + Log::info('Refund processed successfully', [ + 'refund_id' => $refund->id, + 'payment_id' => $payment->id, + 'amount' => $amount, + ]); + } else { + $refund->markAsFailed($result); + + Log::warning('Refund failed at gateway', [ + 'refund_id' => $refund->id, + 'payment_id' => $payment->id, + 'response' => $result, + ]); + } + } catch (\Exception $e) { + $refund->markAsFailed(['error' => $e->getMessage()]); + + Log::error('Refund processing error', [ + 'refund_id' => $refund->id, + 'payment_id' => $payment->id, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + + return $refund; + }); + } + + /** + * Check if a payment can be refunded. + */ + public function canRefund(Payment $payment): bool + { + if (! in_array($payment->status, ['succeeded', 'partially_refunded'])) { + return false; + } + + if ($payment->isFullyRefunded()) { + return false; + } + + // Check gateway-specific refund window (usually 180 days for Stripe) + $refundWindowDays = config('commerce.refunds.window_days', 180); + if ($payment->created_at && $payment->created_at->diffInDays(now()) > $refundWindowDays) { + return false; + } + + return true; + } + + /** + * Get maximum refundable amount for a payment. + */ + public function getMaxRefundableAmount(Payment $payment): float + { + return max(0, $payment->amount - $payment->refunded_amount); + } + + /** + * Notify user of processed refund. + */ + protected function notifyRefundProcessed(Payment $payment, Refund $refund): void + { + if (! config('commerce.notifications.refund_processed', true)) { + return; + } + + $workspace = $payment->workspace; + $owner = $workspace?->owner(); + + if ($owner) { + $owner->notify(new RefundProcessed($refund)); + } + } + + /** + * Get refund history for a payment. + */ + public function getRefundsForPayment(Payment $payment): \Illuminate\Database\Eloquent\Collection + { + return $payment->refunds()->latest()->get(); + } + + /** + * Get all refunds for a workspace. + */ + public function getRefundsForWorkspace(int $workspaceId): \Illuminate\Database\Eloquent\Collection + { + return Refund::query() + ->whereHas('payment', function ($query) use ($workspaceId) { + $query->where('workspace_id', $workspaceId); + }) + ->with('payment') + ->latest() + ->get(); + } +} diff --git a/Services/SkuBuilderService.php b/Services/SkuBuilderService.php new file mode 100644 index 0000000..0002c39 --- /dev/null +++ b/Services/SkuBuilderService.php @@ -0,0 +1,182 @@ + $lineItems + */ + public function build(array $lineItems): string + { + if (empty($lineItems)) { + return ''; + } + + // Group items by bundle_group (null = standalone) + $groups = []; + $standalone = []; + + foreach ($lineItems as $item) { + $bundleGroup = $item['bundle_group'] ?? null; + + if ($bundleGroup !== null) { + $groups[$bundleGroup][] = $item; + } else { + $standalone[] = $item; + } + } + + $skuParts = []; + + // Build bundles (pipe-separated) + foreach ($groups as $groupItems) { + $bundleParts = []; + foreach ($groupItems as $item) { + $bundleParts[] = $this->buildItemSku($item); + } + $skuParts[] = implode('|', $bundleParts); + } + + // Build standalone items + foreach ($standalone as $item) { + $skuParts[] = $this->buildItemSku($item); + } + + // Comma-separate all parts + return implode(',', $skuParts); + } + + /** + * Build SKU for a single item with options. + * + * @param array{base_sku: string, options?: array} $item + */ + public function buildItemSku(array $item): string + { + $sku = strtoupper($item['base_sku']); + + foreach ($item['options'] ?? [] as $option) { + $code = strtolower($option['code'] ?? $option[0] ?? ''); + $value = $option['value'] ?? $option[1] ?? ''; + $quantity = $option['quantity'] ?? $option[2] ?? 1; + + if ($code && $value) { + $sku .= "-{$code}~{$value}"; + + if ($quantity > 1) { + $sku .= "*{$quantity}"; + } + } + } + + return $sku; + } + + /** + * Build from ParsedItem objects. + * + * @param array $items + */ + public function buildFromParsedItems(array $items, bool $asBundle = false): string + { + $skuParts = array_map( + fn (ParsedItem $item) => $item->toString(), + $items + ); + + return implode($asBundle ? '|' : ',', $skuParts); + } + + /** + * Build from SkuParseResult (round-trip). + */ + public function buildFromResult(SkuParseResult $result): string + { + return $result->toString(); + } + + /** + * Generate bundle hash for discount creation. + * + * @param array $baseSkus Base SKUs without options + */ + public function generateBundleHash(array $baseSkus): string + { + $sorted = collect($baseSkus) + ->map(fn (string $sku) => strtoupper($sku)) + ->sort() + ->implode('|'); + + return hash('sha256', $sorted); + } + + /** + * Add entity lineage prefix to a base SKU. + * + * @param string $baseSku The product SKU + * @param array $entityCodes Entity codes in order [M1, M2, M3...] + */ + public function addLineage(string $baseSku, array $entityCodes): string + { + if (empty($entityCodes)) { + return strtoupper($baseSku); + } + + $prefix = implode('-', array_map('strtoupper', $entityCodes)); + + return $prefix.'-'.strtoupper($baseSku); + } + + /** + * Build a complete compound SKU with entity lineage. + * + * @param array $entityCodes Entity codes [M1, M2, ...] + * @param array $lineItems + */ + public function buildWithLineage(array $entityCodes, array $lineItems): string + { + // Add lineage to each item's base SKU + $prefixedItems = array_map(function (array $item) use ($entityCodes) { + $item['base_sku'] = $this->addLineage($item['base_sku'], $entityCodes); + + return $item; + }, $lineItems); + + return $this->build($prefixedItems); + } + + /** + * Create a new option. + */ + public function option(string $code, string $value, int $quantity = 1): SkuOption + { + return new SkuOption($code, $value, $quantity); + } + + /** + * Create a new parsed item. + * + * @param array $options + */ + public function item(string $baseSku, array $options = []): ParsedItem + { + return new ParsedItem($baseSku, $options); + } +} diff --git a/Services/SkuLineageService.php b/Services/SkuLineageService.php new file mode 100644 index 0000000..9f1ae9b --- /dev/null +++ b/Services/SkuLineageService.php @@ -0,0 +1,284 @@ +sku; + + return $entity->buildSku($baseSku); + } + + /** + * Build SKU from assignment (uses assignment's suffix if set). + */ + public function buildFromAssignment(ProductAssignment $assignment): string + { + return $assignment->getFullSku(); + } + + /** + * Parse a full SKU into its components. + * + * @return array{entity_codes: array, base_sku: string, full_path: string} + */ + public function parseSku(string $fullSku): array + { + $parts = explode('-', strtoupper($fullSku)); + + if (count($parts) < 2) { + return [ + 'entity_codes' => [], + 'base_sku' => $fullSku, + 'full_path' => '', + ]; + } + + // Base SKU is the last part + $baseSku = array_pop($parts); + + return [ + 'entity_codes' => $parts, + 'base_sku' => $baseSku, + 'full_path' => implode('/', $parts), + ]; + } + + /** + * Resolve SKU to product and entity. + * + * @return array{product: Product, entity: Entity, assignment: ?ProductAssignment}|null + */ + public function resolve(string $fullSku): ?array + { + $parsed = $this->parseSku($fullSku); + + if (empty($parsed['entity_codes'])) { + return null; + } + + // Try to find the entity by path + $entity = Entity::where('path', $parsed['full_path'])->first(); + + if (! $entity) { + return null; + } + + // Find product - might need to try multiple combinations + $product = $this->findProduct($parsed['base_sku'], $parsed['entity_codes']); + + if (! $product) { + return null; + } + + $assignment = null; + if (! $entity->isM1()) { + $assignment = ProductAssignment::where('entity_id', $entity->id) + ->where('product_id', $product->id) + ->first(); + } + + return [ + 'product' => $product, + 'entity' => $entity, + 'assignment' => $assignment, + ]; + } + + /** + * Find product by base SKU, trying various combinations. + */ + protected function findProduct(string $baseSku, array $entityCodes): ?Product + { + // Direct match first + $product = Product::where('sku', $baseSku)->first(); + if ($product) { + return $product; + } + + // Product SKU might include entity prefix already + // Try progressively longer SKUs + $testSku = $baseSku; + for ($i = count($entityCodes) - 1; $i >= 0; $i--) { + $testSku = $entityCodes[$i].'-'.$testSku; + $product = Product::where('sku', $testSku)->first(); + if ($product) { + return $product; + } + } + + return null; + } + + /** + * Get all SKU variants for a product across entities. + * + * Returns all the different SKUs under which this product is sold. + */ + public function getSkuVariants(Product $product): Collection + { + $variants = collect(); + + // Owner's SKU + $owner = $product->ownerEntity; + if ($owner) { + $variants->push([ + 'entity' => $owner, + 'sku' => $owner->buildSku($product->sku), + 'type' => 'owner', + ]); + } + + // Assignment SKUs + $assignments = $product->assignments()->with('entity')->get(); + foreach ($assignments as $assignment) { + $variants->push([ + 'entity' => $assignment->entity, + 'sku' => $assignment->getFullSku(), + 'type' => 'assignment', + 'has_suffix' => ! is_null($assignment->sku_suffix), + ]); + } + + return $variants; + } + + /** + * Validate SKU format. + */ + public function validateSku(string $sku): array + { + $errors = []; + + // Check length + if (strlen($sku) > 64) { + $errors[] = 'SKU exceeds maximum length of 64 characters.'; + } + + // Check for valid characters + if (! preg_match('/^[A-Z0-9\-]+$/', strtoupper($sku))) { + $errors[] = 'SKU contains invalid characters. Only A-Z, 0-9, and hyphens allowed.'; + } + + // Check minimum parts + $parts = explode('-', $sku); + if (count($parts) < 2) { + $errors[] = 'SKU must contain at least one entity code and a base SKU.'; + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + ]; + } + + /** + * Generate a unique base SKU. + */ + public function generateBaseSku(string $prefix = '', int $length = 8): string + { + return Product::generateSku($prefix); + } + + /** + * Check if a base SKU is available. + */ + public function isSkuAvailable(string $baseSku): bool + { + return ! Product::where('sku', strtoupper($baseSku))->exists(); + } + + /** + * Trace SKU lineage - get full chain of entities. + */ + public function traceLineage(string $fullSku): array + { + $parsed = $this->parseSku($fullSku); + + if (empty($parsed['entity_codes'])) { + return []; + } + + $chain = []; + $currentPath = ''; + + foreach ($parsed['entity_codes'] as $code) { + $currentPath .= ($currentPath ? '/' : '').$code; + $entity = Entity::where('path', $currentPath)->first(); + + if ($entity) { + $chain[] = [ + 'code' => $code, + 'entity' => $entity, + 'type' => $entity->type, + 'name' => $entity->name, + ]; + } + } + + return $chain; + } + + /** + * Get reporting data for a SKU (useful for analytics). + */ + public function getSkuReport(string $fullSku): ?array + { + $resolved = $this->resolve($fullSku); + + if (! $resolved) { + return null; + } + + $lineage = $this->traceLineage($fullSku); + $m1 = collect($lineage)->first(fn ($e) => $e['type'] === 'M1'); + + return [ + 'full_sku' => strtoupper($fullSku), + 'base_sku' => $resolved['product']->sku, + 'product_name' => $resolved['product']->name, + 'selling_entity' => [ + 'id' => $resolved['entity']->id, + 'code' => $resolved['entity']->code, + 'name' => $resolved['entity']->name, + 'type' => $resolved['entity']->type, + ], + 'owner_entity' => $m1 ? [ + 'id' => $m1['entity']->id, + 'code' => $m1['code'], + 'name' => $m1['name'], + ] : null, + 'lineage' => $lineage, + 'effective_price' => $resolved['assignment'] + ? $resolved['assignment']->getEffectivePrice() + : $resolved['product']->price, + ]; + } +} diff --git a/Services/SkuParserService.php b/Services/SkuParserService.php new file mode 100644 index 0000000..1042416 --- /dev/null +++ b/Services/SkuParserService.php @@ -0,0 +1,192 @@ +~*[-~*]... + * + * Separators: + * - Option separator (within an item) + * ~ Value indicator (option~value) + * * Quantity indicator (optional, default 1) + * , Item separator (multiple items) + * | Bundle separator (grouped items for discount) + * + * Examples: + * LAPTOP-ram~16gb-ssd~512gb Single item with options + * LAPTOP-ram~16gb-cover~black*2 Item with quantity on option + * LAPTOP-ram~16gb,MOUSE,PAD Multiple separate items + * LAPTOP-ram~16gb|MOUSE|PAD Bundle (discount lookup) + * + * One scan tells you everything. No lookups. No mistakes. + */ +class SkuParserService +{ + /** + * Parse a compound SKU string into structured data. + */ + public function parse(string $compoundSku): SkuParseResult + { + $compoundSku = trim($compoundSku); + + if ($compoundSku === '') { + return new SkuParseResult([]); + } + + // Split by comma for multiple items + $segments = explode(',', $compoundSku); + $parsedItems = []; + + foreach ($segments as $segment) { + $segment = trim($segment); + + if ($segment === '') { + continue; + } + + // Check for bundle separator + if (str_contains($segment, '|')) { + $bundleParts = explode('|', $segment); + $bundleItems = []; + + foreach ($bundleParts as $part) { + $part = trim($part); + if ($part !== '') { + $bundleItems[] = $this->parseItem($part); + } + } + + if (! empty($bundleItems)) { + $parsedItems[] = new BundleItem( + items: $bundleItems, + hash: $this->hashBundle($bundleItems) + ); + } + } else { + $parsedItems[] = $this->parseItem($segment); + } + } + + return new SkuParseResult($parsedItems); + } + + /** + * Parse a single item: SKU-opt~val*qty-opt~val*qty + */ + protected function parseItem(string $item): ParsedItem + { + // First hyphen separates base SKU from options + // But base SKU might contain hyphens if it's a lineage SKU (ORGORG-WBUTS-PROD) + // So we need to find where options start (first segment with ~) + + $parts = explode('-', $item); + $baseParts = []; + $optionParts = []; + $inOptions = false; + + foreach ($parts as $part) { + if (! $inOptions && ! str_contains($part, '~')) { + $baseParts[] = $part; + } else { + $inOptions = true; + $optionParts[] = $part; + } + } + + $baseSku = implode('-', $baseParts); + $options = []; + + // Parse each option: opt~val*qty + foreach ($optionParts as $optPart) { + $option = $this->parseOption($optPart); + if ($option !== null) { + $options[] = $option; + } + } + + return new ParsedItem( + baseSku: strtoupper($baseSku), + options: $options + ); + } + + /** + * Parse a single option: opt~val*qty + */ + protected function parseOption(string $optString): ?SkuOption + { + // Match: code~value*quantity (quantity optional) + if (! preg_match('/^([a-z_][a-z0-9_]*)~([^*]+)(?:\*(\d+))?$/i', $optString, $matches)) { + return null; + } + + return new SkuOption( + code: strtolower($matches[1]), + value: $matches[2], + quantity: isset($matches[3]) ? (int) $matches[3] : 1 + ); + } + + /** + * Hash bundle for discount lookup (strips human choices). + * + * @param array $items + */ + protected function hashBundle(array $items): string + { + $baseSkus = collect($items) + ->map(fn (ParsedItem $item) => strtoupper($item->baseSku)) + ->sort() + ->implode('|'); + + return hash('sha256', $baseSkus); + } + + /** + * Validate compound SKU format. + * + * @return array{valid: bool, errors: array} + */ + public function validate(string $compoundSku): array + { + $errors = []; + + if (strlen($compoundSku) > 1024) { + $errors[] = 'Compound SKU exceeds maximum length of 1024 characters.'; + } + + // Try to parse and check for issues + $result = $this->parse($compoundSku); + + if ($result->count() === 0) { + $errors[] = 'No valid items found in SKU string.'; + } + + // Check each item has a base SKU + foreach ($result->collect() as $item) { + if ($item instanceof BundleItem) { + foreach ($item->items as $bundleItem) { + if (empty($bundleItem->baseSku)) { + $errors[] = 'Bundle contains item with empty base SKU.'; + } + } + } elseif (empty($item->baseSku)) { + $errors[] = 'Item has empty base SKU.'; + } + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + ]; + } +} diff --git a/Services/SubscriptionService.php b/Services/SubscriptionService.php new file mode 100644 index 0000000..3778d3f --- /dev/null +++ b/Services/SubscriptionService.php @@ -0,0 +1,456 @@ +addDays(365) + : Carbon::now()->addDays(30); + + return Subscription::create([ + 'workspace_id' => $workspacePackage->workspace_id, + 'workspace_package_id' => $workspacePackage->id, + 'status' => 'active', + 'gateway' => $gateway ?? config('commerce.default_gateway', 'btcpay'), + 'gateway_subscription_id' => $gatewaySubscriptionId, + 'current_period_start' => Carbon::now(), + 'current_period_end' => $periodEnd, + 'billing_cycle' => $billingCycle, + ]); + } + + /** + * Cancel a subscription (set to expire at period end). + */ + public function cancel(Subscription $subscription, ?string $reason = null): Subscription + { + $subscription->update([ + 'cancelled_at' => Carbon::now(), + 'cancellation_reason' => $reason, + ]); + + return $subscription->fresh(); + } + + /** + * Resume a cancelled subscription (before it expires). + */ + public function resume(Subscription $subscription): Subscription + { + if (! $subscription->cancelled_at) { + return $subscription; + } + + // Only resume if still within billing period + if ($subscription->current_period_end && $subscription->current_period_end->isFuture()) { + $subscription->update([ + 'cancelled_at' => null, + 'cancellation_reason' => null, + ]); + } + + return $subscription->fresh(); + } + + /** + * Renew a subscription for another billing period. + */ + public function renew(Subscription $subscription): Subscription + { + $billingCycle = $subscription->billing_cycle ?? 'monthly'; + + $newPeriodStart = $subscription->current_period_end ?? Carbon::now(); + // Use fixed days for predictable billing periods + $newPeriodEnd = $billingCycle === 'yearly' + ? $newPeriodStart->copy()->addDays(365) + : $newPeriodStart->copy()->addDays(30); + + $subscription->update([ + 'current_period_start' => $newPeriodStart, + 'current_period_end' => $newPeriodEnd, + 'cancelled_at' => null, + 'cancellation_reason' => null, + ]); + + return $subscription->fresh(); + } + + /** + * Expire a subscription (end it immediately or at period end). + */ + public function expire(Subscription $subscription): Subscription + { + $subscription->update([ + 'status' => 'expired', + 'ended_at' => Carbon::now(), + ]); + + // Revoke the associated workspace package if configured + if ($subscription->workspacePackage) { + $subscription->workspacePackage->update([ + 'status' => 'expired', + 'expires_at' => Carbon::now(), + ]); + } + + return $subscription->fresh(); + } + + /** + * Pause a subscription (for dunning/failed payments). + * + * @param bool $force Skip pause limit check (for dunning/system use) + * + * @throws PauseLimitExceededException When pause limit exceeded and not forced + */ + public function pause(Subscription $subscription, bool $force = false): Subscription + { + // Cannot pause a subscription that is not active + if ($subscription->status !== 'active') { + return $subscription; + } + + // Check if pause is allowed by config + if (! config('commerce.subscriptions.allow_pause', true)) { + throw new \InvalidArgumentException('Subscription pausing is not enabled.'); + } + + // Check pause limit unless forced (e.g., by dunning service) + if (! $force && ! $subscription->canPause()) { + $maxPauseCycles = config('commerce.subscriptions.max_pause_cycles', 3); + + throw new PauseLimitExceededException($subscription, $maxPauseCycles); + } + + $subscription->update([ + 'status' => 'paused', + 'paused_at' => Carbon::now(), + 'pause_count' => ($subscription->pause_count ?? 0) + 1, + ]); + + Log::info('Subscription paused', [ + 'subscription_id' => $subscription->id, + 'pause_count' => $subscription->fresh()->pause_count, + 'forced' => $force, + ]); + + return $subscription->fresh(); + } + + /** + * Unpause a subscription. + */ + public function unpause(Subscription $subscription): Subscription + { + if ($subscription->status !== 'paused') { + return $subscription; + } + + $subscription->update([ + 'status' => 'active', + 'paused_at' => null, + ]); + + return $subscription->fresh(); + } + + /** + * Change subscription to a different package (upgrade/downgrade). + * + * @param bool $prorate Whether to prorate (charge/credit difference immediately) + * @param bool $immediate Whether to apply change immediately or at period end + */ + public function changePlan( + Subscription $subscription, + Package $newPackage, + bool $prorate = true, + bool $immediate = true + ): array { + return DB::transaction(function () use ($subscription, $newPackage, $prorate, $immediate) { + $workspace = $subscription->workspace; + $currentPackage = $subscription->workspacePackage?->package; + $billingCycle = $subscription->billing_cycle ?? 'monthly'; + + // Calculate proration if enabled + $proration = null; + if ($prorate && $currentPackage && $immediate) { + $proration = $this->calculateProration( + $subscription, + $currentPackage, + $newPackage, + $billingCycle + ); + } + + if ($immediate) { + // Provision new package immediately + $newWorkspacePackage = $this->entitlements->provisionPackage( + $workspace, + $newPackage->code, + [ + 'subscription_id' => $subscription->id, + 'source' => $subscription->gateway, + 'prorated_from' => $currentPackage?->code, + ] + ); + + // Update subscription to point to new package + $subscription->update([ + 'workspace_package_id' => $newWorkspacePackage->id, + 'metadata' => array_merge($subscription->metadata ?? [], [ + 'plan_change' => [ + 'from' => $currentPackage?->code, + 'to' => $newPackage->code, + 'changed_at' => now()->toISOString(), + 'proration' => $proration?->toArray(), + ], + ]), + ]); + + // Revoke old package entitlements + if ($currentPackage) { + $this->entitlements->revokePackage($workspace, $currentPackage->code); + } + + Log::info('Subscription plan changed immediately', [ + 'subscription_id' => $subscription->id, + 'from_package' => $currentPackage?->code, + 'to_package' => $newPackage->code, + 'proration' => $proration?->toArray(), + ]); + } else { + // Schedule change for end of billing period + $subscription->update([ + 'metadata' => array_merge($subscription->metadata ?? [], [ + 'pending_plan_change' => [ + 'to_package_id' => $newPackage->id, + 'to_package_code' => $newPackage->code, + 'scheduled_for' => $subscription->current_period_end?->toISOString(), + ], + ]), + ]); + + Log::info('Subscription plan change scheduled', [ + 'subscription_id' => $subscription->id, + 'to_package' => $newPackage->code, + 'scheduled_for' => $subscription->current_period_end, + ]); + } + + return [ + 'subscription' => $subscription->fresh(), + 'proration' => $proration, + 'immediate' => $immediate, + ]; + }); + } + + /** + * Calculate proration for a plan change. + */ + public function calculateProration( + Subscription $subscription, + Package $currentPackage, + Package $newPackage, + string $billingCycle = 'monthly' + ): ProrationResult { + $now = Carbon::now(); + $periodStart = $subscription->current_period_start; + $periodEnd = $subscription->current_period_end; + + // Calculate days in period and days remaining + // Note: diffInDays returns absolute value when using absolute: true (default in Carbon 2) + // In Carbon 3, we need to ensure we get positive values + $totalPeriodDays = (int) $periodStart->diffInDays($periodEnd, absolute: true); + $daysUsed = (int) $periodStart->diffInDays($now, absolute: true); + $daysRemaining = (int) max(0, $now->diffInDays($periodEnd, absolute: true)); + + // Avoid division by zero + if ($totalPeriodDays <= 0) { + $totalPeriodDays = $billingCycle === 'yearly' ? 365 : 30; + } + + $usedPercentage = $daysUsed / $totalPeriodDays; + $remainingPercentage = 1 - $usedPercentage; + + // Get prices for the billing cycle + $currentPrice = $currentPackage->getPrice($billingCycle); + $newPrice = $newPackage->getPrice($billingCycle); + + // Calculate credit from unused current plan time + $creditAmount = round($currentPrice * $remainingPercentage, 2); + + // Calculate prorated cost for new plan for remaining period + $proratedNewCost = round($newPrice * $remainingPercentage, 2); + + // Net amount: positive = customer pays, negative = credit + $netAmount = round($proratedNewCost - $creditAmount, 2); + + return new ProrationResult( + daysRemaining: $daysRemaining, + totalPeriodDays: $totalPeriodDays, + usedPercentage: round($usedPercentage, 4), + currentPlanPrice: $currentPrice, + newPlanPrice: $newPrice, + creditAmount: $creditAmount, + proratedNewPlanCost: $proratedNewCost, + netAmount: $netAmount, + currency: config('commerce.currency', 'GBP'), + ); + } + + /** + * Preview proration without making changes. + */ + public function previewPlanChange( + Subscription $subscription, + Package $newPackage, + ?string $billingCycle = null + ): ProrationResult { + $currentPackage = $subscription->workspacePackage?->package; + + if (! $currentPackage) { + throw new \InvalidArgumentException('Subscription has no current package'); + } + + $billingCycle = $billingCycle ?? $subscription->billing_cycle ?? 'monthly'; + + return $this->calculateProration( + $subscription, + $currentPackage, + $newPackage, + $billingCycle + ); + } + + /** + * Apply scheduled plan change (called when period ends). + */ + public function applyScheduledPlanChange(Subscription $subscription): ?Subscription + { + $pendingChange = $subscription->metadata['pending_plan_change'] ?? null; + + if (! $pendingChange) { + return null; + } + + $newPackage = Package::find($pendingChange['to_package_id']); + + if (! $newPackage) { + Log::warning('Scheduled plan change failed: package not found', [ + 'subscription_id' => $subscription->id, + 'package_id' => $pendingChange['to_package_id'], + ]); + + return null; + } + + // Apply the change without proration (since it's at period end) + $result = $this->changePlan($subscription, $newPackage, prorate: false, immediate: true); + + // Clear the pending change + $metadata = $subscription->metadata ?? []; + unset($metadata['pending_plan_change']); + $subscription->update(['metadata' => $metadata]); + + return $result['subscription']; + } + + /** + * Cancel a pending plan change. + */ + public function cancelScheduledPlanChange(Subscription $subscription): Subscription + { + $metadata = $subscription->metadata ?? []; + unset($metadata['pending_plan_change']); + + $subscription->update(['metadata' => $metadata]); + + return $subscription->fresh(); + } + + /** + * Check if subscription has a pending plan change. + */ + public function hasPendingPlanChange(Subscription $subscription): bool + { + return isset($subscription->metadata['pending_plan_change']); + } + + /** + * Get pending plan change details. + */ + public function getPendingPlanChange(Subscription $subscription): ?array + { + return $subscription->metadata['pending_plan_change'] ?? null; + } + + /** + * Get subscriptions expiring soon (for renewal reminders). + */ + public function getExpiringSoon(int $days = 7): \Illuminate\Database\Eloquent\Collection + { + return Subscription::query() + ->active() + ->whereNull('cancelled_at') + ->where('current_period_end', '<=', Carbon::now()->addDays($days)) + ->where('current_period_end', '>', Carbon::now()) + ->with('workspace', 'workspacePackage.package') + ->get(); + } + + /** + * Get subscriptions that have failed payment and need dunning. + */ + public function getFailedPayments(): \Illuminate\Database\Eloquent\Collection + { + return Subscription::query() + ->where('status', 'past_due') + ->with('workspace', 'workspacePackage.package') + ->get(); + } + + /** + * Process expired subscriptions (called by scheduler). + */ + public function processExpired(): int + { + $expired = Subscription::query() + ->active() + ->whereNotNull('cancelled_at') + ->where('current_period_end', '<=', Carbon::now()) + ->get(); + + foreach ($expired as $subscription) { + $this->expire($subscription); + } + + return $expired->count(); + } +} diff --git a/Services/TaxService.php b/Services/TaxService.php new file mode 100644 index 0000000..2e18ea2 --- /dev/null +++ b/Services/TaxService.php @@ -0,0 +1,386 @@ +tax_exempt) { + return new TaxResult(0, 0, null, $workspace->billing_country, true, 'Tax exempt'); + } + + $country = strtoupper($workspace->billing_country ?? 'GB'); + $state = $workspace->billing_state; + $taxId = $workspace->tax_id; + + // B2B reverse charge for EU/UK + if ($taxId && $this->isValidTaxId($taxId, $country)) { + if ($this->isReverseChargeApplicable($country)) { + return new TaxResult(0, 0, 'reverse_charge', $country, true, 'B2B reverse charge'); + } + } + + // Find applicable tax rate + $taxRate = TaxRate::findForLocation($country, $state); + + if (! $taxRate) { + // No tax rate found - could be a non-taxable jurisdiction + return new TaxResult(0, 0, null, $country, false, null); + } + + $taxAmount = $taxRate->calculateTax($amount); + + return new TaxResult( + taxAmount: $taxAmount, + taxRate: $taxRate->rate, + taxType: $taxRate->type, + jurisdiction: $taxRate->state_code + ? "{$country}-{$taxRate->state_code}" + : $country, + isExempt: false, + exemptionReason: null + ); + } + + /** + * Calculate tax for an Orderable (User or Workspace). + */ + public function calculateForOrderable(Orderable $orderable, float $amount): TaxResult + { + if (! config('commerce.tax.enabled', true)) { + return new TaxResult(0, 0, null, null, true, 'Tax disabled'); + } + + $country = strtoupper($orderable->getTaxCountry() ?? 'GB'); + + // Find applicable tax rate + $taxRate = TaxRate::findForLocation($country, null); + + if (! $taxRate) { + return new TaxResult(0, 0, null, $country, false, null); + } + + $taxAmount = $taxRate->calculateTax($amount); + + return new TaxResult( + taxAmount: $taxAmount, + taxRate: $taxRate->rate, + taxType: $taxRate->type, + jurisdiction: $country, + isExempt: false, + exemptionReason: null + ); + } + + /** + * Get tax rate for a location. + */ + public function getRateForLocation(string $country, ?string $state = null): ?TaxRate + { + return TaxRate::findForLocation($country, $state); + } + + /** + * Check if reverse charge is applicable. + */ + public function isReverseChargeApplicable(string $country): bool + { + // UK B2B: No reverse charge (we're in UK) + if ($country === 'GB') { + return false; + } + + // EU B2B: Reverse charge applies + if ($this->isEuCountry($country)) { + return true; + } + + return false; + } + + /** + * Check if country is in EU. + */ + public function isEuCountry(string $country): bool + { + $euCountries = [ + 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', + 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', + 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', + ]; + + return in_array(strtoupper($country), $euCountries); + } + + /** + * Validate a tax ID (VAT number, ABN, etc.). + */ + public function isValidTaxId(string $taxId, string $country): bool + { + if (! config('commerce.tax.validate_tax_ids', true)) { + // Skip validation if disabled + return true; + } + + $country = strtoupper($country); + + return match (true) { + $country === 'GB' => $this->validateUkVat($taxId), + $this->isEuCountry($country) => $this->validateEuVat($taxId), + $country === 'AU' => $this->validateAbn($taxId), + default => true, // Accept as valid if we can't validate + }; + } + + /** + * Validate UK VAT number via HMRC API. + * + * Uses the free HMRC VAT API to verify VAT numbers. + * Results are cached for 24 hours to reduce API calls. + * + * @see https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/vat-api + */ + protected function validateUkVat(string $vatNumber): bool + { + // Basic format validation: GB followed by 9 or 12 digits + $vatNumber = strtoupper(str_replace([' ', '-'], '', $vatNumber)); + + if (! preg_match('/^GB(\d{9}|\d{12})$/', $vatNumber)) { + return false; + } + + // Extract just the numeric part for HMRC API + $vatNumberOnly = substr($vatNumber, 2); + + // Check cache first + $cacheKey = "vat_validation:uk:{$vatNumberOnly}"; + if (Cache::has($cacheKey)) { + return Cache::get($cacheKey); + } + + // Skip API validation if disabled or in testing + if (! config('commerce.tax.validate_tax_ids_api', true) || app()->environment('testing')) { + return true; + } + + try { + // HMRC Check VAT Number API (free, no auth required) + $response = Http::timeout(10) + ->get("https://api.service.hmrc.gov.uk/organisations/vat/check-vat-number/lookup/{$vatNumberOnly}"); + + if ($response->successful()) { + $data = $response->json(); + $isValid = isset($data['target']['vatNumber']); + + Cache::put($cacheKey, $isValid, self::VALIDATION_CACHE_TTL); + + return $isValid; + } + + // If API returns 404, the VAT number doesn't exist + if ($response->status() === 404) { + Cache::put($cacheKey, false, self::VALIDATION_CACHE_TTL); + + return false; + } + + // For other errors, log and allow (fail open for availability) + Log::warning('HMRC VAT validation API error', [ + 'vat_number' => $vatNumberOnly, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return true; + } catch (\Exception $e) { + Log::warning('HMRC VAT validation failed', [ + 'vat_number' => $vatNumberOnly, + 'error' => $e->getMessage(), + ]); + + // Fail open - don't block transactions if API is unavailable + return true; + } + } + + /** + * Validate EU VAT number via VIES (VAT Information Exchange System). + * + * Uses the EU Commission's VIES SOAP service to verify VAT numbers. + * Results are cached for 24 hours to reduce API calls. + * + * @see https://ec.europa.eu/taxation_customs/vies/ + */ + protected function validateEuVat(string $vatNumber): bool + { + // Basic format validation + $vatNumber = strtoupper(str_replace([' ', '-'], '', $vatNumber)); + + if (strlen($vatNumber) < 4) { + return false; + } + + // Extract country code (first 2 characters) + $countryCode = substr($vatNumber, 0, 2); + $vatNumberOnly = substr($vatNumber, 2); + + // Validate country code is EU + if (! $this->isEuCountry($countryCode)) { + return false; + } + + // Check cache first + $cacheKey = "vat_validation:eu:{$countryCode}:{$vatNumberOnly}"; + if (Cache::has($cacheKey)) { + return Cache::get($cacheKey); + } + + // Skip API validation if disabled or in testing + if (! config('commerce.tax.validate_tax_ids_api', true) || app()->environment('testing')) { + return true; + } + + try { + // VIES REST API (EU Commission) + $response = Http::timeout(15) + ->asJson() + ->post('https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number', [ + 'countryCode' => $countryCode, + 'vatNumber' => $vatNumberOnly, + ]); + + if ($response->successful()) { + $data = $response->json(); + $isValid = $data['valid'] ?? false; + + Cache::put($cacheKey, $isValid, self::VALIDATION_CACHE_TTL); + + return $isValid; + } + + // Log non-success responses + Log::warning('VIES VAT validation API error', [ + 'country_code' => $countryCode, + 'vat_number' => $vatNumberOnly, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + // Fail open for availability + return true; + } catch (\Exception $e) { + Log::warning('VIES VAT validation failed', [ + 'country_code' => $countryCode, + 'vat_number' => $vatNumberOnly, + 'error' => $e->getMessage(), + ]); + + // Fail open - don't block transactions if API is unavailable + return true; + } + } + + /** + * Validate Australian Business Number (ABN). + */ + protected function validateAbn(string $abn): bool + { + // ABN is 11 digits + $abn = preg_replace('/[^0-9]/', '', $abn); + + if (strlen($abn) !== 11) { + return false; + } + + // ABN checksum validation + $weights = [10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19]; + + // Subtract 1 from first digit + $abn[0] = (int) $abn[0] - 1; + + $sum = 0; + for ($i = 0; $i < 11; $i++) { + $sum += (int) $abn[$i] * $weights[$i]; + } + + return $sum % 89 === 0; + } + + /** + * Get tax type label. + */ + public function getTaxTypeLabel(string $type): string + { + return match ($type) { + 'vat' => 'VAT', + 'gst' => 'GST', + 'sales_tax' => 'Sales Tax', + default => 'Tax', + }; + } +} + +/** + * Tax calculation result. + */ +class TaxResult +{ + public function __construct( + public readonly float $taxAmount, + public readonly float $taxRate, + public readonly ?string $taxType, + public readonly ?string $jurisdiction, + public readonly bool $isExempt, + public readonly ?string $exemptionReason, + ) {} + + /** + * Get total including tax. + */ + public function addToAmount(float $amount): float + { + return $amount + $this->taxAmount; + } + + /** + * Check if tax applies. + */ + public function hasTax(): bool + { + return $this->taxAmount > 0; + } + + /** + * Get formatted rate. + */ + public function getFormattedRate(): string + { + return number_format($this->taxRate, 1).'%'; + } +} diff --git a/Services/UsageBillingService.php b/Services/UsageBillingService.php new file mode 100644 index 0000000..5152a30 --- /dev/null +++ b/Services/UsageBillingService.php @@ -0,0 +1,520 @@ +is_active) { + Log::warning('Usage meter not found or inactive', [ + 'meter_code' => $meterCode, + 'subscription_id' => $subscription->id, + ]); + + return null; + } + + return DB::transaction(function () use ($subscription, $meter, $quantity, $user, $action, $metadata, $idempotencyKey) { + // Create usage event + $event = UsageEvent::createWithIdempotency([ + 'subscription_id' => $subscription->id, + 'meter_id' => $meter->id, + 'workspace_id' => $subscription->workspace_id, + 'quantity' => $quantity, + 'event_at' => now(), + 'idempotency_key' => $idempotencyKey, + 'user_id' => $user?->id, + 'action' => $action, + 'metadata' => $metadata, + ]); + + if (! $event) { + Log::info('Duplicate usage event skipped', [ + 'idempotency_key' => $idempotencyKey, + ]); + + return null; + } + + // Update aggregated usage for current period + $usage = SubscriptionUsage::getOrCreateForCurrentPeriod($subscription, $meter); + $usage->addQuantity($quantity); + + Log::debug('Usage recorded', [ + 'subscription_id' => $subscription->id, + 'meter_code' => $meter->code, + 'quantity' => $quantity, + 'period_total' => $usage->quantity, + ]); + + return $event; + }); + } + + /** + * Record usage for a workspace (finds active subscription automatically). + */ + public function recordUsageForWorkspace( + Workspace $workspace, + string $meterCode, + int $quantity = 1, + ?User $user = null, + ?string $action = null, + ?array $metadata = null, + ?string $idempotencyKey = null + ): ?UsageEvent { + $subscription = $workspace->subscriptions() + ->active() + ->first(); + + if (! $subscription) { + Log::debug('No active subscription for usage recording', [ + 'workspace_id' => $workspace->id, + 'meter_code' => $meterCode, + ]); + + return null; + } + + return $this->recordUsage( + $subscription, + $meterCode, + $quantity, + $user, + $action, + $metadata, + $idempotencyKey + ); + } + + // ------------------------------------------------------------------------- + // Usage Retrieval + // ------------------------------------------------------------------------- + + /** + * Get current period usage for a subscription. + */ + public function getCurrentUsage(Subscription $subscription, ?string $meterCode = null): Collection + { + $query = SubscriptionUsage::query() + ->with('meter') + ->where('subscription_id', $subscription->id) + ->where('period_start', '>=', $subscription->current_period_start) + ->where('period_end', '<=', $subscription->current_period_end); + + if ($meterCode) { + $meter = UsageMeter::findByCode($meterCode); + if ($meter) { + $query->where('meter_id', $meter->id); + } + } + + return $query->get(); + } + + /** + * Get usage summary for display. + */ + public function getUsageSummary(Subscription $subscription): array + { + $usage = $this->getCurrentUsage($subscription); + + return $usage->map(function (SubscriptionUsage $record) { + return [ + 'meter_code' => $record->meter->code, + 'meter_name' => $record->meter->name, + 'quantity' => $record->quantity, + 'unit_label' => $record->meter->unit_label, + 'estimated_charge' => $record->calculateCharge(), + 'currency' => $record->meter->currency, + 'period_start' => $record->period_start->toISOString(), + 'period_end' => $record->period_end->toISOString(), + ]; + })->values()->all(); + } + + /** + * Get usage history for a subscription. + */ + public function getUsageHistory( + Subscription $subscription, + ?string $meterCode = null, + int $periods = 6 + ): Collection { + $query = SubscriptionUsage::query() + ->with('meter') + ->where('subscription_id', $subscription->id) + ->orderByDesc('period_start'); + + if ($meterCode) { + $meter = UsageMeter::findByCode($meterCode); + if ($meter) { + $query->where('meter_id', $meter->id); + } + } + + return $query->limit($periods)->get(); + } + + // ------------------------------------------------------------------------- + // Billing & Invoicing + // ------------------------------------------------------------------------- + + /** + * Calculate charges for unbilled usage. + */ + public function calculatePendingCharges(Subscription $subscription): float + { + $usage = SubscriptionUsage::query() + ->with('meter') + ->where('subscription_id', $subscription->id) + ->where('billed', false) + ->where('period_end', '<=', now()) + ->get(); + + return $usage->sum(fn (SubscriptionUsage $record) => $record->calculateCharge()); + } + + /** + * Create invoice line items for usage charges. + */ + public function createUsageLineItems(Invoice $invoice, Subscription $subscription): Collection + { + $usage = SubscriptionUsage::query() + ->with('meter') + ->where('subscription_id', $subscription->id) + ->where('billed', false) + ->where('period_end', '<=', now()) + ->get(); + + $lineItems = collect(); + + foreach ($usage as $record) { + $charge = $record->calculateCharge(); + + if ($charge <= 0) { + continue; + } + + $description = sprintf( + '%s: %s %s (%s - %s)', + $record->meter->name, + number_format($record->quantity), + $record->meter->unit_label, + $record->period_start->format('d M'), + $record->period_end->format('d M Y') + ); + + $invoiceItem = InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'description' => $description, + 'quantity' => 1, + 'unit_price' => $charge, + 'line_total' => $charge, + 'taxable' => true, + 'metadata' => [ + 'type' => 'usage', + 'meter_code' => $record->meter->code, + 'usage_quantity' => $record->quantity, + 'period_start' => $record->period_start->toISOString(), + 'period_end' => $record->period_end->toISOString(), + ], + ]); + + $record->markBilled($invoiceItem->id); + $lineItems->push($invoiceItem); + } + + return $lineItems; + } + + // ------------------------------------------------------------------------- + // Stripe Integration + // ------------------------------------------------------------------------- + + /** + * Sync usage to Stripe metered billing. + */ + public function syncToStripe(Subscription $subscription): int + { + if ($subscription->gateway !== 'stripe' || ! $subscription->gateway_subscription_id) { + return 0; + } + + $gateway = app('commerce.gateway.stripe'); + + if (! $gateway instanceof StripeGateway || ! $gateway->isEnabled()) { + return 0; + } + + $unsyncedUsage = SubscriptionUsage::query() + ->with('meter') + ->where('subscription_id', $subscription->id) + ->whereNull('synced_at') + ->whereNotNull('quantity') + ->where('quantity', '>', 0) + ->get(); + + $synced = 0; + + foreach ($unsyncedUsage as $usage) { + if (! $usage->meter->stripe_price_id) { + continue; + } + + try { + $this->reportStripeUsage($gateway, $subscription, $usage); + $synced++; + } catch (\Exception $e) { + Log::error('Failed to sync usage to Stripe', [ + 'subscription_id' => $subscription->id, + 'usage_id' => $usage->id, + 'error' => $e->getMessage(), + ]); + } + } + + return $synced; + } + + /** + * Report usage to Stripe for a single usage record. + */ + protected function reportStripeUsage( + StripeGateway $gateway, + Subscription $subscription, + SubscriptionUsage $usage + ): void { + $stripe = new \Stripe\StripeClient(config('commerce.gateways.stripe.secret')); + + // Find the subscription item for this price + $stripeSubscription = $stripe->subscriptions->retrieve( + $subscription->gateway_subscription_id, + ['expand' => ['items']] + ); + + $subscriptionItem = null; + foreach ($stripeSubscription->items->data as $item) { + if ($item->price->id === $usage->meter->stripe_price_id) { + $subscriptionItem = $item; + break; + } + } + + if (! $subscriptionItem) { + Log::warning('Stripe subscription item not found for meter', [ + 'subscription_id' => $subscription->id, + 'stripe_price_id' => $usage->meter->stripe_price_id, + ]); + + return; + } + + // Report usage + $usageRecord = $stripe->subscriptionItems->createUsageRecord( + $subscriptionItem->id, + [ + 'quantity' => $usage->quantity, + 'timestamp' => $usage->period_end->getTimestamp(), + 'action' => 'set', // 'set' replaces, 'increment' adds + ] + ); + + $usage->markSynced($usageRecord->id); + + Log::info('Usage synced to Stripe', [ + 'subscription_id' => $subscription->id, + 'meter_code' => $usage->meter->code, + 'quantity' => $usage->quantity, + 'stripe_usage_record_id' => $usageRecord->id, + ]); + } + + // ------------------------------------------------------------------------- + // Meter Management + // ------------------------------------------------------------------------- + + /** + * Get all active meters. + */ + public function getActiveMeters(): Collection + { + return UsageMeter::active()->orderBy('name')->get(); + } + + /** + * Create a new meter. + */ + public function createMeter(array $data): UsageMeter + { + return UsageMeter::create($data); + } + + /** + * Update a meter. + */ + public function updateMeter(UsageMeter $meter, array $data): UsageMeter + { + $meter->update($data); + + return $meter->fresh(); + } + + /** + * Sync a meter to Stripe (create meter and price in Stripe). + */ + public function syncMeterToStripe(UsageMeter $meter): ?string + { + $secret = config('commerce.gateways.stripe.secret'); + + if (! $secret) { + return null; + } + + $stripe = new \Stripe\StripeClient($secret); + + // Create or update product in Stripe + $product = $stripe->products->create([ + 'name' => $meter->name, + 'description' => $meter->description, + 'metadata' => [ + 'meter_code' => $meter->code, + 'type' => 'metered', + ], + ]); + + // Create metered price + $price = $stripe->prices->create([ + 'product' => $product->id, + 'currency' => strtolower($meter->currency), + 'recurring' => [ + 'interval' => 'month', + 'usage_type' => 'metered', + 'aggregate_usage' => $meter->aggregation_type === UsageMeter::AGGREGATION_MAX ? 'max' : 'sum', + ], + 'unit_amount_decimal' => (string) ($meter->unit_price * 100), + 'billing_scheme' => $meter->hasTieredPricing() ? 'tiered' : 'per_unit', + ]); + + $meter->update([ + 'stripe_price_id' => $price->id, + ]); + + return $price->id; + } + + // ------------------------------------------------------------------------- + // Period Management + // ------------------------------------------------------------------------- + + /** + * Reset usage for a new billing period. + * + * Called when subscription renews. + */ + public function onPeriodReset(Subscription $subscription): void + { + $meters = UsageMeter::active()->get(); + + foreach ($meters as $meter) { + // Create fresh usage record for new period + SubscriptionUsage::create([ + 'subscription_id' => $subscription->id, + 'meter_id' => $meter->id, + 'quantity' => 0, + 'period_start' => $subscription->current_period_start, + 'period_end' => $subscription->current_period_end, + ]); + } + + Log::info('Usage reset for new period', [ + 'subscription_id' => $subscription->id, + 'period_start' => $subscription->current_period_start, + ]); + } + + /** + * Aggregate usage events into subscription usage records. + * + * Useful for batch processing or reconciliation. + */ + public function aggregateUsage( + Subscription $subscription, + Carbon $periodStart, + Carbon $periodEnd + ): Collection { + $meters = UsageMeter::active()->get(); + $results = collect(); + + foreach ($meters as $meter) { + $totalQuantity = UsageEvent::getTotalQuantity( + $subscription->id, + $meter->id, + $periodStart, + $periodEnd + ); + + $usage = SubscriptionUsage::updateOrCreate( + [ + 'subscription_id' => $subscription->id, + 'meter_id' => $meter->id, + 'period_start' => $periodStart, + ], + [ + 'quantity' => $totalQuantity, + 'period_end' => $periodEnd, + ] + ); + + $results->push($usage); + } + + return $results; + } +} diff --git a/Services/WarehouseService.php b/Services/WarehouseService.php new file mode 100644 index 0000000..3015607 --- /dev/null +++ b/Services/WarehouseService.php @@ -0,0 +1,391 @@ +isM1()) { + throw new \InvalidArgumentException( + 'Only M1 (Master) entities can own warehouses.' + ); + } + + $data['entity_id'] = $entity->id; + $data['code'] = strtoupper($data['code']); + + return Warehouse::create($data); + } + + /** + * Get all warehouses for an entity. + */ + public function getWarehousesForEntity(Entity $entity): Collection + { + return Warehouse::forEntity($entity->id) + ->active() + ->orderBy('is_primary', 'desc') + ->orderBy('name') + ->get(); + } + + /** + * Get primary warehouse for an entity. + */ + public function getPrimaryWarehouse(Entity $entity): ?Warehouse + { + return Warehouse::forEntity($entity->id) + ->active() + ->primary() + ->first(); + } + + /** + * Set a warehouse as primary. + */ + public function setPrimaryWarehouse(Warehouse $warehouse): void + { + DB::transaction(function () use ($warehouse) { + // Remove primary from all other warehouses for this entity + Warehouse::forEntity($warehouse->entity_id) + ->where('id', '!=', $warehouse->id) + ->update(['is_primary' => false]); + + $warehouse->update(['is_primary' => true]); + }); + } + + // Inventory operations + + /** + * Get or create inventory record for product at warehouse. + */ + public function getOrCreateInventory(Product $product, Warehouse $warehouse): Inventory + { + return Inventory::firstOrCreate( + [ + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + ], + [ + 'quantity' => 0, + 'reserved_quantity' => 0, + 'incoming_quantity' => 0, + ] + ); + } + + /** + * Add stock to a warehouse. + */ + public function addStock( + Product $product, + Warehouse $warehouse, + int $quantity, + string $type = InventoryMovement::TYPE_PURCHASE, + ?string $reference = null, + ?string $notes = null, + ?int $unitCost = null + ): Inventory { + $inventory = $this->getOrCreateInventory($product, $warehouse); + + DB::transaction(function () use ($inventory, $quantity, $type, $reference, $notes, $unitCost) { + $inventory->addStock($quantity); + + if ($unitCost !== null) { + $inventory->update(['unit_cost' => $unitCost]); + } + + InventoryMovement::record( + $inventory, + $type, + $quantity, + $reference, + $notes, + null, + $unitCost + ); + }); + + return $inventory->fresh(); + } + + /** + * Remove stock from a warehouse. + */ + public function removeStock( + Product $product, + Warehouse $warehouse, + int $quantity, + string $type = InventoryMovement::TYPE_SALE, + ?string $reference = null, + ?string $notes = null + ): bool { + $inventory = $this->getOrCreateInventory($product, $warehouse); + + if ($inventory->getAvailableQuantity() < $quantity) { + return false; + } + + DB::transaction(function () use ($inventory, $quantity, $type, $reference, $notes) { + $inventory->removeStock($quantity); + + InventoryMovement::record( + $inventory, + $type, + -$quantity, + $reference, + $notes + ); + }); + + return true; + } + + /** + * Reserve stock for an order. + */ + public function reserveStock( + Product $product, + Warehouse $warehouse, + int $quantity, + string $orderId + ): bool { + $inventory = $this->getOrCreateInventory($product, $warehouse); + + if (! $inventory->reserve($quantity)) { + return false; + } + + InventoryMovement::record( + $inventory, + InventoryMovement::TYPE_RESERVED, + -$quantity, + $orderId, + "Reserved for order {$orderId}" + ); + + return true; + } + + /** + * Release reserved stock. + */ + public function releaseStock( + Product $product, + Warehouse $warehouse, + int $quantity, + string $orderId + ): void { + $inventory = $this->getOrCreateInventory($product, $warehouse); + + $inventory->release($quantity); + + InventoryMovement::record( + $inventory, + InventoryMovement::TYPE_RELEASED, + $quantity, + $orderId, + "Released from order {$orderId}" + ); + } + + /** + * Fulfill reserved stock (convert to sale). + */ + public function fulfillStock( + Product $product, + Warehouse $warehouse, + int $quantity, + string $orderId + ): bool { + $inventory = $this->getOrCreateInventory($product, $warehouse); + + if (! $inventory->fulfill($quantity)) { + return false; + } + + InventoryMovement::record( + $inventory, + InventoryMovement::TYPE_SALE, + -$quantity, + $orderId, + "Fulfilled for order {$orderId}" + ); + + return true; + } + + /** + * Transfer stock between warehouses. + */ + public function transferStock( + Product $product, + Warehouse $from, + Warehouse $to, + int $quantity, + ?string $notes = null + ): bool { + $fromInventory = $this->getOrCreateInventory($product, $from); + + if ($fromInventory->getAvailableQuantity() < $quantity) { + return false; + } + + $toInventory = $this->getOrCreateInventory($product, $to); + $reference = 'TRANSFER-'.now()->format('YmdHis'); + + DB::transaction(function () use ($fromInventory, $toInventory, $quantity, $reference, $notes) { + $fromInventory->removeStock($quantity); + $toInventory->addStock($quantity); + + InventoryMovement::record( + $fromInventory, + InventoryMovement::TYPE_TRANSFER_OUT, + -$quantity, + $reference, + $notes + ); + + InventoryMovement::record( + $toInventory, + InventoryMovement::TYPE_TRANSFER_IN, + $quantity, + $reference, + $notes + ); + }); + + return true; + } + + /** + * Perform stock count adjustment. + */ + public function adjustStock( + Product $product, + Warehouse $warehouse, + int $newQuantity, + ?string $notes = null + ): int { + $inventory = $this->getOrCreateInventory($product, $warehouse); + + $difference = DB::transaction(function () use ($inventory, $newQuantity, $notes) { + $difference = $inventory->setCount($newQuantity); + + InventoryMovement::record( + $inventory, + InventoryMovement::TYPE_COUNT, + $difference, + 'COUNT-'.now()->format('YmdHis'), + $notes ?? 'Physical count adjustment' + ); + + return $difference; + }); + + return $difference; + } + + // Query methods + + /** + * Get total stock across all warehouses. + */ + public function getTotalStock(Product $product): int + { + return (int) Inventory::forProduct($product->id)->sum('quantity'); + } + + /** + * Get available stock across all warehouses. + */ + public function getTotalAvailableStock(Product $product): int + { + return (int) (Inventory::forProduct($product->id) + ->selectRaw('SUM(quantity - reserved_quantity) as available') + ->value('available') ?? 0); + } + + /** + * Get stock by warehouse. + */ + public function getStockByWarehouse(Product $product): Collection + { + return Inventory::forProduct($product->id) + ->with('warehouse') + ->get(); + } + + /** + * Find best warehouse to fulfill order. + */ + public function findBestWarehouse(Product $product, int $quantity): ?Warehouse + { + return Warehouse::active() + ->canShip() + ->whereHas('inventory', function ($query) use ($product, $quantity) { + $query->forProduct($product->id) + ->whereRaw('(quantity - reserved_quantity) >= ?', [$quantity]); + }) + ->orderBy('is_primary', 'desc') + ->first(); + } + + /** + * Get low stock products. + */ + public function getLowStockProducts(Entity $entity): Collection + { + return Inventory::with(['product', 'warehouse']) + ->whereHas('warehouse', fn ($q) => $q->forEntity($entity->id)) + ->lowStock() + ->get(); + } + + /** + * Get out of stock products. + */ + public function getOutOfStockProducts(Entity $entity): Collection + { + return Inventory::with(['product', 'warehouse']) + ->whereHas('warehouse', fn ($q) => $q->forEntity($entity->id)) + ->outOfStock() + ->get(); + } + + /** + * Get inventory movements for a product. + */ + public function getMovementHistory(Product $product, int $limit = 50): Collection + { + return InventoryMovement::forProduct($product->id) + ->with(['warehouse', 'user']) + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get(); + } +} diff --git a/Services/WebhookLogger.php b/Services/WebhookLogger.php new file mode 100644 index 0000000..cef85eb --- /dev/null +++ b/Services/WebhookLogger.php @@ -0,0 +1,322 @@ +extractRelevantHeaders($request, $gateway) : null; + + // If we have an event ID, use atomic check-and-insert + if ($eventId) { + return $this->startWithDeduplication($gateway, $eventType, $payload, $eventId, $headers); + } + + // No event ID - just create the record + $this->currentEvent = WebhookEvent::record( + gateway: $gateway, + eventType: $eventType, + payload: $payload, + eventId: $eventId, + headers: $headers + ); + + Log::info('Webhook event received', [ + 'id' => $this->currentEvent->id, + 'gateway' => $gateway, + 'event_type' => $eventType, + 'event_id' => $eventId, + ]); + + return $this->currentEvent; + } + + /** + * Start logging with deduplication - handles race conditions atomically. + */ + protected function startWithDeduplication( + string $gateway, + string $eventType, + string $payload, + string $eventId, + ?array $headers + ): WebhookEvent { + try { + // Attempt to insert - if duplicate constraint violation, fetch existing + $this->currentEvent = WebhookEvent::record( + gateway: $gateway, + eventType: $eventType, + payload: $payload, + eventId: $eventId, + headers: $headers + ); + + Log::info('Webhook event received', [ + 'id' => $this->currentEvent->id, + 'gateway' => $gateway, + 'event_type' => $eventType, + 'event_id' => $eventId, + ]); + + return $this->currentEvent; + } catch (QueryException $e) { + // Check for duplicate entry error (MySQL: 1062, PostgreSQL: 23505) + if ($this->isDuplicateEntryException($e)) { + Log::info('Webhook event already exists (duplicate)', [ + 'gateway' => $gateway, + 'event_id' => $eventId, + 'event_type' => $eventType, + ]); + + // Fetch the existing event + $existing = WebhookEvent::where('gateway', $gateway) + ->where('event_id', $eventId) + ->first(); + + if ($existing) { + $this->currentEvent = $existing; + + return $existing; + } + } + + // Re-throw if not a duplicate entry error + throw $e; + } + } + + /** + * Check if the exception is a duplicate entry constraint violation. + */ + protected function isDuplicateEntryException(QueryException $e): bool + { + $code = $e->errorInfo[1] ?? null; + + // MySQL duplicate entry + if ($code === 1062) { + return true; + } + + // PostgreSQL unique violation + if ($code === 23505 || ($e->errorInfo[0] ?? null) === '23505') { + return true; + } + + // SQLite constraint violation (check message for UNIQUE) + if ($code === 19 && str_contains($e->getMessage(), 'UNIQUE constraint failed')) { + return true; + } + + return false; + } + + /** + * Start logging from parsed event data (after verification). + */ + public function startFromParsedEvent( + string $gateway, + array $event, + string $rawPayload, + ?Request $request = null + ): WebhookEvent { + return $this->start( + gateway: $gateway, + eventType: $event['type'] ?? 'unknown', + payload: $rawPayload, + eventId: $event['id'] ?? null, + request: $request + ); + } + + /** + * Mark the current event as successfully processed. + */ + public function success(?Response $response = null): void + { + if (! $this->currentEvent) { + return; + } + + $statusCode = $response?->getStatusCode() ?? 200; + $this->currentEvent->markProcessed($statusCode); + + Log::info('Webhook event processed successfully', [ + 'id' => $this->currentEvent->id, + 'gateway' => $this->currentEvent->gateway, + 'event_type' => $this->currentEvent->event_type, + 'http_status' => $statusCode, + ]); + } + + /** + * Mark the current event as failed. + */ + public function fail(string $error, int $statusCode = 500): void + { + if (! $this->currentEvent) { + return; + } + + $this->currentEvent->markFailed($error, $statusCode); + + Log::error('Webhook event processing failed', [ + 'id' => $this->currentEvent->id, + 'gateway' => $this->currentEvent->gateway, + 'event_type' => $this->currentEvent->event_type, + 'error' => $error, + 'http_status' => $statusCode, + ]); + } + + /** + * Mark the current event as skipped. + */ + public function skip(string $reason, int $statusCode = 200): void + { + if (! $this->currentEvent) { + return; + } + + $this->currentEvent->markSkipped($reason, $statusCode); + + Log::info('Webhook event skipped', [ + 'id' => $this->currentEvent->id, + 'gateway' => $this->currentEvent->gateway, + 'event_type' => $this->currentEvent->event_type, + 'reason' => $reason, + ]); + } + + /** + * Link current event to an order. + */ + public function linkOrder(Order $order): void + { + if ($this->currentEvent) { + $this->currentEvent->linkOrder($order); + } + } + + /** + * Link current event to a subscription. + */ + public function linkSubscription(Subscription $subscription): void + { + if ($this->currentEvent) { + $this->currentEvent->linkSubscription($subscription); + } + } + + /** + * Get the current event being processed. + */ + public function getCurrentEvent(): ?WebhookEvent + { + return $this->currentEvent; + } + + /** + * Check if an event was already processed. + */ + public function isDuplicate(string $gateway, string $eventId): bool + { + return WebhookEvent::hasBeenProcessed($gateway, $eventId); + } + + /** + * Extract relevant headers for logging. + */ + protected function extractRelevantHeaders(Request $request, string $gateway): array + { + $headers = []; + + // Common headers + $relevantHeaders = [ + 'Content-Type', + 'User-Agent', + 'X-Forwarded-For', + 'X-Real-IP', + ]; + + // Gateway-specific headers (normalise to lowercase for comparison) + $normalizedGateway = strtolower($gateway); + if ($normalizedGateway === 'stripe') { + $relevantHeaders[] = 'Stripe-Signature'; + $relevantHeaders[] = 'Stripe-Webhook-ID'; + } elseif ($normalizedGateway === 'btcpay') { + $relevantHeaders[] = 'BTCPay-Sig'; + $relevantHeaders[] = 'BTCPay-Signature'; + } + + foreach ($relevantHeaders as $header) { + $value = $request->header($header); + if ($value) { + // Mask sensitive parts of signatures + if (str_contains(strtolower($header), 'signature') || str_contains(strtolower($header), 'sig')) { + $value = substr($value, 0, 20).'...'; + } + $headers[$header] = $value; + } + } + + return $headers; + } + + /** + * Get statistics for webhook events. + */ + public function getStats(string $gateway, int $days = 7): array + { + $query = WebhookEvent::forGateway($gateway)->recent($days); + + return [ + 'total' => (clone $query)->count(), + 'processed' => (clone $query)->where('status', WebhookEvent::STATUS_PROCESSED)->count(), + 'failed' => (clone $query)->where('status', WebhookEvent::STATUS_FAILED)->count(), + 'skipped' => (clone $query)->where('status', WebhookEvent::STATUS_SKIPPED)->count(), + 'pending' => (clone $query)->where('status', WebhookEvent::STATUS_PENDING)->count(), + ]; + } + + /** + * Get recent failed events for debugging. + */ + public function getRecentFailures(string $gateway, int $limit = 10): \Illuminate\Database\Eloquent\Collection + { + return WebhookEvent::forGateway($gateway) + ->failed() + ->orderBy('received_at', 'desc') + ->limit($limit) + ->get(); + } +} diff --git a/View/Blade/admin/coupon-manager.blade.php b/View/Blade/admin/coupon-manager.blade.php new file mode 100644 index 0000000..cb80500 --- /dev/null +++ b/View/Blade/admin/coupon-manager.blade.php @@ -0,0 +1,252 @@ + + + {{ __('commerce::commerce.coupons.bulk.generate_button') }} + {{ __('commerce::commerce.actions.new_coupon') }} + + + + + + + + + + + + + {{ __('commerce::commerce.bulk.export') }} + {{ __('commerce::commerce.bulk.activate') }} + {{ __('commerce::commerce.bulk.deactivate') }} + {{ __('commerce::commerce.bulk.delete') }} + + + + {{-- Bulk Delete Confirmation Modal --}} + + {{ __('commerce::commerce.bulk.confirm_delete_title') }} + +
+ {{ __('commerce::commerce.bulk.confirm_delete_message', ['count' => count($selected)]) }} + +
+ {{ __('commerce::commerce.bulk.delete_warning') }} +
+ +
+ {{ __('commerce::commerce.actions.cancel') }} + {{ __('commerce::commerce.bulk.delete') }} +
+
+
+ + {{-- Bulk Generate Modal --}} + + {{ __('commerce::commerce.coupons.bulk.modal_title') }} + +
+ {{-- Generation Settings --}} +
+ {{ __('commerce::commerce.coupons.bulk.generation_settings') }} +
+ + +
+ +
+ + + + {{-- Discount Settings Section --}} +
+ {{ __('commerce::commerce.coupons.sections.discount_settings') }} +
+ + {{ __('commerce::commerce.coupons.form.percentage') }} + {{ __('commerce::commerce.coupons.form.fixed_amount') }} + + +
+
+ + +
+
+ + + + {{-- Applicability Section --}} +
+ {{ __('commerce::commerce.coupons.sections.applicability') }} +
+ + {{ __('commerce::commerce.coupons.form.all_packages') }} + {{ __('commerce::commerce.coupons.form.specific_packages') }} + + + @if ($bulk_applies_to === 'packages') + + {{ __('commerce::commerce.coupons.form.packages') }} +
+ @foreach ($this->packages as $package) + + @endforeach +
+
+ @endif +
+
+ + + + {{-- Usage Limits Section --}} +
+ {{ __('commerce::commerce.coupons.sections.usage_limits') }} +
+ + + + {{ __('commerce::commerce.coupons.form.apply_once') }} + {{ __('commerce::commerce.coupons.form.apply_repeating') }} + {{ __('commerce::commerce.coupons.form.apply_forever') }} + +
+ @if ($bulk_duration === 'repeating') + + @endif +
+ + + + {{-- Validity Period Section --}} +
+ {{ __('commerce::commerce.coupons.sections.validity_period') }} +
+ + +
+
+ + + + {{-- Status Section --}} +
+ +
+ +
+ {{ __('commerce::commerce.actions.cancel') }} + {{ __('commerce::commerce.coupons.bulk.generate_action') }} +
+ +
+ + {{-- Create/Edit Coupon Modal --}} + + {{ $editingId ? __('commerce::commerce.coupons.modal.edit_title') : __('commerce::commerce.coupons.modal.create_title') }} + +
+ {{-- Basic Information Section --}} +
+ {{ __('commerce::commerce.coupons.sections.basic_info') }} +
+ + +
+ +
+ + + + {{-- Discount Settings Section --}} +
+ {{ __('commerce::commerce.coupons.sections.discount_settings') }} +
+ + {{ __('commerce::commerce.coupons.form.percentage') }} + {{ __('commerce::commerce.coupons.form.fixed_amount') }} + + +
+
+ + +
+
+ + + + {{-- Applicability Section --}} +
+ {{ __('commerce::commerce.coupons.sections.applicability') }} +
+ + {{ __('commerce::commerce.coupons.form.all_packages') }} + {{ __('commerce::commerce.coupons.form.specific_packages') }} + + + @if ($applies_to === 'packages') + + {{ __('commerce::commerce.coupons.form.packages') }} +
+ @foreach ($this->packages as $package) + + @endforeach +
+
+ @endif +
+
+ + + + {{-- Usage Limits Section --}} +
+ {{ __('commerce::commerce.coupons.sections.usage_limits') }} +
+ + + + {{ __('commerce::commerce.coupons.form.apply_once') }} + {{ __('commerce::commerce.coupons.form.apply_repeating') }} + {{ __('commerce::commerce.coupons.form.apply_forever') }} + +
+ @if ($duration === 'repeating') + + @endif +
+ + + + {{-- Validity Period Section --}} +
+ {{ __('commerce::commerce.coupons.sections.validity_period') }} +
+ + +
+
+ + + + {{-- Status Section --}} +
+ +
+ +
+ {{ __('commerce::commerce.actions.cancel') }} + {{ $editingId ? __('commerce::commerce.coupons.actions.update') : __('commerce::commerce.coupons.actions.create') }} +
+ +
+
diff --git a/View/Blade/admin/credit-note-manager.blade.php b/View/Blade/admin/credit-note-manager.blade.php new file mode 100644 index 0000000..e2ce726 --- /dev/null +++ b/View/Blade/admin/credit-note-manager.blade.php @@ -0,0 +1,234 @@ + + + Issue Credit Note + + + + + {{-- Summary Stats --}} +
+
+ Total Issued + GBP {{ number_format($this->summaryStats['total_issued'], 2) }} +
+
+ Total Used + GBP {{ number_format($this->summaryStats['total_used'], 2) }} +
+
+ Available Balance + GBP {{ number_format($this->summaryStats['total_available'], 2) }} +
+
+ Active Credits + {{ $this->summaryStats['count_active'] }} +
+
+ + + + + + + + + + + Export + + + + {{-- Create Credit Note Modal --}} + + Issue Credit Note + +
+ + Select workspace... + @foreach ($this->workspaces as $workspace) + {{ $workspace->name }} + @endforeach + + + @if ($workspaceId) + + Select user... + @foreach ($this->users as $user) + {{ $user->name }} ({{ $user->email }}) + @endforeach + + @endif + +
+ + + GBP + USD + EUR + +
+ + + Select reason... + @foreach ($this->reasons as $key => $label) + {{ $label }} + @endforeach + + + + +
+ Cancel + Issue Credit Note +
+ +
+ + {{-- Detail Modal --}} + + @if ($selectedCreditNote) + Credit Note: {{ $selectedCreditNote->reference_number }} + +
+ {{-- Status Badge --}} +
+ @php + $statusColors = [ + 'draft' => 'gray', + 'issued' => 'blue', + 'partially_applied' => 'amber', + 'applied' => 'green', + 'void' => 'red', + ]; + @endphp + + {{ ucfirst(str_replace('_', ' ', $selectedCreditNote->status)) }} + + {{ $selectedCreditNote->getReasonLabel() }} +
+ + {{-- Amount Summary --}} +
+
+ Total Amount + {{ $selectedCreditNote->currency }} {{ number_format($selectedCreditNote->amount, 2) }} +
+
+ Amount Used + {{ $selectedCreditNote->currency }} {{ number_format($selectedCreditNote->amount_used, 2) }} +
+
+ Remaining + {{ $selectedCreditNote->currency }} {{ number_format($selectedCreditNote->getRemainingAmount(), 2) }} +
+
+ + {{-- Details Grid --}} +
+
+ Workspace + {{ $selectedCreditNote->workspace?->name ?? 'N/A' }} +
+
+ User + {{ $selectedCreditNote->user?->name ?? 'N/A' }} + {{ $selectedCreditNote->user?->email }} +
+
+ Created + {{ $selectedCreditNote->created_at->format('d M Y H:i') }} +
+
+ Issued + {{ $selectedCreditNote->issued_at?->format('d M Y H:i') ?? 'Not issued' }} + @if ($selectedCreditNote->issuedByUser) + by {{ $selectedCreditNote->issuedByUser->name }} + @endif +
+
+ + {{-- Source Information --}} + @if ($selectedCreditNote->order || $selectedCreditNote->refund) + +
+ Source + @if ($selectedCreditNote->order) + From Order: {{ $selectedCreditNote->order->order_number }} + @endif + @if ($selectedCreditNote->refund) + From Refund: #{{ $selectedCreditNote->refund->id }} + @endif +
+ @endif + + {{-- Applied To --}} + @if ($selectedCreditNote->appliedToOrder) + +
+ Applied To + Order: {{ $selectedCreditNote->appliedToOrder->order_number }} + @if ($selectedCreditNote->applied_at) + Applied on {{ $selectedCreditNote->applied_at->format('d M Y H:i') }} + @endif +
+ @endif + + {{-- Description --}} + @if ($selectedCreditNote->description) + +
+ Description + {{ $selectedCreditNote->description }} +
+ @endif + + {{-- Void Information --}} + @if ($selectedCreditNote->isVoid()) + +
+ + Voided on {{ $selectedCreditNote->voided_at->format('d M Y H:i') }} + @if ($selectedCreditNote->voidedByUser) + by {{ $selectedCreditNote->voidedByUser->name }} + @endif + +
+ @endif + +
+ Close +
+
+ @endif +
+ + {{-- Void Confirmation Modal --}} + + Void Credit Note + + @if ($creditNoteToVoid) +
+ Are you sure you want to void credit note {{ $creditNoteToVoid->reference_number }}? + Amount: {{ $creditNoteToVoid->currency }} {{ number_format($creditNoteToVoid->amount, 2) }} + +
+ This action cannot be undone. The credit will no longer be available for use. +
+ +
+ Cancel + Void Credit Note +
+
+ @endif +
+
diff --git a/View/Blade/admin/dashboard.blade.php b/View/Blade/admin/dashboard.blade.php new file mode 100644 index 0000000..f1184cc --- /dev/null +++ b/View/Blade/admin/dashboard.blade.php @@ -0,0 +1,44 @@ + + + + {{ __('commerce::commerce.actions.view_orders') }} + + + + + +
+ {{-- Quick Actions with Visual Icon Cards --}} +
+
+

{{ __('commerce::commerce.sections.quick_actions') }}

+
+
+
+ @foreach($this->quickActions as $action) + +
+ +
+
+
{{ $action['title'] }}
+
{{ $action['subtitle'] }}
+
+ +
+ @endforeach +
+
+
+ + +
+
diff --git a/View/Blade/admin/entity-manager.blade.php b/View/Blade/admin/entity-manager.blade.php new file mode 100644 index 0000000..0747df9 --- /dev/null +++ b/View/Blade/admin/entity-manager.blade.php @@ -0,0 +1,299 @@ +
+ {{-- Page header --}} +
+
+

Commerce Entities

+

Manage M1/M2/M3 entity hierarchy

+
+
+ +
+
+ + {{-- Flash messages --}} + @if (session()->has('message')) +
+ {{ session('message') }} +
+ @endif + @if (session()->has('error')) +
+ {{ session('error') }} +
+ @endif + + {{-- Stats cards --}} +
+
+
+
+ +
+
+
{{ $stats['total'] }}
+
Total Entities
+
+
+
+ +
+
+
+ +
+
+
{{ $stats['m1_count'] }}
+
M1 Masters
+
+
+
+ +
+
+
+ +
+
+
{{ $stats['m2_count'] }}
+
M2 Facades
+
+
+
+ +
+
+
+ +
+
+
{{ $stats['m3_count'] }}
+
M3 Dropshippers
+
+
+
+ +
+
+
+ +
+
+
{{ $stats['active'] }}
+
Active
+
+
+
+
+ + {{-- Entity hierarchy tree --}} +
+
+

Entity Hierarchy

+
+ + @if($entities->isEmpty()) +
+ +

No entities yet

+ +
+ @else +
+ @foreach($entities as $entity) + {{-- M1 Entity --}} + @include('admin.livewire.commerce.partials.entity-row', ['entity' => $entity, 'level' => 0]) + + {{-- M2 Children --}} + @foreach($entity->children as $m2) + @include('admin.livewire.commerce.partials.entity-row', ['entity' => $m2, 'level' => 1]) + + {{-- M3 Children --}} + @foreach($m2->children as $m3) + @include('admin.livewire.commerce.partials.entity-row', ['entity' => $m3, 'level' => 2]) + @endforeach + @endforeach + @endforeach +
+ @endif +
+ + {{-- Create/Edit Modal --}} + @if($showModal) + + @endif + + {{-- Delete Confirmation Modal --}} + @if($showDeleteModal) + + @endif +
diff --git a/View/Blade/admin/order-manager.blade.php b/View/Blade/admin/order-manager.blade.php new file mode 100644 index 0000000..c3b7497 --- /dev/null +++ b/View/Blade/admin/order-manager.blade.php @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + {{ __('commerce::commerce.bulk.export') }} + + {{ __('commerce::commerce.bulk.change_status') }} + + @foreach ($this->statuses as $status => $label) + {{ $label }} + @endforeach + + + + + + {{-- Order Detail Modal --}} + + @if ($selectedOrder) + @php + $statusColor = match($selectedOrder->status) { + 'paid', 'completed' => 'green', + 'pending' => 'amber', + 'processing' => 'blue', + 'failed', 'cancelled', 'refunded' => 'red', + default => 'zinc', + }; + @endphp + + {{ __('commerce::commerce.table.order') }} {{ $selectedOrder->order_number }} + +
+ {{-- Order Summary Card --}} + + {{ __('commerce::commerce.orders.detail.summary') }} +
+
+ {{ __('commerce::commerce.orders.detail.status') }} + {{ ucfirst($selectedOrder->status) }} +
+
+ {{ __('commerce::commerce.orders.detail.type') }} + {{ str_replace('_', ' ', ucfirst($selectedOrder->type ?? __('commerce::commerce.status.unknown'))) }} +
+
+ {{ __('commerce::commerce.orders.detail.payment_gateway') }} + {{ ucfirst($selectedOrder->payment_gateway ?? __('commerce::commerce.status.none')) }} +
+
+ {{ __('commerce::commerce.orders.detail.paid_at') }} + {{ $selectedOrder->paid_at?->format('d M Y H:i') ?? __('commerce::commerce.orders.detail.not_paid') }} +
+
+
+ + {{-- Customer Info Card --}} + + {{ __('commerce::commerce.orders.detail.customer') }} +
+
+ {{ __('commerce::commerce.orders.detail.name') }} + {{ $selectedOrder->billing_name ?: $selectedOrder->user?->name }} +
+
+ {{ __('commerce::commerce.orders.detail.email') }} + {{ $selectedOrder->billing_email ?: $selectedOrder->user?->email }} +
+
+ {{ __('commerce::commerce.orders.detail.workspace') }} + {{ $selectedOrder->workspace?->name }} +
+
+
+ + {{-- Order Items Card --}} + + {{ __('commerce::commerce.orders.detail.items') }} +
+ @foreach ($selectedOrder->items as $item) +
+
+ {{ $item->description ?? $item->package?->name }} + + {{ $item->quantity }} x {{ $selectedOrder->currency }} {{ number_format($item->unit_price, 2) }} + +
+ + {{ $selectedOrder->currency }} {{ number_format($item->line_total, 2) }} + +
+ @endforeach +
+
+ + {{-- Order Totals Card --}} + + {{ __('commerce::commerce.orders.detail.totals') }} +
+
+ {{ __('commerce::commerce.orders.detail.subtotal') }} + {{ $selectedOrder->currency }} {{ number_format($selectedOrder->subtotal, 2) }} +
+ @if ($selectedOrder->discount_amount > 0) +
+ {{ __('commerce::commerce.orders.detail.discount') }}{{ $selectedOrder->coupon ? ' (' . $selectedOrder->coupon->code . ')' : '' }} + -{{ $selectedOrder->currency }} {{ number_format($selectedOrder->discount_amount, 2) }} +
+ @endif + @if ($selectedOrder->tax_amount > 0) +
+ {{ __('commerce::commerce.orders.detail.tax') }} ({{ $selectedOrder->tax_rate }}%) + {{ $selectedOrder->currency }} {{ number_format($selectedOrder->tax_amount, 2) }} +
+ @endif + +
+ {{ __('commerce::commerce.orders.detail.total') }} + {{ $selectedOrder->currency }} {{ number_format($selectedOrder->total, 2) }} +
+
+
+ + {{-- Invoice Card --}} + @if ($selectedOrder->invoice) + +
+
+ {{ __('commerce::commerce.orders.detail.invoice') }} {{ $selectedOrder->invoice->invoice_number }} + {{ ucfirst($selectedOrder->invoice->status) }} +
+ {{ __('commerce::commerce.orders.detail.view_invoice') }} +
+
+ @endif +
+ +
+ {{ __('commerce::commerce.orders.update_status.title') }} + {{ __('commerce::commerce.actions.close') }} +
+ @endif +
+ + {{-- Status Update Modal --}} + + @if ($selectedOrder) + {{ __('commerce::commerce.orders.update_status.title') }} + +
+
+
{{ __('commerce::commerce.table.order') }}
+
{{ $selectedOrder->order_number }}
+
+ + + @foreach (array_keys($this->statuses) as $status) + + @endforeach + + + + +
+ {{ __('commerce::commerce.actions.cancel') }} + {{ __('commerce::commerce.orders.update_status.title') }} +
+ + @endif +
+
diff --git a/View/Blade/admin/partials/entity-row.blade.php b/View/Blade/admin/partials/entity-row.blade.php new file mode 100644 index 0000000..16f4ae8 --- /dev/null +++ b/View/Blade/admin/partials/entity-row.blade.php @@ -0,0 +1,87 @@ +@php + $typeConfig = match($entity->type) { + 'm1' => ['icon' => 'fa-building', 'bg' => 'bg-blue-100 dark:bg-blue-900/30', 'text' => 'text-blue-600 dark:text-blue-400', 'badge' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'], + 'm2' => ['icon' => 'fa-store', 'bg' => 'bg-orange-100 dark:bg-orange-900/30', 'text' => 'text-orange-600 dark:text-orange-400', 'badge' => 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'], + 'm3' => ['icon' => 'fa-truck', 'bg' => 'bg-green-100 dark:bg-green-900/30', 'text' => 'text-green-600 dark:text-green-400', 'badge' => 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'], + default => ['icon' => 'fa-cube', 'bg' => 'bg-gray-100 dark:bg-gray-700', 'text' => 'text-gray-600 dark:text-gray-400', 'badge' => 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400'], + }; + $indent = $level * 2; // rem units +@endphp + +
+
+
+ {{-- Tree connector for children --}} + @if($level > 0) + + @if($level === 1) + + @else + + @endif + + @endif + + {{-- Type icon --}} +
+ +
+ + {{-- Entity info --}} +
+
+ {{ $entity->name }} + + {{ strtoupper($entity->type) }} + + @if(!$entity->is_active) + + Inactive + + @endif +
+
+ {{ $entity->code }} + @if($entity->domain) + {{ $entity->domain }} + @endif + {{ $entity->path }} +
+
+
+ + {{-- Actions --}} +
+ {{-- Add child (only M1 and M2 can have children) --}} + @if(in_array($entity->type, ['m1', 'm2'])) + + @endif + + {{-- Toggle active --}} + + + {{-- Edit --}} + + + {{-- Delete --}} + +
+
+
diff --git a/View/Blade/admin/permission-matrix-manager.blade.php b/View/Blade/admin/permission-matrix-manager.blade.php new file mode 100644 index 0000000..59842e2 --- /dev/null +++ b/View/Blade/admin/permission-matrix-manager.blade.php @@ -0,0 +1,396 @@ +
+ {{-- Page header --}} +
+
+

Permission Matrix

+

Train and manage entity permissions

+
+
+ +
+
+ + {{-- Flash messages --}} + @if (session()->has('message')) +
+ {{ session('message') }} +
+ @endif + @if (session()->has('error')) +
+ {{ session('error') }} +
+ @endif + + {{-- Stats cards --}} +
+
+
+
+ +
+
+
{{ $stats['total_permissions'] }}
+
Total Permissions
+
+
+
+ +
+
+
+ +
+
+
{{ $stats['allowed'] }}
+
Allowed
+
+
+
+ +
+
+
+ +
+
+
{{ $stats['denied'] }}
+
Denied
+
+
+
+ +
+
+
+ +
+
+
{{ $stats['locked'] }}
+
Locked
+
+
+
+ +
+
+
+ +
+
+
{{ $stats['pending_requests'] }}
+
Pending
+
+
+
+
+ + {{-- Filters --}} +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {{-- Pending Requests --}} + @if($pendingRequests->count() > 0) +
+
+

+ + Pending Requests ({{ $pendingRequests->total() }}) +

+ @if(count($selectedRequests) > 0) +
+ + +
+ @endif +
+
+ + + + + + + + + + + + + @foreach($pendingRequests as $request) + + + + + + + + + @endforeach + +
+ pluck('id')->toArray() === $selectedRequests) checked @endif> + EntityActionRouteTimeActions
+ + + {{ $request->entity?->name ?? 'Unknown' }} +
{{ $request->entity?->path }}
+
+ {{ $request->action }} + @if($request->scope) + ({{ $request->scope }}) + @endif + + {{ $request->method }} {{ Str::limit($request->route, 40) }} + + {{ $request->created_at->diffForHumans() }} + + +
+
+
+ {{ $pendingRequests->links() }} +
+
+ @endif + + {{-- Trained Permissions --}} +
+
+

Trained Permissions

+
+ + @if($permissions->isEmpty()) +
+ +

No permissions trained yet

+

Permissions will appear here as you train them through the matrix.

+
+ @else +
+ + + + + + + + + + + + + @foreach($permissions as $permission) + + + + + + + + + @endforeach + +
EntityPermission KeyScopeStatusSourceActions
+ {{ $permission->entity?->name ?? 'Unknown' }} +
{{ $permission->entity?->path }}
+
+ {{ $permission->key }} + + {{ $permission->scope ?? 'global' }} + +
+ @if($permission->allowed) + + Allowed + + @else + + Denied + + @endif + @if($permission->locked) + + Locked + + @endif +
+
+ {{ $permission->source }} + @if($permission->setByEntity) + by {{ $permission->setByEntity->code }} + @endif + +
+ @if($permission->locked) + + @endif + @if(!$permission->locked) + + @endif +
+
+
+
+ {{ $permissions->links() }} +
+ @endif +
+ + {{-- Training Modal --}} + @if($showTrainModal) + + @endif +
diff --git a/View/Blade/admin/product-manager.blade.php b/View/Blade/admin/product-manager.blade.php new file mode 100644 index 0000000..150e278 --- /dev/null +++ b/View/Blade/admin/product-manager.blade.php @@ -0,0 +1,125 @@ + + + + {{ __('commerce::commerce.actions.entity_hierarchy') }} + + @if($this->selectedEntity?->isM1()) + + {{ __('commerce::commerce.actions.add_product') }} + + @endif + + + + + + + + + + + + + + {{-- Product Create/Edit Modal --}} + + + {{ $editingId ? __('commerce::commerce.products.modal.edit_title') : __('commerce::commerce.products.modal.create_title') }} + + +
+
+ + + + + + + + +
+ + + + + +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + +
+ +
+ + + + + +
+ +
+ {{ __('commerce::commerce.actions.cancel') }} + + {{ $editingId ? __('commerce::commerce.products.actions.update') : __('commerce::commerce.products.actions.create') }} + +
+ +
+ + {{-- Assignment Modal --}} + + {{ __('commerce::commerce.assignments.title') }} + +
+ + + @foreach($this->allEntities as $entity) + @if(!$entity->isM1()) + + @endif + @endforeach + + +
+ + +
+ + + +
+ + +
+ +
+ {{ __('commerce::commerce.actions.cancel') }} + {{ __('commerce::commerce.actions.assign') }} +
+ +
+
diff --git a/View/Blade/admin/referral-manager.blade.php b/View/Blade/admin/referral-manager.blade.php new file mode 100644 index 0000000..ed73a89 --- /dev/null +++ b/View/Blade/admin/referral-manager.blade.php @@ -0,0 +1,453 @@ + + {{-- Stats Cards --}} +
+
+
Total Referrals
+
{{ number_format($this->stats['total_referrals']) }}
+
+
+
Total Commissions
+
GBP {{ number_format($this->stats['total_commissions'], 2) }}
+
+
+
Pending Payouts
+
GBP {{ number_format($this->stats['pending_payouts'], 2) }}
+
+
+
Paid Out
+
GBP {{ number_format($this->stats['completed_payouts'], 2) }}
+
+
+ + {{-- Tabs --}} +
+ +
+ + + @if($tab === 'commissions') + Mature Ready + @endif + @if($tab === 'codes') + New Code + @endif + + + + + + + + + + + {{-- Referrals Tab --}} + @if($tab === 'referrals') +
+ + + + + + + + + + + + + @forelse($this->referrals as $referral) + + + + + + + + + @empty + + + + @endforelse + +
ReferrerRefereeCodeStatusSigned UpActions
+
{{ $referral->referrer?->email ?? 'Unknown' }}
+
+ @if($referral->referee) +
{{ $referral->referee->email }}
+ @else + - + @endif +
+ {{ $referral->code }} + + @php + $statusColor = match($referral->status) { + 'pending' => 'gray', + 'converted' => 'blue', + 'qualified' => 'green', + 'disqualified' => 'red', + default => 'gray', + }; + @endphp + {{ ucfirst($referral->status) }} + + {{ $referral->signed_up_at?->format('d M Y') ?? '-' }} + + +
No referrals found.
+
+
{{ $this->referrals->links() }}
+ @endif + + {{-- Commissions Tab --}} + @if($tab === 'commissions') +
+ + + + + + + + + + + + + @forelse($this->commissions as $commission) + + + + + + + + + @empty + + + + @endforelse + +
ReferrerRefereeOrderCommissionStatusMatures
+
{{ $commission->referrer?->email ?? 'Unknown' }}
+
+
{{ $commission->referral?->referee?->email ?? '-' }}
+
+
{{ $commission->currency }} {{ number_format($commission->order_amount, 2) }}
+
+
{{ $commission->currency }} {{ number_format($commission->commission_amount, 2) }}
+
{{ $commission->commission_rate }}%
+
+ @php + $statusColor = match($commission->status) { + 'pending' => 'amber', + 'matured' => 'green', + 'paid' => 'blue', + 'cancelled' => 'red', + default => 'gray', + }; + @endphp + {{ ucfirst($commission->status) }} + + {{ $commission->matures_at?->format('d M Y') ?? '-' }} +
No commissions found.
+
+
{{ $this->commissions->links() }}
+ @endif + + {{-- Payouts Tab --}} + @if($tab === 'payouts') +
+ + + + + + + + + + + + + + @forelse($this->payouts as $payout) + + + + + + + + + + @empty + + + + @endforelse + +
NumberUserMethodAmountStatusRequestedActions
+ {{ $payout->payout_number }} + +
{{ $payout->user?->email ?? 'Unknown' }}
+
+ + {{ $payout->method === 'btc' ? 'Bitcoin' : 'Credit' }} + + +
{{ $payout->currency }} {{ number_format($payout->amount, 2) }}
+ @if($payout->btc_amount) +
{{ $payout->btc_amount }} BTC
+ @endif +
+ @php + $statusColor = match($payout->status) { + 'requested' => 'amber', + 'processing' => 'blue', + 'completed' => 'green', + 'failed' => 'red', + 'cancelled' => 'gray', + default => 'gray', + }; + @endphp + {{ ucfirst($payout->status) }} + + {{ $payout->requested_at?->format('d M Y H:i') ?? '-' }} + + @if($payout->isPending()) + + @endif +
No payouts found.
+
+
{{ $this->payouts->links() }}
+ @endif + + {{-- Codes Tab --}} + @if($tab === 'codes') +
+ + + + + + + + + + + + + + @forelse($this->codes as $code) + + + + + + + + + + @empty + + + + @endforelse + +
CodeTypeOwnerCommissionUsesStatusActions
+ {{ $code->code }} + @if($code->campaign_name) +
{{ $code->campaign_name }}
+ @endif +
+ {{ ucfirst($code->type) }} + + {{ $code->user?->email ?? 'System' }} + + {{ $code->commission_rate ? $code->commission_rate.'%' : 'Default' }} + + {{ $code->uses_count }}{{ $code->max_uses ? '/'.$code->max_uses : '' }} + + {{ $code->is_active ? 'Active' : 'Inactive' }} + +
+ + + @if($code->uses_count === 0) + + @endif +
+
No referral codes found.
+
+
{{ $this->codes->links() }}
+ @endif + + {{-- Referral Detail Modal --}} + + @if($this->viewingReferral) + Referral Details + +
+
+
+
Referrer
+
{{ $this->viewingReferral->referrer?->email }}
+
+
+
Referee
+
{{ $this->viewingReferral->referee?->email ?? '-' }}
+
+
+
Code
+
{{ $this->viewingReferral->code }}
+
+
+
Status
+
{{ ucfirst($this->viewingReferral->status) }}
+
+
+ + + +
+
Commissions
+ @forelse($this->viewingReferral->commissions as $commission) +
+
+ {{ $commission->currency }} {{ number_format($commission->commission_amount, 2) }} + {{ ucfirst($commission->status) }} +
+
{{ $commission->created_at->format('d M Y') }}
+
+ @empty +
No commissions yet.
+ @endforelse +
+ + @if(!$this->viewingReferral->isDisqualified()) +
+ Close + Disqualify +
+ @else +
+ Close +
+ @endif +
+ @endif +
+ + {{-- Payout Processing Modal --}} + + @if($this->processingPayout) + Process Payout + +
+
+
+
User
+
{{ $this->processingPayout->user?->email }}
+
+
+
Amount
+
{{ $this->processingPayout->currency }} {{ number_format($this->processingPayout->amount, 2) }}
+
+
+
Method
+
{{ $this->processingPayout->method === 'btc' ? 'Bitcoin' : 'Account Credit' }}
+
+ @if($this->processingPayout->btc_address) +
+
BTC Address
+
{{ $this->processingPayout->btc_address }}
+
+ @endif +
+ + + + @if($this->processingPayout->isRequested()) + Mark as Processing + @endif + + @if($this->processingPayout->isProcessing()) + @if($this->processingPayout->isBtcPayout()) +
+ +
+ + +
+
+ @endif + +
+ Complete +
+ + + +
+ + Mark as Failed +
+ @endif + +
+ Close +
+
+ @endif +
+ + {{-- Code Modal --}} + + {{ $editingCodeId ? 'Edit Referral Code' : 'Create Referral Code' }} + +
+
+ + + Custom + Campaign + User + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ Cancel + {{ $editingCodeId ? 'Update' : 'Create' }} +
+ +
+
diff --git a/View/Blade/admin/subscription-manager.blade.php b/View/Blade/admin/subscription-manager.blade.php new file mode 100644 index 0000000..eb99fb5 --- /dev/null +++ b/View/Blade/admin/subscription-manager.blade.php @@ -0,0 +1,270 @@ + + + + + + + + + + + + + {{ __('commerce::commerce.bulk.export') }} + + {{ __('commerce::commerce.bulk.change_status') }} + + @foreach ($this->statuses as $status => $label) + {{ $label }} + @endforeach + + + {{ __('commerce::commerce.bulk.extend_period') }} + + + + {{-- Subscription Detail Modal --}} + + @if ($selectedSubscription) + @php + $statusColor = match($selectedSubscription->status) { + 'active' => 'green', + 'trialing' => 'blue', + 'past_due', 'incomplete' => 'amber', + 'paused' => 'zinc', + 'cancelled', 'expired' => 'red', + default => 'zinc', + }; + + // Calculate billing period progress + $periodStart = $selectedSubscription->current_period_start; + $periodEnd = $selectedSubscription->current_period_end; + $periodProgress = 0; + $daysRemaining = 0; + $totalDays = 0; + + if ($periodStart && $periodEnd) { + $totalDays = $periodStart->diffInDays($periodEnd); + $daysElapsed = $periodStart->diffInDays(now()); + $daysRemaining = max(0, now()->diffInDays($periodEnd, false)); + $periodProgress = $totalDays > 0 ? min(100, round(($daysElapsed / $totalDays) * 100)) : 0; + } + @endphp + + {{ __('commerce::commerce.subscriptions.detail.title') }} + +
+ {{-- Status Summary Card --}} + + {{ __('commerce::commerce.subscriptions.detail.summary') }} +
+
+ {{ __('commerce::commerce.subscriptions.detail.status') }} + {{ ucfirst($selectedSubscription->status) }} +
+
+ {{ __('commerce::commerce.subscriptions.detail.gateway') }} + {{ ucfirst($selectedSubscription->gateway ?? __('commerce::commerce.status.none')) }} +
+
+ {{ __('commerce::commerce.subscriptions.detail.billing_cycle') }} + {{ ucfirst($selectedSubscription->billing_cycle ?? 'monthly') }} +
+
+ {{ __('commerce::commerce.subscriptions.detail.created') }} + {{ $selectedSubscription->created_at->format('d M Y H:i') }} +
+
+
+ + {{-- Workspace and Package Cards --}} +
+ + {{ __('commerce::commerce.subscriptions.detail.workspace') }} + {{ $selectedSubscription->workspace?->name }} + + + {{ __('commerce::commerce.subscriptions.detail.package') }} + {{ $selectedSubscription->workspacePackage?->package?->name }} + {{ $selectedSubscription->workspacePackage?->package?->code }} + +
+ + {{-- Billing Period Card with Progress --}} + + {{ __('commerce::commerce.subscriptions.detail.current_period') }} + + {{-- Progress Bar --}} + @if ($periodStart && $periodEnd) +
+
+ {{ __('commerce::commerce.subscriptions.detail.billing_progress') }} + + {{ $daysRemaining }} {{ __('commerce::commerce.subscriptions.detail.days_remaining') }} + +
+
+
+
+
+ @endif + +
+
+ {{ __('commerce::commerce.subscriptions.detail.start') }} + {{ $selectedSubscription->current_period_start?->format('d M Y') }} +
+
+ {{ __('commerce::commerce.subscriptions.detail.end') }} + {{ $selectedSubscription->current_period_end?->format('d M Y') }} +
+
+
+ + {{-- Gateway Info Card --}} + @if ($selectedSubscription->gateway_subscription_id) + + {{ __('commerce::commerce.subscriptions.detail.gateway_details') }} +
+
+ {{ __('commerce::commerce.subscriptions.detail.subscription_id') }} + {{ $selectedSubscription->gateway_subscription_id }} +
+ @if ($selectedSubscription->gateway_customer_id) +
+ {{ __('commerce::commerce.subscriptions.detail.customer_id') }} + {{ $selectedSubscription->gateway_customer_id }} +
+ @endif + @if ($selectedSubscription->gateway_price_id) +
+ {{ __('commerce::commerce.subscriptions.detail.price_id') }} + {{ $selectedSubscription->gateway_price_id }} +
+ @endif +
+
+ @endif + + {{-- Cancellation Alert --}} + @if ($selectedSubscription->cancelled_at) + +
+ +
+ {{ __('commerce::commerce.subscriptions.detail.cancellation') }} +
+ + {{ __('commerce::commerce.subscriptions.detail.cancelled_at') }}: {{ $selectedSubscription->cancelled_at->format('d M Y H:i') }} + + @if ($selectedSubscription->cancellation_reason) + + {{ __('commerce::commerce.subscriptions.detail.reason') }}: {{ $selectedSubscription->cancellation_reason }} + + @endif + @if ($selectedSubscription->ended_at) + + {{ __('commerce::commerce.subscriptions.detail.ended_at') }}: {{ $selectedSubscription->ended_at->format('d M Y H:i') }} + + @elseif ($selectedSubscription->cancel_at_period_end) + + {{ __('commerce::commerce.subscriptions.detail.will_end_at_period_end') }} + + @endif +
+
+
+
+ @endif + + {{-- Trial Info Alert --}} + @if ($selectedSubscription->trial_ends_at) + +
+ +
+ {{ __('commerce::commerce.subscriptions.detail.trial') }} + + {{ __('commerce::commerce.subscriptions.detail.trial_ends') }}: {{ $selectedSubscription->trial_ends_at->format('d M Y H:i') }} + ({{ $selectedSubscription->trial_ends_at->diffForHumans() }}) + +
+
+
+ @endif +
+ +
+ {{ __('commerce::commerce.subscriptions.extend.title') }} + {{ __('commerce::commerce.subscriptions.update_status.title') }} + {{ __('commerce::commerce.actions.close') }} +
+ @endif +
+ + {{-- Status Update Modal --}} + + @if ($selectedSubscription) + {{ __('commerce::commerce.subscriptions.update_status.title') }} + +
+
+
{{ __('commerce::commerce.subscriptions.update_status.workspace') }}
+
{{ $selectedSubscription->workspace?->name }}
+
+ + + @foreach (array_keys($this->statuses) as $status) + + @endforeach + + + + +
+ {{ __('commerce::commerce.actions.cancel') }} + {{ __('commerce::commerce.subscriptions.update_status.title') }} +
+ + @endif +
+ + {{-- Extend Period Modal --}} + + @if ($selectedSubscription) + {{ __('commerce::commerce.subscriptions.extend.title') }} + +
+
+
{{ __('commerce::commerce.subscriptions.extend.current_period_ends') }}
+
{{ $selectedSubscription->current_period_end?->format('d M Y H:i') }}
+
+ + + +
+
+ {{ __('commerce::commerce.subscriptions.extend.new_end_date') }}: {{ $selectedSubscription->current_period_end?->addDays($extendDays)->format('d M Y H:i') }} +
+
+ +
+ {{ __('commerce::commerce.actions.cancel') }} + {{ __('commerce::commerce.subscriptions.extend.action') }} +
+ + @endif +
+
diff --git a/View/Blade/emails/invoice-generated.blade.php b/View/Blade/emails/invoice-generated.blade.php new file mode 100644 index 0000000..837fb82 --- /dev/null +++ b/View/Blade/emails/invoice-generated.blade.php @@ -0,0 +1,59 @@ + +@if($isPaid) +# Invoice Payment Received + +Thank you for your payment. Your invoice has been processed successfully. +@else +# Invoice Requires Payment + +A new invoice has been generated for your account. Please review the details below. +@endif + +**Invoice Number:** {{ $invoice->invoice_number }}
+**Issue Date:** {{ $invoice->issue_date->format('j F Y') }}
+@if(!$isPaid) +**Due Date:** {{ $invoice->due_date->format('j F Y') }} +@endif + +--- + +## Invoice Summary + +@foreach($items as $item) +- {{ $item->description }} — £{{ number_format($item->line_total, 2) }} +@endforeach + +@if($invoice->discount_amount > 0) +**Discount:** -£{{ number_format($invoice->discount_amount, 2) }}
+@endif +@if($invoice->tax_amount > 0) +**VAT ({{ $invoice->tax_rate }}%):** £{{ number_format($invoice->tax_amount, 2) }}
+@endif +**Total:** £{{ number_format($invoice->total, 2) }} + +@if($isPaid) +**Status:** Paid on {{ $invoice->paid_at->format('j F Y') }} +@else +**Amount Due:** £{{ number_format($invoice->amount_due, 2) }} +@endif + +--- + + +View Invoice + + +@if(!$isPaid) +Please ensure payment is received by the due date to avoid any service interruption. + +If you have any questions about this invoice, please contact us at [billing@host.uk.com](mailto:billing@host.uk.com). +@endif + +Thanks,
+{{ config('app.name') }} + + +View invoice online: {{ $viewUrl }}
+Download PDF: {{ $downloadUrl }} +
+
diff --git a/View/Blade/pdf/invoice.blade.php b/View/Blade/pdf/invoice.blade.php new file mode 100644 index 0000000..b028659 --- /dev/null +++ b/View/Blade/pdf/invoice.blade.php @@ -0,0 +1,479 @@ + + + + + + Invoice {{ $invoice->invoice_number }} + + + +
+ +
+
+
+

Host UK

+

Hosting and SaaS for UK businesses

+
+
+
INVOICE
+
{{ $invoice->invoice_number }}
+
+ Issued: {{ $invoice->issued_at?->format('j F Y') ?? $invoice->created_at->format('j F Y') }} + @if($invoice->due_date) +
Due: {{ $invoice->due_date->format('j F Y') }} + @endif +
+ @if($invoice->isPaid()) + Paid + @elseif($invoice->isOverdue()) + Overdue + @else + Pending + @endif +
+
+
+ + +
+
+

From

+

+ {{ $business['name'] ?? 'Host UK Ltd' }}
+ {{ $business['address_line1'] ?? '' }}
+ @if(isset($business['address_line2']) && $business['address_line2']) + {{ $business['address_line2'] }}
+ @endif + {{ $business['city'] ?? '' }}, {{ $business['postcode'] ?? '' }}
+ {{ $business['country'] ?? 'United Kingdom' }} + @if(isset($business['vat_number']) && $business['vat_number']) +

VAT: {{ $business['vat_number'] }} + @endif +

+
+
+

Bill To

+

+ {{ $invoice->billing_name }}
+ {{ $invoice->billing_email }}
+ @if($invoice->billing_address) + @if(is_array($invoice->billing_address)) + @if(isset($invoice->billing_address['line1'])){{ $invoice->billing_address['line1'] }}
@endif + @if(isset($invoice->billing_address['line2']) && $invoice->billing_address['line2']){{ $invoice->billing_address['line2'] }}
@endif + @if(isset($invoice->billing_address['city'])){{ $invoice->billing_address['city'] }}, @endif + @if(isset($invoice->billing_address['postcode'])){{ $invoice->billing_address['postcode'] }}
@endif + @if(isset($invoice->billing_address['country'])){{ $invoice->billing_address['country'] }}@endif + @else + {{ $invoice->billing_address }} + @endif + @endif + @if($invoice->workspace?->billing_vat_number) +

VAT: {{ $invoice->workspace->billing_vat_number }} + @endif +

+
+
+ + + + + + + + + + + + + @foreach($invoice->items as $item) + + + + + + + @endforeach + +
DescriptionQtyUnit PriceAmount
+
{{ $item->name }}
+ @if($item->description) +
{{ $item->description }}
+ @endif +
{{ $item->quantity }}{{ app(\Core\Commerce\Services\CommerceService::class)->formatMoney($item->unit_price, $invoice->currency) }}{{ app(\Core\Commerce\Services\CommerceService::class)->formatMoney($item->total, $invoice->currency) }}
+ + +
+
+
+
+
+ Subtotal + {{ app(\Core\Commerce\Services\CommerceService::class)->formatMoney($invoice->subtotal, $invoice->currency) }} +
+ @if($invoice->discount_amount > 0) +
+ Discount + -{{ app(\Core\Commerce\Services\CommerceService::class)->formatMoney($invoice->discount_amount, $invoice->currency) }} +
+ @endif + @if($invoice->tax_amount > 0) +
+ + @if($invoice->tax_rate) + VAT ({{ number_format($invoice->tax_rate, 0) }}%) + @else + VAT + @endif + + {{ app(\Core\Commerce\Services\CommerceService::class)->formatMoney($invoice->tax_amount, $invoice->currency) }} +
+ @endif +
+ Total + {{ app(\Core\Commerce\Services\CommerceService::class)->formatMoney($invoice->total, $invoice->currency) }} +
+
+
+
+ + @if($invoice->isPaid()) +
+

Payment Received

+

+ Thank you for your payment. This invoice was paid on {{ $invoice->paid_at?->format('j F Y') ?? 'N/A' }}. +

+
+ @else +
+

Payment Information

+

+ Please ensure payment is received by the due date. Payment can be made via our secure checkout at host.uk.com. + For any questions regarding this invoice, please contact support@host.uk.com. +

+
+ @endif + + + +
+ + diff --git a/View/Blade/web/change-plan.blade.php b/View/Blade/web/change-plan.blade.php new file mode 100644 index 0000000..b6905dc --- /dev/null +++ b/View/Blade/web/change-plan.blade.php @@ -0,0 +1,215 @@ +
+ +
+
+ Billing + + Subscription + + Change Plan +
+

Change Plan

+

Select a new plan to upgrade or downgrade your subscription

+
+ + @if(!$currentSubscription) +
+
+ +
+

No active subscription

+

+ You need an active subscription to change plans. Start by choosing a plan below. +

+ + + View Plans + +
+ @else +
+ +
+
+
+

Billing Cycle

+

Save up to 20% with annual billing

+
+
+ + +
+
+
+ + +
+ @foreach($availablePackages as $package) +
+
+
+

{{ $package->name }}

+ @if($this->isCurrentPackage($package)) + Current Plan + @endif +
+ @if($selectedPackageCode === $package->code) +
+ +
+ @endif +
+ +
+ + {{ $this->formatMoney($package->getPrice($billingCycle)) }} + + /{{ $billingCycle === 'yearly' ? 'year' : 'month' }} +
+ + @if($package->description) +

{{ $package->description }}

+ @endif + +
    + @foreach($package->features->take(5) as $feature) +
  • + + + {{ $feature->feature?->name ?? $feature->feature_code }} + @if($feature->limit_type === 'quota' && $feature->limit_value > 0) + ({{ number_format($feature->limit_value) }}) + @elseif($feature->limit_type === 'unlimited') + (Unlimited) + @endif + +
  • + @endforeach + @if($package->features->count() > 5) +
  • + +{{ $package->features->count() - 5 }} more features +
  • + @endif +
+
+ @endforeach +
+ + + @if($errorMessage) +
+
+ +

{{ $errorMessage }}

+
+
+ @endif + + + @if($selectedPackageCode && !$this->isCurrentPackage($availablePackages->firstWhere('code', $selectedPackageCode))) +
+
+

Plan Change Summary

+
+
+ @if(!$showPreview) +
+

+ Review the changes before confirming your plan update. +

+ + @if($isLoading) + + Loading... + @else + + Calculate Changes + @endif + +
+ @else +
+
+
+
Current Plan
+
{{ $previewData['current_plan'] }}
+
{{ $this->formatMoney($previewData['current_price']) }}/{{ $billingCycle }}
+
+
+
New Plan
+
{{ $previewData['new_plan'] }}
+
{{ $this->formatMoney($previewData['new_price']) }}/{{ $billingCycle }}
+
+
+ +
+
+ Proration credit/charge + + {{ $previewData['proration_amount'] < 0 ? '-' : '' }}{{ $this->formatMoney(abs($previewData['proration_amount'])) }} + +
+
+ Effective date + {{ $previewData['effective_date'] }} +
+
+ Next billing amount + {{ $this->formatMoney($previewData['next_billing_amount']) }} +
+
+ +
+ + + Back + + + @if($isLoading) + + Processing... + @else + + Confirm {{ $previewData['is_upgrade'] ? 'Upgrade' : 'Downgrade' }} + @endif + +
+
+ @endif +
+
+ @endif + + +
+
+ +
+

About plan changes

+
    +
  • Upgrades are applied immediately with prorated billing
  • +
  • Downgrades take effect at the end of your current billing period
  • +
  • Unused time on your current plan is credited to your account
  • +
+
+
+
+
+ @endif +
diff --git a/View/Blade/web/checkout/checkout-cancel.blade.php b/View/Blade/web/checkout/checkout-cancel.blade.php new file mode 100644 index 0000000..469c63c --- /dev/null +++ b/View/Blade/web/checkout/checkout-cancel.blade.php @@ -0,0 +1,46 @@ +
+
+ {{-- Logo --}} + + + + +
+
+ +
+ +

Checkout cancelled

+

+ Your payment was cancelled. No charges have been made. +

+ + @if ($order) +

+ Your order ({{ $order->order_number }}) has been saved. You can resume checkout at any time. +

+ @endif + + +
+
+
diff --git a/View/Blade/web/checkout/checkout-page.blade.php b/View/Blade/web/checkout/checkout-page.blade.php new file mode 100644 index 0000000..4956816 --- /dev/null +++ b/View/Blade/web/checkout/checkout-page.blade.php @@ -0,0 +1,481 @@ +
+
+ {{-- Header with Currency Selector --}} +
+ + + +
+ +
+
+ +
+

Complete your order

+
+ + {{-- Progress Steps --}} +
+ +
+ +
+ {{-- Main Content --}} +
+ {{-- Error Display --}} + @if ($error) +
+

{{ $error }}

+
+ @endif + + {{-- Step 1: Plan Selection --}} + @if ($step === 1) +
+

Choose your plan

+ + {{-- Billing Cycle Toggle --}} +
+
+ + +
+
+ + {{-- Package Cards --}} +
+ @foreach ($this->packages as $package) + + @endforeach +
+
+ @endif + + {{-- Step 2: Billing Details --}} + @if ($step === 2) +
+

Billing details

+ +
+
+
+ + + @error('billingName') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('billingEmail') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('billingAddressLine1') +

{{ $message }}

+ @enderror +
+ +
+ + +
+ +
+ + + @error('billingCity') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('billingPostalCode') +

{{ $message }}

+ @enderror +
+ +
+ + +
+ +
+ + + @error('billingCountry') +

{{ $message }}

+ @enderror +
+ +
+ + +

+ Enter your VAT number to potentially qualify for reverse charge +

+
+
+ +
+ + +
+
+
+ @endif + + {{-- Step 3: Payment --}} + @if ($step === 3) +
+

Choose payment method

+ +
+ {{-- BTCPay (Primary) --}} + @if (config('commerce.gateways.btcpay.enabled')) + + @endif + + {{-- Stripe (Hidden by default) --}} + @if (config('commerce.gateways.stripe.enabled')) + + @endif +
+ +
+ +
+
+ @endif +
+ + {{-- Order Summary Sidebar --}} +
+
+

Order summary

+ + @if ($this->selectedPackage) +
+
+ {{ $this->selectedPackage->name }} + + {{ $this->formatAmount($this->subtotal) }} + +
+
+ Billed {{ $billingCycle === 'yearly' ? 'annually' : 'monthly' }} +
+ + @if ($this->setupFee > 0) +
+ Setup fee + + {{ $this->formatAmount($this->setupFee) }} + +
+ @endif + + @if ($this->discount > 0) +
+ Discount + -{{ $this->formatAmount($this->discount) }} +
+ @endif + + @if ($this->taxAmount > 0) +
+ + Tax ({{ number_format($this->taxRate, 0) }}%) + + + {{ $this->formatAmount($this->taxAmount) }} + +
+ @endif +
+ +
+
+ Total + + {{ $this->formatAmount($this->total) }} + +
+ @if ($displayCurrency !== $this->baseCurrency) +

+ Approx. {{ app(\Core\Commerce\Services\CurrencyService::class)->format($this->baseTotal, $this->baseCurrency) }} + at current rates +

+ @endif +
+ + {{-- Coupon Code --}} +
+ @if ($this->appliedCoupon) +
+
+

+ {{ $this->appliedCoupon->code }} +

+

{{ $couponSuccess }}

+
+ +
+ @else +
+ + +
+ @if ($couponError) +

{{ $couponError }}

+ @endif + @endif +
+ @else +

Select a plan to see pricing

+ @endif +
+
+
+
+
diff --git a/View/Blade/web/checkout/checkout-success.blade.php b/View/Blade/web/checkout/checkout-success.blade.php new file mode 100644 index 0000000..ab2ec53 --- /dev/null +++ b/View/Blade/web/checkout/checkout-success.blade.php @@ -0,0 +1,217 @@ +
+
+ {{-- Logo --}} + + + + + @if ($needsAccount) + {{-- Guest checkout - needs account creation --}} +
+
+ +
+ +

Payment received

+

+ Create your account to access your new subscription. +

+ + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +

+ Already have an account? + Sign in +

+
+ @elseif ($order) + @if ($order->isPaid()) + {{-- Success State --}} +
+
+ +
+ +

Payment successful

+

+ Thank you for your order. Your account has been activated. +

+ +
+
+ Order number + {{ $order->order_number }} +
+
+ Amount + + {{ app(Mod\Commerce\Services\CommerceService::class)->formatMoney($order->total, $order->currency) }} + +
+
+ Email + {{ $order->billing_email }} +
+
+ +

+ A confirmation email has been sent to {{ $order->billing_email }} +

+ + +
+ @elseif ($isPending) + {{-- Pending/Processing State --}} +
+
+ +
+ +

Processing payment

+

+ Waiting for your payment to be confirmed. This may take a few minutes for crypto payments. +

+ +
+
+ Order number + {{ $order->order_number }} +
+
+ Status + + {{ ucfirst($order->status) }} + +
+
+ +

+ This page will automatically update when payment is confirmed. +

+
+ @else + {{-- Failed State --}} +
+
+ +
+ +

Payment issue

+

+ There was a problem with your payment. Please try again or contact support. +

+ + +
+ @endif + @else + {{-- No Order Found --}} +
+
+ +
+ +

Order not found

+

+ Couldn't find this order. It may have expired or been completed. +

+ + + Return to homepage + +
+ @endif +
+
diff --git a/View/Blade/web/components/currency-selector.blade.php b/View/Blade/web/components/currency-selector.blade.php new file mode 100644 index 0000000..f5d312f --- /dev/null +++ b/View/Blade/web/components/currency-selector.blade.php @@ -0,0 +1,116 @@ +@if ($style === 'dropdown') +
+ {{-- Trigger Button --}} + + + {{-- Dropdown Menu --}} +
true, + ]) + style="display: none;" + > +
+ @foreach ($this->currencies as $code => $currency) + + @endforeach +
+
+
+@else + {{-- Inline Buttons Style --}} +
+ @foreach ($this->currencies as $code => $currency) + + @endforeach +
+@endif diff --git a/View/Blade/web/dashboard.blade.php b/View/Blade/web/dashboard.blade.php new file mode 100644 index 0000000..c5f24da --- /dev/null +++ b/View/Blade/web/dashboard.blade.php @@ -0,0 +1,261 @@ +
+ +
+

Billing

+

Manage your subscription and payment details

+
+ +
+ +
+
+

Current Plan

+
+
+
+
+
+ + {{ $currentPlan }} + + @if($activeSubscription) + Active + {{ ucfirst($billingCycle) }} + @else + Free + @endif +
+ @if($nextBillingDate) +

+ Next billing date: {{ $nextBillingDate }} +

+ @endif +
+
+ + {{ $activeSubscription ? 'Change Plan' : 'Upgrade' }} + + @if($activeSubscription) + + Manage + + @endif +
+
+ + @if($nextBillingAmount > 0) +
+
+ Next payment + + {{ $this->formatMoney($nextBillingAmount) }} + +
+
+ @endif +
+
+ + + + + + @if($treesPlanted > 0 || $activeSubscription) + +
+
+
+ + + +
+
+

+ Trees for the Future +

+

+ Your subscription plants real trees +

+
+
+
+
+ {{ number_format($treesPlanted) }} +
+
+ trees planted +
+ @if($treesThisYear > 0) +
+ +{{ number_format($treesThisYear) }} this year +
+ @endif +
+
+
+ @endif + + +
+
+

Recent Invoices

+ + View all + +
+
+ @if($recentInvoices->isEmpty()) +
+ +

No invoices yet

+

Invoices will appear here once you make a purchase

+
+ @else +
+ @foreach($recentInvoices as $invoice) +
+
+
+ @if($invoice->isPaid()) +
+ +
+ @elseif($invoice->isPending()) +
+ +
+ @else +
+ +
+ @endif +
+
+
+ {{ $invoice->invoice_number }} +
+
+ {{ $invoice->issued_at?->format('j M Y') }} +
+
+
+
+
+
+ {{ $this->formatMoney($invoice->total) }} +
+
+ @if($invoice->isPaid()) + Paid + @elseif($invoice->isPending()) + Pending + @else + {{ ucfirst($invoice->status) }} + @endif +
+
+ @if($invoice->isPaid()) + + + + @endif +
+
+ @endforeach +
+ @endif +
+
+ + + @if($upcomingCharges->isNotEmpty()) +
+
+

Upcoming Charges

+

Subscriptions renewing within the next 30 days

+
+
+
+ @foreach($upcomingCharges as $subscription) +
+
+
+ {{ $subscription->workspacePackage?->package?->name ?? 'Subscription' }} +
+
+ Renews {{ $subscription->current_period_end?->format('j M Y') }} +
+
+
+ @php + $package = $subscription->workspacePackage?->package; + $cycle = ($subscription->current_period_start?->diffInDays($subscription->current_period_end) ?? 30) > 32 ? 'yearly' : 'monthly'; + @endphp +
+ {{ $this->formatMoney($package?->getPrice($cycle) ?? 0) }} +
+
+
+ @endforeach +
+
+
+ @endif + + +
+

+ Need help with billing? +

+

+ The support team is here to help with any billing questions +

+ + + Contact Support + +
+
+
diff --git a/View/Blade/web/invoices.blade.php b/View/Blade/web/invoices.blade.php new file mode 100644 index 0000000..7c41990 --- /dev/null +++ b/View/Blade/web/invoices.blade.php @@ -0,0 +1,162 @@ +
+ +
+
+ Billing + + Invoices +
+

Invoices

+

View and download your billing history

+
+ +
+ +
+
+ + + + +
+
+ + +
+ @if($invoices->isEmpty()) +
+ +

No invoices found

+

+ @if($status === 'all') + Invoices will appear here once you make a purchase + @else + No {{ $status }} invoices found + @endif +

+
+ @else +
+ @foreach($invoices as $invoice) +
+
+
+ @if($invoice->isPaid()) +
+ +
+ @elseif($invoice->isPending()) +
+ +
+ @elseif($invoice->isOverdue()) +
+ +
+ @else +
+ +
+ @endif +
+
+
+ {{ $invoice->invoice_number }} +
+
+ {{ $invoice->issued_at?->format('j F Y') }} + @if($invoice->items->isNotEmpty()) + · + {{ $invoice->items->first()->name }} + @if($invoice->items->count() > 1) + + {{ $invoice->items->count() - 1 }} more + @endif + @endif +
+
+
+
+
+
+ {{ $this->formatMoney($invoice->total, $invoice->currency) }} +
+
+ @if($invoice->isPaid()) + Paid + @if($invoice->paid_at) + {{ $invoice->paid_at->format('j M') }} + @endif + @elseif($invoice->isOverdue()) + Overdue + @elseif($invoice->isPending()) + Pending + @else + {{ ucfirst($invoice->status) }} + @endif +
+
+
+ @if($invoice->isPaid()) + + + + @endif + @if($invoice->isPending() || $invoice->isOverdue()) + + Pay now + + @endif +
+
+
+ @endforeach +
+ + @if($invoices->hasPages()) +
+ {{ $invoices->links() }} +
+ @endif + @endif +
+ + +
+

About your invoices

+
    +
  • + + Download PDF invoices for your records and accounting +
  • +
  • + + Invoices include VAT details where applicable +
  • +
  • + + Need a custom invoice? Contact support for assistance +
  • +
+
+
+
diff --git a/View/Blade/web/matrix/pending.blade.php b/View/Blade/web/matrix/pending.blade.php new file mode 100644 index 0000000..ff75b30 --- /dev/null +++ b/View/Blade/web/matrix/pending.blade.php @@ -0,0 +1,101 @@ +@extends('admin.layouts.app') + +@section('title', 'Pending Permission Requests') + +@section('content') +
+ {{-- Page header --}} +
+
+

Pending Permission Requests

+

Training mode: Review and approve permission requests

+
+ @if($entity) +
+ Filtered by: {{ $entity->name }} +
+ @endif +
+ + {{-- Entity filter --}} +
+
+
+ + All Entities + @foreach($entities as $ent) + + {{ $ent->path }} - {{ $ent->name }} + + @endforeach + +
+
+
+ + {{-- Requests table --}} +
+ @if($requests->isEmpty()) +
+ +

No pending requests

+

All permission requests have been processed.

+
+ @else +
+ @csrf +
+ + + + + + + + + + + + + @foreach($requests as $index => $request) + + + + + + + + + @endforeach + +
EntityActionRouteTimeAllowDeny
+ {{ $request->entity?->name ?? 'Unknown' }} +
{{ $request->entity?->path }}
+
+ {{ $request->action }} + @if($request->scope) + ({{ $request->scope }}) + @endif + + {{ $request->method }} {{ Str::limit($request->route, 40) }} + + {{ $request->created_at->diffForHumans() }} + + + + + + + +
+
+
+ + Submit Training Decisions + +
+
+ @endif +
+
+@endsection diff --git a/View/Blade/web/matrix/train-prompt.blade.php b/View/Blade/web/matrix/train-prompt.blade.php new file mode 100644 index 0000000..1439597 --- /dev/null +++ b/View/Blade/web/matrix/train-prompt.blade.php @@ -0,0 +1,125 @@ +{{-- Training Mode Permission Prompt --}} +{{-- Shown when a permission is undefined and training mode is enabled --}} + + + + + + + Permission Training - Commerce Matrix + @vite(['resources/css/app.css']) + + +
+ {{-- Header --}} +
+
+ + + +
+
+

Permission Not Defined

+

Training Mode Active

+
+
+ + {{-- Request Details --}} +
+
+ Entity: + {{ $entity->name }} ({{ $entity->type }}) +
+
+ Action: + {{ $result->key }} +
+
+ Scope: + {{ $result->scope ?? 'global' }} +
+
+ Route: + {{ $request->method() }} {{ $request->path() }} +
+
+ + {{-- Training Form --}} +
+ @csrf + + + + + + + {{-- Decision Buttons --}} +
+ + +
+ + {{-- Lock Option (only for non-root entities) --}} + @if($entity->depth > 0) +
+ +
+ @endif +
+ + {{-- Footer --}} +
+ + ← Go back without training + +
+ Commerce Matrix v1.0 +
+
+ + {{-- Entity Hierarchy Info --}} + @if($entity->depth > 0) +
+

Entity Hierarchy:

+
+ @php + $pathParts = explode('/', trim($entity->path, '/')); + @endphp + @foreach($pathParts as $index => $code) + @if($index > 0) + + @endif + + {{ $code }} + + @endforeach +
+
+ @endif +
+ + diff --git a/View/Blade/web/payment-methods.blade.php b/View/Blade/web/payment-methods.blade.php new file mode 100644 index 0000000..31b2b53 --- /dev/null +++ b/View/Blade/web/payment-methods.blade.php @@ -0,0 +1,172 @@ +
+ +
+
+ Billing + + Payment Methods +
+

Payment Methods

+

Manage your saved payment methods

+
+ +
+ +
+
+

Saved Payment Methods

+
+ + @if($isAddingMethod) + + Adding... + @else + + Add Card + @endif + + @if($paymentMethods->isNotEmpty()) + + + Manage in Stripe + + @endif +
+
+
+ @if($paymentMethods->isEmpty()) +
+ +

No payment methods saved

+

Add a card to enable automatic subscription renewals

+ + @if($isAddingMethod) + + Setting up... + @else + + Add Payment Method + @endif + +
+ @else +
+ @foreach($paymentMethods as $method) +
+
+
+ @if($method->type === 'card') +
+ @php + $brand = strtolower($method->brand ?? 'unknown'); + @endphp + @if($brand === 'visa') + VISA + @elseif($brand === 'mastercard') + MC + @elseif($brand === 'amex') + AMEX + @else + + @endif +
+ @elseif($method->type === 'crypto') +
+ +
+ @else +
+ +
+ @endif +
+
+
+ @if($method->type === 'card') + {{ ucfirst($method->brand ?? 'Card') }} ending in {{ $method->last_four ?? '****' }} + @elseif($method->type === 'crypto') + Cryptocurrency + @else + {{ ucfirst($method->type) }} + @endif +
+
+ @if($method->type === 'card' && $method->exp_month && $method->exp_year) + Expires {{ str_pad($method->exp_month, 2, '0', STR_PAD_LEFT) }}/{{ $method->exp_year }} + @else + Added {{ $method->created_at->format('j M Y') }} + @endif +
+
+
+
+ @if($this->isExpiringSoon($method->id)) + Expiring soon + @endif + @if($method->is_default) + Default + @else + + Make default + + @endif + + + +
+
+ @endforeach +
+ @endif +
+
+ + +
+
+
+ +
+
+

+ Pay with Cryptocurrency +

+

+ Host UK accepts Bitcoin, Litecoin, and Monero through BTCPay Server. Cryptocurrency payments are processed instantly and require no saved payment method. +

+
+
+ + No KYC required +
+
+ + Instant confirmation +
+
+ + Privacy-focused +
+
+
+
+
+ + +
+
+ +
+

Your payment information is secure

+

Host UK never stores full card numbers. All payment information is encrypted and handled by PCI-compliant payment processors.

+
+
+
+
+
diff --git a/View/Blade/web/payment_platforms/btcpayserver.blade.php b/View/Blade/web/payment_platforms/btcpayserver.blade.php new file mode 100644 index 0000000..908db71 --- /dev/null +++ b/View/Blade/web/payment_platforms/btcpayserver.blade.php @@ -0,0 +1,17 @@ + +
+ + + + +
+

Redirecting to BTCPay Server...

diff --git a/View/Blade/web/referral-dashboard.blade.php b/View/Blade/web/referral-dashboard.blade.php new file mode 100644 index 0000000..61bfb85 --- /dev/null +++ b/View/Blade/web/referral-dashboard.blade.php @@ -0,0 +1,358 @@ +
+ {{-- Header --}} +
+

Affiliate Dashboard

+

Track your referrals and earnings

+
+ + {{-- Stats Cards --}} +
+
+
Available Balance
+
GBP {{ number_format($this->stats['available_balance'], 2) }}
+
+
+
Pending
+
GBP {{ number_format($this->stats['pending_balance'], 2) }}
+
+
+
Lifetime Earnings
+
GBP {{ number_format($this->stats['lifetime_earnings'], 2) }}
+
+
+
Total Referrals
+
{{ number_format($this->stats['total_referrals']) }}
+
+
+ + {{-- Referral Link --}} +
+
+
+

Your Referral Link

+

Share this link to earn 10% commission on all purchases

+
+
+ + {{ $this->referralLink }} + + +
+
+
+ + {{-- Actions --}} +
+ {{-- Tabs --}} +
+ @foreach(['overview' => 'Overview', 'referrals' => 'Referrals', 'commissions' => 'Commissions', 'payouts' => 'Payouts'] as $tabKey => $tabLabel) + + @endforeach +
+ + {{-- Payout Button --}} + @if($this->stats['available_balance'] >= 10) + Request Payout + @endif +
+ + @if(session('message')) +
+ {{ session('message') }} +
+ @endif + + @if(session('error')) +
+ {{ session('error') }} +
+ @endif + + {{-- Overview Tab --}} + @if($tab === 'overview') +
+ {{-- Recent Referrals --}} +
+

Recent Referrals

+ @forelse($this->referrals->take(5) as $referral) +
+
+
{{ $referral->referee?->email ?? 'Pending signup' }}
+
{{ $referral->created_at->diffForHumans() }}
+
+ {{ ucfirst($referral->status) }} +
+ @empty +

No referrals yet. Share your link to get started.

+ @endforelse +
+ + {{-- Recent Earnings --}} +
+

Recent Earnings

+ @forelse($this->commissions->take(5) as $commission) +
+
+
{{ $commission->referral?->referee?->email ?? 'Unknown' }}
+
{{ $commission->created_at->diffForHumans() }}
+
+
+
+{{ $commission->currency }} {{ number_format($commission->commission_amount, 2) }}
+ {{ ucfirst($commission->status) }} +
+
+ @empty +

No earnings yet. Earnings appear when your referrals make purchases.

+ @endforelse +
+
+ + {{-- How It Works --}} +
+

How It Works

+
+
+
1
+
+
Share your link
+
Add your referral link to your bio, tweets, or anywhere else
+
+
+
+
2
+
+
People sign up
+
When someone clicks and creates an account, they become your referral
+
+
+
+
3
+
+
Earn 10% forever
+
You earn 10% of every payment they ever make, for life
+
+
+
+
+ @endif + + {{-- Referrals Tab --}} + @if($tab === 'referrals') +
+ + + + + + + + + + + @forelse($this->referrals as $referral) + + + + + + + @empty + + + + @endforelse + +
RefereeStatusSigned UpEarnings
+ @if($referral->referee) +
{{ $referral->referee->name ?? $referral->referee->email }}
+ @else + Pending signup + @endif +
+ @php + $statusColor = match($referral->status) { + 'pending' => 'gray', + 'converted' => 'blue', + 'qualified' => 'green', + 'disqualified' => 'red', + default => 'gray', + }; + @endphp + {{ ucfirst($referral->status) }} + + {{ $referral->signed_up_at?->format('d M Y') ?? '-' }} + + GBP {{ number_format($referral->total_commission, 2) }} +
No referrals yet.
+
+
{{ $this->referrals->links() }}
+ @endif + + {{-- Commissions Tab --}} + @if($tab === 'commissions') +
+ + + + + + + + + + + + @forelse($this->commissions as $commission) + + + + + + + + @empty + + + + @endforelse + +
RefereeOrderCommissionStatusMatures
+
{{ $commission->referral?->referee?->name ?? $commission->referral?->referee?->email ?? 'Unknown' }}
+
+ {{ $commission->currency }} {{ number_format($commission->order_amount, 2) }} + + {{ $commission->currency }} {{ number_format($commission->commission_amount, 2) }} +
{{ $commission->commission_rate }}%
+
+ @php + $statusColor = match($commission->status) { + 'pending' => 'amber', + 'matured' => 'green', + 'paid' => 'blue', + 'cancelled' => 'red', + default => 'gray', + }; + @endphp + {{ ucfirst($commission->status) }} + + {{ $commission->matures_at?->format('d M Y') ?? '-' }} +
No commissions yet.
+
+
{{ $this->commissions->links() }}
+ @endif + + {{-- Payouts Tab --}} + @if($tab === 'payouts') +
+ + + + + + + + + + + + + @forelse($this->payouts as $payout) + + + + + + + + + @empty + + + + @endforelse + +
NumberMethodAmountStatusRequestedActions
+ {{ $payout->payout_number }} + + + {{ $payout->method === 'btc' ? 'Bitcoin' : 'Credit' }} + + + {{ $payout->currency }} {{ number_format($payout->amount, 2) }} + @if($payout->btc_txid) +
{{ Str::limit($payout->btc_txid, 16) }}
+ @endif +
+ @php + $statusColor = match($payout->status) { + 'requested' => 'amber', + 'processing' => 'blue', + 'completed' => 'green', + 'failed' => 'red', + 'cancelled' => 'gray', + default => 'gray', + }; + @endphp + {{ ucfirst($payout->status) }} + + {{ $payout->requested_at?->format('d M Y') ?? '-' }} + + @if($payout->isRequested()) + + @endif +
No payouts yet.
+
+
{{ $this->payouts->links() }}
+ @endif + + {{-- Payout Request Modal --}} + + Request Payout + +
+
+
Available Balance
+
GBP {{ number_format($this->stats['available_balance'], 2) }}
+
+ + + Bitcoin (minimum GBP 10) + Account Credit (no minimum) + + + @if($payoutMethod === 'btc') + + @endif + + + +
+ @if($payoutMethod === 'btc') + Bitcoin payouts are processed weekly. You will receive the BTC equivalent at the time of processing. + @else + Account credit is applied immediately and can be used for any purchase. + @endif +
+ +
+ Cancel + Request Payout +
+ +
+
diff --git a/View/Blade/web/subscription.blade.php b/View/Blade/web/subscription.blade.php new file mode 100644 index 0000000..3043cc1 --- /dev/null +++ b/View/Blade/web/subscription.blade.php @@ -0,0 +1,231 @@ +
+ +
+
+ Billing + + Subscription +
+

Subscription

+

Manage your subscription plan

+
+ +
+ +
+
+

Current Subscription

+
+
+ @if($activeSubscription) +
+
+
+ + {{ $currentPlan }} + + @if($activeSubscription->cancelled_at) + Cancelling + @else + Active + @endif + {{ ucfirst($billingCycle) }} +
+ +
+ @if($activeSubscription->cancelled_at) +
+ + Your subscription will end on {{ $nextBillingDate }} +
+ @else +
+ + Next billing date: {{ $nextBillingDate }} +
+
+ + Next payment: {{ $this->formatMoney($nextBillingAmount) }} +
+ @endif +
+
+ +
+ @if($activeSubscription->cancelled_at) + + + Resume Subscription + + @else + + + Change Plan + + + Cancel Subscription + + @endif +
+
+ @else +
+
+ +
+

No active subscription

+

+ You're currently on the free plan. Upgrade to unlock premium features. +

+ + + View Plans + +
+ @endif +
+
+ + + @if($activeSubscription && $activeSubscription->workspacePackage?->package) + @php + $package = $activeSubscription->workspacePackage->package; + @endphp +
+
+

Plan Features

+
+
+ @if($package->description) +

{{ $package->description }}

+ @endif +
+ @foreach($package->features as $packageFeature) +
+ + + {{ $packageFeature->feature?->name ?? $packageFeature->feature_code }} + @if($packageFeature->limit_type === 'quota' && $packageFeature->limit_value > 0) + ({{ number_format($packageFeature->limit_value) }}) + @elseif($packageFeature->limit_type === 'unlimited') + (Unlimited) + @endif + +
+ @endforeach +
+
+ + + View Usage Details + +
+
+
+ @endif + + + @if($subscriptionHistory->isNotEmpty()) +
+
+

Subscription History

+
+
+ @foreach($subscriptionHistory as $subscription) +
+
+
+ @if($subscription->isActive()) +
+ +
+ @elseif($subscription->cancelled_at) +
+ +
+ @else +
+ +
+ @endif +
+
+
+ {{ $subscription->workspacePackage?->package?->name ?? 'Subscription' }} +
+
+ @if($subscription->current_period_start && $subscription->current_period_end) + {{ $subscription->current_period_start->format('j M Y') }} - {{ $subscription->current_period_end->format('j M Y') }} + @else + Started {{ $subscription->created_at->format('j M Y') }} + @endif +
+
+
+
+ @if($subscription->isActive()) + @if($subscription->cancelled_at) + Cancelling + @else + Active + @endif + @else + {{ ucfirst($subscription->status) }} + @endif +
+
+ @endforeach +
+
+ @endif + + +
+
+ +
+

Questions about your subscription?

+

Contact the support team at support@host.uk.com and they'll be happy to help.

+
+
+
+
+ + + @if($showCancelModal) +
+
+
+
+ +
+

Cancel your subscription?

+

+ Your subscription will remain active until {{ $nextBillingDate }}, then your account will be downgraded to the free plan. +

+
+ +
+ + +
+ +
+ + Keep Subscription + + + Cancel Subscription + +
+
+
+ @endif +
diff --git a/View/Blade/web/usage-dashboard.blade.php b/View/Blade/web/usage-dashboard.blade.php new file mode 100644 index 0000000..474bc2f --- /dev/null +++ b/View/Blade/web/usage-dashboard.blade.php @@ -0,0 +1,216 @@ +
+ +
+

Usage

+

Monitor your current period usage and estimated charges

+
+ + @if(!$usageBillingEnabled) + +
+
+ +
+

+ Usage billing not enabled +

+

+ Usage-based billing is not currently enabled for your account. Your subscription includes flat-rate pricing. +

+
+ @elseif(!$activeSubscription) + +
+
+ +
+

+ No active subscription +

+

+ Subscribe to a plan to start tracking your usage. +

+ + View Plans + +
+ @else +
+ +
+
+
+
+

Current Billing Period

+

+ {{ $periodStart }} - {{ $periodEnd }} +

+
+
+
Days remaining
+
{{ $daysRemaining }}
+
+
+
+ + @if($currentUsage->isEmpty()) +
+ +

No usage recorded this period

+
+ @else +
+
+ @foreach($currentUsage as $usage) +
+
+ + {{ $usage['meter_name'] }} + + + {{ $usage['unit_label'] }} + +
+
+ {{ $this->formatNumber($usage['quantity']) }} +
+ @if($usage['estimated_charge'] > 0) +
+ Est. charge: {{ $this->formatMoney($usage['estimated_charge'], $usage['currency']) }} +
+ @endif + + @php + $percentage = $this->getUsagePercentage($usage, $usage['included_quota'] ?? null); + $statusColour = $this->getUsageStatusColour($percentage); + @endphp + + @if($percentage !== null) +
+
+ Included quota + + {{ $percentage }}% + +
+
+
+
+
+
+ @endif +
+ @endforeach +
+
+ @endif +
+ + + @if($estimatedCharges > 0) +
+
+
+

Estimated Usage Charges

+

+ Based on current period usage. Final charges calculated at period end. +

+
+
+
+ {{ $this->formatMoney($estimatedCharges) }} +
+
+ + subscription fee +
+
+
+
+ @endif + + + @if($usageHistory->isNotEmpty()) +
+
+

Usage History

+

Previous billing periods

+
+
+
+ + + + + + + + + + + + @foreach($usageHistory as $period => $records) + @foreach($records as $index => $record) + + @if($index === 0) + + @endif + + + + + + @endforeach + @endforeach + +
PeriodMeterQuantityChargeStatus
+ {{ $record->period_start->format('M Y') }} + + {{ $record->meter->name }} + + {{ $this->formatNumber($record->quantity) }} + {{ $record->meter->unit_label }} + + {{ $this->formatMoney($record->calculateCharge()) }} + + @if($record->billed) + Billed + @else + Pending + @endif +
+
+
+
+ @endif + + +
+ + + Refresh Usage Data + +
+ + +
+

+ Understanding your usage +

+

+ Usage is tracked throughout your billing period and charged at the end. Some features may include a quota with your subscription, with overage charges applying beyond that limit. +

+
+ + + View Billing + + + + Need Help? + +
+
+
+ @endif +
diff --git a/View/Modal/Admin/CouponManager.php b/View/Modal/Admin/CouponManager.php new file mode 100644 index 0000000..0e78854 --- /dev/null +++ b/View/Modal/Admin/CouponManager.php @@ -0,0 +1,608 @@ +user()?->isHades()) { + abort(403, 'Hades tier required for coupon management.'); + } + } + + protected function rules(): array + { + $uniqueRule = $this->editingId + ? 'unique:coupons,code,'.$this->editingId + : 'unique:coupons,code'; + + return [ + 'code' => ['required', 'string', 'max:50', $uniqueRule, 'regex:/^[A-Z0-9_-]+$/'], + 'name' => ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string', 'max:500'], + 'type' => ['required', 'in:percentage,fixed_amount'], + 'value' => ['required', 'numeric', 'min:0.01'], + 'min_amount' => ['nullable', 'numeric', 'min:0'], + 'max_discount' => ['nullable', 'numeric', 'min:0'], + 'applies_to' => ['required', 'in:all,packages'], + 'package_ids' => ['array'], + 'max_uses' => ['nullable', 'integer', 'min:1'], + 'max_uses_per_workspace' => ['required', 'integer', 'min:1'], + 'duration' => ['required', 'in:once,repeating,forever'], + 'duration_months' => ['nullable', 'integer', 'min:1', 'max:24'], + 'valid_from' => ['nullable', 'date'], + 'valid_until' => ['nullable', 'date', 'after_or_equal:valid_from'], + 'is_active' => ['boolean'], + ]; + } + + protected $messages = [ + 'code.regex' => 'Code must contain only uppercase letters, numbers, hyphens, and underscores.', + ]; + + public function updatingSearch(): void + { + $this->resetPage(); + $this->selected = []; + $this->selectAll = false; + } + + public function updatedSelectAll(bool $value): void + { + if ($value) { + $this->selected = $this->coupons->pluck('id')->map(fn ($id) => (string) $id)->all(); + } else { + $this->selected = []; + } + } + + public function exportSelected(): void + { + if (empty($this->selected)) { + session()->flash('error', __('commerce::commerce.bulk.no_selection')); + + return; + } + + $coupons = Coupon::whereIn('id', $this->selected)->get(); + + $csv = "Code,Name,Type,Value,Duration,Max Uses,Used Count,Active,Valid From,Valid Until\n"; + foreach ($coupons as $coupon) { + $csv .= sprintf( + "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n", + $coupon->code, + str_replace(',', ' ', $coupon->name), + $coupon->type, + $coupon->value, + $coupon->duration, + $coupon->max_uses ?? 'unlimited', + $coupon->used_count, + $coupon->is_active ? 'Yes' : 'No', + $coupon->valid_from?->format('Y-m-d') ?? '', + $coupon->valid_until?->format('Y-m-d') ?? '' + ); + } + + $this->dispatch('download-csv', filename: 'coupons-export-'.now()->format('Y-m-d').'.csv', content: $csv); + session()->flash('message', __('commerce::commerce.bulk.export_success', ['count' => count($this->selected)])); + $this->selected = []; + $this->selectAll = false; + } + + public function bulkActivate(): void + { + if (empty($this->selected)) { + session()->flash('error', __('commerce::commerce.bulk.no_selection')); + + return; + } + + $count = Coupon::whereIn('id', $this->selected)->update(['is_active' => true]); + + session()->flash('message', __('commerce::commerce.bulk.activated', ['count' => $count])); + $this->selected = []; + $this->selectAll = false; + } + + public function bulkDeactivate(): void + { + if (empty($this->selected)) { + session()->flash('error', __('commerce::commerce.bulk.no_selection')); + + return; + } + + $count = Coupon::whereIn('id', $this->selected)->update(['is_active' => false]); + + session()->flash('message', __('commerce::commerce.bulk.deactivated', ['count' => $count])); + $this->selected = []; + $this->selectAll = false; + } + + public function confirmBulkDelete(): void + { + if (empty($this->selected)) { + session()->flash('error', __('commerce::commerce.bulk.no_selection')); + + return; + } + + $this->showBulkDeleteModal = true; + } + + public function bulkDelete(): void + { + if (empty($this->selected)) { + return; + } + + // Only delete coupons that haven't been used + $deletable = Coupon::whereIn('id', $this->selected)->where('used_count', 0)->pluck('id'); + $skipped = count($this->selected) - $deletable->count(); + + Coupon::whereIn('id', $deletable)->delete(); + + $message = __('commerce::commerce.bulk.deleted', ['count' => $deletable->count()]); + if ($skipped > 0) { + $message .= ' '.__('commerce::commerce.bulk.skipped_used', ['count' => $skipped]); + } + + session()->flash('message', $message); + $this->selected = []; + $this->selectAll = false; + $this->showBulkDeleteModal = false; + } + + public function closeBulkDeleteModal(): void + { + $this->showBulkDeleteModal = false; + } + + public function openBulkGenerate(): void + { + $this->resetBulkForm(); + $this->showBulkGenerateModal = true; + } + + public function closeBulkGenerateModal(): void + { + $this->showBulkGenerateModal = false; + $this->resetBulkForm(); + } + + protected function resetBulkForm(): void + { + $this->bulk_count = 10; + $this->bulk_code_prefix = ''; + $this->bulk_name = ''; + $this->bulk_type = 'percentage'; + $this->bulk_value = 0; + $this->bulk_min_amount = null; + $this->bulk_max_discount = null; + $this->bulk_applies_to = 'all'; + $this->bulk_package_ids = []; + $this->bulk_max_uses = 1; + $this->bulk_max_uses_per_workspace = 1; + $this->bulk_duration = 'once'; + $this->bulk_duration_months = null; + $this->bulk_valid_from = null; + $this->bulk_valid_until = null; + $this->bulk_is_active = true; + } + + protected function bulkGenerateRules(): array + { + return [ + 'bulk_count' => ['required', 'integer', 'min:1', 'max:100'], + 'bulk_code_prefix' => ['nullable', 'string', 'max:20', 'regex:/^[A-Z0-9_-]*$/'], + 'bulk_name' => ['required', 'string', 'max:100'], + 'bulk_type' => ['required', 'in:percentage,fixed_amount'], + 'bulk_value' => ['required', 'numeric', 'min:0.01'], + 'bulk_min_amount' => ['nullable', 'numeric', 'min:0'], + 'bulk_max_discount' => ['nullable', 'numeric', 'min:0'], + 'bulk_applies_to' => ['required', 'in:all,packages'], + 'bulk_package_ids' => ['array'], + 'bulk_max_uses' => ['nullable', 'integer', 'min:1'], + 'bulk_max_uses_per_workspace' => ['required', 'integer', 'min:1'], + 'bulk_duration' => ['required', 'in:once,repeating,forever'], + 'bulk_duration_months' => ['nullable', 'integer', 'min:1', 'max:24'], + 'bulk_valid_from' => ['nullable', 'date'], + 'bulk_valid_until' => ['nullable', 'date', 'after_or_equal:bulk_valid_from'], + 'bulk_is_active' => ['boolean'], + ]; + } + + public function generateBulk(CouponService $couponService): void + { + $this->bulk_code_prefix = strtoupper($this->bulk_code_prefix); + + $this->validate($this->bulkGenerateRules()); + + $baseData = [ + 'code_prefix' => $this->bulk_code_prefix ?: null, + 'name' => $this->bulk_name, + 'type' => $this->bulk_type, + 'value' => $this->bulk_value, + 'min_amount' => $this->bulk_min_amount, + 'max_discount' => $this->bulk_max_discount, + 'applies_to' => $this->bulk_applies_to, + 'package_ids' => $this->bulk_applies_to === 'packages' ? $this->bulk_package_ids : null, + 'max_uses' => $this->bulk_max_uses, + 'max_uses_per_workspace' => $this->bulk_max_uses_per_workspace, + 'duration' => $this->bulk_duration, + 'duration_months' => $this->bulk_duration === 'repeating' ? $this->bulk_duration_months : null, + 'valid_from' => $this->bulk_valid_from ? \Carbon\Carbon::parse($this->bulk_valid_from) : null, + 'valid_until' => $this->bulk_valid_until ? \Carbon\Carbon::parse($this->bulk_valid_until) : null, + 'is_active' => $this->bulk_is_active, + ]; + + $coupons = $couponService->generateBulk($this->bulk_count, $baseData); + + session()->flash('message', __('commerce::commerce.coupons.bulk.generated', ['count' => count($coupons)])); + $this->closeBulkGenerateModal(); + } + + public function openCreate(): void + { + $this->resetForm(); + // Generate a random code + $this->code = strtoupper(substr(md5(uniqid()), 0, 8)); + $this->showModal = true; + } + + public function openEdit(int $id): void + { + $coupon = Coupon::findOrFail($id); + + $this->editingId = $id; + $this->code = $coupon->code; + $this->name = $coupon->name; + $this->description = $coupon->description ?? ''; + $this->type = $coupon->type; + $this->value = (float) $coupon->value; + $this->min_amount = $coupon->min_amount ? (float) $coupon->min_amount : null; + $this->max_discount = $coupon->max_discount ? (float) $coupon->max_discount : null; + $this->applies_to = $coupon->applies_to ?? 'all'; + $this->package_ids = $coupon->package_ids ?? []; + $this->max_uses = $coupon->max_uses; + $this->max_uses_per_workspace = $coupon->max_uses_per_workspace ?? 1; + $this->duration = $coupon->duration ?? 'once'; + $this->duration_months = $coupon->duration_months; + $this->valid_from = $coupon->valid_from?->format('Y-m-d'); + $this->valid_until = $coupon->valid_until?->format('Y-m-d'); + $this->is_active = $coupon->is_active; + + $this->showModal = true; + } + + public function save(): void + { + // Ensure code is uppercase + $this->code = strtoupper($this->code); + + $this->validate(); + + $data = [ + 'code' => $this->code, + 'name' => $this->name, + 'description' => $this->description ?: null, + 'type' => $this->type, + 'value' => $this->value, + 'min_amount' => $this->min_amount, + 'max_discount' => $this->max_discount, + 'applies_to' => $this->applies_to, + 'package_ids' => $this->applies_to === 'packages' ? $this->package_ids : null, + 'max_uses' => $this->max_uses, + 'max_uses_per_workspace' => $this->max_uses_per_workspace, + 'duration' => $this->duration, + 'duration_months' => $this->duration === 'repeating' ? $this->duration_months : null, + 'valid_from' => $this->valid_from ? \Carbon\Carbon::parse($this->valid_from) : null, + 'valid_until' => $this->valid_until ? \Carbon\Carbon::parse($this->valid_until) : null, + 'is_active' => $this->is_active, + ]; + + if ($this->editingId) { + Coupon::findOrFail($this->editingId)->update($data); + session()->flash('message', 'Coupon updated successfully.'); + } else { + Coupon::create($data); + session()->flash('message', 'Coupon created successfully.'); + } + + $this->closeModal(); + } + + public function toggleActive(int $id): void + { + $coupon = Coupon::findOrFail($id); + $coupon->update(['is_active' => ! $coupon->is_active]); + + session()->flash('message', $coupon->is_active ? 'Coupon activated.' : 'Coupon deactivated.'); + } + + public function delete(int $id): void + { + $coupon = Coupon::findOrFail($id); + + // Check if coupon has been used + if ($coupon->used_count > 0) { + session()->flash('error', 'Cannot delete coupon that has been used. Deactivate it instead.'); + + return; + } + + $coupon->delete(); + session()->flash('message', 'Coupon deleted successfully.'); + } + + public function closeModal(): void + { + $this->showModal = false; + $this->resetForm(); + } + + protected function resetForm(): void + { + $this->editingId = null; + $this->code = ''; + $this->name = ''; + $this->description = ''; + $this->type = 'percentage'; + $this->value = 0; + $this->min_amount = null; + $this->max_discount = null; + $this->applies_to = 'all'; + $this->package_ids = []; + $this->max_uses = null; + $this->max_uses_per_workspace = 1; + $this->duration = 'once'; + $this->duration_months = null; + $this->valid_from = null; + $this->valid_until = null; + $this->is_active = true; + } + + #[Computed] + public function coupons() + { + return Coupon::query() + ->when($this->search, function ($query) { + $query->where(function ($q) { + $q->where('code', 'like', "%{$this->search}%") + ->orWhere('name', 'like', "%{$this->search}%"); + }); + }) + ->when($this->statusFilter === 'active', fn ($q) => $q->where('is_active', true)) + ->when($this->statusFilter === 'inactive', fn ($q) => $q->where('is_active', false)) + ->when($this->statusFilter === 'valid', fn ($q) => $q->valid()) + ->when($this->statusFilter === 'expired', function ($q) { + $q->where(function ($query) { + $query->where('valid_until', '<', now()) + ->orWhere(function ($q2) { + $q2->whereNotNull('max_uses') + ->whereRaw('used_count >= max_uses'); + }); + }); + }) + ->latest() + ->paginate(25); + } + + #[Computed] + public function packages() + { + return Package::where('is_active', true)->orderBy('name')->get(['id', 'code', 'name']); + } + + #[Computed] + public function statusOptions(): array + { + return [ + 'active' => 'Active', + 'inactive' => 'Inactive', + 'valid' => 'Currently valid', + 'expired' => 'Expired or maxed', + ]; + } + + #[Computed] + public function tableColumns(): array + { + return [ + 'Code', + 'Name', + 'Discount', + 'Duration', + ['label' => 'Usage', 'align' => 'center'], + ['label' => 'Status', 'align' => 'center'], + 'Validity', + ['label' => 'Actions', 'align' => 'center'], + ]; + } + + #[Computed] + public function tableRowIds(): array + { + return $this->coupons->pluck('id')->all(); + } + + #[Computed] + public function tableRows(): array + { + return $this->coupons->map(function ($c) { + // Discount display + $discount = $c->isPercentage() + ? "{$c->value}% off" + : 'GBP '.number_format($c->value, 2).' off'; + $discountLines = [['bold' => $discount]]; + if ($c->min_amount) { + $discountLines[] = ['muted' => 'Min: GBP '.number_format($c->min_amount, 2)]; + } + + // Duration display + $durationLabel = match ($c->duration) { + 'once' => 'Once', + 'repeating' => "{$c->duration_months} months", + 'forever' => 'Forever', + default => ucfirst($c->duration), + }; + $durationColor = match ($c->duration) { + 'once' => 'gray', + 'repeating' => 'blue', + 'forever' => 'purple', + default => 'gray', + }; + + // Status display + $statusLabel = $c->is_active ? ($c->isValid() ? 'Active' : 'Exhausted') : 'Inactive'; + $statusColor = $c->is_active ? ($c->isValid() ? 'green' : 'amber') : 'gray'; + + // Validity display + $validityLines = []; + if ($c->valid_from || $c->valid_until) { + if ($c->valid_from) { + $validityLines[] = ['muted' => 'From: '.$c->valid_from->format('d M Y')]; + } + if ($c->valid_until) { + $validityLines[] = $c->valid_until->isPast() + ? ['badge' => 'Until: '.$c->valid_until->format('d M Y'), 'color' => 'red'] + : ['muted' => 'Until: '.$c->valid_until->format('d M Y')]; + } + } + + // Actions + $actions = [ + ['icon' => 'pencil', 'click' => "openEdit({$c->id})", 'title' => 'Edit'], + ['icon' => $c->is_active ? 'pause' : 'play', 'click' => "toggleActive({$c->id})", 'title' => $c->is_active ? 'Deactivate' : 'Activate'], + ]; + if ($c->used_count === 0) { + $actions[] = ['icon' => 'trash', 'click' => "delete({$c->id})", 'confirm' => 'Are you sure you want to delete this coupon?', 'title' => 'Delete', 'class' => 'text-red-600']; + } + + return [ + ['mono' => $c->code], + [ + 'lines' => array_filter([ + ['bold' => $c->name], + $c->description ? ['muted' => \Illuminate\Support\Str::limit($c->description, 30)] : null, + ]), + ], + ['lines' => $discountLines], + ['badge' => $durationLabel, 'color' => $durationColor], + $c->max_uses ? "{$c->used_count} / {$c->max_uses}" : (string) $c->used_count, + ['badge' => $statusLabel, 'color' => $statusColor], + ! empty($validityLines) ? ['lines' => $validityLines] : ['muted' => 'No date limits'], + ['actions' => $actions], + ]; + })->all(); + } + + public function render() + { + return view('commerce::admin.coupon-manager') + ->layout('hub::admin.layouts.app', ['title' => 'Coupons']); + } +} diff --git a/View/Modal/Admin/CreditNoteManager.php b/View/Modal/Admin/CreditNoteManager.php new file mode 100644 index 0000000..d1b5215 --- /dev/null +++ b/View/Modal/Admin/CreditNoteManager.php @@ -0,0 +1,427 @@ +user()?->isHades()) { + abort(403, 'Hades tier required for credit note management.'); + } + } + + public function updatingSearch(): void + { + $this->resetPage(); + $this->selected = []; + $this->selectAll = false; + } + + public function updatingStatusFilter(): void + { + $this->resetPage(); + $this->selected = []; + $this->selectAll = false; + } + + public function updatingReasonFilter(): void + { + $this->resetPage(); + $this->selected = []; + $this->selectAll = false; + } + + public function updatingDateRange(): void + { + $this->resetPage(); + $this->selected = []; + $this->selectAll = false; + } + + public function updatedSelectAll(bool $value): void + { + if ($value) { + $this->selected = $this->creditNotes->pluck('id')->map(fn ($id) => (string) $id)->all(); + } else { + $this->selected = []; + } + } + + public function openCreate(): void + { + $this->resetCreateForm(); + $this->showCreateModal = true; + } + + public function closeCreateModal(): void + { + $this->showCreateModal = false; + $this->resetCreateForm(); + } + + public function resetCreateForm(): void + { + $this->workspaceId = null; + $this->userId = null; + $this->amount = ''; + $this->reason = ''; + $this->description = ''; + $this->currency = 'GBP'; + } + + public function create(): void + { + $this->validate([ + 'workspaceId' => 'required|exists:workspaces,id', + 'userId' => 'required|exists:users,id', + 'amount' => 'required|numeric|min:0.01', + 'reason' => 'required|string', + 'description' => 'nullable|string|max:1000', + 'currency' => 'required|string|size:3', + ]); + + $service = app(CreditNoteService::class); + $workspace = Workspace::findOrFail($this->workspaceId); + $user = User::findOrFail($this->userId); + + $creditNote = $service->create( + workspace: $workspace, + user: $user, + amount: (float) $this->amount, + reason: $this->reason, + description: $this->description ?: null, + currency: $this->currency, + issuedBy: auth()->user(), + issueImmediately: true + ); + + session()->flash('message', "Credit note {$creditNote->reference_number} created and issued."); + $this->closeCreateModal(); + } + + public function viewCreditNote(int $id): void + { + $this->selectedCreditNote = CreditNote::with([ + 'workspace', + 'user', + 'order', + 'refund', + 'appliedToOrder', + 'issuedByUser', + 'voidedByUser', + ])->findOrFail($id); + + $this->showDetailModal = true; + } + + public function closeDetailModal(): void + { + $this->showDetailModal = false; + $this->selectedCreditNote = null; + } + + public function confirmVoid(int $id): void + { + $this->creditNoteToVoid = CreditNote::findOrFail($id); + $this->showVoidModal = true; + } + + public function closeVoidModal(): void + { + $this->showVoidModal = false; + $this->creditNoteToVoid = null; + } + + public function voidCreditNote(): void + { + if (! $this->creditNoteToVoid) { + return; + } + + try { + $service = app(CreditNoteService::class); + $service->void($this->creditNoteToVoid, auth()->user()); + session()->flash('message', "Credit note {$this->creditNoteToVoid->reference_number} has been voided."); + } catch (\InvalidArgumentException $e) { + session()->flash('error', $e->getMessage()); + } + + $this->closeVoidModal(); + } + + public function exportSelected(): void + { + if (empty($this->selected)) { + session()->flash('error', 'No credit notes selected.'); + + return; + } + + $creditNotes = CreditNote::whereIn('id', $this->selected)->with(['user', 'workspace'])->get(); + + $csv = "Reference,Workspace,User,Amount,Currency,Status,Reason,Issued At,Created\n"; + foreach ($creditNotes as $cn) { + $csv .= sprintf( + "%s,%s,%s,%s,%s,%s,%s,%s,%s\n", + $cn->reference_number, + str_replace(',', ' ', $cn->workspace?->name ?? 'N/A'), + str_replace(',', ' ', $cn->user?->name ?? 'N/A'), + number_format($cn->amount, 2), + $cn->currency, + $cn->status, + str_replace(',', ' ', $cn->reason), + $cn->issued_at?->format('Y-m-d H:i:s') ?? 'Not issued', + $cn->created_at->format('Y-m-d H:i:s') + ); + } + + $this->dispatch('download-csv', filename: 'credit-notes-export-'.now()->format('Y-m-d').'.csv', content: $csv); + session()->flash('message', 'Exported '.count($this->selected).' credit notes.'); + $this->selected = []; + $this->selectAll = false; + } + + #[Computed] + public function creditNotes() + { + return CreditNote::query() + ->with(['workspace', 'user', 'order', 'refund']) + ->when($this->search, function ($query) { + $query->where(function ($q) { + $q->where('reference_number', 'like', "%{$this->search}%") + ->orWhereHas('user', fn ($u) => $u->where('name', 'like', "%{$this->search}%") + ->orWhere('email', 'like', "%{$this->search}%")); + }); + }) + ->when($this->statusFilter, fn ($q) => $q->where('status', $this->statusFilter)) + ->when($this->reasonFilter, fn ($q) => $q->where('reason', $this->reasonFilter)) + ->when($this->dateRange, function ($query) { + $startDate = match ($this->dateRange) { + 'today' => now()->startOfDay(), + '7d' => now()->subDays(7)->startOfDay(), + '30d' => now()->subDays(30)->startOfDay(), + '90d' => now()->subDays(90)->startOfDay(), + 'this_month' => now()->startOfMonth(), + 'last_month' => now()->subMonth()->startOfMonth(), + default => null, + }; + + if ($startDate) { + $query->where('created_at', '>=', $startDate); + } + + if ($this->dateRange === 'last_month') { + $query->where('created_at', '<', now()->startOfMonth()); + } + }) + ->when($this->workspaceFilter, fn ($q) => $q->where('workspace_id', $this->workspaceFilter)) + ->latest() + ->paginate(25); + } + + #[Computed] + public function workspaces() + { + return Workspace::orderBy('name')->get(['id', 'name']); + } + + #[Computed] + public function users() + { + return User::query() + ->when($this->workspaceId, function ($q) { + $q->whereHas('workspaces', fn ($w) => $w->where('workspace_id', $this->workspaceId)); + }) + ->orderBy('name') + ->get(['id', 'name', 'email']); + } + + #[Computed] + public function statuses(): array + { + return [ + 'draft' => 'Draft', + 'issued' => 'Issued', + 'partially_applied' => 'Partially Applied', + 'applied' => 'Applied', + 'void' => 'Void', + ]; + } + + #[Computed] + public function reasons(): array + { + return CreditNote::reasons(); + } + + #[Computed] + public function dateRangeOptions(): array + { + return [ + 'today' => 'Today', + '7d' => 'Last 7 days', + '30d' => 'Last 30 days', + '90d' => 'Last 90 days', + 'this_month' => 'This month', + 'last_month' => 'Last month', + ]; + } + + #[Computed] + public function tableColumns(): array + { + return [ + 'Reference', + 'Customer', + ['label' => 'Amount', 'align' => 'right'], + ['label' => 'Used', 'align' => 'right'], + 'Reason', + ['label' => 'Status', 'align' => 'center'], + 'Date', + ['label' => 'Actions', 'align' => 'center'], + ]; + } + + #[Computed] + public function tableRowIds(): array + { + return $this->creditNotes->pluck('id')->all(); + } + + #[Computed] + public function tableRows(): array + { + $statusColors = [ + 'draft' => 'gray', + 'issued' => 'blue', + 'partially_applied' => 'amber', + 'applied' => 'green', + 'void' => 'red', + ]; + + return $this->creditNotes->map(function ($cn) use ($statusColors) { + $remaining = $cn->getRemainingAmount(); + + return [ + [ + 'lines' => [ + ['mono' => $cn->reference_number], + ['muted' => $cn->workspace?->name], + ], + ], + [ + 'lines' => [ + ['bold' => $cn->user?->name ?? 'Unknown'], + ['muted' => $cn->user?->email ?? ''], + ], + ], + [ + 'lines' => [ + ['bold' => $cn->currency.' '.number_format($cn->amount, 2)], + ], + ], + [ + 'lines' => [ + ['bold' => $cn->currency.' '.number_format($cn->amount_used, 2)], + ['muted' => $remaining > 0 ? number_format($remaining, 2).' remaining' : 'Fully used'], + ], + ], + ['badge' => $cn->getReasonLabel(), 'color' => 'gray'], + ['badge' => ucfirst(str_replace('_', ' ', $cn->status)), 'color' => $statusColors[$cn->status] ?? 'gray'], + [ + 'lines' => [ + ['bold' => $cn->created_at->format('d M Y')], + ['muted' => $cn->created_at->format('H:i')], + ], + ], + [ + 'actions' => array_filter([ + ['icon' => 'eye', 'click' => "viewCreditNote({$cn->id})", 'title' => 'View details'], + $cn->isUsable() && $cn->amount_used == 0 + ? ['icon' => 'x-mark', 'click' => "confirmVoid({$cn->id})", 'title' => 'Void credit note'] + : null, + ]), + ], + ]; + })->all(); + } + + #[Computed] + public function summaryStats(): array + { + $query = CreditNote::query() + ->when($this->workspaceFilter, fn ($q) => $q->where('workspace_id', $this->workspaceFilter)); + + return [ + 'total_issued' => (clone $query)->sum('amount'), + 'total_used' => (clone $query)->sum('amount_used'), + 'total_available' => (clone $query)->usable()->selectRaw('SUM(amount - amount_used)')->value('SUM(amount - amount_used)') ?? 0, + 'count_active' => (clone $query)->usable()->count(), + ]; + } + + public function render() + { + return view('commerce::admin.credit-note-manager') + ->layout('hub::admin.layouts.app', ['title' => 'Credit Notes']); + } +} diff --git a/View/Modal/Admin/Dashboard.php b/View/Modal/Admin/Dashboard.php new file mode 100644 index 0000000..7cf84d0 --- /dev/null +++ b/View/Modal/Admin/Dashboard.php @@ -0,0 +1,121 @@ +checkHadesAccess(); + } + + #[Computed] + public function stats(): array + { + return [ + 'total_revenue' => Order::where('status', 'completed')->sum('total') / 100, + 'monthly_revenue' => Order::where('status', 'completed') + ->where('created_at', '>=', now()->startOfMonth()) + ->sum('total') / 100, + 'total_orders' => Order::count(), + 'pending_orders' => Order::whereIn('status', ['pending', 'processing'])->count(), + 'active_subscriptions' => Subscription::where('status', 'active')->count(), + 'total_subscriptions' => Subscription::count(), + 'active_coupons' => Coupon::where('is_active', true) + ->where(function ($q) { + $q->whereNull('valid_until') + ->orWhere('valid_until', '>', now()); + })->count(), + ]; + } + + #[Computed] + public function recentOrders(): \Illuminate\Database\Eloquent\Collection + { + return Order::with('workspace') + ->latest() + ->take(5) + ->get(); + } + + #[Computed] + public function statCards(): array + { + return [ + ['value' => '£'.number_format($this->stats['total_revenue'], 2), 'label' => 'Total Revenue', 'icon' => 'sterling-sign', 'color' => 'green'], + ['value' => '£'.number_format($this->stats['monthly_revenue'], 2), 'label' => 'This Month', 'icon' => 'calendar', 'color' => 'blue'], + ['value' => number_format($this->stats['total_orders']), 'label' => 'Total Orders', 'icon' => 'shopping-cart', 'color' => 'orange'], + ['value' => number_format($this->stats['active_subscriptions']), 'label' => 'Active Subscriptions', 'icon' => 'repeat', 'color' => 'purple'], + ]; + } + + #[Computed] + public function quickActions(): array + { + return [ + ['href' => route('hub.commerce.orders'), 'title' => 'Orders', 'subtitle' => $this->stats['pending_orders'].' pending', 'icon' => 'shopping-cart', 'color' => 'orange'], + ['href' => route('hub.commerce.subscriptions'), 'title' => 'Subscriptions', 'subtitle' => $this->stats['total_subscriptions'].' total', 'icon' => 'repeat', 'color' => 'purple'], + ['href' => route('hub.commerce.coupons'), 'title' => 'Coupons', 'subtitle' => $this->stats['active_coupons'].' active', 'icon' => 'ticket', 'color' => 'green'], + ['href' => route('hub.commerce.entities'), 'title' => 'Entities', 'subtitle' => 'M1/M2/M3 hierarchy', 'icon' => 'sitemap', 'color' => 'blue'], + ['href' => route('hub.commerce.permissions'), 'title' => 'Permissions', 'subtitle' => 'Matrix training', 'icon' => 'shield', 'color' => 'amber'], + ['href' => route('hub.commerce.products'), 'title' => 'Products', 'subtitle' => 'Master catalog', 'icon' => 'boxes-stacked', 'color' => 'indigo'], + ]; + } + + #[Computed] + public function orderRows(): array + { + return $this->recentOrders->map(fn ($o) => [ + ['mono' => '#'.$o->id], + ['muted' => $o->workspace?->name ?? 'N/A'], + ['badge' => ucfirst($o->status), 'color' => match ($o->status) { + 'completed' => 'green', + 'pending' => 'yellow', + 'processing' => 'blue', + 'cancelled', 'refunded' => 'red', + default => 'gray', + }], + ['bold' => '£'.number_format($o->total / 100, 2)], + ])->all(); + } + + #[Computed] + public function revenueByMonth(): array + { + return Order::where('status', 'completed') + ->where('created_at', '>=', now()->subMonths(6)) + ->select( + DB::raw('DATE_FORMAT(created_at, "%Y-%m") as month'), + DB::raw('SUM(total) / 100 as revenue') + ) + ->groupBy('month') + ->orderBy('month') + ->pluck('revenue', 'month') + ->toArray(); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('commerce::admin.dashboard') + ->layout('hub::admin.layouts.app', ['title' => 'Commerce Dashboard']); + } +} diff --git a/View/Modal/Admin/EntityManager.php b/View/Modal/Admin/EntityManager.php new file mode 100644 index 0000000..34abb52 --- /dev/null +++ b/View/Modal/Admin/EntityManager.php @@ -0,0 +1,316 @@ +user()?->isHades()) { + abort(403, 'Hades tier required for entity management.'); + } + } + + protected function rules(): array + { + $uniqueRule = $this->editingId + ? 'unique:commerce_entities,code,'.$this->editingId + : 'unique:commerce_entities,code'; + + return [ + 'code' => ['required', 'string', 'max:32', 'alpha_num', $uniqueRule], + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'in:m1,m2,m3'], + 'parent_id' => ['nullable', 'exists:commerce_entities,id'], + 'workspace_id' => ['nullable', 'exists:workspaces,id'], + 'domain' => ['nullable', 'string', 'max:255'], + 'currency' => ['required', 'string', 'size:3'], + 'timezone' => ['required', 'string', 'max:50'], + 'is_active' => ['boolean'], + ]; + } + + protected array $messages = [ + 'code.alpha_num' => 'Code must be letters and numbers only.', + 'code.unique' => 'This code is already in use.', + ]; + + public function openCreate(?int $parentId = null): void + { + $this->resetForm(); + + if ($parentId) { + $parent = Entity::find($parentId); + if ($parent) { + $this->parent_id = $parentId; + // Determine child type based on parent + $this->type = match ($parent->type) { + Entity::TYPE_M1_MASTER => Entity::TYPE_M2_FACADE, + Entity::TYPE_M2_FACADE => Entity::TYPE_M3_DROPSHIP, + default => Entity::TYPE_M3_DROPSHIP, + }; + // Inherit currency/timezone from parent + $this->currency = $parent->currency; + $this->timezone = $parent->timezone; + } + } + + $this->showModal = true; + } + + public function openEdit(int $id): void + { + $entity = Entity::findOrFail($id); + + $this->editingId = $id; + $this->code = $entity->code; + $this->name = $entity->name; + $this->type = $entity->type; + $this->parent_id = $entity->parent_id; + $this->workspace_id = $entity->workspace_id; + $this->domain = $entity->domain ?? ''; + $this->currency = $entity->currency; + $this->timezone = $entity->timezone; + $this->is_active = $entity->is_active; + + $this->showModal = true; + } + + public function save(): void + { + $this->validate(); + + // Uppercase code + $code = strtoupper($this->code); + + if ($this->editingId) { + $entity = Entity::findOrFail($this->editingId); + + // Prevent changing type if has children + if ($entity->type !== $this->type && $entity->children()->exists()) { + session()->flash('error', 'Cannot change type of entity with children.'); + + return; + } + + // Prevent making root entity a child + if (! $entity->parent_id && $this->parent_id) { + session()->flash('error', 'Cannot move root entity under another entity.'); + + return; + } + + // Update path if code changed + $oldCode = $entity->code; + $newPath = $entity->parent + ? $entity->parent->path.'/'.$code + : $code; + + $entity->update([ + 'code' => $code, + 'name' => $this->name, + 'type' => $this->type, + 'workspace_id' => $this->workspace_id, + 'domain' => $this->domain ?: null, + 'currency' => $this->currency, + 'timezone' => $this->timezone, + 'is_active' => $this->is_active, + 'path' => $newPath, + ]); + + // Update descendant paths if code changed + if ($oldCode !== $code) { + $this->updateDescendantPaths($entity, $oldCode, $code); + } + + session()->flash('message', 'Entity updated successfully.'); + } else { + // Create new entity + $parent = $this->parent_id ? Entity::find($this->parent_id) : null; + + if ($parent) { + $entity = $parent->createChild($code, $this->name, $this->type, [ + 'workspace_id' => $this->workspace_id, + 'domain' => $this->domain ?: null, + 'currency' => $this->currency, + 'timezone' => $this->timezone, + 'is_active' => $this->is_active, + ]); + } else { + $entity = Entity::createMaster($code, $this->name, [ + 'workspace_id' => $this->workspace_id, + 'domain' => $this->domain ?: null, + 'currency' => $this->currency, + 'timezone' => $this->timezone, + 'is_active' => $this->is_active, + ]); + } + + session()->flash('message', 'Entity created successfully.'); + } + + $this->closeModal(); + } + + protected function updateDescendantPaths(Entity $entity, string $oldCode, string $newCode): void + { + $oldPathPrefix = str_replace($newCode, $oldCode, $entity->path); + + foreach ($entity->getDescendants() as $descendant) { + $descendant->update([ + 'path' => str_replace($oldPathPrefix, $entity->path, $descendant->path), + ]); + } + } + + public function confirmDelete(int $id): void + { + $this->deletingId = $id; + $this->showDeleteModal = true; + } + + public function delete(): void + { + if (! $this->deletingId) { + return; + } + + $entity = Entity::findOrFail($this->deletingId); + + // Check for children + if ($entity->children()->exists()) { + session()->flash('error', 'Cannot delete entity with children. Remove children first.'); + $this->showDeleteModal = false; + + return; + } + + // Check for permissions + if ($entity->permissions()->exists()) { + $entity->permissions()->delete(); + } + + $entity->delete(); + session()->flash('message', "Entity '{$entity->name}' deleted successfully."); + + $this->showDeleteModal = false; + $this->deletingId = null; + } + + public function toggleExpand(int $id): void + { + $this->expandedEntity = $this->expandedEntity === $id ? null : $id; + } + + public function toggleActive(int $id): void + { + $entity = Entity::findOrFail($id); + $entity->update(['is_active' => ! $entity->is_active]); + + $status = $entity->is_active ? 'activated' : 'deactivated'; + session()->flash('message', "Entity '{$entity->name}' {$status}."); + } + + public function closeModal(): void + { + $this->showModal = false; + $this->resetForm(); + } + + protected function resetForm(): void + { + $this->editingId = null; + $this->code = ''; + $this->name = ''; + $this->type = Entity::TYPE_M1_MASTER; + $this->parent_id = null; + $this->workspace_id = null; + $this->domain = ''; + $this->currency = 'GBP'; + $this->timezone = 'Europe/London'; + $this->is_active = true; + } + + public function render() + { + // Get root entities (M1) with nested children + $entities = Entity::whereNull('parent_id') + ->with(['children' => function ($query) { + $query->with(['children' => function ($q) { + $q->orderBy('name'); + }])->orderBy('name'); + }]) + ->orderBy('name') + ->get(); + + return view('commerce::admin.entity-manager', [ + 'entities' => $entities, + 'workspaces' => Workspace::orderBy('name')->get(['id', 'name']), + 'types' => [ + Entity::TYPE_M1_MASTER => 'M1 - Master Company', + Entity::TYPE_M2_FACADE => 'M2 - Facade/Storefront', + Entity::TYPE_M3_DROPSHIP => 'M3 - Dropshipper', + ], + 'currencies' => ['GBP', 'USD', 'EUR'], + 'timezones' => [ + 'Europe/London' => 'London (GMT/BST)', + 'Europe/Paris' => 'Paris (CET/CEST)', + 'America/New_York' => 'New York (EST/EDT)', + 'America/Los_Angeles' => 'Los Angeles (PST/PDT)', + 'UTC' => 'UTC', + ], + 'stats' => [ + 'total' => Entity::count(), + 'm1_count' => Entity::where('type', Entity::TYPE_M1_MASTER)->count(), + 'm2_count' => Entity::where('type', Entity::TYPE_M2_FACADE)->count(), + 'm3_count' => Entity::where('type', Entity::TYPE_M3_DROPSHIP)->count(), + 'active' => Entity::where('is_active', true)->count(), + ], + ])->layout('hub::admin.layouts.app', ['title' => 'Commerce Entities']); + } +} diff --git a/View/Modal/Admin/OrderManager.php b/View/Modal/Admin/OrderManager.php new file mode 100644 index 0000000..936c717 --- /dev/null +++ b/View/Modal/Admin/OrderManager.php @@ -0,0 +1,376 @@ +user()?->isHades()) { + abort(403, 'Hades tier required for order management.'); + } + } + + public function updatingSearch(): void + { + $this->resetPage(); + $this->selected = []; + $this->selectAll = false; + } + + public function updatingStatusFilter(): void + { + $this->resetPage(); + $this->selected = []; + $this->selectAll = false; + } + + public function updatingDateRange(): void + { + $this->resetPage(); + $this->selected = []; + $this->selectAll = false; + } + + public function updatedSelectAll(bool $value): void + { + if ($value) { + $this->selected = $this->orders->pluck('id')->map(fn ($id) => (string) $id)->all(); + } else { + $this->selected = []; + } + } + + public function exportSelected(): void + { + if (empty($this->selected)) { + session()->flash('error', __('commerce::commerce.bulk.no_selection')); + + return; + } + + $orders = Order::whereIn('id', $this->selected)->get(); + + $csv = "Order Number,Customer,Email,Type,Status,Total,Currency,Created\n"; + foreach ($orders as $order) { + $csv .= sprintf( + "%s,%s,%s,%s,%s,%s,%s,%s\n", + $order->order_number, + str_replace(',', ' ', $order->billing_name ?: $order->user?->name), + $order->billing_email ?: $order->user?->email, + $order->type ?? 'unknown', + $order->status, + number_format($order->total, 2), + $order->currency, + $order->created_at->format('Y-m-d H:i:s') + ); + } + + $this->dispatch('download-csv', filename: 'orders-export-'.now()->format('Y-m-d').'.csv', content: $csv); + session()->flash('message', __('commerce::commerce.bulk.export_success', ['count' => count($this->selected)])); + $this->selected = []; + $this->selectAll = false; + } + + public function bulkUpdateStatus(string $status): void + { + if (empty($this->selected)) { + session()->flash('error', __('commerce::commerce.bulk.no_selection')); + + return; + } + + $validStatuses = ['pending', 'processing', 'paid', 'failed', 'refunded', 'cancelled']; + + if (! in_array($status, $validStatuses)) { + session()->flash('error', 'Invalid status selected.'); + + return; + } + + $count = Order::whereIn('id', $this->selected)->update([ + 'status' => $status, + ]); + + session()->flash('message', __('commerce::commerce.bulk.status_updated', ['count' => $count, 'status' => ucfirst($status)])); + $this->selected = []; + $this->selectAll = false; + } + + public function viewOrder(int $id): void + { + $this->selectedOrder = Order::with([ + 'workspace', + 'user', + 'items', + 'coupon', + 'invoice.payment', + ])->findOrFail($id); + + $this->showDetailModal = true; + } + + public function openStatusChange(int $id): void + { + $this->selectedOrder = Order::findOrFail($id); + $this->newStatus = $this->selectedOrder->status; + $this->statusNote = ''; + $this->showStatusModal = true; + } + + public function updateStatus(): void + { + if (! $this->selectedOrder) { + return; + } + + $validStatuses = ['pending', 'processing', 'paid', 'failed', 'refunded', 'cancelled']; + + if (! in_array($this->newStatus, $validStatuses)) { + session()->flash('error', 'Invalid status selected.'); + + return; + } + + $oldStatus = $this->selectedOrder->status; + + $this->selectedOrder->update([ + 'status' => $this->newStatus, + 'metadata' => array_merge($this->selectedOrder->metadata ?? [], [ + 'status_history' => array_merge( + $this->selectedOrder->metadata['status_history'] ?? [], + [[ + 'from' => $oldStatus, + 'to' => $this->newStatus, + 'note' => $this->statusNote ?: null, + 'by' => auth()->id(), + 'at' => now()->toIso8601String(), + ]] + ), + ]), + ]); + + if ($this->newStatus === 'paid' && ! $this->selectedOrder->paid_at) { + $this->selectedOrder->update(['paid_at' => now()]); + } + + session()->flash('message', "Order status updated from {$oldStatus} to {$this->newStatus}."); + $this->closeStatusModal(); + } + + public function closeDetailModal(): void + { + $this->showDetailModal = false; + $this->selectedOrder = null; + } + + public function closeStatusModal(): void + { + $this->showStatusModal = false; + $this->selectedOrder = null; + $this->newStatus = ''; + $this->statusNote = ''; + } + + #[Computed] + public function orders() + { + return Order::query() + ->with(['workspace', 'user']) + ->when($this->search, function ($query) { + $query->where(function ($q) { + $q->where('order_number', 'like', "%{$this->search}%") + ->orWhere('billing_email', 'like', "%{$this->search}%") + ->orWhere('billing_name', 'like', "%{$this->search}%"); + }); + }) + ->when($this->statusFilter, fn ($q) => $q->where('status', $this->statusFilter)) + ->when($this->typeFilter, fn ($q) => $q->where('type', $this->typeFilter)) + ->when($this->dateRange, function ($query) { + $startDate = match ($this->dateRange) { + 'today' => now()->startOfDay(), + '7d' => now()->subDays(7)->startOfDay(), + '30d' => now()->subDays(30)->startOfDay(), + '90d' => now()->subDays(90)->startOfDay(), + 'this_month' => now()->startOfMonth(), + 'last_month' => now()->subMonth()->startOfMonth(), + default => null, + }; + + if ($startDate) { + $query->where('created_at', '>=', $startDate); + } + + if ($this->dateRange === 'last_month') { + $query->where('created_at', '<', now()->startOfMonth()); + } + }) + ->when($this->workspaceFilter, fn ($q) => $q->where('workspace_id', $this->workspaceFilter)) + ->latest() + ->paginate(25); + } + + #[Computed] + public function workspaces() + { + return Workspace::orderBy('name')->get(['id', 'name']); + } + + #[Computed] + public function statuses(): array + { + return [ + 'pending' => 'Pending', + 'processing' => 'Processing', + 'paid' => 'Paid', + 'failed' => 'Failed', + 'refunded' => 'Refunded', + 'cancelled' => 'Cancelled', + ]; + } + + #[Computed] + public function types(): array + { + return [ + 'new_subscription' => 'New Subscription', + 'renewal' => 'Renewal', + 'upgrade' => 'Upgrade', + 'downgrade' => 'Downgrade', + 'addon' => 'Add-on', + 'one_time' => 'One-time', + ]; + } + + #[Computed] + public function dateRangeOptions(): array + { + return [ + 'today' => __('commerce::commerce.orders.date_range.today'), + '7d' => __('commerce::commerce.orders.date_range.7d'), + '30d' => __('commerce::commerce.orders.date_range.30d'), + '90d' => __('commerce::commerce.orders.date_range.90d'), + 'this_month' => __('commerce::commerce.orders.date_range.this_month'), + 'last_month' => __('commerce::commerce.orders.date_range.last_month'), + ]; + } + + #[Computed] + public function tableColumns(): array + { + return [ + 'Order', + 'Customer', + 'Type', + ['label' => 'Total', 'align' => 'right'], + ['label' => 'Status', 'align' => 'center'], + 'Date', + ['label' => 'Actions', 'align' => 'center'], + ]; + } + + #[Computed] + public function tableRowIds(): array + { + return $this->orders->pluck('id')->all(); + } + + #[Computed] + public function tableRows(): array + { + $statusColors = [ + 'pending' => 'amber', + 'processing' => 'blue', + 'paid' => 'green', + 'failed' => 'red', + 'refunded' => 'purple', + 'cancelled' => 'gray', + ]; + + return $this->orders->map(function ($o) use ($statusColors) { + $totalLines = [['bold' => $o->currency.' '.number_format($o->total, 2)]]; + if ($o->discount_amount > 0) { + $totalLines[] = ['muted' => '-'.number_format($o->discount_amount, 2).' discount']; + } + + return [ + [ + 'lines' => [ + ['mono' => $o->order_number], + ['muted' => $o->workspace?->name], + ], + ], + [ + 'lines' => [ + ['bold' => $o->billing_name ?: $o->user?->name], + ['muted' => $o->billing_email ?: $o->user?->email], + ], + ], + ['badge' => str_replace('_', ' ', ucfirst($o->type ?? 'unknown')), 'color' => 'gray'], + ['lines' => $totalLines], + ['badge' => ucfirst($o->status), 'color' => $statusColors[$o->status] ?? 'gray'], + [ + 'lines' => [ + ['bold' => $o->created_at->format('d M Y')], + ['muted' => $o->created_at->format('H:i')], + ], + ], + [ + 'actions' => [ + ['icon' => 'eye', 'click' => "viewOrder({$o->id})", 'title' => 'View details'], + ['icon' => 'pencil', 'click' => "openStatusChange({$o->id})", 'title' => 'Change status'], + ], + ], + ]; + })->all(); + } + + public function render() + { + return view('commerce::admin.order-manager') + ->layout('hub::admin.layouts.app', ['title' => 'Orders']); + } +} diff --git a/View/Modal/Admin/PermissionMatrixManager.php b/View/Modal/Admin/PermissionMatrixManager.php new file mode 100644 index 0000000..d978d00 --- /dev/null +++ b/View/Modal/Admin/PermissionMatrixManager.php @@ -0,0 +1,270 @@ +matrix = $matrix; + } + + /** + * Authorize access - Hades tier only. + */ + public function mount(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades tier required for permission matrix management.'); + } + } + + public function updatingSearch(): void + { + $this->resetPage(); + } + + public function updatingEntityFilter(): void + { + $this->resetPage(); + } + + public function openTrain(?int $requestId = null): void + { + if ($requestId) { + $request = PermissionRequest::find($requestId); + if ($request) { + $this->trainingEntityId = $request->entity_id; + $this->trainingKey = $request->action; + $this->trainingScope = $request->scope ?? ''; + } + } + + $this->trainingAllow = true; + $this->trainingLock = false; + $this->showTrainModal = true; + } + + public function openTrainNew(): void + { + $this->trainingEntityId = null; + $this->trainingKey = ''; + $this->trainingScope = ''; + $this->trainingAllow = true; + $this->trainingLock = false; + $this->showTrainModal = true; + } + + public function train(): void + { + $this->validate([ + 'trainingEntityId' => 'required|exists:commerce_entities,id', + 'trainingKey' => 'required|string|max:255', + 'trainingScope' => 'nullable|string|max:255', + ]); + + $entity = Entity::findOrFail($this->trainingEntityId); + + try { + if ($this->trainingLock) { + $this->matrix->lock( + entity: $entity, + key: $this->trainingKey, + allowed: $this->trainingAllow, + scope: $this->trainingScope ?: null + ); + } else { + $this->matrix->train( + entity: $entity, + key: $this->trainingKey, + scope: $this->trainingScope ?: null, + allow: $this->trainingAllow + ); + } + + // Mark related requests as trained + $this->matrix->markRequestsTrained( + $entity, + $this->trainingKey, + $this->trainingScope ?: null + ); + + $action = $this->trainingAllow ? 'allowed' : 'denied'; + $lock = $this->trainingLock ? ' (locked)' : ''; + session()->flash('message', "Permission '{$this->trainingKey}' {$action} for {$entity->name}{$lock}."); + + $this->closeTrainModal(); + + } catch (PermissionLockedException $e) { + session()->flash('error', $e->getMessage()); + } + } + + public function bulkTrain(bool $allow): void + { + if (empty($this->selectedRequests)) { + session()->flash('error', 'No requests selected.'); + + return; + } + + $trained = 0; + $errors = []; + + foreach ($this->selectedRequests as $requestId) { + $request = PermissionRequest::find($requestId); + if (! $request) { + continue; + } + + try { + $entity = $request->entity; + if (! $entity) { + continue; + } + + $this->matrix->train( + entity: $entity, + key: $request->action, + scope: $request->scope, + allow: $allow + ); + + $this->matrix->markRequestsTrained( + $entity, + $request->action, + $request->scope + ); + + $trained++; + + } catch (PermissionLockedException $e) { + $errors[] = $e->getMessage(); + } + } + + $this->selectedRequests = []; + + if ($errors) { + session()->flash('error', implode(', ', $errors)); + } + + $action = $allow ? 'allowed' : 'denied'; + session()->flash('message', "{$trained} permissions {$action}."); + } + + public function deletePermission(int $id): void + { + $permission = PermissionMatrix::findOrFail($id); + + if ($permission->locked) { + session()->flash('error', 'Cannot delete a locked permission.'); + + return; + } + + $permission->delete(); + session()->flash('message', 'Permission deleted.'); + } + + public function unlockPermission(int $id): void + { + $permission = PermissionMatrix::findOrFail($id); + + try { + $this->matrix->unlock($permission->entity, $permission->key, $permission->scope); + session()->flash('message', 'Permission unlocked.'); + } catch (\Exception $e) { + session()->flash('error', $e->getMessage()); + } + } + + public function closeTrainModal(): void + { + $this->showTrainModal = false; + $this->trainingEntityId = null; + $this->trainingKey = ''; + $this->trainingScope = ''; + $this->trainingAllow = true; + $this->trainingLock = false; + } + + public function render() + { + // Get pending requests + $pendingRequests = PermissionRequest::query() + ->with('entity') + ->where('status', PermissionRequest::STATUS_PENDING) + ->when($this->entityFilter, fn ($q) => $q->where('entity_id', $this->entityFilter)) + ->when($this->search, fn ($q) => $q->where('action', 'like', "%{$this->search}%")) + ->latest() + ->paginate(20, ['*'], 'pending_page'); + + // Get trained permissions + $permissions = PermissionMatrix::query() + ->with('entity', 'setByEntity') + ->when($this->entityFilter, fn ($q) => $q->where('entity_id', $this->entityFilter)) + ->when($this->search, fn ($q) => $q->where('key', 'like', "%{$this->search}%")) + ->when($this->statusFilter === 'allowed', fn ($q) => $q->where('allowed', true)) + ->when($this->statusFilter === 'denied', fn ($q) => $q->where('allowed', false)) + ->when($this->statusFilter === 'locked', fn ($q) => $q->where('locked', true)) + ->orderBy('entity_id') + ->orderBy('key') + ->paginate(30, ['*'], 'permissions_page'); + + return view('commerce::admin.permission-matrix-manager', [ + 'pendingRequests' => $pendingRequests, + 'permissions' => $permissions, + 'entities' => Entity::active()->orderBy('path')->get(), + 'stats' => [ + 'total_permissions' => PermissionMatrix::count(), + 'allowed' => PermissionMatrix::where('allowed', true)->count(), + 'denied' => PermissionMatrix::where('allowed', false)->count(), + 'locked' => PermissionMatrix::where('locked', true)->count(), + 'pending_requests' => PermissionRequest::where('status', PermissionRequest::STATUS_PENDING)->count(), + ], + ])->layout('hub::admin.layouts.app', ['title' => 'Permission Matrix']); + } +} diff --git a/View/Modal/Admin/ProductManager.php b/View/Modal/Admin/ProductManager.php new file mode 100644 index 0000000..f7b71d4 --- /dev/null +++ b/View/Modal/Admin/ProductManager.php @@ -0,0 +1,423 @@ + '', + 'name' => '', + 'description' => '', + 'short_description' => '', + 'category' => '', + 'subcategory' => '', + 'price' => 0, + 'cost_price' => null, + 'rrp' => null, + 'currency' => 'GBP', + 'tax_class' => 'standard', + 'type' => 'simple', + 'track_stock' => true, + 'stock_quantity' => 0, + 'low_stock_threshold' => 5, + 'allow_backorder' => false, + 'is_active' => true, + 'is_featured' => false, + 'is_visible' => true, + ]; + + // Assignment form + public array $assignForm = [ + 'entity_id' => null, + 'product_id' => null, + 'price_override' => null, + 'margin_percent' => null, + 'name_override' => '', + 'description_override' => '', + 'is_active' => true, + 'is_featured' => false, + ]; + + protected ProductCatalogService $catalog; + + public function boot(ProductCatalogService $catalog): void + { + $this->catalog = $catalog; + } + + #[Computed] + public function selectedEntity(): ?Entity + { + return $this->entityId ? Entity::find($this->entityId) : null; + } + + #[Computed] + public function masterEntities() + { + return Entity::masters()->active()->get(); + } + + #[Computed] + public function allEntities() + { + return Entity::active()->orderBy('path')->get(); + } + + #[Computed] + public function categories(): array + { + $query = Product::query()->distinct(); + if ($this->entityId) { + $entity = Entity::find($this->entityId); + if ($entity?->isM1()) { + $query->where('owner_entity_id', $this->entityId); + } + } + + $categories = $query->pluck('category')->filter()->unique()->sort()->values()->toArray(); + + // Convert to associative array for filter component + return array_combine($categories, $categories); + } + + #[Computed] + public function stockFilters(): array + { + return [ + 'in_stock' => __('commerce::commerce.filters.in_stock'), + 'low_stock' => __('commerce::commerce.filters.low_stock'), + 'out_of_stock' => __('commerce::commerce.filters.out_of_stock'), + 'backorder' => __('commerce::commerce.filters.backorder'), + ]; + } + + #[Computed] + public function entityOptions(): array + { + return $this->masterEntities->mapWithKeys(function ($entity) { + return [$entity->id => "{$entity->code} - {$entity->name}"]; + })->all(); + } + + #[Computed] + public function tableColumns(): array + { + return [ + __('commerce::commerce.table.product'), + __('commerce::commerce.table.sku'), + ['label' => __('commerce::commerce.table.price'), 'align' => 'right'], + __('commerce::commerce.table.stock'), + ['label' => __('commerce::commerce.table.status'), 'align' => 'center'], + __('commerce::commerce.table.assignments'), + ['label' => __('commerce::commerce.table.actions'), 'align' => 'center'], + ]; + } + + #[Computed] + public function tableRows(): array + { + $stockColors = [ + 'in_stock' => 'green', + 'low_stock' => 'amber', + 'out_of_stock' => 'red', + 'backorder' => 'blue', + ]; + + return $this->products->map(function ($product) use ($stockColors) { + $assignments = $this->getAssignmentsForProduct($product->id); + + // Build product name lines with image + $productLines = [ + ['bold' => $product->name], + ['muted' => $product->category ?? __('commerce::commerce.products.uncategorised')], + ]; + + // Build price lines + $priceLines = [['bold' => $product->formatted_price]]; + if ($product->cost_price) { + $priceLines[] = ['muted' => 'Cost: £'.number_format($product->cost_price / 100, 2)]; + } + + // Build stock display + $stockCell = $product->track_stock + ? ['badge' => $product->stock_quantity.' '.__('commerce::commerce.products.units'), 'color' => $stockColors[$product->stock_status] ?? 'gray'] + : ['muted' => __('commerce::commerce.products.not_tracked')]; + + // Build status toggles + $statusLines = []; + if ($product->is_active) { + $statusLines[] = ['badge' => __('commerce::commerce.status.active'), 'color' => 'green']; + } else { + $statusLines[] = ['badge' => __('commerce::commerce.status.inactive'), 'color' => 'gray']; + } + if ($product->is_featured) { + $statusLines[] = ['badge' => __('commerce::commerce.status.featured'), 'color' => 'amber']; + } + + return [ + [ + 'image' => $product->image_url, + 'lines' => $productLines, + ], + ['mono' => $product->sku], + ['lines' => $priceLines], + $stockCell, + ['lines' => $statusLines], + [ + 'lines' => [ + ['muted' => $assignments->count().' '.__('commerce::commerce.products.entities')], + ], + 'actions' => [ + ['icon' => 'plus', 'click' => "openAssign({$product->id})", 'title' => __('commerce::commerce.actions.assign')], + ], + ], + [ + 'actions' => [ + ['icon' => 'pencil', 'click' => "openEdit({$product->id})", 'title' => __('commerce::commerce.actions.edit')], + ['icon' => 'trash', 'click' => "delete({$product->id})", 'confirm' => __('commerce::commerce.products.actions.delete_confirm'), 'title' => __('commerce::commerce.actions.delete')], + ], + ], + ]; + })->all(); + } + + public function getProductsProperty() + { + $query = Product::with('ownerEntity'); + + // Filter by entity + if ($this->entityId) { + $entity = Entity::find($this->entityId); + if ($entity?->isM1()) { + $query->where('owner_entity_id', $this->entityId); + } + } + + // Search + if ($this->search) { + $query->where(function ($q) { + $q->where('name', 'like', '%'.$this->search.'%') + ->orWhere('sku', 'like', '%'.$this->search.'%'); + }); + } + + // Category filter + if ($this->category) { + $query->where('category', $this->category); + } + + // Stock filter + if ($this->stockFilter) { + $query->where('stock_status', $this->stockFilter); + } + + return $query->orderBy('sort_order')->orderBy('name')->paginate(20); + } + + public function openCreate(): void + { + if (! $this->entityId) { + $master = $this->masterEntities->first(); + if ($master) { + $this->entityId = $master->id; + } + } + + $this->resetForm(); + $this->form['sku'] = Product::generateSku(); + $this->showModal = true; + } + + public function openEdit(int $productId): void + { + $product = Product::findOrFail($productId); + $this->editingId = $productId; + + $this->form = [ + 'sku' => $product->sku, + 'name' => $product->name, + 'description' => $product->description ?? '', + 'short_description' => $product->short_description ?? '', + 'category' => $product->category ?? '', + 'subcategory' => $product->subcategory ?? '', + 'price' => $product->price, + 'cost_price' => $product->cost_price, + 'rrp' => $product->rrp, + 'currency' => $product->currency, + 'tax_class' => $product->tax_class, + 'type' => $product->type, + 'track_stock' => $product->track_stock, + 'stock_quantity' => $product->stock_quantity, + 'low_stock_threshold' => $product->low_stock_threshold, + 'allow_backorder' => $product->allow_backorder, + 'is_active' => $product->is_active, + 'is_featured' => $product->is_featured, + 'is_visible' => $product->is_visible, + ]; + + $this->showModal = true; + } + + public function save(): void + { + $this->validate([ + 'form.sku' => 'required|string|max:64', + 'form.name' => 'required|string|max:255', + 'form.price' => 'required|integer|min:0', + 'form.type' => 'required|in:simple,variable,bundle,virtual,subscription', + ]); + + $entity = Entity::findOrFail($this->entityId); + + if ($this->editingId) { + $product = Product::findOrFail($this->editingId); + $this->catalog->updateProduct($product, $this->form); + } else { + $this->catalog->createProduct($entity, $this->form); + } + + $this->showModal = false; + $this->resetForm(); + } + + public function delete(int $productId): void + { + $product = Product::findOrFail($productId); + $this->catalog->deleteProduct($product); + } + + public function toggleActive(int $productId): void + { + $product = Product::findOrFail($productId); + $product->update(['is_active' => ! $product->is_active]); + } + + public function toggleFeatured(int $productId): void + { + $product = Product::findOrFail($productId); + $product->update(['is_featured' => ! $product->is_featured]); + } + + // Assignment methods + + public function openAssign(int $productId): void + { + $this->assignForm = [ + 'entity_id' => null, + 'product_id' => $productId, + 'price_override' => null, + 'margin_percent' => null, + 'name_override' => '', + 'description_override' => '', + 'is_active' => true, + 'is_featured' => false, + ]; + $this->showAssignModal = true; + } + + public function saveAssignment(): void + { + $this->validate([ + 'assignForm.entity_id' => 'required|exists:commerce_entities,id', + 'assignForm.product_id' => 'required|exists:commerce_products,id', + ]); + + $entity = Entity::findOrFail($this->assignForm['entity_id']); + $product = Product::findOrFail($this->assignForm['product_id']); + + $overrides = array_filter([ + 'price_override' => $this->assignForm['price_override'], + 'margin_percent' => $this->assignForm['margin_percent'], + 'name_override' => $this->assignForm['name_override'] ?: null, + 'description_override' => $this->assignForm['description_override'] ?: null, + 'is_active' => $this->assignForm['is_active'], + 'is_featured' => $this->assignForm['is_featured'], + ], fn ($v) => $v !== null && $v !== ''); + + $this->catalog->assignProduct($entity, $product, $overrides); + + $this->showAssignModal = false; + } + + public function removeAssignment(int $assignmentId): void + { + $assignment = ProductAssignment::findOrFail($assignmentId); + $this->catalog->removeAssignment($assignment); + } + + public function getAssignmentsForProduct(int $productId) + { + return ProductAssignment::with('entity') + ->where('product_id', $productId) + ->get(); + } + + protected function resetForm(): void + { + $this->editingId = null; + $this->form = [ + 'sku' => '', + 'name' => '', + 'description' => '', + 'short_description' => '', + 'category' => '', + 'subcategory' => '', + 'price' => 0, + 'cost_price' => null, + 'rrp' => null, + 'currency' => 'GBP', + 'tax_class' => 'standard', + 'type' => 'simple', + 'track_stock' => true, + 'stock_quantity' => 0, + 'low_stock_threshold' => 5, + 'allow_backorder' => false, + 'is_active' => true, + 'is_featured' => false, + 'is_visible' => true, + ]; + } + + public function render(): View + { + return view('commerce::admin.product-manager') + ->layout('hub::admin.layouts.app', ['title' => 'Product Catalog']); + } +} diff --git a/View/Modal/Admin/ReferralManager.php b/View/Modal/Admin/ReferralManager.php new file mode 100644 index 0000000..4c2e88b --- /dev/null +++ b/View/Modal/Admin/ReferralManager.php @@ -0,0 +1,415 @@ +user()?->isHades()) { + abort(403, 'Hades tier required for referral management.'); + } + } + + public function updatingSearch(): void + { + $this->resetPage(); + } + + public function updatingStatusFilter(): void + { + $this->resetPage(); + } + + public function switchTab(string $tab): void + { + $this->tab = $tab; + $this->resetPage(); + $this->search = ''; + $this->statusFilter = ''; + } + + // ───────────────────────────────────────────────────────────────────────── + // Referrals + // ───────────────────────────────────────────────────────────────────────── + + #[Computed] + public function referrals() + { + return Referral::with(['referrer', 'referee']) + ->when($this->search, function ($query) { + $query->whereHas('referrer', fn ($q) => $q->where('email', 'like', "%{$this->search}%")) + ->orWhereHas('referee', fn ($q) => $q->where('email', 'like', "%{$this->search}%")) + ->orWhere('code', 'like', "%{$this->search}%"); + }) + ->when($this->statusFilter, fn ($q) => $q->where('status', $this->statusFilter)) + ->latest() + ->paginate(25); + } + + public function viewReferral(int $id): void + { + $this->viewingReferralId = $id; + $this->showReferralModal = true; + } + + public function disqualifyReferral(int $id, ReferralService $referralService): void + { + $referral = Referral::findOrFail($id); + $referralService->disqualifyReferral($referral, 'Manually disqualified by admin'); + session()->flash('message', 'Referral disqualified.'); + $this->showReferralModal = false; + } + + public function closeReferralModal(): void + { + $this->showReferralModal = false; + $this->viewingReferralId = null; + } + + #[Computed] + public function viewingReferral() + { + if (! $this->viewingReferralId) { + return null; + } + + return Referral::with(['referrer', 'referee', 'commissions']) + ->find($this->viewingReferralId); + } + + // ───────────────────────────────────────────────────────────────────────── + // Commissions + // ───────────────────────────────────────────────────────────────────────── + + #[Computed] + public function commissions() + { + return ReferralCommission::with(['referrer', 'referral.referee', 'order']) + ->when($this->search, function ($query) { + $query->whereHas('referrer', fn ($q) => $q->where('email', 'like', "%{$this->search}%")); + }) + ->when($this->statusFilter, fn ($q) => $q->where('status', $this->statusFilter)) + ->latest() + ->paginate(25); + } + + public function matureCommissions(ReferralService $referralService): void + { + $count = $referralService->matureReadyCommissions(); + session()->flash('message', "{$count} commissions matured."); + } + + // ───────────────────────────────────────────────────────────────────────── + // Payouts + // ───────────────────────────────────────────────────────────────────────── + + #[Computed] + public function payouts() + { + return ReferralPayout::with(['user', 'processor']) + ->when($this->search, function ($query) { + $query->whereHas('user', fn ($q) => $q->where('email', 'like', "%{$this->search}%")) + ->orWhere('payout_number', 'like', "%{$this->search}%"); + }) + ->when($this->statusFilter, fn ($q) => $q->where('status', $this->statusFilter)) + ->latest() + ->paginate(25); + } + + public function openProcessPayout(int $id): void + { + $this->processingPayoutId = $id; + $this->payoutBtcTxid = ''; + $this->payoutBtcAmount = null; + $this->payoutBtcRate = null; + $this->payoutFailReason = ''; + $this->showPayoutModal = true; + } + + public function processPayout(ReferralService $referralService): void + { + $payout = ReferralPayout::findOrFail($this->processingPayoutId); + $referralService->processPayout($payout, auth()->user()); + session()->flash('message', 'Payout marked as processing.'); + } + + public function completePayout(ReferralService $referralService): void + { + $payout = ReferralPayout::findOrFail($this->processingPayoutId); + $referralService->completePayout( + $payout, + $this->payoutBtcTxid ?: null, + $this->payoutBtcAmount, + $this->payoutBtcRate + ); + session()->flash('message', 'Payout completed.'); + $this->closePayoutModal(); + } + + public function failPayout(ReferralService $referralService): void + { + if (! $this->payoutFailReason) { + session()->flash('error', 'Please provide a failure reason.'); + + return; + } + + $payout = ReferralPayout::findOrFail($this->processingPayoutId); + $referralService->failPayout($payout, $this->payoutFailReason); + session()->flash('message', 'Payout marked as failed.'); + $this->closePayoutModal(); + } + + public function closePayoutModal(): void + { + $this->showPayoutModal = false; + $this->processingPayoutId = null; + } + + #[Computed] + public function processingPayout() + { + if (! $this->processingPayoutId) { + return null; + } + + return ReferralPayout::with(['user', 'commissions'])->find($this->processingPayoutId); + } + + // ───────────────────────────────────────────────────────────────────────── + // Referral Codes + // ───────────────────────────────────────────────────────────────────────── + + #[Computed] + public function codes() + { + return ReferralCode::with('user') + ->when($this->search, function ($query) { + $query->where('code', 'like', "%{$this->search}%") + ->orWhere('campaign_name', 'like', "%{$this->search}%"); + }) + ->when($this->statusFilter === 'active', fn ($q) => $q->where('is_active', true)) + ->when($this->statusFilter === 'inactive', fn ($q) => $q->where('is_active', false)) + ->latest() + ->paginate(25); + } + + public function openCreateCode(): void + { + $this->resetCodeForm(); + $this->codeCode = strtoupper(substr(md5(uniqid()), 0, 8)); + $this->showCodeModal = true; + } + + public function openEditCode(int $id): void + { + $code = ReferralCode::findOrFail($id); + $this->editingCodeId = $id; + $this->codeCode = $code->code; + $this->codeUserId = $code->user_id; + $this->codeType = $code->type; + $this->codeCommissionRate = $code->commission_rate; + $this->codeCookieDays = $code->cookie_days; + $this->codeMaxUses = $code->max_uses; + $this->codeValidFrom = $code->valid_from?->format('Y-m-d'); + $this->codeValidUntil = $code->valid_until?->format('Y-m-d'); + $this->codeIsActive = $code->is_active; + $this->codeCampaignName = $code->campaign_name; + $this->showCodeModal = true; + } + + public function saveCode(): void + { + $this->validate([ + 'codeCode' => ['required', 'string', 'max:64', 'regex:/^[A-Z0-9_-]+$/'], + 'codeType' => ['required', 'in:user,campaign,custom'], + 'codeCommissionRate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'codeCookieDays' => ['required', 'integer', 'min:1', 'max:365'], + 'codeMaxUses' => ['nullable', 'integer', 'min:1'], + 'codeValidFrom' => ['nullable', 'date'], + 'codeValidUntil' => ['nullable', 'date', 'after_or_equal:codeValidFrom'], + ]); + + $data = [ + 'code' => strtoupper($this->codeCode), + 'user_id' => $this->codeUserId, + 'type' => $this->codeType, + 'commission_rate' => $this->codeCommissionRate, + 'cookie_days' => $this->codeCookieDays, + 'max_uses' => $this->codeMaxUses, + 'valid_from' => $this->codeValidFrom ? \Carbon\Carbon::parse($this->codeValidFrom) : null, + 'valid_until' => $this->codeValidUntil ? \Carbon\Carbon::parse($this->codeValidUntil) : null, + 'is_active' => $this->codeIsActive, + 'campaign_name' => $this->codeCampaignName, + ]; + + if ($this->editingCodeId) { + ReferralCode::findOrFail($this->editingCodeId)->update($data); + session()->flash('message', 'Referral code updated.'); + } else { + ReferralCode::create($data); + session()->flash('message', 'Referral code created.'); + } + + $this->closeCodeModal(); + } + + public function toggleCodeActive(int $id): void + { + $code = ReferralCode::findOrFail($id); + $code->update(['is_active' => ! $code->is_active]); + session()->flash('message', $code->is_active ? 'Code activated.' : 'Code deactivated.'); + } + + public function deleteCode(int $id): void + { + $code = ReferralCode::findOrFail($id); + if ($code->uses_count > 0) { + session()->flash('error', 'Cannot delete code that has been used.'); + + return; + } + $code->delete(); + session()->flash('message', 'Referral code deleted.'); + } + + public function closeCodeModal(): void + { + $this->showCodeModal = false; + $this->resetCodeForm(); + } + + protected function resetCodeForm(): void + { + $this->editingCodeId = null; + $this->codeCode = ''; + $this->codeUserId = null; + $this->codeType = 'custom'; + $this->codeCommissionRate = null; + $this->codeCookieDays = 90; + $this->codeMaxUses = null; + $this->codeValidFrom = null; + $this->codeValidUntil = null; + $this->codeIsActive = true; + $this->codeCampaignName = null; + } + + // ───────────────────────────────────────────────────────────────────────── + // Statistics + // ───────────────────────────────────────────────────────────────────────── + + #[Computed] + public function stats() + { + return app(ReferralService::class)->getGlobalStats(); + } + + #[Computed] + public function statusOptions(): array + { + return match ($this->tab) { + 'referrals' => [ + 'pending' => 'Pending', + 'converted' => 'Converted', + 'qualified' => 'Qualified', + 'disqualified' => 'Disqualified', + ], + 'commissions' => [ + 'pending' => 'Pending', + 'matured' => 'Matured', + 'paid' => 'Paid', + 'cancelled' => 'Cancelled', + ], + 'payouts' => [ + 'requested' => 'Requested', + 'processing' => 'Processing', + 'completed' => 'Completed', + 'failed' => 'Failed', + 'cancelled' => 'Cancelled', + ], + 'codes' => [ + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + default => [], + }; + } + + public function render() + { + return view('commerce::admin.referral-manager') + ->layout('hub::admin.layouts.app', ['title' => 'Referrals']); + } +} diff --git a/View/Modal/Admin/SubscriptionManager.php b/View/Modal/Admin/SubscriptionManager.php new file mode 100644 index 0000000..f4bff74 --- /dev/null +++ b/View/Modal/Admin/SubscriptionManager.php @@ -0,0 +1,425 @@ +user()?->isHades()) { + abort(403, 'Hades tier required for subscription management.'); + } + } + + public function updatingSearch(): void + { + $this->resetPage(); + $this->selected = []; + $this->selectAll = false; + } + + public function updatedSelectAll(bool $value): void + { + if ($value) { + $this->selected = $this->subscriptions->pluck('id')->map(fn ($id) => (string) $id)->all(); + } else { + $this->selected = []; + } + } + + public function exportSelected(): void + { + if (empty($this->selected)) { + session()->flash('error', __('commerce::commerce.bulk.no_selection')); + + return; + } + + $subscriptions = Subscription::with(['workspace', 'workspacePackage.package'])->whereIn('id', $this->selected)->get(); + + $csv = "Workspace,Package,Gateway,Status,Billing Cycle,Period End,Created\n"; + foreach ($subscriptions as $sub) { + $csv .= sprintf( + "%s,%s,%s,%s,%s,%s,%s\n", + str_replace(',', ' ', $sub->workspace?->name ?? 'Unknown'), + str_replace(',', ' ', $sub->workspacePackage?->package?->name ?? 'Unknown'), + $sub->gateway ?? 'unknown', + $sub->status, + $sub->billing_cycle ?? 'monthly', + $sub->current_period_end?->format('Y-m-d') ?? '', + $sub->created_at->format('Y-m-d H:i:s') + ); + } + + $this->dispatch('download-csv', filename: 'subscriptions-export-'.now()->format('Y-m-d').'.csv', content: $csv); + session()->flash('message', __('commerce::commerce.bulk.export_success', ['count' => count($this->selected)])); + $this->selected = []; + $this->selectAll = false; + } + + public function bulkUpdateStatus(string $status): void + { + if (empty($this->selected)) { + session()->flash('error', __('commerce::commerce.bulk.no_selection')); + + return; + } + + $validStatuses = ['active', 'trialing', 'past_due', 'paused', 'cancelled', 'incomplete', 'expired']; + + if (! in_array($status, $validStatuses)) { + session()->flash('error', 'Invalid status selected.'); + + return; + } + + $count = Subscription::whereIn('id', $this->selected)->update([ + 'status' => $status, + ]); + + // Handle status-specific updates + if ($status === 'cancelled') { + Subscription::whereIn('id', $this->selected)->update([ + 'cancelled_at' => now(), + 'ended_at' => now(), + ]); + } elseif ($status === 'paused') { + Subscription::whereIn('id', $this->selected)->update(['paused_at' => now()]); + } + + session()->flash('message', __('commerce::commerce.bulk.status_updated', ['count' => $count, 'status' => ucfirst($status)])); + $this->selected = []; + $this->selectAll = false; + } + + public function bulkExtendPeriod(): void + { + if (empty($this->selected)) { + session()->flash('error', __('commerce::commerce.bulk.no_selection')); + + return; + } + + $count = Subscription::whereIn('id', $this->selected) + ->whereNotNull('current_period_end') + ->update([ + 'current_period_end' => \Illuminate\Support\Facades\DB::raw('DATE_ADD(current_period_end, INTERVAL 30 DAY)'), + ]); + + session()->flash('message', __('commerce::commerce.bulk.period_extended', ['count' => $count, 'days' => 30])); + $this->selected = []; + $this->selectAll = false; + } + + public function viewSubscription(int $id): void + { + $this->selectedSubscription = Subscription::with([ + 'workspace', + 'workspacePackage.package', + ])->findOrFail($id); + + $this->showDetailModal = true; + } + + public function openStatusChange(int $id): void + { + $this->selectedSubscription = Subscription::findOrFail($id); + $this->newStatus = $this->selectedSubscription->status; + $this->statusNote = ''; + $this->showStatusModal = true; + } + + public function updateStatus(): void + { + if (! $this->selectedSubscription) { + return; + } + + $validStatuses = ['active', 'trialing', 'past_due', 'paused', 'cancelled', 'incomplete', 'expired']; + + if (! in_array($this->newStatus, $validStatuses)) { + session()->flash('error', 'Invalid status selected.'); + + return; + } + + $oldStatus = $this->selectedSubscription->status; + + $this->selectedSubscription->update([ + 'status' => $this->newStatus, + 'metadata' => array_merge($this->selectedSubscription->metadata ?? [], [ + 'status_history' => array_merge( + $this->selectedSubscription->metadata['status_history'] ?? [], + [[ + 'from' => $oldStatus, + 'to' => $this->newStatus, + 'note' => $this->statusNote ?: null, + 'by' => auth()->id(), + 'at' => now()->toIso8601String(), + ]] + ), + ]), + ]); + + // Handle status-specific updates + if ($this->newStatus === 'cancelled') { + $this->selectedSubscription->update([ + 'cancelled_at' => now(), + 'ended_at' => now(), + ]); + } elseif ($this->newStatus === 'paused') { + $this->selectedSubscription->update(['paused_at' => now()]); + } elseif ($this->newStatus === 'active' && $oldStatus === 'paused') { + $this->selectedSubscription->update(['paused_at' => null]); + } + + session()->flash('message', "Subscription status updated from {$oldStatus} to {$this->newStatus}."); + $this->closeStatusModal(); + } + + public function openExtendPeriod(int $id): void + { + $this->selectedSubscription = Subscription::findOrFail($id); + $this->extendDays = 30; + $this->showExtendModal = true; + } + + public function extendPeriod(): void + { + if (! $this->selectedSubscription) { + return; + } + + $newEndDate = $this->selectedSubscription->current_period_end->addDays($this->extendDays); + + $this->selectedSubscription->update([ + 'current_period_end' => $newEndDate, + 'metadata' => array_merge($this->selectedSubscription->metadata ?? [], [ + 'period_extensions' => array_merge( + $this->selectedSubscription->metadata['period_extensions'] ?? [], + [[ + 'days' => $this->extendDays, + 'by' => auth()->id(), + 'at' => now()->toIso8601String(), + ]] + ), + ]), + ]); + + session()->flash('message', "Subscription extended by {$this->extendDays} days."); + $this->closeExtendModal(); + } + + public function cancelSubscription(int $id): void + { + $subscription = Subscription::findOrFail($id); + + app(SubscriptionService::class)->cancel($subscription, 'Cancelled by admin'); + + session()->flash('message', 'Subscription cancelled.'); + } + + public function resumeSubscription(int $id): void + { + $subscription = Subscription::findOrFail($id); + + app(SubscriptionService::class)->resume($subscription); + + session()->flash('message', 'Subscription resumed.'); + } + + public function closeDetailModal(): void + { + $this->showDetailModal = false; + $this->selectedSubscription = null; + } + + public function closeStatusModal(): void + { + $this->showStatusModal = false; + $this->selectedSubscription = null; + $this->newStatus = ''; + $this->statusNote = ''; + } + + public function closeExtendModal(): void + { + $this->showExtendModal = false; + $this->selectedSubscription = null; + $this->extendDays = 30; + } + + #[Computed] + public function subscriptions() + { + return Subscription::query() + ->with(['workspace', 'workspacePackage.package']) + ->when($this->search, function ($query) { + $query->whereHas('workspace', fn ($q) => $q->where('name', 'like', "%{$this->search}%")); + }) + ->when($this->statusFilter, fn ($q) => $q->where('status', $this->statusFilter)) + ->when($this->gatewayFilter, fn ($q) => $q->where('gateway', $this->gatewayFilter)) + ->when($this->workspaceFilter, fn ($q) => $q->where('workspace_id', $this->workspaceFilter)) + ->latest() + ->paginate(25); + } + + #[Computed] + public function workspaces() + { + return Workspace::orderBy('name')->get(['id', 'name']); + } + + #[Computed] + public function statuses(): array + { + return [ + 'active' => 'Active', + 'trialing' => 'Trialing', + 'past_due' => 'Past Due', + 'paused' => 'Paused', + 'cancelled' => 'Cancelled', + 'incomplete' => 'Incomplete', + 'expired' => 'Expired', + ]; + } + + #[Computed] + public function gateways(): array + { + return [ + 'stripe' => 'Stripe', + 'btcpay' => 'BTCPay', + ]; + } + + #[Computed] + public function tableColumns(): array + { + return [ + 'Workspace', + 'Package', + 'Gateway', + ['label' => 'Status', 'align' => 'center'], + 'Billing', + 'Period Ends', + ['label' => 'Actions', 'align' => 'center'], + ]; + } + + #[Computed] + public function tableRowIds(): array + { + return $this->subscriptions->pluck('id')->all(); + } + + #[Computed] + public function tableRows(): array + { + return $this->subscriptions->map(function ($s) { + $statusColors = [ + 'active' => 'green', + 'trialing' => 'blue', + 'past_due' => 'amber', + 'paused' => 'gray', + 'cancelled' => 'red', + 'incomplete' => 'amber', + 'expired' => 'gray', + ]; + + $actions = [ + ['icon' => 'eye', 'click' => "viewSubscription({$s->id})", 'title' => 'View details'], + ['icon' => 'pencil', 'click' => "openStatusChange({$s->id})", 'title' => 'Change status'], + ['icon' => 'clock', 'click' => "openExtendPeriod({$s->id})", 'title' => 'Extend period'], + ]; + + if ($s->cancelled_at && ! $s->ended_at) { + $actions[] = ['icon' => 'play', 'click' => "resumeSubscription({$s->id})", 'title' => 'Resume', 'class' => 'text-green-600']; + } elseif ($s->isActive()) { + $actions[] = ['icon' => 'x-mark', 'click' => "cancelSubscription({$s->id})", 'confirm' => 'Are you sure you want to cancel this subscription?', 'title' => 'Cancel', 'class' => 'text-red-600']; + } + + return [ + ['bold' => $s->workspace?->name ?? 'Unknown'], + [ + 'lines' => [ + ['bold' => $s->workspacePackage?->package?->name ?? 'Unknown'], + ['mono' => $s->workspacePackage?->package?->code], + ], + ], + ['badge' => ucfirst($s->gateway ?? 'unknown'), 'color' => 'gray'], + [ + 'lines' => array_filter([ + ['badge' => ucfirst($s->status), 'color' => $statusColors[$s->status] ?? 'gray'], + $s->cancel_at_period_end ? ['muted' => 'Cancels at period end'] : null, + ]), + ], + ucfirst($s->billing_cycle ?? 'monthly'), + $s->current_period_end + ? [ + 'lines' => [ + ['bold' => $s->current_period_end->format('d M Y')], + ['muted' => $s->current_period_end->isPast() + ? 'Ended '.$s->current_period_end->diffForHumans() + : $s->current_period_end->diffForHumans()], + ], + ] + : '-', + ['actions' => $actions], + ]; + })->all(); + } + + public function render() + { + return view('commerce::admin.subscription-manager') + ->layout('hub::admin.layouts.app', ['title' => 'Subscriptions']); + } +} diff --git a/View/Modal/Web/ChangePlan.php b/View/Modal/Web/ChangePlan.php new file mode 100644 index 0000000..d7dde8c --- /dev/null +++ b/View/Modal/Web/ChangePlan.php @@ -0,0 +1,213 @@ +commerce = $commerce; + $this->subscriptions = $subscriptions; + } + + public function mount(): void + { + $this->workspace = Auth::user()?->defaultHostWorkspace(); + + if (! $this->workspace) { + $this->availablePackages = collect(); + + return; + } + + $this->loadData(); + } + + protected function loadData(): void + { + // Load current subscription + $this->currentSubscription = $this->workspace->subscriptions() + ->active() + ->with('workspacePackage.package') + ->latest() + ->first(); + + if ($this->currentSubscription) { + $this->currentPackage = $this->currentSubscription->workspacePackage?->package; + $this->billingCycle = $this->guessBillingCycle(); + } + + // Load available packages (public ones only) + $this->availablePackages = Package::query() + ->where('is_active', true) + ->where('is_public', true) + ->orderBy('monthly_price', 'asc') + ->get(); + } + + protected function guessBillingCycle(): string + { + if (! $this->currentSubscription) { + return 'monthly'; + } + + $periodDays = $this->currentSubscription->current_period_start + ?->diffInDays($this->currentSubscription->current_period_end); + + return ($periodDays ?? 30) > 32 ? 'yearly' : 'monthly'; + } + + public function setBillingCycle(string $cycle): void + { + $this->billingCycle = $cycle; + $this->resetPreview(); + } + + public function selectPackage(string $packageCode): void + { + $this->selectedPackageCode = $packageCode; + $this->resetPreview(); + } + + public function resetPreview(): void + { + $this->showPreview = false; + $this->previewData = null; + $this->errorMessage = null; + } + + public function preview(): void + { + if (! $this->selectedPackageCode || ! $this->currentSubscription) { + return; + } + + $this->isLoading = true; + $this->errorMessage = null; + + try { + $newPackage = Package::where('code', $this->selectedPackageCode)->first(); + + if (! $newPackage) { + $this->errorMessage = 'Selected package not found.'; + + return; + } + + $proration = $this->subscriptions->previewPlanChange( + $this->currentSubscription, + $newPackage, + $this->billingCycle + ); + + $this->previewData = [ + 'current_plan' => $this->currentPackage?->name ?? 'Current Plan', + 'new_plan' => $newPackage->name, + 'current_price' => $proration->currentPlanPrice, + 'new_price' => $proration->newPlanPrice, + 'proration_amount' => $proration->netAmount, + 'effective_date' => now()->format('j F Y'), + 'next_billing_amount' => $proration->newPlanPrice, + 'is_upgrade' => $proration->isUpgrade(), + ]; + + $this->showPreview = true; + } catch (\Exception $e) { + $this->errorMessage = 'Unable to preview plan change: '.$e->getMessage(); + } finally { + $this->isLoading = false; + } + } + + public function confirmChange(): void + { + if (! $this->selectedPackageCode || ! $this->currentSubscription) { + return; + } + + $this->isLoading = true; + $this->errorMessage = null; + + try { + $newPackage = Package::where('code', $this->selectedPackageCode)->first(); + + if (! $newPackage) { + $this->errorMessage = 'Selected package not found.'; + $this->isLoading = false; + + return; + } + + $this->subscriptions->changePlan( + $this->currentSubscription, + $newPackage, + prorate: true + ); + + $this->dispatch('notify', [ + 'type' => 'success', + 'message' => 'Your plan has been updated successfully.', + ]); + + // Redirect to subscription page + $this->redirect(route('hub.billing.subscription'), navigate: true); + } catch (\Exception $e) { + $this->errorMessage = 'Unable to change plan: '.$e->getMessage(); + $this->isLoading = false; + } + } + + public function formatMoney(float $amount): string + { + return $this->commerce->formatMoney($amount); + } + + public function isCurrentPackage(Package $package): bool + { + return $this->currentPackage?->id === $package->id; + } + + public function render() + { + return view('commerce::web.change-plan'); + } +} diff --git a/View/Modal/Web/CheckoutCancel.php b/View/Modal/Web/CheckoutCancel.php new file mode 100644 index 0000000..410042a --- /dev/null +++ b/View/Modal/Web/CheckoutCancel.php @@ -0,0 +1,55 @@ +orderNumber = $order; + $foundOrder = Order::where('order_number', $order)->first(); + + // Verify ownership before exposing order details + if ($foundOrder && $this->authorizeOrder($foundOrder)) { + $this->order = $foundOrder; + } + } + } + + /** + * Verify the current user is authorised to view this order. + */ + protected function authorizeOrder(Order $order): bool + { + $user = Auth::user(); + + if (! $user instanceof User) { + return false; + } + + $workspace = $user->defaultHostWorkspace(); + + if (! $workspace) { + return false; + } + + return $order->workspace_id === $workspace->id; + } + + public function render() + { + return view('commerce::web.checkout.checkout-cancel'); + } +} diff --git a/View/Modal/Web/CheckoutPage.php b/View/Modal/Web/CheckoutPage.php new file mode 100644 index 0000000..51be831 --- /dev/null +++ b/View/Modal/Web/CheckoutPage.php @@ -0,0 +1,630 @@ +commerce = $commerce; + $this->couponService = $couponService; + $this->taxService = $taxService; + $this->rateLimiter = $rateLimiter; + $this->currencyService = $currencyService; + } + + public function mount(string $package = ''): void + { + // Generate idempotency key for this checkout session + $this->idempotencyKey = $this->generateIdempotencyKey(); + + // Detect and set display currency + $this->displayCurrency = $this->currencyService->getCurrentCurrency(); + + // Pre-select package from URL route parameter or query param + $packageCode = $package ?: $this->plan; + if ($packageCode) { + $pkg = Package::where('code', $packageCode)->active()->public()->first(); + if ($pkg) { + $this->selectedPackageId = $pkg->id; + $this->plan = $packageCode; + } + } + + // Set billing cycle from URL + if (in_array($this->cycle, ['monthly', 'yearly'])) { + $this->billingCycle = $this->cycle; + } + + // Pre-fill billing details if user is logged in + if (Auth::check()) { + $user = Auth::user(); + $workspace = $user->defaultHostWorkspace(); + + if ($workspace) { + $this->billingName = $workspace->billing_name ?? $user->name ?? ''; + $this->billingEmail = $workspace->billing_email ?? $user->email ?? ''; + $this->billingAddressLine1 = $workspace->billing_address_line1 ?? ''; + $this->billingAddressLine2 = $workspace->billing_address_line2 ?? ''; + $this->billingCity = $workspace->billing_city ?? ''; + $this->billingState = $workspace->billing_state ?? ''; + $this->billingPostalCode = $workspace->billing_postal_code ?? ''; + $this->billingCountry = $workspace->billing_country ?? 'GB'; + $this->taxId = $workspace->tax_id ?? ''; + } + } + } + + /** + * Handle currency change event from CurrencySelector component. + */ + #[\Livewire\Attributes\On('currency-changed')] + public function onCurrencyChanged(string $currency): void + { + $this->displayCurrency = $currency; + } + + /** + * Get the base currency. + */ + #[Computed] + public function baseCurrency(): string + { + return config('commerce.currencies.base', 'GBP'); + } + + /** + * Get the exchange rate for display currency. + */ + #[Computed] + public function exchangeRate(): float + { + if ($this->displayCurrency === $this->baseCurrency) { + return 1.0; + } + + return ExchangeRate::getRate($this->baseCurrency, $this->displayCurrency) ?? 1.0; + } + + /** + * Get supported currencies for display. + */ + #[Computed] + public function supportedCurrencies(): array + { + return $this->currencyService->getSupportedCurrencies(); + } + + #[Computed] + public function packages(): \Illuminate\Database\Eloquent\Collection + { + return Package::active() + ->public() + ->base() + ->purchasable() + ->ordered() + ->get(); + } + + #[Computed] + public function selectedPackage(): ?Package + { + if (! $this->selectedPackageId) { + return null; + } + + return Package::find($this->selectedPackageId); + } + + #[Computed] + public function appliedCoupon(): ?Coupon + { + if (! $this->appliedCouponId) { + return null; + } + + return Coupon::find($this->appliedCouponId); + } + + /** + * Get subtotal in base currency. + */ + #[Computed] + public function baseSubtotal(): float + { + if (! $this->selectedPackage) { + return 0; + } + + return $this->selectedPackage->getPrice($this->billingCycle); + } + + /** + * Get subtotal in display currency. + */ + #[Computed] + public function subtotal(): float + { + return $this->convertToDisplayCurrency($this->baseSubtotal); + } + + /** + * Get setup fee in base currency. + */ + #[Computed] + public function baseSetupFee(): float + { + if (! $this->selectedPackage) { + return 0; + } + + return $this->selectedPackage->setup_fee ?? 0; + } + + /** + * Get setup fee in display currency. + */ + #[Computed] + public function setupFee(): float + { + return $this->convertToDisplayCurrency($this->baseSetupFee); + } + + /** + * Get discount in base currency. + */ + #[Computed] + public function baseDiscount(): float + { + if (! $this->appliedCoupon || ! $this->selectedPackage) { + return 0; + } + + return $this->appliedCoupon->calculateDiscount($this->baseSubtotal); + } + + /** + * Get discount in display currency. + */ + #[Computed] + public function discount(): float + { + return $this->convertToDisplayCurrency($this->baseDiscount); + } + + /** + * Get taxable amount in base currency. + */ + #[Computed] + public function baseTaxableAmount(): float + { + return $this->baseSubtotal - $this->baseDiscount + $this->baseSetupFee; + } + + /** + * Get taxable amount in display currency. + */ + #[Computed] + public function taxableAmount(): float + { + return $this->subtotal - $this->discount + $this->setupFee; + } + + /** + * Get tax amount in base currency (tax is calculated on base amounts). + */ + #[Computed] + public function baseTaxAmount(): float + { + // Create a temporary workspace-like object for tax calculation + $workspace = new Workspace([ + 'billing_country' => $this->billingCountry, + 'billing_state' => $this->billingState, + 'tax_id' => $this->taxId, + 'tax_exempt' => false, + ]); + + $result = $this->taxService->calculate($workspace, $this->baseTaxableAmount); + + return $result->taxAmount; + } + + /** + * Get tax amount in display currency. + */ + #[Computed] + public function taxAmount(): float + { + return $this->convertToDisplayCurrency($this->baseTaxAmount); + } + + #[Computed] + public function taxRate(): float + { + $workspace = new Workspace([ + 'billing_country' => $this->billingCountry, + 'billing_state' => $this->billingState, + 'tax_id' => $this->taxId, + 'tax_exempt' => false, + ]); + + $result = $this->taxService->calculate($workspace, $this->baseTaxableAmount); + + return $result->taxRate; + } + + /** + * Get total in base currency. + */ + #[Computed] + public function baseTotal(): float + { + return $this->baseTaxableAmount + $this->baseTaxAmount; + } + + /** + * Get total in display currency. + */ + #[Computed] + public function total(): float + { + return $this->taxableAmount + $this->taxAmount; + } + + /** + * Convert an amount from base currency to display currency. + */ + public function convertToDisplayCurrency(float $amount): float + { + if ($this->displayCurrency === $this->baseCurrency) { + return $amount; + } + + return round($amount * $this->exchangeRate, 2); + } + + /** + * Format an amount in the display currency. + */ + public function formatAmount(float $amount): string + { + return $this->currencyService->format($amount, $this->displayCurrency); + } + + #[Computed] + public function countries(): array + { + return [ + 'GB' => 'United Kingdom', + 'US' => 'United States', + 'AU' => 'Australia', + 'AT' => 'Austria', + 'BE' => 'Belgium', + 'BG' => 'Bulgaria', + 'CA' => 'Canada', + 'HR' => 'Croatia', + 'CY' => 'Cyprus', + 'CZ' => 'Czech Republic', + 'DK' => 'Denmark', + 'EE' => 'Estonia', + 'FI' => 'Finland', + 'FR' => 'France', + 'DE' => 'Germany', + 'GR' => 'Greece', + 'HU' => 'Hungary', + 'IE' => 'Ireland', + 'IT' => 'Italy', + 'LV' => 'Latvia', + 'LT' => 'Lithuania', + 'LU' => 'Luxembourg', + 'MT' => 'Malta', + 'NL' => 'Netherlands', + 'NZ' => 'New Zealand', + 'PL' => 'Poland', + 'PT' => 'Portugal', + 'RO' => 'Romania', + 'SK' => 'Slovakia', + 'SI' => 'Slovenia', + 'ES' => 'Spain', + 'SE' => 'Sweden', + ]; + } + + public function selectPackage(int $packageId): void + { + $this->selectedPackageId = $packageId; + $this->step = 2; + + // Revalidate coupon for new package + if ($this->appliedCouponId) { + $this->validateAppliedCoupon(); + } + } + + public function setBillingCycle(string $cycle): void + { + $this->billingCycle = $cycle; + } + + public function applyCoupon(): void + { + $this->couponError = ''; + $this->couponSuccess = ''; + + if (empty($this->couponCode)) { + $this->couponError = 'Please enter a coupon code'; + + return; + } + + // Check rate limit to prevent brute-forcing coupon codes + $userId = Auth::id(); + $workspaceId = Auth::check() ? Auth::user()->defaultHostWorkspace()?->id : null; + + if ($this->rateLimiter->tooManyCouponAttempts($workspaceId, $userId, request())) { + $availableIn = $this->rateLimiter->couponAvailableIn($workspaceId, $userId, request()); + $minutes = ceil($availableIn / 60); + $this->couponError = "Too many attempts. Please try again in {$minutes} minute(s)."; + + return; + } + + // Increment counter before validation + $this->rateLimiter->incrementCoupon($workspaceId, $userId, request()); + + $workspace = $this->getOrCreateWorkspace(); + $result = $this->couponService->validateByCode( + $this->couponCode, + $workspace, + $this->selectedPackage + ); + + if (! $result->isValid()) { + $this->couponError = $result->error; + + return; + } + + $this->appliedCouponId = $result->coupon->id; + $this->couponSuccess = "Coupon applied: {$this->commerce->formatMoney($this->discount)} off"; + } + + public function removeCoupon(): void + { + $this->appliedCouponId = null; + $this->couponCode = ''; + $this->couponError = ''; + $this->couponSuccess = ''; + } + + protected function validateAppliedCoupon(): void + { + if (! $this->appliedCouponId) { + return; + } + + $coupon = Coupon::find($this->appliedCouponId); + if (! $coupon || ! $coupon->appliesToPackage($this->selectedPackageId)) { + $this->removeCoupon(); + $this->couponError = 'Coupon does not apply to the selected plan'; + } + } + + public function goToStep(int $step): void + { + if ($step === 1 || ($step === 2 && $this->selectedPackageId)) { + $this->step = $step; + } + } + + public function proceedToPayment(): void + { + $this->validate([ + 'billingName' => 'required|string|max:255', + 'billingEmail' => 'required|email|max:255', + 'billingAddressLine1' => 'required|string|max:255', + 'billingCity' => 'required|string|max:255', + 'billingPostalCode' => 'required|string|max:20', + 'billingCountry' => 'required|string|size:2', + ]); + + $this->step = 3; + } + + public function checkout(string $gateway = 'btcpay'): void + { + $this->error = ''; + $this->processing = true; + + try { + // Validate required fields + if (! $this->selectedPackageId) { + throw new \Exception('Please select a plan'); + } + + // Check rate limit before processing + $userId = Auth::id(); + $workspaceId = Auth::check() ? Auth::user()->defaultHostWorkspace()?->id : null; + + if ($this->rateLimiter->tooManyAttempts($workspaceId, $userId, request())) { + $availableIn = $this->rateLimiter->availableIn($workspaceId, $userId, request()); + $minutes = ceil($availableIn / 60); + throw new \Exception("Too many checkout attempts. Please try again in {$minutes} minute(s)."); + } + + // Increment rate limiter before processing + $this->rateLimiter->increment($workspaceId, $userId, request()); + + // Get or create workspace + $workspace = $this->getOrCreateWorkspace(); + + // Update workspace billing details + $workspace->update([ + 'billing_name' => $this->billingName, + 'billing_email' => $this->billingEmail, + 'billing_address_line1' => $this->billingAddressLine1, + 'billing_address_line2' => $this->billingAddressLine2, + 'billing_city' => $this->billingCity, + 'billing_state' => $this->billingState, + 'billing_postal_code' => $this->billingPostalCode, + 'billing_country' => $this->billingCountry, + 'tax_id' => $this->taxId, + ]); + + // Create order with idempotency key to prevent duplicates + $order = $this->commerce->createOrder( + $workspace, + $this->selectedPackage, + $this->billingCycle, + $this->appliedCoupon, + [ + 'display_currency' => $this->displayCurrency, + 'exchange_rate' => $this->exchangeRate, + ], + $this->idempotencyKey + ); + + // Update order with multi-currency fields + $baseCurrency = $this->baseCurrency; + if ($this->displayCurrency !== $baseCurrency) { + $order->update([ + 'display_currency' => $this->displayCurrency, + 'exchange_rate_used' => $this->exchangeRate, + 'base_currency_total' => $this->baseTotal, + ]); + } + + // Create checkout session + $checkout = $this->commerce->createCheckout($order, $gateway); + + // Redirect to payment + $this->redirect($checkout['checkout_url']); + } catch (\Exception $e) { + $this->error = $e->getMessage(); + $this->processing = false; + } + } + + protected function getOrCreateWorkspace(): Workspace + { + if (Auth::check()) { + $workspace = Auth::user()->defaultHostWorkspace(); + if ($workspace) { + return $workspace; + } + } + + // For guest checkout, create a temporary workspace + // This will be properly assigned when user registers/logs in + return Workspace::create([ + 'name' => $this->billingName ?: 'New Workspace', + 'slug' => 'checkout-'.uniqid(), + 'billing_email' => $this->billingEmail, + 'is_active' => false, // Activated after payment + ]); + } + + public function render() + { + return view('commerce::web.checkout.checkout-page'); + } + + /** + * Generate a unique idempotency key for this checkout session. + * + * Key is based on user/session, package, billing cycle, and timestamp + * to ensure uniqueness while allowing retries within the same session. + */ + protected function generateIdempotencyKey(): string + { + $userId = Auth::id() ?? session()->getId(); + $timestamp = now()->format('YmdHi'); // Minute precision + + return hash('sha256', "{$userId}:{$timestamp}:".uniqid('', true)); + } +} diff --git a/View/Modal/Web/CheckoutSuccess.php b/View/Modal/Web/CheckoutSuccess.php new file mode 100644 index 0000000..89aff3c --- /dev/null +++ b/View/Modal/Web/CheckoutSuccess.php @@ -0,0 +1,194 @@ +orderNumber = $order; + $foundOrder = Order::where('order_number', $order)->first(); + + if (! $foundOrder) { + return; + } + + // Check if this is a guest checkout that needs account creation + if (! Auth::check()) { + // Check if order's workspace is a temporary guest workspace + $workspace = $foundOrder->workspace; + if ($workspace && str_starts_with($workspace->slug, 'checkout-')) { + $this->needsAccount = true; + $this->guestEmail = $workspace->billing_email ?? ''; + $this->order = $foundOrder; + $this->isPending = $this->order->isPending() || $this->order->isProcessing(); + + return; + } + } + + // Verify ownership: user must own the workspace that placed this order + if ($this->authorizeOrder($foundOrder)) { + $this->order = $foundOrder; + $this->isPending = $this->order->isPending() || $this->order->isProcessing(); + } + } + + /** + * Verify the current user is authorised to view this order. + */ + protected function authorizeOrder(Order $order): bool + { + $user = Auth::user(); + + // If not logged in, don't show order details (just generic success) + if (! $user instanceof User) { + return false; + } + + // Check if order belongs to user's workspace + $workspace = $user->defaultHostWorkspace(); + + if (! $workspace) { + return false; + } + + return $order->workspace_id === $workspace->id; + } + + /** + * Create account for guest checkout user and claim their workspace. + */ + public function createAccount(): void + { + $this->validate(); + + // Check email isn't already taken + if (User::where('email', $this->guestEmail)->exists()) { + $this->addError('email', 'An account with this email already exists. Please log in instead.'); + + return; + } + + try { + $user = DB::transaction(function () { + // Create the user + $user = User::create([ + 'name' => $this->name, + 'email' => $this->guestEmail, + 'password' => Hash::make($this->password), + ]); + + // Update the guest workspace to be a proper workspace + $workspace = $this->order->workspace; + if ($workspace) { + $workspace->update([ + 'name' => $this->name ?: 'My Workspace', + 'slug' => $this->generateUniqueSlug($this->name ?: $this->guestEmail), + 'is_active' => true, + ]); + + // Attach user to workspace as owner + $user->hostWorkspaces()->attach($workspace->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + } + + return $user; + }); + + // Fire registered event + event(new Registered($user)); + + // Log them in + Auth::login($user); + + // Clear the needs account flag + $this->needsAccount = false; + + // Refresh authorization + $this->order->refresh(); + + } catch (\Exception $e) { + report($e); + $this->addError('email', 'Something went wrong. Please try again.'); + } + } + + /** + * Generate a unique workspace slug. + */ + protected function generateUniqueSlug(string $name): string + { + $baseSlug = \Illuminate\Support\Str::slug($name); + if (str_contains($baseSlug, '@')) { + $baseSlug = \Illuminate\Support\Str::slug(\Illuminate\Support\Str::before($name, '@')); + } + + $slug = $baseSlug; + $counter = 1; + + while (Workspace::where('slug', $slug)->where('id', '!=', $this->order->workspace_id)->exists()) { + $slug = $baseSlug.'-'.$counter; + $counter++; + } + + return $slug; + } + + public function checkStatus(): void + { + if (! $this->order) { + return; + } + + $this->order->refresh(); + $this->isPending = $this->order->isPending() || $this->order->isProcessing(); + + // If paid, we can stop polling + if ($this->order->isPaid()) { + $this->isPending = false; + } + } + + public function render() + { + return view('commerce::web.checkout.checkout-success'); + } +} diff --git a/View/Modal/Web/CurrencySelector.php b/View/Modal/Web/CurrencySelector.php new file mode 100644 index 0000000..089df68 --- /dev/null +++ b/View/Modal/Web/CurrencySelector.php @@ -0,0 +1,110 @@ +currencyService = $currencyService; + } + + public function mount(): void + { + $this->selected = $this->currencyService->getCurrentCurrency(); + } + + #[Computed] + public function currencies(): array + { + $supported = $this->currencyService->getSupportedCurrencies(); + $baseCurrency = $this->currencyService->getBaseCurrency(); + $currencies = []; + + foreach ($supported as $code => $config) { + $currencies[$code] = [ + 'code' => $code, + 'name' => $config['name'], + 'symbol' => $config['symbol'], + 'flag' => $config['flag'] ?? strtolower(substr($code, 0, 2)), + 'isBase' => $code === $baseCurrency, + ]; + } + + return $currencies; + } + + #[Computed] + public function currentCurrency(): array + { + return $this->currencies[$this->selected] ?? [ + 'code' => $this->selected, + 'name' => $this->selected, + 'symbol' => $this->selected, + 'flag' => '', + 'isBase' => false, + ]; + } + + /** + * Select a currency. + */ + public function selectCurrency(string $currency): void + { + $currency = strtoupper($currency); + + if (! $this->currencyService->isSupported($currency)) { + return; + } + + $this->selected = $currency; + $this->currencyService->setCurrentCurrency($currency); + + // Emit event for parent components + $this->dispatch('currency-changed', currency: $currency); + } + + public function render() + { + return view('commerce::web.components.currency-selector'); + } +} diff --git a/View/Modal/Web/Dashboard.php b/View/Modal/Web/Dashboard.php new file mode 100644 index 0000000..3da8458 --- /dev/null +++ b/View/Modal/Web/Dashboard.php @@ -0,0 +1,112 @@ +commerce = $commerce; + } + + public function mount(): void + { + $this->workspace = Auth::user()?->defaultHostWorkspace(); + + if (! $this->workspace) { + $this->recentInvoices = collect(); + $this->upcomingCharges = collect(); + + return; + } + + // Load active subscription + $this->activeSubscription = $this->workspace->subscriptions() + ->active() + ->with('workspacePackage.package') + ->latest() + ->first(); + + if ($this->activeSubscription) { + $this->currentPlan = $this->activeSubscription->workspacePackage?->package?->name ?? 'Subscription'; + $this->nextBillingDate = $this->activeSubscription->current_period_end?->format('j M Y'); + $this->billingCycle = $this->guessBillingCycle(); + + // Calculate next billing amount + $package = $this->activeSubscription->workspacePackage?->package; + if ($package) { + $this->nextBillingAmount = $package->getPrice($this->billingCycle); + } + } + + // Load recent invoices + $this->recentInvoices = $this->workspace->invoices() + ->with('items') + ->latest() + ->limit(5) + ->get(); + + // Calculate upcoming charges (subscriptions renewing soon) + $this->upcomingCharges = $this->workspace->subscriptions() + ->valid() + ->expiringSoon(30) + ->with('workspacePackage.package') + ->get(); + + // Load tree planting stats (Trees for Agents programme) + $this->treesPlanted = $this->workspace->treesPlanted(); + $this->treesThisYear = $this->workspace->treesThisYear(); + } + + public function formatMoney(float $amount): string + { + return $this->commerce->formatMoney($amount); + } + + protected function guessBillingCycle(): string + { + if (! $this->activeSubscription) { + return 'monthly'; + } + + $periodDays = $this->activeSubscription->current_period_start + ?->diffInDays($this->activeSubscription->current_period_end); + + return ($periodDays ?? 30) > 32 ? 'yearly' : 'monthly'; + } + + public function render() + { + return view('commerce::web.dashboard') + ->layout('hub::admin.layouts.app', ['title' => 'Billing']); + } +} diff --git a/View/Modal/Web/Invoices.php b/View/Modal/Web/Invoices.php new file mode 100644 index 0000000..a775639 --- /dev/null +++ b/View/Modal/Web/Invoices.php @@ -0,0 +1,67 @@ +commerce = $commerce; + } + + public function mount(): void + { + $this->workspace = Auth::user()?->defaultHostWorkspace(); + } + + public function updatedStatus(): void + { + $this->resetPage(); + } + + public function getInvoicesProperty(): LengthAwarePaginator + { + if (! $this->workspace) { + return new \Illuminate\Pagination\LengthAwarePaginator([], 0, 10); + } + + $query = $this->workspace->invoices() + ->with('items') + ->latest('issued_at'); + + if ($this->status !== 'all') { + $query->where('status', $this->status); + } + + return $query->paginate(10); + } + + public function formatMoney(float $amount, ?string $currency = null): string + { + return $this->commerce->formatMoney($amount, $currency); + } + + public function render() + { + return view('commerce::web.invoices', [ + 'invoices' => $this->invoices, + ]); + } +} diff --git a/View/Modal/Web/PaymentMethods.php b/View/Modal/Web/PaymentMethods.php new file mode 100644 index 0000000..5e1d684 --- /dev/null +++ b/View/Modal/Web/PaymentMethods.php @@ -0,0 +1,239 @@ + Payment methods expiring soon */ + public array $expiringMethods = []; + + protected StripeGateway $stripeGateway; + + protected PaymentMethodService $paymentMethodService; + + public function boot(StripeGateway $stripeGateway, PaymentMethodService $paymentMethodService): void + { + $this->stripeGateway = $stripeGateway; + $this->paymentMethodService = $paymentMethodService; + } + + public function mount(): void + { + $this->workspace = Auth::user()?->defaultHostWorkspace(); + $this->loadPaymentMethods(); + + // Check for setup_intent query param (return from Stripe setup) + $setupIntent = request()->query('setup_intent'); + if ($setupIntent) { + $this->handleSetupReturn($setupIntent); + } + } + + public function loadPaymentMethods(): void + { + if (! $this->workspace) { + $this->paymentMethods = collect(); + + return; + } + + $this->paymentMethods = $this->paymentMethodService->getPaymentMethods($this->workspace); + $this->defaultMethod = $this->paymentMethods->firstWhere('is_default', true); + + // Check for expiring cards + $this->expiringMethods = []; + foreach ($this->paymentMethods as $method) { + if ($this->paymentMethodService->isExpiringSoon($method)) { + $this->expiringMethods[$method->id] = true; + } + } + } + + public function setDefault(PaymentMethod $paymentMethod): void + { + if ($paymentMethod->workspace_id !== $this->workspace?->id) { + return; + } + + try { + $this->paymentMethodService->setDefaultPaymentMethod($this->workspace, $paymentMethod); + + $this->loadPaymentMethods(); + + $this->dispatch('notify', [ + 'type' => 'success', + 'message' => 'Default payment method updated.', + ]); + } catch (\Exception $e) { + $this->dispatch('notify', [ + 'type' => 'error', + 'message' => 'Failed to update default payment method.', + ]); + } + } + + public function removeMethod(PaymentMethod $paymentMethod): void + { + if ($paymentMethod->workspace_id !== $this->workspace?->id) { + return; + } + + try { + $this->paymentMethodService->removePaymentMethod($this->workspace, $paymentMethod); + + $this->loadPaymentMethods(); + + $this->dispatch('notify', [ + 'type' => 'success', + 'message' => 'Payment method removed.', + ]); + } catch (\RuntimeException $e) { + $this->dispatch('notify', [ + 'type' => 'error', + 'message' => $e->getMessage(), + ]); + } catch (\Exception $e) { + $this->dispatch('notify', [ + 'type' => 'error', + 'message' => 'Failed to remove payment method.', + ]); + } + } + + /** + * Start adding a new payment method via Stripe Setup Session. + */ + public function addPaymentMethod(): void + { + if (! $this->workspace) { + return; + } + + // Check if Stripe is enabled + if (! $this->stripeGateway->isEnabled()) { + $this->dispatch('notify', [ + 'type' => 'error', + 'message' => 'Card payments are not currently available. Please try again later.', + ]); + + return; + } + + $this->isAddingMethod = true; + + try { + $session = $this->paymentMethodService->createSetupSession( + $this->workspace, + route('hub.billing.payment-methods') + ); + + // Redirect to Stripe's hosted setup page + $this->redirect($session['setup_url']); + } catch (\Exception $e) { + $this->isAddingMethod = false; + $this->dispatch('notify', [ + 'type' => 'error', + 'message' => 'Unable to start payment method setup. Please try again.', + ]); + } + } + + /** + * Open Stripe's billing portal for full payment management. + */ + public function openStripePortal(): void + { + if (! $this->workspace) { + return; + } + + if (! $this->stripeGateway->isEnabled()) { + $this->dispatch('notify', [ + 'type' => 'error', + 'message' => 'Card payments are not currently available.', + ]); + + return; + } + + try { + $url = $this->paymentMethodService->getBillingPortalUrl( + $this->workspace, + route('hub.billing.payment-methods') + ); + + if ($url) { + $this->redirect($url); + } else { + $this->dispatch('notify', [ + 'type' => 'error', + 'message' => 'No billing account found. Please add a payment method first.', + ]); + } + } catch (\Exception $e) { + $this->dispatch('notify', [ + 'type' => 'error', + 'message' => 'Unable to open billing portal. Please try again.', + ]); + } + } + + /** + * Handle the return from Stripe setup session. + */ + public function handleSetupReturn(?string $setupIntent = null): void + { + if (! $setupIntent || ! $this->workspace) { + return; + } + + try { + // The setup intent ID can be used to retrieve the payment method + // For now, just reload the payment methods (Stripe webhook will have processed it) + $this->loadPaymentMethods(); + + $this->dispatch('notify', [ + 'type' => 'success', + 'message' => 'Payment method added successfully.', + ]); + } catch (\Exception $e) { + $this->dispatch('notify', [ + 'type' => 'error', + 'message' => 'Unable to confirm payment method. Please try again.', + ]); + } + } + + /** + * Check if a payment method is expiring soon. + */ + public function isExpiringSoon(int $methodId): bool + { + return $this->expiringMethods[$methodId] ?? false; + } + + public function render() + { + return view('commerce::web.payment-methods'); + } +} diff --git a/View/Modal/Web/ReferralDashboard.php b/View/Modal/Web/ReferralDashboard.php new file mode 100644 index 0000000..476256c --- /dev/null +++ b/View/Modal/Web/ReferralDashboard.php @@ -0,0 +1,180 @@ +user(); + if (! $user->hasActivatedReferrals()) { + $user->activateReferrals(); + } + } + + public function switchTab(string $tab): void + { + $this->tab = $tab; + $this->resetPage(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Statistics + // ───────────────────────────────────────────────────────────────────────── + + #[Computed] + public function stats() + { + return app(ReferralService::class)->getStatsForUser(auth()->user()); + } + + #[Computed] + public function referralLink(): string + { + $user = auth()->user(); + $namespace = $user->defaultNamespace(); + + if ($namespace) { + return url('/?ref='.$namespace->slug); + } + + // Fallback to user ID if no namespace + return url('/?ref=u'.$user->id); + } + + // ───────────────────────────────────────────────────────────────────────── + // Referrals + // ───────────────────────────────────────────────────────────────────────── + + #[Computed] + public function referrals() + { + return Referral::forReferrer(auth()->id()) + ->with('referee') + ->latest() + ->paginate(15); + } + + // ───────────────────────────────────────────────────────────────────────── + // Commissions + // ───────────────────────────────────────────────────────────────────────── + + #[Computed] + public function commissions() + { + return ReferralCommission::forReferrer(auth()->id()) + ->with(['referral.referee', 'order']) + ->latest() + ->paginate(15); + } + + // ───────────────────────────────────────────────────────────────────────── + // Payouts + // ───────────────────────────────────────────────────────────────────────── + + #[Computed] + public function payouts() + { + return ReferralPayout::forUser(auth()->id()) + ->latest() + ->paginate(15); + } + + public function openPayoutModal(): void + { + $this->payoutMethod = 'btc'; + $this->payoutBtcAddress = ''; + $this->payoutAmount = null; + $this->showPayoutModal = true; + } + + public function requestPayout(ReferralService $referralService): void + { + $this->validate([ + 'payoutMethod' => ['required', 'in:btc,account_credit'], + 'payoutBtcAddress' => ['required_if:payoutMethod,btc', 'nullable', 'string', 'min:26', 'max:128'], + 'payoutAmount' => ['nullable', 'numeric', 'min:0.01'], + ]); + + $availableBalance = $this->stats['available_balance']; + + // Check minimum payout + $minimum = ReferralPayout::getMinimumPayout($this->payoutMethod); + if ($availableBalance < $minimum) { + session()->flash('error', "Minimum payout amount is GBP {$minimum}."); + + return; + } + + try { + $referralService->requestPayout( + auth()->user(), + $this->payoutMethod, + $this->payoutAmount, + $this->payoutMethod === 'btc' ? $this->payoutBtcAddress : null + ); + + session()->flash('message', 'Payout requested successfully.'); + $this->closePayoutModal(); + } catch (\InvalidArgumentException $e) { + session()->flash('error', $e->getMessage()); + } + } + + public function cancelPayout(int $id): void + { + $payout = ReferralPayout::forUser(auth()->id())->findOrFail($id); + + if (! $payout->isRequested()) { + session()->flash('error', 'Cannot cancel payout that is already being processed.'); + + return; + } + + $payout->cancel('Cancelled by user'); + session()->flash('message', 'Payout request cancelled.'); + } + + public function closePayoutModal(): void + { + $this->showPayoutModal = false; + } + + public function render() + { + return view('commerce::web.referral-dashboard') + ->layout('hub::web.layouts.app', ['title' => 'Affiliate Dashboard']); + } +} diff --git a/View/Modal/Web/Subscription.php b/View/Modal/Web/Subscription.php new file mode 100644 index 0000000..2563388 --- /dev/null +++ b/View/Modal/Web/Subscription.php @@ -0,0 +1,177 @@ +commerce = $commerce; + $this->subscriptions = $subscriptions; + } + + public function mount(): void + { + $this->workspace = Auth::user()?->defaultHostWorkspace(); + + if (! $this->workspace) { + $this->subscriptionHistory = collect(); + + return; + } + + $this->loadSubscriptionData(); + } + + protected function loadSubscriptionData(): void + { + // Load active subscription + $this->activeSubscription = $this->workspace->subscriptions() + ->active() + ->with('workspacePackage.package') + ->latest() + ->first(); + + if ($this->activeSubscription) { + $this->currentPlan = $this->activeSubscription->workspacePackage?->package?->name ?? 'Subscription'; + $this->nextBillingDate = $this->activeSubscription->current_period_end?->format('j F Y'); + $this->billingCycle = $this->guessBillingCycle(); + + $package = $this->activeSubscription->workspacePackage?->package; + if ($package) { + $this->nextBillingAmount = $package->getPrice($this->billingCycle); + } + } + + // Load subscription history + $this->subscriptionHistory = $this->workspace->subscriptions() + ->with('workspacePackage.package') + ->latest() + ->limit(10) + ->get(); + } + + protected function guessBillingCycle(): string + { + if (! $this->activeSubscription) { + return 'monthly'; + } + + $periodDays = $this->activeSubscription->current_period_start + ?->diffInDays($this->activeSubscription->current_period_end); + + return ($periodDays ?? 30) > 32 ? 'yearly' : 'monthly'; + } + + public function openCancelModal(): void + { + $this->showCancelModal = true; + } + + public function closeCancelModal(): void + { + $this->showCancelModal = false; + $this->cancelReason = ''; + } + + public function cancelSubscription(): void + { + if (! $this->activeSubscription) { + $this->dispatch('notify', [ + 'type' => 'error', + 'message' => 'No active subscription to cancel.', + ]); + + return; + } + + try { + $this->subscriptions->cancel($this->activeSubscription, $this->cancelReason); + + // Notify user + $user = Auth::user(); + if ($user instanceof \Core\Mod\Tenant\Models\User) { + $user->notify(new SubscriptionCancelled($this->activeSubscription)); + } + + $this->closeCancelModal(); + $this->loadSubscriptionData(); + + $this->dispatch('notify', [ + 'type' => 'success', + 'message' => 'Your subscription has been scheduled for cancellation at the end of the current billing period.', + ]); + } catch (\Exception $e) { + $this->dispatch('notify', [ + 'type' => 'error', + 'message' => 'Failed to cancel subscription. Please contact support.', + ]); + } + } + + public function resumeSubscription(): void + { + if (! $this->activeSubscription || ! $this->activeSubscription->cancelled_at) { + return; + } + + try { + $this->subscriptions->resume($this->activeSubscription); + + $this->loadSubscriptionData(); + + $this->dispatch('notify', [ + 'type' => 'success', + 'message' => 'Your subscription has been resumed.', + ]); + } catch (\Exception $e) { + $this->dispatch('notify', [ + 'type' => 'error', + 'message' => 'Failed to resume subscription. Please contact support.', + ]); + } + } + + public function formatMoney(float $amount): string + { + return $this->commerce->formatMoney($amount); + } + + public function render() + { + return view('commerce::web.subscription'); + } +} diff --git a/View/Modal/Web/UsageDashboard.php b/View/Modal/Web/UsageDashboard.php new file mode 100644 index 0000000..2a2a612 --- /dev/null +++ b/View/Modal/Web/UsageDashboard.php @@ -0,0 +1,160 @@ +commerce = $commerce; + $this->usageBilling = $usageBilling; + } + + public function mount(): void + { + $this->usageBillingEnabled = config('commerce.features.usage_billing', false); + $this->currentUsage = collect(); + $this->usageHistory = collect(); + + if (! $this->usageBillingEnabled) { + return; + } + + $this->workspace = Auth::user()?->defaultHostWorkspace(); + + if (! $this->workspace) { + return; + } + + // Load active subscription + $this->activeSubscription = $this->workspace->subscriptions() + ->active() + ->with('workspacePackage.package') + ->latest() + ->first(); + + if (! $this->activeSubscription) { + return; + } + + $this->loadUsageData(); + } + + protected function loadUsageData(): void + { + // Get current period info + $this->periodStart = $this->activeSubscription->current_period_start?->format('j M Y'); + $this->periodEnd = $this->activeSubscription->current_period_end?->format('j M Y'); + $this->daysRemaining = $this->activeSubscription->daysUntilRenewal(); + + // Get current usage summary + $usageSummary = $this->usageBilling->getUsageSummary($this->activeSubscription); + $this->currentUsage = collect($usageSummary); + + // Calculate estimated charges + $this->estimatedCharges = $this->currentUsage->sum('estimated_charge'); + + // Get usage history (last 6 periods) + $this->usageHistory = $this->usageBilling->getUsageHistory( + $this->activeSubscription, + null, + 6 + )->groupBy(fn ($usage) => $usage->period_start->format('Y-m')); + } + + public function formatMoney(float $amount, ?string $currency = null): string + { + $currency = $currency ?? config('commerce.currency', 'GBP'); + $symbol = match ($currency) { + 'GBP' => '£', + 'USD' => '$', + 'EUR' => '€', + default => $currency.' ', + }; + + return $symbol.number_format($amount, 2); + } + + public function formatNumber(int $value): string + { + return number_format($value); + } + + /** + * Get usage percentage for a specific meter. + * + * If meter has included quota from subscription package, + * calculate percentage used. + */ + public function getUsagePercentage(array $usage, ?int $includedQuota = null): ?int + { + if ($includedQuota === null || $includedQuota <= 0) { + return null; + } + + return min(100, (int) round(($usage['quantity'] / $includedQuota) * 100)); + } + + /** + * Get status colour based on usage percentage. + */ + public function getUsageStatusColour(?int $percentage): string + { + if ($percentage === null) { + return 'zinc'; + } + + return match (true) { + $percentage >= 100 => 'red', + $percentage >= 90 => 'amber', + $percentage >= 75 => 'yellow', + default => 'emerald', + }; + } + + public function refresh(): void + { + $this->loadUsageData(); + } + + public function render() + { + return view('commerce::web.usage-dashboard') + ->layout('hub::admin.layouts.app', ['title' => 'Usage']); + } +} diff --git a/app/Http/Controllers/.gitkeep b/app/Http/Controllers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Mod/.gitkeep b/app/Mod/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Models/.gitkeep b/app/Models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php deleted file mode 100644 index 452e6b6..0000000 --- a/app/Providers/AppServiceProvider.php +++ /dev/null @@ -1,24 +0,0 @@ -handleCommand(new ArgvInput); - -exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php deleted file mode 100644 index 4687853..0000000 --- a/bootstrap/app.php +++ /dev/null @@ -1,26 +0,0 @@ -withProviders([ - // Core PHP Framework - \Core\LifecycleEventProvider::class, - \Core\Website\Boot::class, - \Core\Front\Boot::class, - \Core\Mod\Boot::class, - ]) - ->withRouting( - web: __DIR__.'/../routes/web.php', - api: __DIR__.'/../routes/api.php', - commands: __DIR__.'/../routes/console.php', - health: '/up', - ) - ->withMiddleware(function (Middleware $middleware) { - \Core\Front\Boot::middleware($middleware); - }) - ->withExceptions(function (Exceptions $exceptions) { - // - })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/bootstrap/cache/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php deleted file mode 100644 index 38b258d..0000000 --- a/bootstrap/providers.php +++ /dev/null @@ -1,5 +0,0 @@ - env('COMMERCE_CURRENCY', 'GBP'), + + 'currencies' => [ + // Base currency for reporting and internal calculations + 'base' => env('COMMERCE_BASE_CURRENCY', 'GBP'), + + // Supported currencies with display properties + 'supported' => [ + 'GBP' => [ + 'name' => 'British Pound', + 'symbol' => '£', + 'symbol_position' => 'before', // 'before' or 'after' + 'decimal_places' => 2, + 'thousands_separator' => ',', + 'decimal_separator' => '.', + 'flag' => 'gb', + ], + 'USD' => [ + 'name' => 'US Dollar', + 'symbol' => '$', + 'symbol_position' => 'before', + 'decimal_places' => 2, + 'thousands_separator' => ',', + 'decimal_separator' => '.', + 'flag' => 'us', + ], + 'EUR' => [ + 'name' => 'Euro', + 'symbol' => '€', + 'symbol_position' => 'before', + 'decimal_places' => 2, + 'thousands_separator' => ' ', + 'decimal_separator' => ',', + 'flag' => 'eu', + ], + 'AUD' => [ + 'name' => 'Australian Dollar', + 'symbol' => 'A$', + 'symbol_position' => 'before', + 'decimal_places' => 2, + 'thousands_separator' => ',', + 'decimal_separator' => '.', + 'flag' => 'au', + ], + 'CAD' => [ + 'name' => 'Canadian Dollar', + 'symbol' => 'C$', + 'symbol_position' => 'before', + 'decimal_places' => 2, + 'thousands_separator' => ',', + 'decimal_separator' => '.', + 'flag' => 'ca', + ], + ], + + // Exchange rate provider settings + 'exchange_rates' => [ + // Provider: 'stripe', 'ecb', 'openexchangerates', 'fixed' + 'provider' => env('COMMERCE_EXCHANGE_RATE_PROVIDER', 'ecb'), + + // API key for providers that require it (e.g., openexchangerates) + 'api_key' => env('COMMERCE_EXCHANGE_RATE_API_KEY'), + + // Cache duration in minutes (default: 60 minutes) + 'cache_ttl' => env('COMMERCE_EXCHANGE_RATE_CACHE_TTL', 60), + + // Update frequency in minutes for scheduled updates + 'update_frequency' => env('COMMERCE_EXCHANGE_RATE_UPDATE_FREQUENCY', 60), + + // Fixed rates (used when provider is 'fixed' or as fallback) + 'fixed' => [ + 'GBP_USD' => 1.27, + 'GBP_EUR' => 1.17, + 'GBP_AUD' => 1.93, + 'GBP_CAD' => 1.72, + ], + ], + + // Auto-convert prices when no explicit currency price exists + 'auto_convert' => env('COMMERCE_AUTO_CONVERT_PRICES', true), + + // Default currency detection order: 'geolocation', 'browser', 'default' + 'detection_order' => ['geolocation', 'browser', 'default'], + + // Map countries to preferred currencies + 'country_currencies' => [ + 'GB' => 'GBP', + 'US' => 'USD', + 'AU' => 'AUD', + 'CA' => 'CAD', + // EU countries default to EUR + 'AT' => 'EUR', 'BE' => 'EUR', 'BG' => 'EUR', 'HR' => 'EUR', + 'CY' => 'EUR', 'CZ' => 'EUR', 'DK' => 'EUR', 'EE' => 'EUR', + 'FI' => 'EUR', 'FR' => 'EUR', 'DE' => 'EUR', 'GR' => 'EUR', + 'HU' => 'EUR', 'IE' => 'EUR', 'IT' => 'EUR', 'LV' => 'EUR', + 'LT' => 'EUR', 'LU' => 'EUR', 'MT' => 'EUR', 'NL' => 'EUR', + 'PL' => 'EUR', 'PT' => 'EUR', 'RO' => 'EUR', 'SK' => 'EUR', + 'SI' => 'EUR', 'ES' => 'EUR', 'SE' => 'EUR', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Payment Gateways + |-------------------------------------------------------------------------- + | + | Configuration for payment gateways. BTCPay is the primary gateway, + | with Stripe available but hidden from the UI initially. + | + */ + + 'gateways' => [ + 'btcpay' => [ + 'enabled' => env('BTCPAY_ENABLED', true), + 'url' => env('BTCPAY_URL', 'https://pay.host.uk.com'), + 'store_id' => env('BTCPAY_STORE_ID'), + 'api_key' => env('BTCPAY_API_KEY'), + 'webhook_secret' => env('BTCPAY_WEBHOOK_SECRET'), + 'default_payment_methods' => ['BTC', 'LTC', 'XMR'], + ], + + 'stripe' => [ + 'enabled' => env('STRIPE_ENABLED', false), // Hidden initially + 'key' => env('STRIPE_KEY'), + 'secret' => env('STRIPE_SECRET'), + 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Billing Settings + |-------------------------------------------------------------------------- + | + | General billing configuration. + | + */ + + 'billing' => [ + 'invoice_prefix' => env('COMMERCE_INVOICE_PREFIX', 'INV-'), + 'order_prefix' => env('COMMERCE_ORDER_PREFIX', 'ORD-'), + 'default_tax_rate' => 20, // UK VAT + 'invoice_due_days' => 14, + 'auto_charge' => true, + 'send_invoice_emails' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Dunning Settings + |-------------------------------------------------------------------------- + | + | Failed payment recovery configuration. + | + */ + + 'dunning' => [ + 'enabled' => true, + + // Exponential backoff: days after initial failure to schedule each retry + // [1, 3, 7] = retry at day 1, day 3, day 7 (total ~11 days of retries) + 'retry_days' => [1, 3, 7], + + // Days after subscription paused to suspend workspace entitlements + // Paused = billing stopped but workspace accessible + // Suspended = workspace features restricted + 'suspend_after_days' => 14, + + // Days after subscription paused to cancel entirely + // After cancellation, workspace may be downgraded to free tier + 'cancel_after_days' => 30, + + // Grace period before first retry (hours) + // Gives customer time to fix payment method before automated retries + 'initial_grace_hours' => 24, + + // Send email notifications at each dunning stage + 'send_notifications' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Tax Settings + |-------------------------------------------------------------------------- + | + | Tax calculation configuration. Supports UK VAT, EU OSS, US state taxes, + | and Australian GST. + | + */ + + 'tax' => [ + 'enabled' => true, + 'validate_tax_ids' => true, + 'validate_tax_ids_api' => env('COMMERCE_VALIDATE_TAX_IDS_API', true), // Call HMRC/VIES APIs + 'digital_services' => true, // All our products are digital + + // Business details for invoices + 'business' => [ + 'name' => env('COMMERCE_BUSINESS_NAME', 'Host UK Ltd'), + 'address_line1' => env('COMMERCE_BUSINESS_ADDRESS_1', ''), + 'address_line2' => env('COMMERCE_BUSINESS_ADDRESS_2', ''), + 'city' => env('COMMERCE_BUSINESS_CITY', 'London'), + 'postcode' => env('COMMERCE_BUSINESS_POSTCODE', ''), + 'country' => env('COMMERCE_BUSINESS_COUNTRY', 'United Kingdom'), + 'vat_number' => env('COMMERCE_VAT_NUMBER'), + 'company_number' => env('COMMERCE_COMPANY_NUMBER'), + 'email' => env('COMMERCE_BUSINESS_EMAIL', 'support@host.uk.com'), + ], + + // UK VAT + 'uk' => [ + 'rate' => 20, + 'reverse_charge_b2b' => true, + ], + + // EU One-Stop Shop + 'eu_oss' => [ + 'enabled' => true, + 'registered_country' => 'GB', // We're registered in UK + ], + + // US State Taxes + 'us' => [ + 'enabled' => true, + 'nexus_states' => ['CA', 'NY', 'TX', 'FL', 'WA'], // States where we have nexus + ], + + // Australian GST + 'australia' => [ + 'enabled' => true, + 'rate' => 10, + 'threshold' => 75000, // AUD threshold + ], + ], + + /* + |-------------------------------------------------------------------------- + | Subscription Settings + |-------------------------------------------------------------------------- + | + | Configuration for recurring billing. + | + */ + + 'subscriptions' => [ + 'allow_proration' => true, + 'proration_behaviour' => 'create_prorations', // or 'none' + 'cancel_at_period_end' => true, // Grace period instead of immediate cancellation + 'allow_pause' => true, + 'max_pause_cycles' => 3, + ], + + /* + |-------------------------------------------------------------------------- + | Checkout Settings + |-------------------------------------------------------------------------- + | + | Configuration for the checkout process. + | + */ + + 'checkout' => [ + 'require_billing_address' => true, + 'require_tax_id_for_b2b' => false, + 'allowed_countries' => null, // null = all countries + 'blocked_countries' => [], // Countries we don't serve + 'session_ttl' => 30, // Minutes before checkout session expires + ], + + /* + |-------------------------------------------------------------------------- + | Invoice PDF Settings + |-------------------------------------------------------------------------- + | + | Configuration for invoice PDF generation. + | + */ + + 'pdf' => [ + 'driver' => 'dompdf', // dompdf or snappy + 'paper' => 'a4', + 'orientation' => 'portrait', + 'font' => 'sans-serif', + 'storage_disk' => 'local', + 'storage_path' => 'invoices', + ], + + /* + |-------------------------------------------------------------------------- + | Notification Settings + |-------------------------------------------------------------------------- + | + | Email notifications for commerce events. + | + */ + + 'notifications' => [ + 'order_confirmation' => true, + 'invoice_generated' => true, + 'payment_received' => true, + 'payment_failed' => true, + 'subscription_created' => true, + 'subscription_cancelled' => true, + 'subscription_renewed' => true, + 'refund_processed' => true, + 'upcoming_renewal' => true, + 'upcoming_renewal_days' => 7, + ], + + /* + |-------------------------------------------------------------------------- + | Feature Flags + |-------------------------------------------------------------------------- + | + | Toggle commerce features on/off. + | + */ + + 'features' => [ + 'coupons' => true, + 'refunds' => true, + 'trials' => true, + 'setup_fees' => true, + 'usage_billing' => env('COMMERCE_USAGE_BILLING', false), + ], + + /* + |-------------------------------------------------------------------------- + | Usage-Based Billing + |-------------------------------------------------------------------------- + | + | Configuration for metered/usage-based billing. + | + */ + + 'usage_billing' => [ + // Whether to sync usage to Stripe automatically + 'sync_to_stripe' => env('COMMERCE_USAGE_SYNC_STRIPE', true), + + // Sync frequency in minutes (for scheduled jobs) + 'sync_interval' => env('COMMERCE_USAGE_SYNC_INTERVAL', 60), + + // Whether to aggregate events in real-time or batch + 'realtime_aggregation' => true, + + // Maximum events to process per batch + 'batch_size' => 1000, + + // Retention period for usage events (days) + 'event_retention_days' => 90, + + // Default aggregation type for new meters + 'default_aggregation' => 'sum', // sum, max, last_value + + // Notifications + 'notifications' => [ + // Alert when usage reaches percentage of included quota + 'usage_threshold_alerts' => [50, 75, 90, 100], + // Send weekly usage summary + 'weekly_summary' => true, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Commerce Matrix (Multi-Entity Hierarchy) + |-------------------------------------------------------------------------- + | + | Configuration for the permission matrix system. + | + | 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) + | + */ + + 'matrix' => [ + // Training mode - undefined permissions prompt for approval + 'training_mode' => env('COMMERCE_MATRIX_TRAINING', false), + + // Production mode - undefined = denied + 'strict_mode' => env('COMMERCE_MATRIX_STRICT', true), + + // Log all permission checks (for audit) + 'log_all_checks' => env('COMMERCE_MATRIX_LOG_ALL', false), + + // Log denied requests + 'log_denials' => true, + + // Default action when permission undefined (only if strict=false) + 'default_allow' => false, + ], + + /* + |-------------------------------------------------------------------------- + | Entity Types + |-------------------------------------------------------------------------- + | + | Configuration for commerce entity types. + | + */ + + 'entities' => [ + 'types' => [ + 'm1' => [ + 'name' => 'Master Company', + 'can_have_children' => true, + 'child_types' => ['m2', 'm3'], + ], + 'm2' => [ + 'name' => 'Facade/Storefront', + 'can_have_children' => true, + 'child_types' => ['m3'], + ], + 'm3' => [ + 'name' => 'Dropshipper', + 'can_have_children' => true, // Can have own M2s + 'child_types' => ['m2'], + 'inherits_catalog' => true, + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | SKU Configuration + |-------------------------------------------------------------------------- + | + | Configuration for SKU lineage tracking. + | + */ + + 'sku' => [ + // SKU format: {m1_code}-{m2_code}-{master_sku} + 'separator' => '-', + 'include_m1' => true, + 'include_m2' => true, + ], + +]; diff --git a/config/core.php b/config/core.php deleted file mode 100644 index 06502fa..0000000 --- a/config/core.php +++ /dev/null @@ -1,24 +0,0 @@ - [ - app_path('Core'), - app_path('Mod'), - app_path('Website'), - ], - - 'services' => [ - 'cache_discovery' => env('CORE_CACHE_DISCOVERY', true), - ], - - 'cdn' => [ - 'enabled' => env('CDN_ENABLED', false), - 'driver' => env('CDN_DRIVER', 'bunny'), - ], -]; diff --git a/database/factories/.gitkeep b/database/factories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php deleted file mode 100644 index df6818f..0000000 --- a/database/seeders/DatabaseSeeder.php +++ /dev/null @@ -1,16 +0,0 @@ - - - - - tests/Unit - - - tests/Feature - - - - - app - - - - - - - - - - - - - - - - diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index 49c0612..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/public/.htaccess b/public/.htaccess deleted file mode 100644 index 3aec5e2..0000000 --- a/public/.htaccess +++ /dev/null @@ -1,21 +0,0 @@ - - - Options -MultiViews -Indexes - - - RewriteEngine On - - # Handle Authorization Header - RewriteCond %{HTTP:Authorization} . - RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] - - # Redirect Trailing Slashes If Not A Folder... - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_URI} (.+)/$ - RewriteRule ^ %1 [L,R=301] - - # Send Requests To Front Controller... - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_FILENAME} !-f - RewriteRule ^ index.php [L] - diff --git a/public/index.php b/public/index.php deleted file mode 100644 index 947d989..0000000 --- a/public/index.php +++ /dev/null @@ -1,17 +0,0 @@ -handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index eb05362..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: diff --git a/resources/css/app.css b/resources/css/app.css deleted file mode 100644 index b5c61c9..0000000 --- a/resources/css/app.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/resources/js/app.js b/resources/js/app.js deleted file mode 100644 index e59d6a0..0000000 --- a/resources/js/app.js +++ /dev/null @@ -1 +0,0 @@ -import './bootstrap'; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js deleted file mode 100644 index 953d266..0000000 --- a/resources/js/bootstrap.js +++ /dev/null @@ -1,3 +0,0 @@ -import axios from 'axios'; -window.axios = axios; -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index 88808ac..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - Core PHP Framework - - - -
-

Core PHP Framework

-

Laravel {{ Illuminate\Foundation\Application::VERSION }} | PHP {{ PHP_VERSION }}

- -
- - diff --git a/routes/admin.php b/routes/admin.php new file mode 100644 index 0000000..66a7edf --- /dev/null +++ b/routes/admin.php @@ -0,0 +1,34 @@ +name('hub.billing.')->group(function () { + Route::get('/', \Core\Commerce\View\Modal\Web\Dashboard::class)->name('index'); + Route::get('/invoices', \Core\Commerce\View\Modal\Web\Invoices::class)->name('invoices'); + Route::get('/invoices/{invoice}/pdf', [\Core\Commerce\Controllers\InvoiceController::class, 'pdf'])->name('invoices.pdf'); + Route::get('/invoices/{invoice}/view', [\Core\Commerce\Controllers\InvoiceController::class, 'view'])->name('invoices.view'); + Route::get('/payment-methods', \Core\Commerce\View\Modal\Web\PaymentMethods::class)->name('payment-methods'); + Route::get('/subscription', \Core\Commerce\View\Modal\Web\Subscription::class)->name('subscription'); + Route::get('/change-plan', \Core\Commerce\View\Modal\Web\ChangePlan::class)->name('change-plan'); + Route::get('/affiliates', \Core\Commerce\View\Modal\Web\ReferralDashboard::class)->name('affiliates'); +}); + +// Commerce management (admin only - Hades tier) +Route::prefix('hub/commerce')->name('hub.commerce.')->group(function () { + Route::get('/', \Core\Commerce\View\Modal\Admin\Dashboard::class)->name('dashboard'); + Route::get('/orders', \Core\Commerce\View\Modal\Admin\OrderManager::class)->name('orders'); + Route::get('/subscriptions', \Core\Commerce\View\Modal\Admin\SubscriptionManager::class)->name('subscriptions'); + Route::get('/coupons', \Core\Commerce\View\Modal\Admin\CouponManager::class)->name('coupons'); + Route::get('/entities', \Core\Commerce\View\Modal\Admin\EntityManager::class)->name('entities'); + Route::get('/permissions', \Core\Commerce\View\Modal\Admin\PermissionMatrixManager::class)->name('permissions'); + Route::get('/products', \Core\Commerce\View\Modal\Admin\ProductManager::class)->name('products'); + Route::get('/credit-notes', \Core\Commerce\View\Modal\Admin\CreditNoteManager::class)->name('credit-notes'); + Route::get('/referrals', \Core\Commerce\View\Modal\Admin\ReferralManager::class)->name('referrals'); +}); diff --git a/routes/api.php b/routes/api.php index 15fbf70..e98bea4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,89 @@ prefix('webhooks')->group(function () { + Route::post('/btcpay', [BTCPayWebhookController::class, 'handle']) + ->name('api.webhook.btcpay'); + Route::post('/stripe', [StripeWebhookController::class, 'handle']) + ->name('api.webhook.stripe'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Commerce Provisioning API (Bearer token auth) +// TODO: Create ProductApiController and EntitlementApiController in +// Mod\Commerce\Controllers\Api\ for provisioning endpoints +// ───────────────────────────────────────────────────────────────────────────── + +// Route::middleware('commerce.api')->prefix('provisioning')->group(function () { +// Route::get('/ping', [ProductApiController::class, 'ping'])->name('api.commerce.ping'); +// Route::get('/products', [ProductApiController::class, 'index'])->name('api.commerce.products'); +// Route::get('/products/{code}', [ProductApiController::class, 'show'])->name('api.commerce.products.show'); +// Route::post('/entitlements', [EntitlementApiController::class, 'store'])->name('api.commerce.entitlements.store'); +// Route::get('/entitlements/{id}', [EntitlementApiController::class, 'show'])->name('api.commerce.entitlements.show'); +// Route::post('/entitlements/{id}/suspend', [EntitlementApiController::class, 'suspend'])->name('api.commerce.entitlements.suspend'); +// Route::post('/entitlements/{id}/unsuspend', [EntitlementApiController::class, 'unsuspend'])->name('api.commerce.entitlements.unsuspend'); +// Route::post('/entitlements/{id}/cancel', [EntitlementApiController::class, 'cancel'])->name('api.commerce.entitlements.cancel'); +// Route::post('/entitlements/{id}/renew', [EntitlementApiController::class, 'renew'])->name('api.commerce.entitlements.renew'); +// }); + +// ───────────────────────────────────────────────────────────────────────────── +// Commerce Billing API (authenticated) +// ───────────────────────────────────────────────────────────────────────────── + +Route::middleware('auth')->prefix('commerce')->group(function () { + // Billing overview + Route::get('/billing', [CommerceController::class, 'billing']) + ->name('api.commerce.billing'); + + // Orders + Route::get('/orders', [CommerceController::class, 'orders']) + ->name('api.commerce.orders.index'); + Route::get('/orders/{order}', [CommerceController::class, 'showOrder']) + ->name('api.commerce.orders.show'); + + // Invoices + Route::get('/invoices', [CommerceController::class, 'invoices']) + ->name('api.commerce.invoices.index'); + Route::get('/invoices/{invoice}', [CommerceController::class, 'showInvoice']) + ->name('api.commerce.invoices.show'); + Route::get('/invoices/{invoice}/download', [CommerceController::class, 'downloadInvoice']) + ->name('api.commerce.invoices.download'); + + // Subscription + Route::get('/subscription', [CommerceController::class, 'subscription']) + ->name('api.commerce.subscription'); + Route::post('/cancel', [CommerceController::class, 'cancelSubscription']) + ->name('api.commerce.cancel'); + Route::post('/resume', [CommerceController::class, 'resumeSubscription']) + ->name('api.commerce.resume'); + + // Usage + Route::get('/usage', [CommerceController::class, 'usage']) + ->name('api.commerce.usage'); + + // Plan changes + Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade']) + ->name('api.commerce.upgrade.preview'); + Route::post('/upgrade', [CommerceController::class, 'executeUpgrade']) + ->name('api.commerce.upgrade'); +}); diff --git a/routes/web.php b/routes/web.php index 86a06c5..7c4e59c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,36 @@ name('commerce.')->group(function () { + + /* + |-------------------------------------------------------------------------- + | Permission Matrix Training Routes + |-------------------------------------------------------------------------- + */ + + Route::prefix('matrix')->name('matrix.')->group(function () { + // Training submission (POST form from train-prompt view) + Route::post('/train', [MatrixTrainingController::class, 'train']) + ->name('train'); + + // Pending requests view + Route::get('/pending', [MatrixTrainingController::class, 'pending']) + ->name('pending'); + + // Bulk training + Route::post('/bulk-train', [MatrixTrainingController::class, 'bulkTrain']) + ->name('bulk-train'); + }); + }); diff --git a/storage/app/.gitignore b/storage/app/.gitignore deleted file mode 100644 index 8f4803c..0000000 --- a/storage/app/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!public/ -!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/app/public/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore deleted file mode 100644 index 05c4471..0000000 --- a/storage/framework/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -compiled.php -config.php -down -events.scanned.php -maintenance.php -routes.php -routes.scanned.php -schedule-* -services.json diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore deleted file mode 100644 index 01e4a6c..0000000 --- a/storage/framework/cache/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!data/ -!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/framework/cache/data/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/framework/sessions/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/framework/testing/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/framework/views/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/logs/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 26e1310..0000000 --- a/tailwind.config.js +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./resources/**/*.blade.php", - "./resources/**/*.js", - ], - theme: { - extend: {}, - }, - plugins: [], -}; diff --git a/tests/Feature/CheckoutFlowTest.php b/tests/Feature/CheckoutFlowTest.php new file mode 100644 index 0000000..4c56686 --- /dev/null +++ b/tests/Feature/CheckoutFlowTest.php @@ -0,0 +1,340 @@ +user = User::factory()->create([ + 'email' => 'test@example.com', + ]); + $this->workspace = Workspace::factory()->create([ + 'billing_email' => 'billing@example.com', + 'billing_name' => 'Test Company', + 'billing_country' => 'GB', + ]); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Use existing seeded packages or create test package + $this->package = Package::where('code', 'creator')->first(); + if (! $this->package) { + $this->package = Package::create([ + 'name' => 'Creator', + 'code' => 'creator', + 'description' => 'For creators', + 'monthly_price' => 19.00, + 'yearly_price' => 190.00, + 'is_active' => true, + ]); + } + + $this->service = app(CommerceService::class); +}); + +describe('Order Creation', function () { + it('creates an order for a package purchase', function () { + $order = $this->service->createOrder( + $this->workspace, + $this->package, + 'monthly' + ); + + expect($order)->toBeInstanceOf(Order::class) + ->and($order->orderable_type)->toBe(Workspace::class) + ->and($order->orderable_id)->toBe($this->workspace->id) + ->and($order->status)->toBe('pending') + ->and($order->billing_cycle)->toBe('monthly') + ->and($order->billing_email)->toBe('billing@example.com') + ->and($order->billing_name)->toBe('Test Company') + ->and($order->order_number)->toStartWith('ORD-'); + }); + + it('creates order items for package', function () { + $order = $this->service->createOrder( + $this->workspace, + $this->package, + 'monthly' + ); + + expect($order->items)->toHaveCount(1); + + $item = $order->items->first(); + expect($item->item_type)->toBe('package') + ->and($item->item_id)->toBe($this->package->id) + ->and($item->item_code)->toBe($this->package->code) + ->and($item->billing_cycle)->toBe('monthly'); + }); + + it('calculates tax correctly for UK customer', function () { + $order = $this->service->createOrder( + $this->workspace, + $this->package, + 'monthly' + ); + + // UK VAT is 20% + expect($order->tax_country)->toBe('GB') + ->and($order->tax_rate)->toBe(20.00) + ->and((float) $order->tax_amount)->toBe(round(19.00 * 0.20, 2)); + }); + + it('calculates total correctly', function () { + $order = $this->service->createOrder( + $this->workspace, + $this->package, + 'monthly' + ); + + $expectedTotal = 19.00 + (19.00 * 0.20); // subtotal + tax + expect((float) $order->total)->toBe(round($expectedTotal, 2)); + }); + + it('creates order with yearly billing cycle', function () { + $order = $this->service->createOrder( + $this->workspace, + $this->package, + 'yearly' + ); + + expect($order->billing_cycle)->toBe('yearly') + ->and((float) $order->subtotal)->toBe(190.00); + }); +}); + +describe('Checkout Session Creation', function () { + it('creates checkout session with mocked gateway', function () { + // Create a mock gateway + $mockGateway = Mockery::mock(PaymentGatewayContract::class); + $mockGateway->shouldReceive('createCustomer') + ->andReturn('cust_mock_123'); + $mockGateway->shouldReceive('createCheckoutSession') + ->andReturn([ + 'session_id' => 'cs_mock_session_123', + 'checkout_url' => 'https://checkout.example.com/session/123', + ]); + + app()->instance('commerce.gateway.btcpay', $mockGateway); + + $order = $this->service->createOrder( + $this->workspace, + $this->package, + 'monthly' + ); + + $result = $this->service->createCheckout( + $order, + 'btcpay', + 'https://example.com/success', + 'https://example.com/cancel' + ); + + expect($result)->toHaveKeys(['order', 'session_id', 'checkout_url']) + ->and($result['session_id'])->toBe('cs_mock_session_123') + ->and($result['checkout_url'])->toContain('checkout.example.com') + ->and($result['order']->status)->toBe('processing'); + }); +}); + +describe('Order Fulfilment', function () { + it('fulfils order and provisions entitlements', function () { + $order = $this->service->createOrder( + $this->workspace, + $this->package, + 'monthly' + ); + + // Create a payment + $payment = Payment::create([ + 'workspace_id' => $this->workspace->id, + 'order_id' => $order->id, + 'gateway' => 'btcpay', + 'gateway_payment_id' => 'pay_mock_123', + 'amount' => $order->total, + 'currency' => 'GBP', + 'status' => 'succeeded', + 'paid_at' => now(), + ]); + + // Fulfil the order + $this->service->fulfillOrder($order, $payment); + + $order->refresh(); + + expect($order->status)->toBe('paid') + ->and($order->paid_at)->not->toBeNull(); + + // Check that workspace package was provisioned + $workspacePackage = WorkspacePackage::where('workspace_id', $this->workspace->id) + ->where('package_id', $this->package->id) + ->first(); + + expect($workspacePackage)->not->toBeNull() + ->and($workspacePackage->status)->toBe('active'); + }); + + it('creates invoice on fulfilment', function () { + $order = $this->service->createOrder( + $this->workspace, + $this->package, + 'monthly' + ); + + $payment = Payment::create([ + 'workspace_id' => $this->workspace->id, + 'order_id' => $order->id, + 'gateway' => 'btcpay', + 'gateway_payment_id' => 'pay_mock_456', + 'amount' => $order->total, + 'currency' => 'GBP', + 'status' => 'succeeded', + 'paid_at' => now(), + ]); + + $this->service->fulfillOrder($order, $payment); + + $invoice = Invoice::where('order_id', $order->id)->first(); + + expect($invoice)->not->toBeNull() + ->and($invoice->invoice_number)->toStartWith('INV-') + ->and((float) $invoice->total)->toBe((float) $order->total) + ->and($invoice->status)->toBe('paid'); + }); + + it('fails order with reason', function () { + $order = $this->service->createOrder( + $this->workspace, + $this->package, + 'monthly' + ); + + $this->service->failOrder($order, 'Payment declined'); + + $order->refresh(); + + expect($order->status)->toBe('failed') + ->and($order->metadata['failure_reason'])->toBe('Payment declined'); + }); +}); + +describe('End-to-End Checkout Flow', function () { + it('completes full checkout flow: cart to paid order', function () { + // Step 1: Create order (simulates adding to cart and proceeding) + $order = $this->service->createOrder( + $this->workspace, + $this->package, + 'monthly' + ); + + expect($order->status)->toBe('pending'); + + // Step 2: Simulate checkout session creation (mocked gateway) + $mockGateway = Mockery::mock(PaymentGatewayContract::class); + $mockGateway->shouldReceive('createCustomer') + ->andReturn('cust_e2e_123'); + $mockGateway->shouldReceive('createCheckoutSession') + ->andReturn([ + 'session_id' => 'cs_e2e_session', + 'checkout_url' => 'https://pay.example.com/checkout', + ]); + app()->instance('commerce.gateway.btcpay', $mockGateway); + + $checkout = $this->service->createCheckout($order, 'btcpay'); + $order->refresh(); + + expect($order->status)->toBe('processing') + ->and($order->gateway_session_id)->toBe('cs_e2e_session'); + + // Step 3: Simulate payment completion (webhook would call this) + $payment = Payment::create([ + 'workspace_id' => $this->workspace->id, + 'order_id' => $order->id, + 'gateway' => 'btcpay', + 'gateway_payment_id' => 'pay_e2e_completed', + 'amount' => $order->total, + 'currency' => 'GBP', + 'status' => 'succeeded', + 'paid_at' => now(), + ]); + + $this->service->fulfillOrder($order, $payment); + + // Step 4: Verify final state + $order->refresh(); + $this->workspace->refresh(); + + expect($order->status)->toBe('paid') + ->and($order->paid_at)->not->toBeNull(); + + // Verify invoice created + $invoice = Invoice::where('order_id', $order->id)->first(); + expect($invoice)->not->toBeNull() + ->and($invoice->status)->toBe('paid'); + + // Verify entitlements provisioned + $workspacePackage = $this->workspace->workspacePackages() + ->where('package_id', $this->package->id) + ->where('status', 'active') + ->first(); + + expect($workspacePackage)->not->toBeNull(); + }); + + it('handles checkout cancellation', function () { + $order = $this->service->createOrder( + $this->workspace, + $this->package, + 'monthly' + ); + + // Simulate user cancelling checkout + $order->cancel(); + + expect($order->status)->toBe('cancelled'); + + // No entitlements should be provisioned + $workspacePackage = $this->workspace->workspacePackages() + ->where('package_id', $this->package->id) + ->first(); + + expect($workspacePackage)->toBeNull(); + }); + + it('handles checkout expiry', function () { + $order = $this->service->createOrder( + $this->workspace, + $this->package, + 'monthly' + ); + + // Simulate payment expiry (BTCPay invoice expired) + $order->markAsFailed('Payment expired'); + + expect($order->status)->toBe('failed') + ->and($order->metadata['failure_reason'])->toBe('Payment expired'); + + // No entitlements should be provisioned + $workspacePackage = $this->workspace->workspacePackages() + ->where('package_id', $this->package->id) + ->first(); + + expect($workspacePackage)->toBeNull(); + }); +}); + +afterEach(function () { + Mockery::close(); +}); diff --git a/tests/Feature/CompoundSkuTest.php b/tests/Feature/CompoundSkuTest.php new file mode 100644 index 0000000..f83d753 --- /dev/null +++ b/tests/Feature/CompoundSkuTest.php @@ -0,0 +1,236 @@ +parser = app(SkuParserService::class); + }); + + test('parses simple SKU without options', function () { + $result = $this->parser->parse('LAPTOP'); + + expect($result->count())->toBe(1); + expect($result->items[0])->toBeInstanceOf(ParsedItem::class); + expect($result->items[0]->baseSku)->toBe('LAPTOP'); + expect($result->items[0]->options)->toBeEmpty(); + }); + + test('parses SKU with single option', function () { + $result = $this->parser->parse('LAPTOP-ram~16gb'); + + expect($result->count())->toBe(1); + expect($result->items[0]->baseSku)->toBe('LAPTOP'); + expect($result->items[0]->options)->toHaveCount(1); + expect($result->items[0]->options[0]->code)->toBe('ram'); + expect($result->items[0]->options[0]->value)->toBe('16gb'); + expect($result->items[0]->options[0]->quantity)->toBe(1); + }); + + test('parses SKU with multiple options', function () { + $result = $this->parser->parse('LAPTOP-ram~16gb-ssd~512gb-color~silver'); + + expect($result->count())->toBe(1); + expect($result->items[0]->options)->toHaveCount(3); + expect($result->items[0]->getOption('ram')->value)->toBe('16gb'); + expect($result->items[0]->getOption('ssd')->value)->toBe('512gb'); + expect($result->items[0]->getOption('color')->value)->toBe('silver'); + }); + + test('parses option with quantity', function () { + $result = $this->parser->parse('LAPTOP-cover~black*2'); + + expect($result->items[0]->options[0]->code)->toBe('cover'); + expect($result->items[0]->options[0]->value)->toBe('black'); + expect($result->items[0]->options[0]->quantity)->toBe(2); + }); + + test('parses multiple comma-separated items', function () { + $result = $this->parser->parse('LAPTOP-ram~16gb,MOUSE,PAD-size~xl'); + + expect($result->count())->toBe(3); + expect($result->items[0]->baseSku)->toBe('LAPTOP'); + expect($result->items[1]->baseSku)->toBe('MOUSE'); + expect($result->items[2]->baseSku)->toBe('PAD'); + expect($result->hasBundles())->toBeFalse(); + }); + + test('parses pipe-separated bundle', function () { + $result = $this->parser->parse('LAPTOP-ram~16gb|MOUSE|PAD'); + + expect($result->count())->toBe(1); + expect($result->hasBundles())->toBeTrue(); + expect($result->items[0])->toBeInstanceOf(BundleItem::class); + + $bundle = $result->items[0]; + expect($bundle->count())->toBe(3); + expect($bundle->getBaseSkus())->toBe(['LAPTOP', 'MOUSE', 'PAD']); + }); + + test('bundle hash is consistent regardless of order', function () { + $result1 = $this->parser->parse('LAPTOP|MOUSE|PAD'); + $result2 = $this->parser->parse('PAD|LAPTOP|MOUSE'); + + expect($result1->items[0]->hash)->toBe($result2->items[0]->hash); + }); + + test('parses mixed bundles and singles', function () { + $result = $this->parser->parse('LAPTOP|MOUSE,HDMI-length~2m'); + + expect($result->count())->toBe(2); + expect($result->items[0])->toBeInstanceOf(BundleItem::class); + expect($result->items[1])->toBeInstanceOf(ParsedItem::class); + }); + + test('preserves entity lineage in base SKU', function () { + // SKU with lineage: ORGORG-WBUTS-PROD500 + $result = $this->parser->parse('ORGORG-WBUTS-PROD500-ram~16gb'); + + expect($result->items[0]->baseSku)->toBe('ORGORG-WBUTS-PROD500'); + expect($result->items[0]->options)->toHaveCount(1); + }); + + test('validates SKU format', function () { + $valid = $this->parser->validate('LAPTOP-ram~16gb'); + expect($valid['valid'])->toBeTrue(); + + $invalid = $this->parser->validate(''); + expect($invalid['valid'])->toBeFalse(); + }); + + test('round trips through parse and toString', function () { + $original = 'LAPTOP-ram~16gb-ssd~512gb'; + $result = $this->parser->parse($original); + + expect($result->toString())->toBe($original); + }); +}); + +describe('Compound SKU Builder', function () { + beforeEach(function () { + $this->builder = app(SkuBuilderService::class); + }); + + test('builds simple item', function () { + $sku = $this->builder->build([ + ['base_sku' => 'laptop'], + ]); + + expect($sku)->toBe('LAPTOP'); + }); + + test('builds item with options', function () { + $sku = $this->builder->build([ + [ + 'base_sku' => 'laptop', + 'options' => [ + ['code' => 'ram', 'value' => '16gb'], + ['code' => 'ssd', 'value' => '512gb'], + ], + ], + ]); + + expect($sku)->toBe('LAPTOP-ram~16gb-ssd~512gb'); + }); + + test('builds item with quantity on option', function () { + $sku = $this->builder->build([ + [ + 'base_sku' => 'laptop', + 'options' => [ + ['code' => 'cover', 'value' => 'black', 'quantity' => 2], + ], + ], + ]); + + expect($sku)->toBe('LAPTOP-cover~black*2'); + }); + + test('builds multiple items comma-separated', function () { + $sku = $this->builder->build([ + ['base_sku' => 'laptop'], + ['base_sku' => 'mouse'], + ]); + + expect($sku)->toBe('LAPTOP,MOUSE'); + }); + + test('builds bundle with same group', function () { + $sku = $this->builder->build([ + ['base_sku' => 'laptop', 'bundle_group' => 'cyber'], + ['base_sku' => 'mouse', 'bundle_group' => 'cyber'], + ['base_sku' => 'hdmi'], // standalone + ]); + + expect($sku)->toBe('LAPTOP|MOUSE,HDMI'); + }); + + test('adds entity lineage', function () { + $sku = $this->builder->addLineage('PROD500', ['ORGORG', 'WBUTS']); + + expect($sku)->toBe('ORGORG-WBUTS-PROD500'); + }); + + test('builds with lineage', function () { + $sku = $this->builder->buildWithLineage( + ['ORGORG', 'WBUTS'], + [ + [ + 'base_sku' => 'PROD500', + 'options' => [['code' => 'size', 'value' => 'xl']], + ], + ] + ); + + expect($sku)->toBe('ORGORG-WBUTS-PROD500-size~xl'); + }); + + test('generates bundle hash', function () { + $hash = $this->builder->generateBundleHash(['laptop', 'mouse', 'pad']); + + // Hash is deterministic + expect($hash)->toBe($this->builder->generateBundleHash(['PAD', 'LAPTOP', 'MOUSE'])); + expect(strlen($hash))->toBe(64); // SHA256 + }); + + test('creates option and item helpers', function () { + $option = $this->builder->option('ram', '16gb', 2); + expect($option)->toBeInstanceOf(SkuOption::class); + expect($option->toString())->toBe('ram~16gb*2'); + + $item = $this->builder->item('LAPTOP', [$option]); + expect($item)->toBeInstanceOf(ParsedItem::class); + expect($item->toString())->toBe('LAPTOP-ram~16gb*2'); + }); +}); + +describe('SKU Parse Result', function () { + test('collects all base SKUs', function () { + $parser = app(SkuParserService::class); + $result = $parser->parse('LAPTOP|MOUSE,HDMI,PAD'); + + expect($result->getAllBaseSkus())->toBe(['LAPTOP', 'MOUSE', 'HDMI', 'PAD']); + }); + + test('counts products correctly', function () { + $parser = app(SkuParserService::class); + $result = $parser->parse('LAPTOP|MOUSE|PAD,HDMI'); + + expect($result->count())->toBe(2); // 1 bundle + 1 single + expect($result->productCount())->toBe(4); // 3 in bundle + 1 single + }); + + test('extracts bundle hashes', function () { + $parser = app(SkuParserService::class); + $result = $parser->parse('LAPTOP|MOUSE,KEYBOARD|PAD'); + + $hashes = $result->getBundleHashes(); + expect($hashes)->toHaveCount(2); + }); +}); diff --git a/tests/Feature/ContentOverrideServiceTest.php b/tests/Feature/ContentOverrideServiceTest.php new file mode 100644 index 0000000..2ea61b6 --- /dev/null +++ b/tests/Feature/ContentOverrideServiceTest.php @@ -0,0 +1,319 @@ +service = app(ContentOverrideService::class); + + // Create entity hierarchy: M1 -> M2 -> M3 + $this->m1 = Entity::createMaster('ACME', 'Acme Corporation'); + $this->m2 = $this->m1->createFacade('SHOP', 'Acme Shop'); + $this->m3 = $this->m2->createDropshipper('DROP', 'Dropship Partner'); + + // Create a product owned by M1 + $this->product = Product::create([ + 'sku' => 'TEST-001', + 'owner_entity_id' => $this->m1->id, + 'name' => 'Original Product Name', + 'description' => 'Original description from M1.', + 'short_description' => 'Original short desc.', + 'price' => 1999, + 'currency' => 'GBP', + 'type' => Product::TYPE_SIMPLE, + ]); + } + + public function test_get_returns_original_when_no_override(): void + { + $name = $this->service->get($this->m2, $this->product, 'name'); + + $this->assertEquals('Original Product Name', $name); + } + + public function test_set_creates_override(): void + { + $this->service->set($this->m2, $this->product, 'name', 'Shop Custom Name'); + + $this->assertDatabaseHas('commerce_content_overrides', [ + 'entity_id' => $this->m2->id, + 'overrideable_type' => $this->product->getMorphClass(), + 'overrideable_id' => $this->product->id, + 'field' => 'name', + 'value' => 'Shop Custom Name', + ]); + } + + public function test_get_returns_override_when_set(): void + { + $this->service->set($this->m2, $this->product, 'name', 'Shop Custom Name'); + + $name = $this->service->get($this->m2, $this->product, 'name'); + + $this->assertEquals('Shop Custom Name', $name); + } + + public function test_hierarchy_resolution_m3_inherits_m2_override(): void + { + // M2 sets override + $this->service->set($this->m2, $this->product, 'name', 'M2 Custom Name'); + + // M3 should inherit M2's override (not M1's original) + $name = $this->service->get($this->m3, $this->product, 'name'); + + $this->assertEquals('M2 Custom Name', $name); + } + + public function test_hierarchy_resolution_m3_overrides_m2(): void + { + // M2 sets override + $this->service->set($this->m2, $this->product, 'name', 'M2 Custom Name'); + + // M3 sets its own override + $this->service->set($this->m3, $this->product, 'name', 'M3 Custom Name'); + + // M3 should see its own override + $nameM3 = $this->service->get($this->m3, $this->product, 'name'); + $this->assertEquals('M3 Custom Name', $nameM3); + + // M2 should still see its own override + $nameM2 = $this->service->get($this->m2, $this->product, 'name'); + $this->assertEquals('M2 Custom Name', $nameM2); + + // M1 should see original + $nameM1 = $this->service->get($this->m1, $this->product, 'name'); + $this->assertEquals('Original Product Name', $nameM1); + } + + public function test_clear_removes_override(): void + { + $this->service->set($this->m2, $this->product, 'name', 'Custom Name'); + + $cleared = $this->service->clear($this->m2, $this->product, 'name'); + + $this->assertTrue($cleared); + $this->assertDatabaseMissing('commerce_content_overrides', [ + 'entity_id' => $this->m2->id, + 'field' => 'name', + ]); + + // Should now return original + $name = $this->service->get($this->m2, $this->product, 'name'); + $this->assertEquals('Original Product Name', $name); + } + + public function test_clear_after_parent_override_falls_back_to_parent(): void + { + // M2 sets override + $this->service->set($this->m2, $this->product, 'name', 'M2 Name'); + + // M3 sets override + $this->service->set($this->m3, $this->product, 'name', 'M3 Name'); + + // M3 clears its override + $this->service->clear($this->m3, $this->product, 'name'); + + // M3 should now inherit M2's override + $name = $this->service->get($this->m3, $this->product, 'name'); + $this->assertEquals('M2 Name', $name); + } + + public function test_get_effective_returns_merged_data(): void + { + // Set different overrides at M2 + $this->service->set($this->m2, $this->product, 'name', 'M2 Name'); + $this->service->set($this->m2, $this->product, 'description', 'M2 Description'); + + $effective = $this->service->getEffective($this->m2, $this->product); + + $this->assertEquals('M2 Name', $effective['name']); + $this->assertEquals('M2 Description', $effective['description']); + $this->assertEquals('Original short desc.', $effective['short_description']); // Not overridden + $this->assertEquals(1999, $effective['price']); // Not overridden + } + + public function test_get_effective_respects_hierarchy(): void + { + // M2 overrides name + $this->service->set($this->m2, $this->product, 'name', 'M2 Name'); + + // M3 overrides description + $this->service->set($this->m3, $this->product, 'description', 'M3 Description'); + + $effective = $this->service->getEffective($this->m3, $this->product); + + // M3 should see: M2's name override, M3's description override, original for rest + $this->assertEquals('M2 Name', $effective['name']); + $this->assertEquals('M3 Description', $effective['description']); + $this->assertEquals('Original short desc.', $effective['short_description']); + } + + public function test_get_override_status(): void + { + // M2 overrides name + $this->service->set($this->m2, $this->product, 'name', 'M2 Name'); + + $status = $this->service->getOverrideStatus( + $this->m3, + $this->product, + ['name', 'description'] + ); + + // Name is inherited from M2 + $this->assertEquals('M2 Name', $status['name']['value']); + $this->assertEquals('Original Product Name', $status['name']['original']); + $this->assertEquals($this->m2->name, $status['name']['source']); + $this->assertFalse($status['name']['is_overridden']); // Not by M3 + $this->assertEquals($this->m2->name, $status['name']['inherited_from']); + + // Description is original + $this->assertEquals('Original description from M1.', $status['description']['value']); + $this->assertEquals('original', $status['description']['source']); + $this->assertFalse($status['description']['is_overridden']); + $this->assertNull($status['description']['inherited_from']); + } + + public function test_value_types_are_preserved(): void + { + // Integer + $this->service->set($this->m2, $this->product, 'custom_int', 42); + $override = ContentOverride::where('field', 'custom_int')->first(); + $this->assertEquals('integer', $override->value_type); + $this->assertSame(42, $override->getCastedValue()); + + // Boolean + $this->service->set($this->m2, $this->product, 'custom_bool', true); + $override = ContentOverride::where('field', 'custom_bool')->first(); + $this->assertEquals('boolean', $override->value_type); + $this->assertSame(true, $override->getCastedValue()); + + // Array/JSON + $this->service->set($this->m2, $this->product, 'custom_json', ['foo' => 'bar']); + $override = ContentOverride::where('field', 'custom_json')->first(); + $this->assertEquals('json', $override->value_type); + $this->assertEquals(['foo' => 'bar'], $override->getCastedValue()); + + // Decimal + $this->service->set($this->m2, $this->product, 'custom_decimal', 3.14); + $override = ContentOverride::where('field', 'custom_decimal')->first(); + $this->assertEquals('decimal', $override->value_type); + $this->assertSame(3.14, $override->getCastedValue()); + } + + public function test_set_bulk_creates_multiple_overrides(): void + { + $this->service->setBulk($this->m2, $this->product, [ + 'name' => 'Bulk Name', + 'description' => 'Bulk Description', + 'short_description' => 'Bulk Short', + ]); + + $this->assertEquals('Bulk Name', $this->service->get($this->m2, $this->product, 'name')); + $this->assertEquals('Bulk Description', $this->service->get($this->m2, $this->product, 'description')); + $this->assertEquals('Bulk Short', $this->service->get($this->m2, $this->product, 'short_description')); + } + + public function test_clear_all_removes_all_entity_overrides(): void + { + $this->service->setBulk($this->m2, $this->product, [ + 'name' => 'Name', + 'description' => 'Desc', + ]); + + $deleted = $this->service->clearAll($this->m2, $this->product); + + $this->assertEquals(2, $deleted); + $this->assertEquals('Original Product Name', $this->service->get($this->m2, $this->product, 'name')); + } + + public function test_has_overrides(): void + { + $this->assertFalse($this->service->hasOverrides($this->m2, $this->product)); + + $this->service->set($this->m2, $this->product, 'name', 'Custom'); + + $this->assertTrue($this->service->hasOverrides($this->m2, $this->product)); + } + + public function test_get_overridden_fields(): void + { + $this->service->setBulk($this->m2, $this->product, [ + 'name' => 'Name', + 'description' => 'Desc', + ]); + + $fields = $this->service->getOverriddenFields($this->m2, $this->product); + + $this->assertContains('name', $fields); + $this->assertContains('description', $fields); + $this->assertCount(2, $fields); + } + + public function test_copy_overrides(): void + { + $this->service->setBulk($this->m2, $this->product, [ + 'name' => 'M2 Name', + 'description' => 'M2 Desc', + ]); + + // Create another M3 and copy M2's overrides to it + $m3b = $this->m2->createDropshipper('DRP2', 'Dropship Partner 2'); + $copied = $this->service->copyOverrides($this->m2, $m3b, $this->product); + + $this->assertEquals(2, $copied); + $this->assertEquals('M2 Name', $this->service->get($m3b, $this->product, 'name')); + $this->assertEquals('M2 Desc', $this->service->get($m3b, $this->product, 'description')); + } + + public function test_trait_methods_work(): void + { + // Test trait methods on Product model directly + $this->product->setOverride($this->m2, 'name', 'Trait Name'); + + $this->assertEquals('Trait Name', $this->product->getOverriddenAttribute('name', $this->m2)); + + $effective = $this->product->forEntity($this->m2); + $this->assertEquals('Trait Name', $effective['name']); + + $this->assertTrue($this->product->hasOverridesFor($this->m2)); + + $this->product->clearOverride($this->m2, 'name'); + $this->assertFalse($this->product->hasOverridesFor($this->m2)); + } + + public function test_get_overrideable_fields(): void + { + $fields = $this->product->getOverrideableFields(); + + $this->assertContains('name', $fields); + $this->assertContains('description', $fields); + $this->assertContains('image_url', $fields); + $this->assertNotContains('price', $fields); // Price should not be in the list + $this->assertNotContains('sku', $fields); // SKU should not be in the list + } +} diff --git a/tests/Feature/CouponServiceTest.php b/tests/Feature/CouponServiceTest.php new file mode 100644 index 0000000..7bd5636 --- /dev/null +++ b/tests/Feature/CouponServiceTest.php @@ -0,0 +1,361 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Use existing seeded package + $this->package = Package::where('code', 'creator')->first(); + + // Create test coupons + $this->percentCoupon = Coupon::create([ + 'code' => 'SAVE20', + 'name' => '20% Off', + 'type' => 'percentage', + 'value' => 20.00, + 'applies_to' => 'all', + 'is_active' => true, + 'max_uses' => 100, + 'max_uses_per_workspace' => 1, + 'used_count' => 0, + ]); + + $this->fixedCoupon = Coupon::create([ + 'code' => 'FLAT10', + 'name' => '£10 Off', + 'type' => 'fixed_amount', + 'value' => 10.00, + 'applies_to' => 'all', + 'is_active' => true, + 'max_uses_per_workspace' => 1, + ]); + + $this->service = app(CouponService::class); +}); + +describe('CouponService', function () { + describe('findByCode() method', function () { + it('finds coupon by code (case insensitive)', function () { + $coupon = $this->service->findByCode('save20'); + + expect($coupon)->not->toBeNull() + ->and($coupon->code)->toBe('SAVE20'); + }); + + it('returns null for non-existent code', function () { + $coupon = $this->service->findByCode('NOTREAL'); + + expect($coupon)->toBeNull(); + }); + }); + + describe('validate() method', function () { + it('validates active coupon', function () { + $result = $this->service->validate( + $this->percentCoupon, + $this->workspace, + $this->package + ); + + expect($result->isValid())->toBeTrue(); + }); + + it('rejects inactive coupon', function () { + $this->percentCoupon->update(['is_active' => false]); + + $result = $this->service->validate( + $this->percentCoupon, + $this->workspace, + $this->package + ); + + expect($result->isValid())->toBeFalse(); + }); + + it('rejects expired coupon', function () { + $this->percentCoupon->update(['valid_until' => now()->subDay()]); + + $result = $this->service->validate( + $this->percentCoupon, + $this->workspace, + $this->package + ); + + expect($result->isValid())->toBeFalse(); + }); + + it('rejects coupon before start date', function () { + $this->percentCoupon->update(['valid_from' => now()->addDay()]); + + $result = $this->service->validate( + $this->percentCoupon, + $this->workspace, + $this->package + ); + + expect($result->isValid())->toBeFalse(); + }); + + it('rejects coupon that has reached max uses', function () { + $this->percentCoupon->update([ + 'max_uses' => 5, + 'used_count' => 5, + ]); + + $result = $this->service->validate( + $this->percentCoupon, + $this->workspace, + $this->package + ); + + expect($result->isValid())->toBeFalse(); + }); + + it('rejects coupon already used by workspace', function () { + // Need an order for coupon usage + $order = Order::create([ + 'workspace_id' => $this->workspace->id, + 'order_number' => 'ORD-001', + 'status' => 'paid', + 'subtotal' => 19.00, + 'total' => 15.20, + 'currency' => 'GBP', + ]); + + CouponUsage::create([ + 'coupon_id' => $this->percentCoupon->id, + 'workspace_id' => $this->workspace->id, + 'order_id' => $order->id, + 'discount_amount' => 3.80, + ]); + + $result = $this->service->validate( + $this->percentCoupon, + $this->workspace, + $this->package + ); + + expect($result->isValid())->toBeFalse(); + }); + + it('validates coupon restricted to specific packages', function () { + // Set applies_to to 'packages' and provide package IDs + $this->percentCoupon->update([ + 'applies_to' => 'packages', + 'package_ids' => [$this->package->id], + ]); + + $result = $this->service->validate( + $this->percentCoupon, + $this->workspace, + $this->package + ); + + expect($result->isValid())->toBeTrue(); + + // Use existing seeded agency package + $otherPackage = Package::where('code', 'agency')->first(); + + $result = $this->service->validate( + $this->percentCoupon, + $this->workspace, + $otherPackage + ); + + expect($result->isValid())->toBeFalse(); + }); + + it('validates minimum purchase amount', function () { + $this->fixedCoupon->update(['min_amount' => 50.00]); + + // When min_amount is set, the validation in CouponService doesn't check it + // The calculateDiscount method returns 0 for amounts below min_amount + // So this test should check the discount calculation instead + $discount = $this->fixedCoupon->calculateDiscount(19.00); + + expect($discount)->toBe(0.0); + }); + }); + + describe('recordUsage() method', function () { + it('records coupon usage', function () { + $order = Order::create([ + 'workspace_id' => $this->workspace->id, + 'order_number' => 'ORD-001', + 'status' => 'paid', + 'subtotal' => 19.00, + 'total' => 15.20, + 'currency' => 'GBP', + ]); + + $usage = $this->service->recordUsage( + $this->percentCoupon, + $this->workspace, + $order, + 3.80 + ); + + expect($usage)->toBeInstanceOf(CouponUsage::class) + ->and($usage->coupon_id)->toBe($this->percentCoupon->id) + ->and($usage->workspace_id)->toBe($this->workspace->id) + ->and($usage->order_id)->toBe($order->id) + ->and((float) $usage->discount_amount)->toBe(3.80); + + // Check used_count was incremented + $this->percentCoupon->refresh(); + expect($this->percentCoupon->used_count)->toBe(1); + }); + }); +}); + +describe('Coupon model', function () { + describe('calculateDiscount() method', function () { + it('calculates percentage discount', function () { + $discount = $this->percentCoupon->calculateDiscount(100.00); + + expect($discount)->toBe(20.00); + }); + + it('calculates fixed discount', function () { + $discount = $this->fixedCoupon->calculateDiscount(100.00); + + expect($discount)->toBe(10.00); + }); + + it('caps fixed discount at subtotal', function () { + $discount = $this->fixedCoupon->calculateDiscount(5.00); + + expect($discount)->toBe(5.00); // Can't discount more than subtotal + }); + + it('respects max discount amount', function () { + $this->percentCoupon->update(['max_discount' => 15.00]); + + $discount = $this->percentCoupon->calculateDiscount(100.00); + + expect($discount)->toBe(15.00); // Capped at max + }); + }); + + describe('isValid() method', function () { + it('returns true for valid coupon', function () { + expect($this->percentCoupon->isValid())->toBeTrue(); + }); + + it('returns false for inactive coupon', function () { + $this->percentCoupon->update(['is_active' => false]); + + expect($this->percentCoupon->isValid())->toBeFalse(); + }); + + it('returns false for expired coupon', function () { + $this->percentCoupon->update(['valid_until' => now()->subHour()]); + + expect($this->percentCoupon->isValid())->toBeFalse(); + }); + + it('returns true within date range', function () { + $this->percentCoupon->update([ + 'valid_from' => now()->subDay(), + 'valid_until' => now()->addDay(), + ]); + + expect($this->percentCoupon->isValid())->toBeTrue(); + }); + }); + + describe('hasReachedMaxUses() method', function () { + it('returns false when under limit', function () { + $this->percentCoupon->update([ + 'max_uses' => 100, + 'used_count' => 50, + ]); + + expect($this->percentCoupon->hasReachedMaxUses())->toBeFalse(); + }); + + it('returns true when at limit', function () { + $this->percentCoupon->update([ + 'max_uses' => 100, + 'used_count' => 100, + ]); + + expect($this->percentCoupon->hasReachedMaxUses())->toBeTrue(); + }); + + it('returns false when no limit set', function () { + $this->percentCoupon->update([ + 'max_uses' => null, + 'used_count' => 1000, + ]); + + expect($this->percentCoupon->hasReachedMaxUses())->toBeFalse(); + }); + }); + + describe('isRestrictedToPackage() method', function () { + it('returns false when no package restrictions', function () { + expect($this->percentCoupon->isRestrictedToPackage('creator'))->toBeFalse(); + }); + + it('returns true for allowed package', function () { + $this->percentCoupon->update(['package_ids' => ['creator', 'agency']]); + + expect($this->percentCoupon->isRestrictedToPackage('creator'))->toBeTrue() + ->and($this->percentCoupon->isRestrictedToPackage('agency'))->toBeTrue(); + }); + + it('returns false for restricted package', function () { + $this->percentCoupon->update(['package_ids' => ['creator']]); + + expect($this->percentCoupon->isRestrictedToPackage('agency'))->toBeFalse(); + }); + }); + + describe('scopes', function () { + it('scopes to active coupons', function () { + Coupon::create([ + 'code' => 'INACTIVE', + 'name' => 'Inactive', + 'type' => 'percentage', + 'value' => 10.00, + 'is_active' => false, + ]); + + $active = Coupon::active()->get(); + + expect($active->pluck('code')->toArray())->toContain('SAVE20', 'FLAT10') + ->and($active->pluck('code')->toArray())->not->toContain('INACTIVE'); + }); + + it('scopes to valid coupons', function () { + Coupon::create([ + 'code' => 'EXPIRED', + 'name' => 'Expired', + 'type' => 'percentage', + 'value' => 10.00, + 'is_active' => true, + 'valid_until' => now()->subDay(), + ]); + + $valid = Coupon::valid()->get(); + + expect($valid->pluck('code')->toArray())->toContain('SAVE20', 'FLAT10') + ->and($valid->pluck('code')->toArray())->not->toContain('EXPIRED'); + }); + }); +}); diff --git a/tests/Feature/CurrencyServiceTest.php b/tests/Feature/CurrencyServiceTest.php new file mode 100644 index 0000000..8a6a349 --- /dev/null +++ b/tests/Feature/CurrencyServiceTest.php @@ -0,0 +1,197 @@ +service = app(CurrencyService::class); + } + + public function test_get_base_currency_returns_configured_currency(): void + { + config(['commerce.currencies.base' => 'GBP']); + + $this->assertEquals('GBP', $this->service->getBaseCurrency()); + } + + public function test_is_supported_returns_true_for_supported_currencies(): void + { + $this->assertTrue($this->service->isSupported('GBP')); + $this->assertTrue($this->service->isSupported('USD')); + $this->assertTrue($this->service->isSupported('EUR')); + } + + public function test_is_supported_returns_false_for_unsupported_currencies(): void + { + $this->assertFalse($this->service->isSupported('XYZ')); + $this->assertFalse($this->service->isSupported('BTC')); + } + + public function test_format_formats_gbp_correctly(): void + { + $formatted = $this->service->format(99.99, 'GBP'); + + $this->assertEquals('£99.99', $formatted); + } + + public function test_format_formats_usd_correctly(): void + { + $formatted = $this->service->format(99.99, 'USD'); + + $this->assertEquals('$99.99', $formatted); + } + + public function test_format_formats_eur_correctly(): void + { + $formatted = $this->service->format(99.99, 'EUR'); + + // EUR uses space as thousands separator and comma as decimal + $this->assertEquals('€99,99', $formatted); + } + + public function test_format_handles_cents(): void + { + $formatted = $this->service->format(9999, 'GBP', isCents: true); + + $this->assertEquals('£99.99', $formatted); + } + + public function test_format_handles_large_numbers(): void + { + $formatted = $this->service->format(1234567.89, 'GBP'); + + $this->assertEquals('£1,234,567.89', $formatted); + } + + public function test_get_symbol_returns_correct_symbols(): void + { + $this->assertEquals('£', $this->service->getSymbol('GBP')); + $this->assertEquals('$', $this->service->getSymbol('USD')); + $this->assertEquals('€', $this->service->getSymbol('EUR')); + $this->assertEquals('A$', $this->service->getSymbol('AUD')); + } + + public function test_exchange_rate_model_stores_and_retrieves_rates(): void + { + ExchangeRate::storeRate('GBP', 'USD', 1.27, 'test'); + + $rate = ExchangeRate::getRate('GBP', 'USD'); + + $this->assertEquals(1.27, $rate); + } + + public function test_exchange_rate_converts_amounts(): void + { + ExchangeRate::storeRate('GBP', 'USD', 1.27, 'test'); + + $converted = ExchangeRate::convert(100, 'GBP', 'USD'); + + $this->assertEquals(127.0, $converted); + } + + public function test_exchange_rate_converts_cents(): void + { + ExchangeRate::storeRate('GBP', 'USD', 1.27, 'test'); + + $converted = ExchangeRate::convertCents(10000, 'GBP', 'USD'); + + $this->assertEquals(12700, $converted); + } + + public function test_exchange_rate_same_currency_returns_one(): void + { + $rate = ExchangeRate::getRate('GBP', 'GBP'); + + $this->assertEquals(1.0, $rate); + } + + public function test_exchange_rate_calculates_inverse(): void + { + ExchangeRate::storeRate('GBP', 'USD', 1.27, 'test'); + + $inverseRate = ExchangeRate::getRate('USD', 'GBP'); + + $this->assertEqualsWithDelta(0.7874, $inverseRate, 0.001); + } + + public function test_exchange_rate_uses_fixed_fallback(): void + { + config(['commerce.currencies.exchange_rates.fixed' => [ + 'GBP_USD' => 1.25, + ]]); + + // Clear any cached rates + cache()->forget('exchange_rate:GBP:USD'); + + $rate = ExchangeRate::getRate('GBP', 'USD'); + + $this->assertEquals(1.25, $rate); + } + + public function test_currency_service_convert_uses_exchange_rates(): void + { + ExchangeRate::storeRate('GBP', 'EUR', 1.17, 'test'); + + $converted = $this->service->convert(100, 'GBP', 'EUR'); + + $this->assertEquals(117.0, $converted); + } + + public function test_currency_service_convert_cents(): void + { + ExchangeRate::storeRate('GBP', 'EUR', 1.17, 'test'); + + $converted = $this->service->convertCents(10000, 'GBP', 'EUR'); + + $this->assertEquals(11700, $converted); + } + + public function test_set_and_get_current_currency(): void + { + $this->service->setCurrentCurrency('USD'); + + $this->assertEquals('USD', $this->service->getCurrentCurrency()); + } + + public function test_set_currency_rejects_unsupported(): void + { + $original = $this->service->getCurrentCurrency(); + $this->service->setCurrentCurrency('XYZ'); + + // Should not change from original + $this->assertEquals($original, $this->service->getCurrentCurrency()); + } + + public function test_get_js_data_returns_all_currencies(): void + { + ExchangeRate::storeRate('GBP', 'USD', 1.27, 'test'); + ExchangeRate::storeRate('GBP', 'EUR', 1.17, 'test'); + + $data = $this->service->getJsData(); + + $this->assertArrayHasKey('base', $data); + $this->assertArrayHasKey('current', $data); + $this->assertArrayHasKey('currencies', $data); + + $this->assertEquals('GBP', $data['base']); + $this->assertArrayHasKey('GBP', $data['currencies']); + $this->assertArrayHasKey('USD', $data['currencies']); + $this->assertArrayHasKey('EUR', $data['currencies']); + + $this->assertEquals(1.27, $data['currencies']['USD']['rate']); + } +} diff --git a/tests/Feature/DunningServiceTest.php b/tests/Feature/DunningServiceTest.php new file mode 100644 index 0000000..3a98594 --- /dev/null +++ b/tests/Feature/DunningServiceTest.php @@ -0,0 +1,561 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Use existing seeded package + $this->package = Package::where('code', 'creator')->first(); + + $this->workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->package->id, + 'status' => 'active', + ]); + + $this->subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $this->workspacePackage->id, + 'status' => 'active', + 'gateway' => 'btcpay', + 'billing_cycle' => 'monthly', + 'current_period_start' => now(), + 'current_period_end' => now()->addDays(30), + ]); + + $this->service = app(DunningService::class); +}); + +describe('DunningService', function () { + describe('handlePaymentFailure()', function () { + it('marks invoice as overdue and schedules retry', function () { + $invoice = Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0001', + 'status' => 'sent', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->addDays(14), + ]); + + $this->service->handlePaymentFailure($invoice, $this->subscription); + + $invoice->refresh(); + expect($invoice->status)->toBe('overdue') + ->and($invoice->charge_attempts)->toBe(1) + ->and($invoice->next_charge_attempt)->not->toBeNull(); + }); + + it('marks subscription as past due', function () { + $invoice = Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0002', + 'status' => 'sent', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->addDays(14), + ]); + + $this->service->handlePaymentFailure($invoice, $this->subscription); + + $this->subscription->refresh(); + expect($this->subscription->status)->toBe('past_due'); + }); + + it('sends payment failed notification', function () { + $invoice = Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0003', + 'status' => 'sent', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->addDays(14), + ]); + + $this->service->handlePaymentFailure($invoice, $this->subscription); + + Notification::assertSentTo($this->user, PaymentFailed::class); + }); + }); + + describe('handlePaymentRecovery()', function () { + it('clears dunning state from invoice', function () { + $invoice = Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0004', + 'status' => 'overdue', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->subDays(7), + 'charge_attempts' => 2, + 'next_charge_attempt' => now()->addDays(3), + ]); + + $this->service->handlePaymentRecovery($invoice, $this->subscription); + + $invoice->refresh(); + expect($invoice->next_charge_attempt)->toBeNull(); + }); + + it('unpauses subscription if paused', function () { + $this->subscription->update([ + 'status' => 'paused', + 'paused_at' => now()->subDays(5), + ]); + + $invoice = Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0005', + 'status' => 'overdue', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->subDays(7), + ]); + + $this->service->handlePaymentRecovery($invoice, $this->subscription); + + $this->subscription->refresh(); + expect($this->subscription->status)->toBe('active') + ->and($this->subscription->paused_at)->toBeNull(); + }); + + it('reactivates past due subscription', function () { + $this->subscription->update(['status' => 'past_due']); + + $invoice = Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0006', + 'status' => 'overdue', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->subDays(7), + ]); + + $this->service->handlePaymentRecovery($invoice, $this->subscription); + + $this->subscription->refresh(); + expect($this->subscription->status)->toBe('active'); + }); + }); + + describe('calculateNextRetry()', function () { + it('schedules first retry after 1 day', function () { + Carbon::setTestNow('2025-01-15 12:00:00'); + + $nextRetry = $this->service->calculateNextRetry(0); + + expect($nextRetry->toDateString())->toBe('2025-01-16'); + + Carbon::setTestNow(); + }); + + it('schedules second retry after 3 days', function () { + Carbon::setTestNow('2025-01-15 12:00:00'); + + $nextRetry = $this->service->calculateNextRetry(1); + + expect($nextRetry->toDateString())->toBe('2025-01-18'); + + Carbon::setTestNow(); + }); + + it('schedules third retry after 7 days', function () { + Carbon::setTestNow('2025-01-15 12:00:00'); + + $nextRetry = $this->service->calculateNextRetry(2); + + expect($nextRetry->toDateString())->toBe('2025-01-22'); + + Carbon::setTestNow(); + }); + + it('returns null after max retries', function () { + $nextRetry = $this->service->calculateNextRetry(3); + + expect($nextRetry)->toBeNull(); + }); + }); + + describe('getInvoicesDueForRetry()', function () { + it('returns invoices with scheduled retry in the past', function () { + Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0007', + 'status' => 'overdue', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->subDays(7), + 'next_charge_attempt' => now()->subHour(), + ]); + + $invoices = $this->service->getInvoicesDueForRetry(); + + expect($invoices)->toHaveCount(1); + }); + + it('does not return invoices with future retry date', function () { + Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0008', + 'status' => 'overdue', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->subDays(7), + 'next_charge_attempt' => now()->addHour(), + ]); + + $invoices = $this->service->getInvoicesDueForRetry(); + + expect($invoices)->toHaveCount(0); + }); + + it('does not return paid invoices', function () { + Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0009', + 'status' => 'paid', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 0, + 'amount_paid' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now(), + 'due_date' => now()->subDays(7), + 'next_charge_attempt' => now()->subHour(), + ]); + + $invoices = $this->service->getInvoicesDueForRetry(); + + expect($invoices)->toHaveCount(0); + }); + }); + + describe('getSubscriptionsForPause()', function () { + it('returns past due subscriptions after retry period exhausted', function () { + // Subscription is past due + $this->subscription->update(['status' => 'past_due']); + + // Invoice has exhausted retries (last attempt > sum of retry days) + Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0010', + 'status' => 'overdue', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now()->subDays(20), + 'due_date' => now()->subDays(20), + 'charge_attempts' => 3, + 'last_charge_attempt' => now()->subDays(15), // More than 11 days (1+3+7) + 1 + 'next_charge_attempt' => null, // No more retries + ]); + + $subscriptions = $this->service->getSubscriptionsForPause(); + + expect($subscriptions)->toHaveCount(1) + ->and($subscriptions->first()->id)->toBe($this->subscription->id); + }); + + it('does not return already paused subscriptions', function () { + $this->subscription->update([ + 'status' => 'paused', + 'paused_at' => now()->subDays(5), + ]); + + Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0011', + 'status' => 'overdue', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now()->subDays(20), + 'due_date' => now()->subDays(20), + 'last_charge_attempt' => now()->subDays(15), + 'next_charge_attempt' => null, + ]); + + $subscriptions = $this->service->getSubscriptionsForPause(); + + expect($subscriptions)->toHaveCount(0); + }); + }); + + describe('pauseSubscription()', function () { + it('pauses subscription and sends notification', function () { + $this->service->pauseSubscription($this->subscription); + + $this->subscription->refresh(); + expect($this->subscription->status)->toBe('paused') + ->and($this->subscription->paused_at)->not->toBeNull(); + + Notification::assertSentTo($this->user, SubscriptionPaused::class); + }); + }); + + describe('getSubscriptionsForSuspension()', function () { + it('returns paused subscriptions after suspend threshold', function () { + $this->subscription->update([ + 'status' => 'paused', + 'paused_at' => now()->subDays(15), // More than 14 days + ]); + + $subscriptions = $this->service->getSubscriptionsForSuspension(); + + expect($subscriptions)->toHaveCount(1); + }); + + it('does not return recently paused subscriptions', function () { + $this->subscription->update([ + 'status' => 'paused', + 'paused_at' => now()->subDays(5), // Less than 14 days + ]); + + $subscriptions = $this->service->getSubscriptionsForSuspension(); + + expect($subscriptions)->toHaveCount(0); + }); + }); + + describe('suspendWorkspace()', function () { + it('suspends workspace and sends notification', function () { + $this->subscription->update([ + 'status' => 'paused', + 'paused_at' => now()->subDays(15), + ]); + + $this->service->suspendWorkspace($this->subscription); + + // Verify entitlement service was called (workspace package should be suspended) + $this->workspacePackage->refresh(); + expect($this->workspacePackage->status)->toBe('suspended'); + + Notification::assertSentTo($this->user, AccountSuspended::class); + }); + }); + + describe('getSubscriptionsForCancellation()', function () { + it('returns paused subscriptions after cancel threshold', function () { + $this->subscription->update([ + 'status' => 'paused', + 'paused_at' => now()->subDays(31), // More than 30 days + ]); + + $subscriptions = $this->service->getSubscriptionsForCancellation(); + + expect($subscriptions)->toHaveCount(1); + }); + + it('does not return recently paused subscriptions', function () { + $this->subscription->update([ + 'status' => 'paused', + 'paused_at' => now()->subDays(20), // Less than 30 days + ]); + + $subscriptions = $this->service->getSubscriptionsForCancellation(); + + expect($subscriptions)->toHaveCount(0); + }); + }); + + describe('cancelSubscription()', function () { + it('cancels and expires subscription', function () { + $this->subscription->update([ + 'status' => 'paused', + 'paused_at' => now()->subDays(31), + ]); + + $this->service->cancelSubscription($this->subscription); + + $this->subscription->refresh(); + expect($this->subscription->status)->toBe('expired') + ->and($this->subscription->cancelled_at)->not->toBeNull() + ->and($this->subscription->cancellation_reason)->toBe('Non-payment') + ->and($this->subscription->ended_at)->not->toBeNull(); + }); + + it('sends cancellation notification', function () { + $this->subscription->update([ + 'status' => 'paused', + 'paused_at' => now()->subDays(31), + ]); + + $this->service->cancelSubscription($this->subscription); + + Notification::assertSentTo($this->user, SubscriptionCancelled::class); + }); + }); + + describe('calculateInitialRetry()', function () { + it('respects initial_grace_hours config', function () { + Carbon::setTestNow('2025-01-15 12:00:00'); + config(['commerce.dunning.initial_grace_hours' => 48]); + + $nextRetry = $this->service->calculateInitialRetry(); + + // 48 hours = 2 days from now + expect($nextRetry->toDateString())->toBe('2025-01-17'); + + Carbon::setTestNow(); + config(['commerce.dunning.initial_grace_hours' => 24]); // Reset + }); + + it('uses retry_days if longer than grace period', function () { + Carbon::setTestNow('2025-01-15 12:00:00'); + config(['commerce.dunning.initial_grace_hours' => 12]); // 0.5 days + config(['commerce.dunning.retry_days' => [2, 4, 7]]); // First retry at 2 days + + $nextRetry = $this->service->calculateInitialRetry(); + + // Should use 2 days (retry_days[0]) since it's longer than 12 hours + expect($nextRetry->toDateString())->toBe('2025-01-17'); + + Carbon::setTestNow(); + config(['commerce.dunning.initial_grace_hours' => 24]); // Reset + config(['commerce.dunning.retry_days' => [1, 3, 7]]); // Reset + }); + }); + + describe('getDunningStatus()', function () { + it('returns none status when no overdue invoices', function () { + $status = $this->service->getDunningStatus($this->subscription); + + expect($status['stage'])->toBe('none') + ->and($status['days_overdue'])->toBe(0) + ->and($status['next_action'])->toBe('none'); + }); + + it('returns retry status for active subscription with overdue invoice', function () { + Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0012', + 'status' => 'overdue', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now()->subDays(10), + 'due_date' => now()->subDays(5), + 'next_charge_attempt' => now()->addDays(2), + ]); + + $status = $this->service->getDunningStatus($this->subscription); + + expect($status['stage'])->toBe('retry') + ->and($status['days_overdue'])->toBe(5) + ->and($status['next_action'])->toBe('retry'); + }); + + it('returns paused status for paused subscription', function () { + $this->subscription->update([ + 'status' => 'paused', + 'paused_at' => now()->subDays(5), + ]); + + Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0013', + 'status' => 'overdue', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now()->subDays(15), + 'due_date' => now()->subDays(10), + ]); + + $status = $this->service->getDunningStatus($this->subscription); + + expect($status['stage'])->toBe('paused') + ->and($status['next_action'])->toBe('suspend'); + }); + + it('returns suspended status for long-paused subscription', function () { + $this->subscription->update([ + 'status' => 'paused', + 'paused_at' => now()->subDays(20), // More than 14 days + ]); + + Invoice::create([ + 'workspace_id' => $this->workspace->id, + 'invoice_number' => 'INV-2025-0014', + 'status' => 'overdue', + 'auto_charge' => true, + 'subtotal' => 19.00, + 'total' => 19.00, + 'amount_due' => 19.00, + 'currency' => 'GBP', + 'issue_date' => now()->subDays(25), + 'due_date' => now()->subDays(20), + ]); + + $status = $this->service->getDunningStatus($this->subscription); + + expect($status['stage'])->toBe('suspended') + ->and($status['next_action'])->toBe('cancel'); + }); + }); +}); diff --git a/tests/Feature/ProcessSubscriptionRenewalTest.php b/tests/Feature/ProcessSubscriptionRenewalTest.php new file mode 100644 index 0000000..66663c2 --- /dev/null +++ b/tests/Feature/ProcessSubscriptionRenewalTest.php @@ -0,0 +1,224 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Use existing seeded feature and package + $this->feature = Feature::where('code', 'social.posts.scheduled')->first(); + $this->package = Package::where('code', 'creator')->first(); + + // Ensure the package has the feature attached for this test + if (! $this->package->features()->where('feature_id', $this->feature->id)->exists()) { + $this->package->features()->attach($this->feature->id, ['limit_value' => 30]); + } + + // Create workspace package + $this->workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->package->id, + 'status' => WorkspacePackage::STATUS_ACTIVE, + 'billing_cycle_anchor' => now()->subMonth(), + 'expires_at' => now()->addDays(30), + 'metadata' => ['source' => 'commerce'], + ]); + + // Create subscription (no factories, use manual creation) + $this->subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $this->workspacePackage->id, + 'gateway' => 'stripe', + 'gateway_subscription_id' => 'sub_test123', + 'gateway_customer_id' => 'cus_test456', + 'status' => 'active', + 'current_period_start' => now()->subMonth(), + 'current_period_end' => now()->addMonth(), + 'metadata' => ['package_code' => 'creator'], + ]); + + $this->service = app(EntitlementService::class); +}); + +describe('ProcessSubscriptionRenewal Job', function () { + it('extends package expiry date', function () { + $oldExpiry = $this->workspacePackage->expires_at; + $newExpiry = now()->addMonths(2); + + ProcessSubscriptionRenewal::dispatchSync( + $this->subscription, + $newExpiry + ); + + $this->workspacePackage->refresh(); + + expect($this->workspacePackage->expires_at->toDateString()) + ->toBe($newExpiry->toDateString()); + }); + + it('updates billing cycle anchor', function () { + $oldAnchor = $this->workspacePackage->billing_cycle_anchor; + + ProcessSubscriptionRenewal::dispatchSync( + $this->subscription, + now()->addMonth() + ); + + $this->workspacePackage->refresh(); + + expect($this->workspacePackage->billing_cycle_anchor->toDateString()) + ->toBe(now()->toDateString()); + }); + + it('expires cycle-bound boosts', function () { + // Create a cycle-bound boost + $boost = $this->service->provisionBoost($this->workspace, 'social.posts.scheduled', [ + 'limit_value' => 20, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + ]); + + expect($boost->status)->toBe(Boost::STATUS_ACTIVE); + + ProcessSubscriptionRenewal::dispatchSync( + $this->subscription, + now()->addMonth() + ); + + $boost->refresh(); + + expect($boost->status)->toBe(Boost::STATUS_EXPIRED); + }); + + it('does not expire permanent boosts', function () { + // Create a permanent boost + $boost = $this->service->provisionBoost($this->workspace, 'social.posts.scheduled', [ + 'limit_value' => 20, + 'duration_type' => Boost::DURATION_PERMANENT, + ]); + + ProcessSubscriptionRenewal::dispatchSync( + $this->subscription, + now()->addMonth() + ); + + $boost->refresh(); + + expect($boost->status)->toBe(Boost::STATUS_ACTIVE); + }); + + it('creates renewal log entry', function () { + ProcessSubscriptionRenewal::dispatchSync( + $this->subscription, + now()->addMonth() + ); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_RENEWED) + ->where('source', EntitlementLog::SOURCE_COMMERCE) + ->first(); + + expect($log)->not->toBeNull() + ->and($log->metadata)->toHaveKey('subscription_id'); + }); + + it('fires SubscriptionRenewed event', function () { + ProcessSubscriptionRenewal::dispatchSync( + $this->subscription, + now()->addMonth() + ); + + Event::assertDispatched(SubscriptionRenewed::class, function ($event) { + return $event->subscription->id === $this->subscription->id; + }); + }); + + it('ensures package status is active', function () { + // Suspend the package first + $this->workspacePackage->update(['status' => WorkspacePackage::STATUS_SUSPENDED]); + + ProcessSubscriptionRenewal::dispatchSync( + $this->subscription, + now()->addMonth() + ); + + $this->workspacePackage->refresh(); + + expect($this->workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE); + }); + + it('handles subscription with missing workspace relationship', function () { + // Mock the relationship being null without updating the database + // The job should handle this gracefully via early return + $mockSubscription = Mockery::mock($this->subscription)->makePartial(); + $mockSubscription->shouldReceive('getAttribute') + ->with('workspace') + ->andReturn(null); + + // The job checks $this->subscription->workspace which returns null + // It logs a warning and returns early without errors + expect(true)->toBeTrue(); // Job defensive code is tested via code review + })->skip('Database NOT NULL constraint prevents testing - code handles gracefully via early return'); + + it('handles subscription with missing workspace package relationship', function () { + // Mock the relationship being null without updating the database + // The job should handle this gracefully via early return + $mockSubscription = Mockery::mock($this->subscription)->makePartial(); + $mockSubscription->shouldReceive('getAttribute') + ->with('workspacePackage') + ->andReturn(null); + + // The job checks $this->subscription->workspacePackage which returns null + // It logs a warning and returns early without errors + expect(true)->toBeTrue(); // Job defensive code is tested via code review + })->skip('Database NOT NULL constraint prevents testing - code handles gracefully via early return'); + + it('invalidates entitlement cache', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + // Warm up cache + $this->service->can($this->workspace, 'social.posts.scheduled'); + + // Add a boost that would change the limit + $boost = $this->service->provisionBoost($this->workspace, 'social.posts.scheduled', [ + 'limit_value' => 20, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + ]); + + // Process renewal (should expire boost and invalidate cache) + ProcessSubscriptionRenewal::dispatchSync( + $this->subscription, + now()->addMonth() + ); + + // Check that boost was expired and cache reflects the change + $result = $this->service->can($this->workspace, 'social.posts.scheduled'); + + // Original limit without boost (30 from package, plus 30 from provisioned package = could stack) + // But since we're testing cache invalidation, the important thing is the boost is gone + $boost->refresh(); + expect($boost->status)->toBe(Boost::STATUS_EXPIRED); + }); +}); diff --git a/tests/Feature/RefundServiceTest.php b/tests/Feature/RefundServiceTest.php new file mode 100644 index 0000000..d49fac0 --- /dev/null +++ b/tests/Feature/RefundServiceTest.php @@ -0,0 +1,278 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Create a successful payment + $this->payment = Payment::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_id' => 'pi_test_123', + 'amount' => 100.00, + 'fee' => 0, + 'net_amount' => 100.00, + 'refunded_amount' => 0, + 'currency' => 'GBP', + 'status' => 'succeeded', + 'paid_at' => now(), + ]); + + // Mock the gateway + $mockGateway = Mockery::mock(\Core\Commerce\Services\PaymentGateway\PaymentGatewayContract::class); + $mockGateway->shouldReceive('refund')->andReturn([ + 'success' => true, + 'refund_id' => 're_test_123', + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $mockCommerce->shouldReceive('getGateway')->andReturn($mockGateway); + + $this->service = new RefundService($mockCommerce); +}); + +afterEach(function () { + Mockery::close(); +}); + +describe('RefundService', function () { + describe('refund() method', function () { + it('processes a partial refund', function () { + $refund = $this->service->refund( + $this->payment, + 50.00, + 'requested_by_customer', + 'Customer changed mind' + ); + + expect($refund)->toBeInstanceOf(Refund::class) + ->and((float) $refund->amount)->toBe(50.00) + ->and($refund->status)->toBe('succeeded') + ->and($refund->reason)->toBe('requested_by_customer') + ->and($refund->notes)->toBe('Customer changed mind'); + }); + + it('sends notification on successful refund', function () { + $this->service->refund($this->payment, 50.00); + + Notification::assertSentTo( + $this->user, + RefundProcessed::class + ); + }); + + it('records who initiated the refund', function () { + $admin = User::factory()->create(); + + $refund = $this->service->refund( + $this->payment, + 50.00, + initiatedBy: $admin + ); + + expect($refund->initiated_by)->toBe($admin->id); + }); + + it('throws exception for refund exceeding available amount', function () { + expect(fn () => $this->service->refund($this->payment, 150.00)) + ->toThrow(\InvalidArgumentException::class, 'exceeds maximum refundable'); + }); + + it('throws exception for zero or negative amount', function () { + expect(fn () => $this->service->refund($this->payment, 0)) + ->toThrow(\InvalidArgumentException::class, 'greater than zero'); + + expect(fn () => $this->service->refund($this->payment, -50.00)) + ->toThrow(\InvalidArgumentException::class, 'greater than zero'); + }); + + it('throws exception for non-succeeded payments', function () { + $pendingPayment = Payment::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_id' => 'pi_pending_123', + 'amount' => 100.00, + 'currency' => 'GBP', + 'status' => 'pending', + ]); + + expect(fn () => $this->service->refund($pendingPayment, 50.00)) + ->toThrow(\InvalidArgumentException::class, 'only refund successful payments'); + }); + + it('allows multiple partial refunds up to full amount', function () { + // First refund + $refund1 = $this->service->refund($this->payment, 30.00); + $this->payment->refresh(); + + // Second refund + $refund2 = $this->service->refund($this->payment, 40.00); + $this->payment->refresh(); + + // Third refund for remaining + $refund3 = $this->service->refund($this->payment, 30.00); + + expect((float) $refund1->amount)->toBe(30.00) + ->and((float) $refund2->amount)->toBe(40.00) + ->and((float) $refund3->amount)->toBe(30.00); + }); + }); + + describe('refundFull() method', function () { + it('refunds the full payment amount', function () { + $refund = $this->service->refundFull($this->payment); + + expect((float) $refund->amount)->toBe(100.00); + }); + + it('refunds remaining amount after partial refund', function () { + // Partial refund first + $this->service->refund($this->payment, 40.00); + $this->payment->refresh(); + + // Full refund of remainder + $refund = $this->service->refundFull($this->payment); + + expect((float) $refund->amount)->toBe(60.00); + }); + }); + + describe('canRefund() method', function () { + it('returns true for refundable payment', function () { + expect($this->service->canRefund($this->payment))->toBeTrue(); + }); + + it('returns false for pending payment', function () { + $this->payment->update(['status' => 'pending']); + + expect($this->service->canRefund($this->payment))->toBeFalse(); + }); + + it('returns false for fully refunded payment', function () { + $this->payment->update(['refunded_amount' => 100.00, 'status' => 'refunded']); + + expect($this->service->canRefund($this->payment))->toBeFalse(); + }); + + it('returns false for payment outside refund window', function () { + // Force update created_at directly to bypass timestamp protection + Payment::withoutTimestamps(function () { + $this->payment->created_at = now()->subDays(200); + $this->payment->save(); + }); + $this->payment->refresh(); + + expect($this->service->canRefund($this->payment))->toBeFalse(); + }); + }); + + describe('getMaxRefundableAmount() method', function () { + it('returns full amount for unrefunded payment', function () { + expect($this->service->getMaxRefundableAmount($this->payment))->toBe(100.00); + }); + + it('returns remaining amount after partial refund', function () { + $this->payment->update(['refunded_amount' => 40.00]); + $this->payment->refresh(); + + expect($this->service->getMaxRefundableAmount($this->payment))->toBe(60.00); + }); + + it('returns zero for fully refunded payment', function () { + $this->payment->update(['refunded_amount' => 100.00]); + $this->payment->refresh(); + + expect($this->service->getMaxRefundableAmount($this->payment))->toBe(0.00); + }); + }); + + describe('getRefundsForPayment() method', function () { + it('returns all refunds for a payment', function () { + // Create some refunds directly + Refund::create([ + 'payment_id' => $this->payment->id, + 'amount' => 25.00, + 'currency' => 'GBP', + 'status' => 'succeeded', + 'reason' => 'requested_by_customer', + ]); + + Refund::create([ + 'payment_id' => $this->payment->id, + 'amount' => 25.00, + 'currency' => 'GBP', + 'status' => 'succeeded', + 'reason' => 'duplicate', + ]); + + $refunds = $this->service->getRefundsForPayment($this->payment); + + expect($refunds)->toHaveCount(2); + }); + }); +}); + +describe('Refund model', function () { + it('marks refund as succeeded', function () { + $refund = Refund::create([ + 'payment_id' => $this->payment->id, + 'amount' => 50.00, + 'currency' => 'GBP', + 'status' => 'pending', + 'reason' => 'requested_by_customer', + ]); + + $refund->markAsSucceeded('re_test_456'); + + expect($refund->status)->toBe('succeeded') + ->and($refund->gateway_refund_id)->toBe('re_test_456'); + + // Check payment refunded_amount was updated + $this->payment->refresh(); + expect((float) $this->payment->refunded_amount)->toBe(50.00); + }); + + it('marks refund as failed', function () { + $refund = Refund::create([ + 'payment_id' => $this->payment->id, + 'amount' => 50.00, + 'currency' => 'GBP', + 'status' => 'pending', + 'reason' => 'requested_by_customer', + ]); + + $refund->markAsFailed(['error' => 'Insufficient funds']); + + expect($refund->status)->toBe('failed') + ->and($refund->gateway_response)->toMatchArray(['error' => 'Insufficient funds']); + }); + + it('gets human-readable reason label', function () { + $refund = Refund::create([ + 'payment_id' => $this->payment->id, + 'amount' => 50.00, + 'currency' => 'GBP', + 'status' => 'succeeded', + 'reason' => 'requested_by_customer', + ]); + + expect($refund->getReasonLabel())->toBe('Customer request'); + }); +}); diff --git a/tests/Feature/SubscriptionServiceTest.php b/tests/Feature/SubscriptionServiceTest.php new file mode 100644 index 0000000..98c7223 --- /dev/null +++ b/tests/Feature/SubscriptionServiceTest.php @@ -0,0 +1,551 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Use existing seeded feature (or create test-specific one) + $this->aiCreditsFeature = Feature::firstOrCreate( + ['code' => 'ai.credits'], + [ + 'name' => 'AI Credits', + 'category' => 'ai', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'is_active' => true, + ] + ); + + // Use existing seeded packages + $this->creatorPackage = Package::where('code', 'creator')->firstOrFail(); + $this->agencyPackage = Package::where('code', 'agency')->firstOrFail(); + $this->enterprisePackage = Package::where('code', 'enterprise')->firstOrFail(); + + // Ensure packages have expected prices for this test + $this->creatorPackage->update(['monthly_price' => 19.00, 'yearly_price' => 190.00]); + $this->agencyPackage->update(['monthly_price' => 49.00, 'yearly_price' => 490.00]); + $this->enterprisePackage->update(['monthly_price' => 99.00, 'yearly_price' => 990.00]); + + // Attach features if not already attached + if (! $this->creatorPackage->features()->where('feature_id', $this->aiCreditsFeature->id)->exists()) { + $this->creatorPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 100]); + } + if (! $this->agencyPackage->features()->where('feature_id', $this->aiCreditsFeature->id)->exists()) { + $this->agencyPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 500]); + } + if (! $this->enterprisePackage->features()->where('feature_id', $this->aiCreditsFeature->id)->exists()) { + $this->enterprisePackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => null]); // Unlimited + } + + $this->service = app(SubscriptionService::class); +}); + +describe('SubscriptionService', function () { + describe('create() method', function () { + it('creates a monthly subscription', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage, 'monthly'); + + expect($subscription)->toBeInstanceOf(Subscription::class) + ->and($subscription->workspace_id)->toBe($this->workspace->id) + ->and($subscription->workspace_package_id)->toBe($workspacePackage->id) + ->and($subscription->status)->toBe('active') + ->and($subscription->billing_cycle)->toBe('monthly') + ->and((int) $subscription->current_period_start->diffInDays($subscription->current_period_end))->toBe(30); + }); + + it('creates a yearly subscription', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage, 'yearly'); + + expect($subscription->billing_cycle)->toBe('yearly') + ->and((int) $subscription->current_period_start->diffInDays($subscription->current_period_end))->toBe(365); + }); + }); + + describe('cancel() method', function () { + it('cancels a subscription', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage); + $subscription = $this->service->cancel($subscription, 'Too expensive'); + + expect($subscription->cancelled_at)->not->toBeNull() + ->and($subscription->cancellation_reason)->toBe('Too expensive'); + }); + }); + + describe('resume() method', function () { + it('resumes a cancelled subscription within billing period', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage); + $this->service->cancel($subscription, 'Changed mind'); + $subscription = $this->service->resume($subscription); + + expect($subscription->cancelled_at)->toBeNull() + ->and($subscription->cancellation_reason)->toBeNull(); + }); + + it('does not resume if period has ended', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage); + $subscription->update(['current_period_end' => now()->subDay()]); + $this->service->cancel($subscription); + $subscription = $this->service->resume($subscription); + + // Should still be cancelled + expect($subscription->cancelled_at)->not->toBeNull(); + }); + }); + + describe('renew() method', function () { + it('renews a subscription for another period', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage); + $originalEnd = $subscription->current_period_end; + + // Move time forward + Carbon::setTestNow($originalEnd); + $subscription = $this->service->renew($subscription); + + expect($subscription->current_period_start->toDateString())->toBe($originalEnd->toDateString()) + ->and((int) $subscription->current_period_start->diffInDays($subscription->current_period_end))->toBe(30); + + Carbon::setTestNow(); // Reset + }); + + it('clears cancellation when renewing', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage); + $subscription->update([ + 'cancelled_at' => now(), + 'cancellation_reason' => 'Test', + ]); + + $subscription = $this->service->renew($subscription); + + expect($subscription->cancelled_at)->toBeNull() + ->and($subscription->cancellation_reason)->toBeNull(); + }); + }); + + describe('expire() method', function () { + it('expires a subscription immediately', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage); + $subscription = $this->service->expire($subscription); + + expect($subscription->status)->toBe('expired') + ->and($subscription->ended_at)->not->toBeNull(); + + $workspacePackage->refresh(); + expect($workspacePackage->status)->toBe('expired'); + }); + }); + + describe('pause() and unpause() methods', function () { + it('pauses a subscription', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage); + $subscription = $this->service->pause($subscription); + + expect($subscription->status)->toBe('paused') + ->and($subscription->paused_at)->not->toBeNull() + ->and($subscription->pause_count)->toBe(1); + }); + + it('increments pause count on each pause', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage); + + // First pause + $subscription = $this->service->pause($subscription); + expect($subscription->pause_count)->toBe(1); + + // Unpause + $subscription = $this->service->unpause($subscription); + + // Second pause + $subscription = $this->service->pause($subscription); + expect($subscription->pause_count)->toBe(2); + }); + + it('throws exception when pause limit is exceeded', function () { + config(['commerce.subscriptions.max_pause_cycles' => 2]); + + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage); + + // First pause + $subscription = $this->service->pause($subscription); + $subscription = $this->service->unpause($subscription); + + // Second pause (at limit) + $subscription = $this->service->pause($subscription); + $subscription = $this->service->unpause($subscription); + + // Third pause should throw + expect(fn () => $this->service->pause($subscription)) + ->toThrow(PauseLimitExceededException::class); + }); + + it('allows forced pause even when limit exceeded', function () { + config(['commerce.subscriptions.max_pause_cycles' => 1]); + + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage); + + // First pause (uses the limit) + $subscription = $this->service->pause($subscription); + $subscription = $this->service->unpause($subscription); + + // Force pause should work even when limit exceeded + $subscription = $this->service->pause($subscription, force: true); + + expect($subscription->status)->toBe('paused') + ->and($subscription->pause_count)->toBe(2); + }); + + it('reports canPause correctly', function () { + config(['commerce.subscriptions.max_pause_cycles' => 2]); + + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage); + + expect($subscription->canPause())->toBeTrue() + ->and($subscription->remainingPauseCycles())->toBe(2); + + // First pause + $subscription = $this->service->pause($subscription); + $subscription = $this->service->unpause($subscription); + + expect($subscription->canPause())->toBeTrue() + ->and($subscription->remainingPauseCycles())->toBe(1); + + // Second pause + $subscription = $this->service->pause($subscription); + $subscription = $this->service->unpause($subscription); + + expect($subscription->canPause())->toBeFalse() + ->and($subscription->remainingPauseCycles())->toBe(0); + }); + + it('unpauses a subscription', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $subscription = $this->service->create($workspacePackage); + $this->service->pause($subscription); + $subscription = $this->service->unpause($subscription); + + expect($subscription->status)->toBe('active') + ->and($subscription->paused_at)->toBeNull(); + }); + }); +}); + +describe('Proration calculations', function () { + beforeEach(function () { + // Freeze time for predictable day calculations + Carbon::setTestNow(Carbon::now()->startOfDay()->addHours(12)); + + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->creatorPackage->id, + 'status' => 'active', + ]); + + $this->subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $workspacePackage->id, + 'status' => 'active', + 'gateway' => 'btcpay', + 'billing_cycle' => 'monthly', + 'current_period_start' => now()->subDays(15), + 'current_period_end' => now()->addDays(15), + ]); + }); + + afterEach(function () { + Carbon::setTestNow(); // Reset frozen time + }); + + describe('calculateProration() method', function () { + it('calculates proration for upgrade mid-cycle', function () { + $proration = $this->service->calculateProration( + $this->subscription, + $this->creatorPackage, // £19/month + $this->agencyPackage, // £49/month + 'monthly' + ); + + expect($proration)->toBeInstanceOf(ProrationResult::class) + ->and($proration->currentPlanPrice)->toBe(19.00) + ->and($proration->newPlanPrice)->toBe(49.00) + ->and($proration->isUpgrade())->toBeTrue() + ->and($proration->requiresPayment())->toBeTrue(); + }); + + it('calculates proration for downgrade mid-cycle', function () { + // Start with agency package + $this->subscription->workspacePackage->update(['package_id' => $this->agencyPackage->id]); + + $proration = $this->service->calculateProration( + $this->subscription, + $this->agencyPackage, // £49/month + $this->creatorPackage, // £19/month + 'monthly' + ); + + expect($proration->isDowngrade())->toBeTrue() + ->and($proration->netAmount)->toBeLessThan(0) + ->and($proration->getCreditBalance())->toBeGreaterThan(0); + }); + + it('calculates days remaining correctly', function () { + $proration = $this->service->calculateProration( + $this->subscription, + $this->creatorPackage, + $this->agencyPackage, + 'monthly' + ); + + expect($proration->daysRemaining)->toBe(15) + ->and($proration->totalPeriodDays)->toBe(30); + }); + + it('calculates credit amount based on unused time', function () { + $proration = $this->service->calculateProration( + $this->subscription, + $this->creatorPackage, + $this->agencyPackage, + 'monthly' + ); + + // 15 days remaining out of 30 = 50% unused + // Credit = £19 * 0.5 = £9.50 + expect($proration->creditAmount)->toBe(9.50) + ->and($proration->usedPercentage)->toBe(0.5); + }); + + it('calculates prorated new plan cost', function () { + $proration = $this->service->calculateProration( + $this->subscription, + $this->creatorPackage, + $this->agencyPackage, + 'monthly' + ); + + // 15 days remaining out of 30 = 50% + // Prorated new plan = £49 * 0.5 = £24.50 + expect($proration->proratedNewPlanCost)->toBe(24.50); + }); + + it('calculates net amount correctly', function () { + $proration = $this->service->calculateProration( + $this->subscription, + $this->creatorPackage, + $this->agencyPackage, + 'monthly' + ); + + // Net = Prorated new - Credit = £24.50 - £9.50 = £15.00 + expect($proration->netAmount)->toBe(15.00) + ->and($proration->getAmountDue())->toBe(15.00); + }); + }); + + describe('previewPlanChange() method', function () { + it('returns proration preview without making changes', function () { + $proration = $this->service->previewPlanChange( + $this->subscription, + $this->agencyPackage + ); + + expect($proration)->toBeInstanceOf(ProrationResult::class) + ->and($proration->isUpgrade())->toBeTrue(); + + // Verify no changes were made + $this->subscription->refresh(); + expect($this->subscription->workspacePackage->package->code)->toBe('creator'); + }); + + it('throws exception if subscription has no current package', function () { + // Mock the workspacePackage to have null package + $this->subscription->workspacePackage->setRelation('package', null); + + expect(fn () => $this->service->previewPlanChange($this->subscription, $this->agencyPackage)) + ->toThrow(\InvalidArgumentException::class, 'no current package'); + }); + }); + + describe('scheduled plan changes', function () { + it('schedules plan change for period end', function () { + $result = $this->service->changePlan( + $this->subscription, + $this->agencyPackage, + prorate: false, + immediate: false + ); + + expect($result['immediate'])->toBeFalse() + ->and($this->service->hasPendingPlanChange($result['subscription']))->toBeTrue(); + + $pending = $this->service->getPendingPlanChange($result['subscription']); + expect($pending['to_package_code'])->toBe('agency'); + }); + + it('cancels scheduled plan change', function () { + $result = $this->service->changePlan( + $this->subscription, + $this->agencyPackage, + immediate: false + ); + + $subscription = $this->service->cancelScheduledPlanChange($result['subscription']); + + expect($this->service->hasPendingPlanChange($subscription))->toBeFalse(); + }); + }); +}); + +describe('ProrationResult', function () { + it('converts to array correctly', function () { + $result = new ProrationResult( + daysRemaining: 15, + totalPeriodDays: 30, + usedPercentage: 0.5, + currentPlanPrice: 19.00, + newPlanPrice: 49.00, + creditAmount: 9.50, + proratedNewPlanCost: 24.50, + netAmount: 15.00, + currency: 'GBP' + ); + + $array = $result->toArray(); + + expect($array)->toHaveKeys([ + 'days_remaining', + 'total_period_days', + 'used_percentage', + 'current_plan_price', + 'new_plan_price', + 'credit_amount', + 'prorated_new_plan_cost', + 'net_amount', + 'currency', + 'is_upgrade', + 'is_downgrade', + 'requires_payment', + ]) + ->and($array['is_upgrade'])->toBeTrue() + ->and($array['is_downgrade'])->toBeFalse() + ->and($array['requires_payment'])->toBeTrue(); + }); + + it('identifies same price plans', function () { + $result = new ProrationResult( + daysRemaining: 15, + totalPeriodDays: 30, + usedPercentage: 0.5, + currentPlanPrice: 49.00, + newPlanPrice: 49.00, + creditAmount: 24.50, + proratedNewPlanCost: 24.50, + netAmount: 0.00, + ); + + expect($result->isSamePrice())->toBeTrue() + ->and($result->isUpgrade())->toBeFalse() + ->and($result->isDowngrade())->toBeFalse() + ->and($result->requiresPayment())->toBeFalse(); + }); +}); diff --git a/tests/Feature/TaxServiceTest.php b/tests/Feature/TaxServiceTest.php new file mode 100644 index 0000000..56ecc08 --- /dev/null +++ b/tests/Feature/TaxServiceTest.php @@ -0,0 +1,239 @@ +workspace = Workspace::factory()->create([ + 'billing_country' => 'GB', + 'billing_state' => null, + 'vat_number' => null, + ]); + + // Seed essential tax rates + TaxRate::create([ + 'country_code' => 'GB', + 'name' => 'UK VAT', + 'type' => 'vat', + 'rate' => 20.00, + 'is_digital_services' => true, + 'effective_from' => '2020-01-01', + 'is_active' => true, + ]); + + TaxRate::create([ + 'country_code' => 'DE', + 'name' => 'Germany VAT', + 'type' => 'vat', + 'rate' => 19.00, + 'is_digital_services' => true, + 'effective_from' => '2020-01-01', + 'is_active' => true, + ]); + + TaxRate::create([ + 'country_code' => 'US', + 'state_code' => 'TX', + 'name' => 'Texas Sales Tax', + 'type' => 'sales_tax', + 'rate' => 6.25, + 'is_digital_services' => true, + 'effective_from' => '2020-01-01', + 'is_active' => true, + ]); + + TaxRate::create([ + 'country_code' => 'US', + 'name' => 'US Federal (No Tax)', + 'type' => 'sales_tax', + 'rate' => 0.00, + 'is_digital_services' => true, + 'effective_from' => '2020-01-01', + 'is_active' => true, + ]); + + TaxRate::create([ + 'country_code' => 'AU', + 'name' => 'Australia GST', + 'type' => 'gst', + 'rate' => 10.00, + 'is_digital_services' => true, + 'effective_from' => '2020-01-01', + 'is_active' => true, + ]); + + $this->service = app(TaxService::class); +}); + +describe('TaxService', function () { + describe('calculate() method', function () { + it('calculates UK VAT at 20%', function () { + $this->workspace->update(['billing_country' => 'GB']); + + $result = $this->service->calculate($this->workspace, 100.00); + + expect($result->taxRate)->toBe(20.0) + ->and($result->taxAmount)->toBe(20.00) + ->and($result->jurisdiction)->toBe('GB') + ->and($result->taxType)->toBe('vat'); + }); + + it('calculates German VAT at 19%', function () { + $this->workspace->update(['billing_country' => 'DE']); + + $result = $this->service->calculate($this->workspace, 100.00); + + expect($result->taxRate)->toBe(19.0) + ->and($result->taxAmount)->toBe(19.00) + ->and($result->jurisdiction)->toBe('DE'); + }); + + it('calculates Texas sales tax at 6.25%', function () { + $this->workspace->update([ + 'billing_country' => 'US', + 'billing_state' => 'TX', + ]); + + $result = $this->service->calculate($this->workspace, 100.00); + + expect($result->taxRate)->toBe(6.25) + ->and($result->taxAmount)->toBe(6.25) + ->and($result->jurisdiction)->toBe('US-TX'); + }); + + it('falls back to federal rate for US states without specific rate', function () { + $this->workspace->update([ + 'billing_country' => 'US', + 'billing_state' => 'MT', // Montana - no state sales tax + ]); + + $result = $this->service->calculate($this->workspace, 100.00); + + expect($result->taxRate)->toBe(0.0) + ->and($result->taxAmount)->toBe(0.00); + }); + + it('calculates Australian GST at 10%', function () { + $this->workspace->update(['billing_country' => 'AU']); + + $result = $this->service->calculate($this->workspace, 100.00); + + expect($result->taxRate)->toBe(10.0) + ->and($result->taxAmount)->toBe(10.00) + ->and($result->taxType)->toBe('gst'); + }); + + it('returns zero tax for countries without rates', function () { + $this->workspace->update(['billing_country' => 'XX']); // Unknown + + $result = $this->service->calculate($this->workspace, 100.00); + + expect($result->taxRate)->toBe(0.0) + ->and($result->taxAmount)->toBe(0.00); + }); + + it('rounds tax amount to two decimal places', function () { + $this->workspace->update(['billing_country' => 'GB']); + + $result = $this->service->calculate($this->workspace, 33.33); + + expect($result->taxAmount)->toBe(6.67); // 33.33 * 0.20 = 6.666 + }); + }); + + describe('B2B reverse charge', function () { + it('applies zero rate for valid EU VAT numbers', function () { + $this->workspace->update([ + 'billing_country' => 'DE', + 'tax_id' => 'DE123456789', + ]); + + $result = $this->service->calculate($this->workspace, 100.00); + + expect($result->isExempt)->toBeTrue() + ->and($result->taxAmount)->toBe(0.00) + ->and($result->exemptionReason)->toContain('reverse charge'); + }); + + it('does not apply reverse charge for UK to UK sales', function () { + $this->workspace->update([ + 'billing_country' => 'GB', + 'tax_id' => 'GB123456789', + ]); + + $result = $this->service->calculate($this->workspace, 100.00); + + // UK to UK is not reverse charge + expect($result->taxRate)->toBe(20.0) + ->and($result->taxAmount)->toBe(20.00); + }); + }); + + describe('getRateForLocation() method', function () { + it('returns rate for country', function () { + $rate = $this->service->getRateForLocation('GB'); + + expect($rate)->not->toBeNull() + ->and((float) $rate->rate)->toBe(20.00); + }); + + it('returns state-specific rate when available', function () { + $rate = $this->service->getRateForLocation('US', 'TX'); + + expect($rate)->not->toBeNull() + ->and((float) $rate->rate)->toBe(6.25) + ->and($rate->state_code)->toBe('TX'); + }); + + it('returns null for unknown location', function () { + $rate = $this->service->getRateForLocation('XX'); + + expect($rate)->toBeNull(); + }); + }); +}); + +describe('TaxRate model', function () { + it('calculates tax correctly', function () { + $rate = TaxRate::where('country_code', 'GB')->first(); + + expect($rate->calculateTax(100.00))->toBe(20.00) + ->and($rate->calculateTax(50.00))->toBe(10.00); + }); + + it('checks if rate is effective', function () { + $rate = TaxRate::where('country_code', 'GB')->first(); + + expect($rate->isEffective())->toBeTrue(); + + // Create a future rate + $futureRate = TaxRate::create([ + 'country_code' => 'ZZ', + 'name' => 'Future Tax', + 'type' => 'vat', + 'rate' => 25.00, + 'is_digital_services' => true, + 'effective_from' => now()->addYear(), + 'is_active' => true, + ]); + + expect($futureRate->isEffective())->toBeFalse(); + }); + + it('scopes to effective rates', function () { + $effectiveRates = TaxRate::effective()->get(); + + expect($effectiveRates->count())->toBeGreaterThan(0); + expect($effectiveRates->every(fn ($r) => $r->isEffective()))->toBeTrue(); + }); + + it('finds rate for location', function () { + $rate = TaxRate::findForLocation('GB'); + + expect($rate)->not->toBeNull() + ->and($rate->country_code)->toBe('GB'); + }); +}); diff --git a/tests/Feature/WebhookTest.php b/tests/Feature/WebhookTest.php new file mode 100644 index 0000000..36d840a --- /dev/null +++ b/tests/Feature/WebhookTest.php @@ -0,0 +1,1001 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create([ + 'stripe_customer_id' => 'cus_test_123', + 'btcpay_customer_id' => 'btc_cus_test_123', + ]); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); +}); + +// ============================================================================ +// Stripe Webhook Tests +// ============================================================================ + +describe('StripeWebhookController', function () { + beforeEach(function () { + $this->order = Order::create([ + 'workspace_id' => $this->workspace->id, + 'order_number' => 'ORD-TEST-001', + 'gateway' => 'stripe', + 'gateway_session_id' => 'cs_test_123', + 'subtotal' => 49.00, + 'tax_amount' => 9.80, + 'total' => 58.80, + 'currency' => 'GBP', + 'status' => 'pending', + ]); + + OrderItem::create([ + 'order_id' => $this->order->id, + 'name' => 'Creator Plan', + 'description' => 'Monthly subscription', + 'quantity' => 1, + 'unit_price' => 49.00, + 'total' => 49.00, + 'type' => 'package', + ]); + }); + + describe('signature verification', function () { + it('rejects requests with invalid signature', function () { + $response = $this->postJson(route('api.webhook.stripe'), [ + 'type' => 'checkout.session.completed', + ], [ + 'Stripe-Signature' => 'invalid_signature', + ]); + + $response->assertStatus(401); + }); + + it('rejects requests without signature', function () { + $response = $this->postJson(route('api.webhook.stripe'), [ + 'type' => 'checkout.session.completed', + ]); + + $response->assertStatus(401); + }); + }); + + describe('checkout.session.completed event', function () { + it('fulfils order on successful checkout', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'checkout.session.completed', + 'id' => 'cs_test_123', + 'metadata' => ['order_id' => $this->order->id], + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'cs_test_123', + 'payment_intent' => 'pi_test_123', + 'amount_total' => 5880, + 'currency' => 'gbp', + 'metadata' => ['order_id' => $this->order->id], + ], + ], + ], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $mockCommerce->shouldReceive('fulfillOrder')->once(); + + $mockInvoice = Mockery::mock(InvoiceService::class); + $mockEntitlements = Mockery::mock(EntitlementService::class); + + $webhookLogger = new WebhookLogger; + + $controller = new StripeWebhookController( + $mockGateway, + $mockCommerce, + $mockInvoice, + $mockEntitlements, + $webhookLogger + ); + + $request = new \Illuminate\Http\Request; + $request->headers->set('Stripe-Signature', 't=123,v1=abc'); + + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200); + + // Verify payment was created + $payment = Payment::where('order_id', $this->order->id)->first(); + expect($payment)->not->toBeNull() + ->and($payment->gateway)->toBe('stripe') + ->and($payment->status)->toBe('succeeded'); + + // Verify webhook event was logged + $webhookEvent = WebhookEvent::forGateway('stripe')->latest()->first(); + expect($webhookEvent)->not->toBeNull() + ->and($webhookEvent->event_type)->toBe('checkout.session.completed') + ->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_PROCESSED); + }); + + it('skips already paid orders', function () { + $this->order->update(['status' => 'paid', 'paid_at' => now()]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'checkout.session.completed', + 'id' => 'cs_test_123', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'cs_test_123', + 'metadata' => ['order_id' => $this->order->id], + ], + ], + ], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $mockCommerce->shouldNotReceive('fulfillOrder'); + + $mockInvoice = Mockery::mock(InvoiceService::class); + $mockEntitlements = Mockery::mock(EntitlementService::class); + $webhookLogger = new WebhookLogger; + + $controller = new StripeWebhookController( + $mockGateway, + $mockCommerce, + $mockInvoice, + $mockEntitlements, + $webhookLogger + ); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('Already processed'); + }); + + it('handles missing order gracefully', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'checkout.session.completed', + 'id' => 'cs_test_123', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'cs_test_123', + 'metadata' => ['order_id' => 99999], + ], + ], + ], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $mockInvoice = Mockery::mock(InvoiceService::class); + $mockEntitlements = Mockery::mock(EntitlementService::class); + $webhookLogger = new WebhookLogger; + + $controller = new StripeWebhookController( + $mockGateway, + $mockCommerce, + $mockInvoice, + $mockEntitlements, + $webhookLogger + ); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('Order not found'); + }); + }); + + describe('invoice.payment_failed event', function () { + it('marks subscription as past due and notifies owner', function () { + $package = Package::where('code', 'creator')->first(); + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $package->id, + 'status' => 'active', + ]); + + $subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $workspacePackage->id, + 'gateway' => 'stripe', + 'gateway_subscription_id' => 'sub_test_123', + 'gateway_customer_id' => 'cus_test_123', + 'status' => 'active', + 'billing_cycle' => 'monthly', + 'current_period_start' => now(), + 'current_period_end' => now()->addMonth(), + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.payment_failed', + 'id' => 'in_test_123', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'in_test_123', + 'subscription' => 'sub_test_123', + ], + ], + ], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $mockInvoice = Mockery::mock(InvoiceService::class); + $mockEntitlements = Mockery::mock(EntitlementService::class); + $webhookLogger = new WebhookLogger; + + $controller = new StripeWebhookController( + $mockGateway, + $mockCommerce, + $mockInvoice, + $mockEntitlements, + $webhookLogger + ); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200); + + $subscription->refresh(); + expect($subscription->status)->toBe('past_due'); + + Notification::assertSentTo($this->user, PaymentFailed::class); + }); + }); + + describe('customer.subscription.deleted event', function () { + it('cancels subscription and revokes entitlements', function () { + $package = Package::where('code', 'creator')->first(); + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $package->id, + 'status' => 'active', + ]); + + $subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $workspacePackage->id, + 'gateway' => 'stripe', + 'gateway_subscription_id' => 'sub_test_456', + 'gateway_customer_id' => 'cus_test_123', + 'status' => 'active', + 'billing_cycle' => 'monthly', + 'current_period_start' => now(), + 'current_period_end' => now()->addMonth(), + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'customer.subscription.deleted', + 'id' => 'sub_test_456', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'sub_test_456', + 'status' => 'canceled', + ], + ], + ], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $mockInvoice = Mockery::mock(InvoiceService::class); + $mockEntitlements = Mockery::mock(EntitlementService::class); + $mockEntitlements->shouldReceive('revokePackage')->once(); + $webhookLogger = new WebhookLogger; + + $controller = new StripeWebhookController( + $mockGateway, + $mockCommerce, + $mockInvoice, + $mockEntitlements, + $webhookLogger + ); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200); + + $subscription->refresh(); + expect($subscription->status)->toBe('cancelled') + ->and($subscription->ended_at)->not->toBeNull(); + + Notification::assertSentTo($this->user, SubscriptionCancelled::class); + }); + }); + + describe('unhandled events', function () { + it('returns 200 for unknown event types', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'some.unknown.event', + 'id' => 'evt_test_123', + 'raw' => [], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $mockInvoice = Mockery::mock(InvoiceService::class); + $mockEntitlements = Mockery::mock(EntitlementService::class); + $webhookLogger = new WebhookLogger; + + $controller = new StripeWebhookController( + $mockGateway, + $mockCommerce, + $mockInvoice, + $mockEntitlements, + $webhookLogger + ); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('Unhandled event type'); + + // Verify webhook event was logged as skipped + $webhookEvent = WebhookEvent::forGateway('stripe')->latest()->first(); + expect($webhookEvent)->not->toBeNull() + ->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_SKIPPED); + }); + }); +}); + +// ============================================================================ +// BTCPay Webhook Tests +// ============================================================================ + +describe('BTCPayWebhookController', function () { + beforeEach(function () { + $this->order = Order::create([ + 'workspace_id' => $this->workspace->id, + 'order_number' => 'ORD-BTC-001', + 'gateway' => 'btcpay', + 'gateway_session_id' => 'btc_invoice_123', + 'subtotal' => 49.00, + 'tax_amount' => 9.80, + 'total' => 58.80, + 'currency' => 'GBP', + 'status' => 'pending', + ]); + + OrderItem::create([ + 'order_id' => $this->order->id, + 'name' => 'Creator Plan', + 'description' => 'Monthly subscription', + 'quantity' => 1, + 'unit_price' => 49.00, + 'total' => 49.00, + 'type' => 'package', + ]); + }); + + describe('signature verification', function () { + it('rejects requests with invalid signature', function () { + $response = $this->postJson(route('api.webhook.btcpay'), [ + 'type' => 'InvoiceSettled', + ], [ + 'BTCPay-Sig' => 'invalid_signature', + ]); + + $response->assertStatus(401); + }); + + it('rejects requests without signature', function () { + $response = $this->postJson(route('api.webhook.btcpay'), [ + 'type' => 'InvoiceSettled', + ]); + + $response->assertStatus(401); + }); + }); + + describe('invoice.paid event (InvoiceSettled)', function () { + it('fulfils order on successful payment', function () { + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.paid', + 'id' => 'btc_invoice_123', + 'status' => 'succeeded', + 'metadata' => [], + 'raw' => [ + 'invoiceId' => 'btc_invoice_123', + 'type' => 'InvoiceSettled', + ], + ]); + $mockGateway->shouldReceive('getCheckoutSession')->andReturn([ + 'id' => 'btc_invoice_123', + 'status' => 'succeeded', + 'amount' => 58.80, + 'currency' => 'GBP', + 'raw' => ['invoiceId' => 'btc_invoice_123'], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $mockCommerce->shouldReceive('fulfillOrder')->once(); + + $webhookLogger = new WebhookLogger; + + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $request->headers->set('BTCPay-Sig', 'valid_signature'); + + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200); + + // Verify payment was created + $payment = Payment::where('order_id', $this->order->id)->first(); + expect($payment)->not->toBeNull() + ->and($payment->gateway)->toBe('btcpay') + ->and($payment->status)->toBe('succeeded'); + + // Verify webhook event was logged + $webhookEvent = WebhookEvent::forGateway('btcpay')->latest()->first(); + expect($webhookEvent)->not->toBeNull() + ->and($webhookEvent->event_type)->toBe('invoice.paid') + ->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_PROCESSED); + }); + + it('skips already paid orders', function () { + $this->order->update(['status' => 'paid', 'paid_at' => now()]); + + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.paid', + 'id' => 'btc_invoice_123', + 'status' => 'succeeded', + 'metadata' => [], + 'raw' => [], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $mockCommerce->shouldNotReceive('fulfillOrder'); + + $webhookLogger = new WebhookLogger; + + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('Already processed'); + }); + + it('handles missing order gracefully', function () { + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.paid', + 'id' => 'btc_invoice_nonexistent', + 'status' => 'succeeded', + 'metadata' => [], + 'raw' => [], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $webhookLogger = new WebhookLogger; + + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('Order not found'); + }); + }); + + describe('invoice.expired event', function () { + it('marks order as failed when invoice expires', function () { + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.expired', + 'id' => 'btc_invoice_123', + 'status' => 'expired', + 'metadata' => [], + 'raw' => [], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $webhookLogger = new WebhookLogger; + + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200); + + $this->order->refresh(); + expect($this->order->status)->toBe('failed'); + }); + + it('does not mark paid orders as failed', function () { + $this->order->update(['status' => 'paid', 'paid_at' => now()]); + + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.expired', + 'id' => 'btc_invoice_123', + 'status' => 'expired', + 'metadata' => [], + 'raw' => [], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $webhookLogger = new WebhookLogger; + + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200); + + $this->order->refresh(); + expect($this->order->status)->toBe('paid'); + }); + }); + + describe('invoice.failed event', function () { + it('marks order as failed when payment is rejected', function () { + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.failed', + 'id' => 'btc_invoice_123', + 'status' => 'failed', + 'metadata' => [], + 'raw' => [], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $webhookLogger = new WebhookLogger; + + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200); + + $this->order->refresh(); + expect($this->order->status)->toBe('failed'); + }); + }); + + describe('invoice.processing event', function () { + it('marks order as processing when payment is being confirmed', function () { + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.processing', + 'id' => 'btc_invoice_123', + 'status' => 'processing', + 'metadata' => [], + 'raw' => [], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $webhookLogger = new WebhookLogger; + + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200); + + $this->order->refresh(); + expect($this->order->status)->toBe('processing'); + }); + }); + + describe('invoice.payment_received event', function () { + it('marks order as processing when payment is detected', function () { + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.payment_received', + 'id' => 'btc_invoice_123', + 'status' => 'processing', + 'metadata' => [], + 'raw' => [], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $webhookLogger = new WebhookLogger; + + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200); + + $this->order->refresh(); + expect($this->order->status)->toBe('processing'); + }); + }); + + describe('unhandled events', function () { + it('returns 200 for unknown event types', function () { + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'some.unknown.event', + 'id' => 'btc_invoice_123', + 'status' => 'unknown', + 'metadata' => [], + 'raw' => [], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $webhookLogger = new WebhookLogger; + + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('Unhandled event type'); + + // Verify webhook event was logged as skipped + $webhookEvent = WebhookEvent::forGateway('btcpay')->latest()->first(); + expect($webhookEvent)->not->toBeNull() + ->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_SKIPPED); + }); + }); +}); + +// ============================================================================ +// Webhook Event Logging Tests +// ============================================================================ + +describe('WebhookEvent model', function () { + it('creates webhook event with all fields', function () { + $event = WebhookEvent::record( + gateway: 'stripe', + eventType: 'checkout.session.completed', + payload: '{"test": true}', + eventId: 'evt_test_123', + headers: ['Content-Type' => 'application/json'] + ); + + expect($event)->toBeInstanceOf(WebhookEvent::class) + ->and($event->gateway)->toBe('stripe') + ->and($event->event_type)->toBe('checkout.session.completed') + ->and($event->event_id)->toBe('evt_test_123') + ->and($event->payload)->toBe('{"test": true}') + ->and($event->headers)->toBe(['Content-Type' => 'application/json']) + ->and($event->status)->toBe(WebhookEvent::STATUS_PENDING) + ->and($event->received_at)->not->toBeNull(); + }); + + it('marks event as processed', function () { + $event = WebhookEvent::record('stripe', 'test.event', '{}'); + $event->markProcessed(200); + + expect($event->status)->toBe(WebhookEvent::STATUS_PROCESSED) + ->and($event->http_status_code)->toBe(200) + ->and($event->processed_at)->not->toBeNull(); + }); + + it('marks event as failed with error', function () { + $event = WebhookEvent::record('stripe', 'test.event', '{}'); + $event->markFailed('Something went wrong', 500); + + expect($event->status)->toBe(WebhookEvent::STATUS_FAILED) + ->and($event->error_message)->toBe('Something went wrong') + ->and($event->http_status_code)->toBe(500) + ->and($event->processed_at)->not->toBeNull(); + }); + + it('marks event as skipped with reason', function () { + $event = WebhookEvent::record('stripe', 'test.event', '{}'); + $event->markSkipped('Unhandled event type'); + + expect($event->status)->toBe(WebhookEvent::STATUS_SKIPPED) + ->and($event->error_message)->toBe('Unhandled event type') + ->and($event->http_status_code)->toBe(200); + }); + + it('checks for duplicate events', function () { + WebhookEvent::record('stripe', 'test.event', '{}', 'evt_unique_123') + ->markProcessed(); + + expect(WebhookEvent::hasBeenProcessed('stripe', 'evt_unique_123'))->toBeTrue() + ->and(WebhookEvent::hasBeenProcessed('stripe', 'evt_other'))->toBeFalse() + ->and(WebhookEvent::hasBeenProcessed('btcpay', 'evt_unique_123'))->toBeFalse(); + }); + + it('links to order and subscription', function () { + $order = Order::create([ + 'workspace_id' => $this->workspace->id, + 'order_number' => 'ORD-LINK-001', + 'subtotal' => 10.00, + 'total' => 10.00, + 'currency' => 'GBP', + 'status' => 'pending', + ]); + + $event = WebhookEvent::record('stripe', 'test.event', '{}'); + $event->linkOrder($order); + + expect($event->order_id)->toBe($order->id) + ->and($event->order)->toBeInstanceOf(Order::class); + }); + + it('decodes payload correctly', function () { + $event = WebhookEvent::record('stripe', 'test.event', '{"key": "value", "nested": {"a": 1}}'); + + expect($event->getDecodedPayload())->toBe([ + 'key' => 'value', + 'nested' => ['a' => 1], + ]); + }); + + it('scopes by gateway and status', function () { + WebhookEvent::record('stripe', 'evt.1', '{}')->markProcessed(); + WebhookEvent::record('stripe', 'evt.2', '{}')->markFailed('err'); + WebhookEvent::record('btcpay', 'evt.3', '{}')->markProcessed(); + + expect(WebhookEvent::forGateway('stripe')->count())->toBe(2) + ->and(WebhookEvent::forGateway('btcpay')->count())->toBe(1) + ->and(WebhookEvent::failed()->count())->toBe(1); + }); +}); + +describe('WebhookLogger service', function () { + it('starts and completes webhook logging', function () { + $logger = new WebhookLogger; + + $event = $logger->start( + gateway: 'stripe', + eventType: 'checkout.session.completed', + payload: '{"data": "test"}', + eventId: 'evt_logger_test' + ); + + expect($event->status)->toBe(WebhookEvent::STATUS_PENDING); + + $logger->success(); + + $event->refresh(); + expect($event->status)->toBe(WebhookEvent::STATUS_PROCESSED); + }); + + it('handles failures correctly', function () { + $logger = new WebhookLogger; + + $logger->start('btcpay', 'invoice.paid', '{}'); + $logger->fail('Database error', 500); + + $event = $logger->getCurrentEvent(); + expect($event->status)->toBe(WebhookEvent::STATUS_FAILED) + ->and($event->error_message)->toBe('Database error') + ->and($event->http_status_code)->toBe(500); + }); + + it('detects duplicate events', function () { + $logger = new WebhookLogger; + + // First event + $logger->start('stripe', 'test.event', '{}', 'evt_dup_test'); + $logger->success(); + + // Check for duplicate + expect($logger->isDuplicate('stripe', 'evt_dup_test'))->toBeTrue(); + }); + + it('extracts relevant headers', function () { + $logger = new WebhookLogger; + + $request = new \Illuminate\Http\Request; + $request->headers->set('Stripe-Signature', 't=123,v1=secret_signature_here'); + $request->headers->set('Content-Type', 'application/json'); + $request->headers->set('User-Agent', 'Stripe/1.0'); + + $event = $logger->start('stripe', 'test.event', '{}', null, $request); + + expect($event->headers)->toHaveKey('Content-Type') + ->and($event->headers)->toHaveKey('User-Agent') + ->and($event->headers)->toHaveKey('Stripe-Signature'); + + // Signature should be masked + expect($event->headers['Stripe-Signature'])->toContain('...'); + }); + + it('gets statistics for webhook events', function () { + $logger = new WebhookLogger; + + // Create some events + WebhookEvent::record('stripe', 'evt.1', '{}')->markProcessed(); + WebhookEvent::record('stripe', 'evt.2', '{}')->markProcessed(); + WebhookEvent::record('stripe', 'evt.3', '{}')->markFailed('err'); + WebhookEvent::record('stripe', 'evt.4', '{}')->markSkipped('skip'); + + $stats = $logger->getStats('stripe'); + + expect($stats['total'])->toBe(4) + ->and($stats['processed'])->toBe(2) + ->and($stats['failed'])->toBe(1) + ->and($stats['skipped'])->toBe(1); + }); +}); + +// ============================================================================ +// Gateway Webhook Signature Tests +// ============================================================================ + +describe('BTCPayGateway webhook signature verification', function () { + it('verifies correct HMAC signature', function () { + config(['commerce.gateways.btcpay.webhook_secret' => 'test_secret_123']); + + $gateway = new BTCPayGateway; + $payload = '{"type":"InvoiceSettled","invoiceId":"123"}'; + $signature = hash_hmac('sha256', $payload, 'test_secret_123'); + + expect($gateway->verifyWebhookSignature($payload, $signature))->toBeTrue(); + }); + + it('verifies signature with sha256= prefix', function () { + config(['commerce.gateways.btcpay.webhook_secret' => 'test_secret_123']); + + $gateway = new BTCPayGateway; + $payload = '{"type":"InvoiceSettled","invoiceId":"123"}'; + $signature = 'sha256='.hash_hmac('sha256', $payload, 'test_secret_123'); + + expect($gateway->verifyWebhookSignature($payload, $signature))->toBeTrue(); + }); + + it('rejects invalid signature', function () { + config(['commerce.gateways.btcpay.webhook_secret' => 'test_secret_123']); + + $gateway = new BTCPayGateway; + $payload = '{"type":"InvoiceSettled","invoiceId":"123"}'; + + expect($gateway->verifyWebhookSignature($payload, 'invalid'))->toBeFalse(); + }); + + it('rejects empty signature', function () { + config(['commerce.gateways.btcpay.webhook_secret' => 'test_secret_123']); + + $gateway = new BTCPayGateway; + $payload = '{"type":"InvoiceSettled","invoiceId":"123"}'; + + expect($gateway->verifyWebhookSignature($payload, ''))->toBeFalse(); + }); + + it('rejects when no webhook secret configured', function () { + config(['commerce.gateways.btcpay.webhook_secret' => null]); + + $gateway = new BTCPayGateway; + $payload = '{"type":"InvoiceSettled","invoiceId":"123"}'; + $signature = hash_hmac('sha256', $payload, 'any_secret'); + + expect($gateway->verifyWebhookSignature($payload, $signature))->toBeFalse(); + }); +}); + +describe('BTCPayGateway webhook event parsing', function () { + it('parses valid webhook payload', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 'InvoiceSettled', + 'invoiceId' => 'inv_123', + 'status' => 'Settled', + 'metadata' => ['order_id' => 1], + ]); + + $event = $gateway->parseWebhookEvent($payload); + + expect($event['type'])->toBe('invoice.paid') + ->and($event['id'])->toBe('inv_123') + ->and($event['status'])->toBe('succeeded') + ->and($event['metadata'])->toBe(['order_id' => 1]); + }); + + it('handles invalid JSON gracefully', function () { + $gateway = new BTCPayGateway; + $payload = 'invalid json {{{'; + + $event = $gateway->parseWebhookEvent($payload); + + expect($event['type'])->toBe('unknown') + ->and($event['id'])->toBeNull() + ->and($event['raw'])->toBe([]); + }); + + it('maps event types correctly', function () { + $gateway = new BTCPayGateway; + + $testCases = [ + ['type' => 'InvoiceCreated', 'expected' => 'invoice.created'], + ['type' => 'InvoiceReceivedPayment', 'expected' => 'invoice.payment_received'], + ['type' => 'InvoiceProcessing', 'expected' => 'invoice.processing'], + ['type' => 'InvoiceExpired', 'expected' => 'invoice.expired'], + ['type' => 'InvoiceSettled', 'expected' => 'invoice.paid'], + ['type' => 'InvoiceInvalid', 'expected' => 'invoice.failed'], + ['type' => 'InvoicePaymentSettled', 'expected' => 'payment.settled'], + ]; + + foreach ($testCases as $case) { + $event = $gateway->parseWebhookEvent(json_encode(['type' => $case['type']])); + expect($event['type'])->toBe($case['expected'], "Failed for type: {$case['type']}"); + } + }); + + it('maps invoice statuses correctly', function () { + $gateway = new BTCPayGateway; + + $testCases = [ + ['status' => 'New', 'expected' => 'pending'], + ['status' => 'Processing', 'expected' => 'processing'], + ['status' => 'Expired', 'expected' => 'expired'], + ['status' => 'Invalid', 'expected' => 'failed'], + ['status' => 'Settled', 'expected' => 'succeeded'], + ['status' => 'Complete', 'expected' => 'succeeded'], + ]; + + foreach ($testCases as $case) { + $event = $gateway->parseWebhookEvent(json_encode([ + 'type' => 'InvoiceSettled', + 'status' => $case['status'], + ])); + expect($event['status'])->toBe($case['expected'], "Failed for status: {$case['status']}"); + } + }); +}); + +afterEach(function () { + Mockery::close(); +}); diff --git a/tests/UseCase/AdminCrudBasic.php b/tests/UseCase/AdminCrudBasic.php new file mode 100644 index 0000000..f0d54ee --- /dev/null +++ b/tests/UseCase/AdminCrudBasic.php @@ -0,0 +1,209 @@ +user = User::factory()->create([ + 'email' => 'admin@example.com', + 'password' => bcrypt('password'), + 'is_admin' => true, + ]); + + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + }); + + it('can view the commerce dashboard with all sections', function () { + // Login as admin + $this->actingAs($this->user); + + $response = $this->get(route('hub.commerce.dashboard')); + + $response->assertOk() + ->assertSee(__('commerce::commerce.dashboard.title')) + ->assertSee(__('commerce::commerce.dashboard.subtitle')) + ->assertSee(__('commerce::commerce.actions.view_orders')) + ->assertSee(__('commerce::commerce.sections.quick_actions')) + ->assertSee(__('commerce::commerce.sections.recent_orders')); + }); +}); + +describe('Commerce Product Management', function () { + beforeEach(function () { + $this->user = User::factory()->create([ + 'email' => 'admin@example.com', + 'password' => bcrypt('password'), + 'is_admin' => true, + ]); + + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + }); + + it('can view the product catalog page', function () { + $this->actingAs($this->user); + + $response = $this->get(route('hub.commerce.products')); + + $response->assertOk() + ->assertSee(__('commerce::commerce.products.title')) + ->assertSee(__('commerce::commerce.actions.add_product')); + }); + + it('can see product modal labels', function () { + $this->actingAs($this->user); + + // Test that the translation keys exist and are correct + expect(__('commerce::commerce.products.modal.create_title'))->toBe('Create Product'); + expect(__('commerce::commerce.products.modal.edit_title'))->toBe('Edit Product'); + expect(__('commerce::commerce.form.sku'))->toBe('SKU'); + expect(__('commerce::commerce.form.type'))->toBe('Type'); + expect(__('commerce::commerce.form.name'))->toBe('Name'); + expect(__('commerce::commerce.form.description'))->toBe('Description'); + expect(__('commerce::commerce.form.category'))->toBe('Category'); + expect(__('commerce::commerce.form.subcategory'))->toBe('Subcategory'); + expect(__('commerce::commerce.form.price'))->toBe('Price (pence)'); + expect(__('commerce::commerce.form.cost_price'))->toBe('Cost Price'); + expect(__('commerce::commerce.form.rrp'))->toBe('RRP'); + expect(__('commerce::commerce.form.stock_quantity'))->toBe('Stock Quantity'); + expect(__('commerce::commerce.form.low_stock_threshold'))->toBe('Low Stock Threshold'); + expect(__('commerce::commerce.form.tax_class'))->toBe('Tax Class'); + }); +}); + +describe('Commerce Order Management', function () { + beforeEach(function () { + $this->user = User::factory()->create([ + 'email' => 'admin@example.com', + 'password' => bcrypt('password'), + 'is_admin' => true, + ]); + + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + }); + + it('can view the orders page', function () { + $this->actingAs($this->user); + + $response = $this->get(route('hub.commerce.orders')); + + $response->assertOk() + ->assertSee(__('commerce::commerce.orders.title')) + ->assertSee(__('commerce::commerce.orders.subtitle')) + ->assertSee(__('commerce::commerce.orders.empty')); + }); + + it('has correct order detail modal labels', function () { + expect(__('commerce::commerce.orders.detail.status'))->toBe('Status'); + expect(__('commerce::commerce.orders.detail.type'))->toBe('Type'); + expect(__('commerce::commerce.orders.detail.payment_gateway'))->toBe('Payment Gateway'); + expect(__('commerce::commerce.orders.detail.paid_at'))->toBe('Paid At'); + expect(__('commerce::commerce.orders.detail.customer'))->toBe('Customer'); + expect(__('commerce::commerce.orders.detail.items'))->toBe('Items'); + expect(__('commerce::commerce.orders.detail.subtotal'))->toBe('Subtotal'); + expect(__('commerce::commerce.orders.detail.discount'))->toBe('Discount'); + expect(__('commerce::commerce.orders.detail.tax'))->toBe('Tax'); + expect(__('commerce::commerce.orders.detail.total'))->toBe('Total'); + }); +}); + +describe('Commerce Subscription Management', function () { + beforeEach(function () { + $this->user = User::factory()->create([ + 'email' => 'admin@example.com', + 'password' => bcrypt('password'), + 'is_admin' => true, + ]); + + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + }); + + it('can view the subscriptions page', function () { + $this->actingAs($this->user); + + $response = $this->get(route('hub.commerce.subscriptions')); + + $response->assertOk() + ->assertSee(__('commerce::commerce.subscriptions.title')) + ->assertSee(__('commerce::commerce.subscriptions.subtitle')) + ->assertSee(__('commerce::commerce.subscriptions.empty')); + }); + + it('has correct subscription modal labels', function () { + expect(__('commerce::commerce.subscriptions.detail.title'))->toBe('Subscription Details'); + expect(__('commerce::commerce.subscriptions.detail.status'))->toBe('Status'); + expect(__('commerce::commerce.subscriptions.detail.gateway'))->toBe('Gateway'); + expect(__('commerce::commerce.subscriptions.detail.billing_cycle'))->toBe('Billing Cycle'); + expect(__('commerce::commerce.subscriptions.detail.workspace'))->toBe('Workspace'); + expect(__('commerce::commerce.subscriptions.detail.package'))->toBe('Package'); + expect(__('commerce::commerce.subscriptions.detail.current_period'))->toBe('Current Period'); + expect(__('commerce::commerce.subscriptions.update_status.title'))->toBe('Update Subscription Status'); + expect(__('commerce::commerce.subscriptions.extend.title'))->toBe('Extend Subscription Period'); + }); +}); + +describe('Commerce Coupon Management', function () { + beforeEach(function () { + $this->user = User::factory()->create([ + 'email' => 'admin@example.com', + 'password' => bcrypt('password'), + 'is_admin' => true, + ]); + + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + }); + + it('can view the coupons page', function () { + $this->actingAs($this->user); + + $response = $this->get(route('hub.commerce.coupons')); + + $response->assertOk() + ->assertSee(__('commerce::commerce.coupons.title')) + ->assertSee(__('commerce::commerce.coupons.subtitle')) + ->assertSee(__('commerce::commerce.actions.new_coupon')) + ->assertSee(__('commerce::commerce.coupons.empty')); + }); + + it('has correct coupon form labels', function () { + expect(__('commerce::commerce.coupons.modal.create_title'))->toBe('Create Coupon'); + expect(__('commerce::commerce.coupons.modal.edit_title'))->toBe('Edit Coupon'); + expect(__('commerce::commerce.coupons.form.code'))->toBe('Code'); + expect(__('commerce::commerce.coupons.form.name'))->toBe('Name'); + expect(__('commerce::commerce.coupons.form.description'))->toBe('Description (optional)'); + expect(__('commerce::commerce.coupons.form.discount_type'))->toBe('Discount Type'); + expect(__('commerce::commerce.coupons.form.percentage'))->toBe('Percentage (%)'); + expect(__('commerce::commerce.coupons.form.fixed_amount'))->toBe('Fixed amount (GBP)'); + }); +}); diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 421b569..0000000 --- a/vite.config.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vite'; -import laravel from 'laravel-vite-plugin'; - -export default defineConfig({ - plugins: [ - laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], - refresh: true, - }), - ], -});