feat(commerce): implement RFC.md — billing, subscriptions, Stripe + BTCPay, Commerce Matrix (#845)

Extends prior #860 DunningService with the full RFC.md surface.

Lands across 44 modified/new files:
* Contracts/PaymentGatewayContract.php — implemented by both
  Services/StripeGateway.php and Services/BTCPayGateway.php
* Boot.php — provider bindings + route groups + Commerce Matrix training
  mode middleware
* Services/WebhookService.php — DB::transaction wrapping + ProcessWebhookEvent
  job dispatched ->afterCommit; idempotency via webhook_events unique
  (gateway, event_id) — duplicates rejected silently
* Jobs/ProcessWebhookEvent.php
* DTOs/ — readonly PHP 8.2+ classes per RFC.dto.md
* Services/SubscriptionStateMachine.php — active → suspended (failed
  payment) → cancelled → expired transitions
* Services/ProrationService.php — credit unused old plan time, charge
  new plan remainder, applied via CreditNote + Invoice
* DunningService extended — 1d/3d/7d/14d retry config + cancel
* Migrations — guarded migrations for missing short-name billing tables
  (orders/payments/invoices) + RFC compatibility columns
* routes/api.php — /v1/* endpoints
* Checkout success/cancel routes
* Commerce Matrix training-mode endpoint + record-permissions logic
* Console/Commands — RFC.commands.md signatures
* Events per RFC.events.md
* Models extended

php -l clean. composer validate passes. pest unrunnable in sandbox.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=845
This commit is contained in:
Snider 2026-04-25 22:55:49 +01:00
parent 51f9595797
commit 4e4337e412
51 changed files with 2112 additions and 40 deletions

View file

@ -8,6 +8,7 @@ use Core\Events\AdminPanelBooting;
use Core\Events\ApiRoutesRegistering;
use Core\Events\ConsoleBooting;
use Core\Events\WebRoutesRegistering;
use Core\Mod\Commerce\Contracts\PaymentGatewayContract as RfcPaymentGatewayContract;
use Core\Mod\Commerce\Events\OrderPaid;
use Core\Mod\Commerce\Events\SubscriptionCreated;
use Core\Mod\Commerce\Events\SubscriptionRenewed;
@ -27,12 +28,15 @@ use Core\Mod\Commerce\Services\PaymentGateway\PaymentGatewayContract;
use Core\Mod\Commerce\Services\PaymentGateway\StripeGateway;
use Core\Mod\Commerce\Services\PaymentMethodService;
use Core\Mod\Commerce\Services\PermissionMatrixService;
use Core\Mod\Commerce\Services\ProrationService;
use Core\Mod\Commerce\Services\ReferralService;
use Core\Mod\Commerce\Services\SkuBuilderService;
use Core\Mod\Commerce\Services\SkuParserService;
use Core\Mod\Commerce\Services\SubscriptionStateMachine;
use Core\Mod\Commerce\Services\SubscriptionService;
use Core\Mod\Commerce\Services\TaxService;
use Core\Mod\Commerce\Services\UsageBillingService;
use Core\Mod\Commerce\Services\WebhookService;
use Core\Mod\Commerce\Services\WebhookRateLimiter;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
@ -98,6 +102,9 @@ class Boot extends ServiceProvider
$this->app->singleton(FraudService::class);
$this->app->singleton(CheckoutRateLimiter::class);
$this->app->singleton(WebhookRateLimiter::class);
$this->app->singleton(WebhookService::class);
$this->app->singleton(SubscriptionStateMachine::class);
$this->app->singleton(ProrationService::class);
// Payment Gateways
$this->app->singleton('commerce.gateway.btcpay', function ($app) {
@ -108,6 +115,14 @@ class Boot extends ServiceProvider
return new StripeGateway;
});
$this->app->singleton('commerce.rfc_gateway.btcpay', function ($app) {
return new Services\BTCPayGateway;
});
$this->app->singleton('commerce.rfc_gateway.stripe', function ($app) {
return new Services\StripeGateway;
});
$this->app->bind(PaymentGatewayContract::class, function ($app) {
$defaultGateway = config('commerce.gateways.btcpay.enabled')
? 'btcpay'
@ -115,6 +130,14 @@ class Boot extends ServiceProvider
return $app->make("commerce.gateway.{$defaultGateway}");
});
$this->app->bind(RfcPaymentGatewayContract::class, function ($app) {
$defaultGateway = config('commerce.gateways.btcpay.enabled')
? 'btcpay'
: 'stripe';
return $app->make("commerce.rfc_gateway.{$defaultGateway}");
});
}
// -------------------------------------------------------------------------

View file

@ -22,7 +22,7 @@ use Mod\Trees\Models\TreePlanting;
*/
class PlantSubscriberTrees extends Command
{
protected $signature = 'trees:subscriber-monthly
protected $signature = 'commerce:plant-trees
{--dry-run : Show what would be planted without actually planting}
{--force : Ignore monthly check and plant regardless}';

View file

@ -12,7 +12,7 @@ use Illuminate\Support\Facades\Log;
class ProcessDunning extends Command
{
protected $signature = 'commerce:process-dunning
protected $signature = 'commerce:dunning
{--dry-run : Show what would happen without making changes}
{--stage= : Process only a specific stage (retry, pause, suspend, cancel, expire)}';

View file

@ -15,7 +15,7 @@ use Illuminate\Console\Command;
*/
class RefreshExchangeRates extends Command
{
protected $signature = 'commerce:refresh-exchange-rates
protected $signature = 'commerce:exchange-rates
{--force : Force refresh even if rates are fresh}';
protected $description = 'Refresh exchange rates from the configured provider';

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Contracts;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Models\PaymentMethod;
use Core\Mod\Commerce\Models\Refund;
use Illuminate\Http\Request;
interface PaymentGatewayContract
{
/**
* Create a payment intent or checkout session.
*
* @return array<string, mixed>
*/
public function createSession(Order $order, PaymentMethod $paymentMethod): array;
/**
* Confirm a gateway payment against a local payment record.
*
* @param array<string, mixed> $gatewayData
*/
public function confirmPayment(Payment $payment, array $gatewayData): Payment;
public function refund(Payment $payment, float $amount, string $reason): Refund;
public function validateWebhookSignature(Request $request): bool;
/**
* Parse the request payload into a normalised gateway event.
*
* @return array<string, mixed>
*/
public function parseWebhookEvent(Request $request): array;
}

View file

@ -7,6 +7,7 @@ namespace Core\Mod\Commerce\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Commerce\Models\Invoice;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\PaymentMethod;
use Core\Mod\Commerce\Models\Subscription;
use Core\Mod\Commerce\Services\CommerceService;
use Core\Mod\Commerce\Services\InvoiceService;
@ -52,6 +53,82 @@ class CommerceController extends Controller
return $user->defaultHostWorkspace();
}
public function checkout(Request $request): JsonResponse
{
$validated = $request->validate([
'items' => 'required|array|min:1',
'payment_method_id' => 'required|string',
'coupon_code' => 'nullable|string',
'currency' => 'nullable|string|size:3',
]);
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
if (! method_exists($this->commerceService, 'processCheckout')) {
return response()->json([
'error' => 'checkout_unavailable',
'message' => 'Checkout orchestration is not available for this frontage.',
], 501);
}
$result = $this->commerceService->processCheckout(
$workspace->id,
$validated['items'],
$validated['payment_method_id'] ?? '',
$validated['coupon_code'] ?? null
);
return response()->json(['data' => $result]);
}
public function checkoutStatus(Request $request, string $id): JsonResponse
{
$workspace = $this->getWorkspace($request);
$order = Order::query()
->where('id', $id)
->orWhere('order_number', $id)
->first();
if (! $workspace || ! $order || $order->workspace_id !== $workspace->id) {
return response()->json(['error' => 'Not found'], 404);
}
return response()->json([
'data' => [
'id' => $order->id,
'order_number' => $order->order_number,
'status' => $order->status,
'total' => $order->total,
'currency' => $order->currency,
],
]);
}
public function confirmCheckout(Request $request, string $id): JsonResponse
{
$workspace = $this->getWorkspace($request);
$order = Order::query()
->where('id', $id)
->orWhere('order_number', $id)
->first();
if (! $workspace || ! $order || $order->workspace_id !== $workspace->id) {
return response()->json(['error' => 'Not found'], 404);
}
return response()->json([
'data' => [
'id' => $order->id,
'status' => $order->status,
'paid' => $order->isPaid(),
],
]);
}
/**
* List orders for the workspace.
*
@ -189,6 +266,136 @@ class CommerceController extends Controller
]);
}
public function subscriptions(Request $request): JsonResponse
{
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
return response()->json([
'data' => $workspace->subscriptions()
->latest()
->paginate($request->integer('per_page', 25)),
]);
}
public function cancelSubscriptionById(Request $request, Subscription $subscription): JsonResponse
{
$validated = $request->validate([
'immediate' => 'boolean',
'reason' => 'nullable|string|max:500',
]);
$workspace = $this->getWorkspace($request);
if (! $workspace || $subscription->workspace_id !== $workspace->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$subscription = $this->subscriptionService->cancel(
$subscription,
$validated['immediate'] ?? false,
$validated['reason'] ?? ''
);
return response()->json(['data' => $subscription]);
}
public function changePlan(Request $request, Subscription $subscription): JsonResponse
{
$validated = $request->validate([
'package_code' => 'required|string|exists:entitlement_packages,code',
'prorate' => 'boolean',
]);
$workspace = $this->getWorkspace($request);
if (! $workspace || $subscription->workspace_id !== $workspace->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$newPackage = Package::where('code', $validated['package_code'])->firstOrFail();
$result = $this->subscriptionService->changePlan(
$subscription,
$newPackage,
$validated['prorate'] ?? true
);
return response()->json(['data' => $result]);
}
public function paymentMethods(Request $request): JsonResponse
{
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
return response()->json([
'data' => PaymentMethod::query()
->where('workspace_id', $workspace->id)
->where('is_active', true)
->latest()
->get(),
]);
}
public function storePaymentMethod(Request $request): JsonResponse
{
$validated = $request->validate([
'gateway' => 'required|string|max:32',
'gateway_payment_method_id' => 'nullable|string|max:255',
'type' => 'required|string|max:32',
'is_default' => 'boolean',
]);
$workspace = $this->getWorkspace($request);
if (! $workspace) {
return response()->json(['error' => 'No workspace found'], 404);
}
$method = PaymentMethod::create(array_merge($validated, [
'workspace_id' => $workspace->id,
'is_active' => true,
]));
if ($method->is_default) {
$method->setAsDefault();
}
return response()->json(['data' => $method], 201);
}
public function deletePaymentMethod(Request $request, PaymentMethod $paymentMethod): JsonResponse
{
$workspace = $this->getWorkspace($request);
if (! $workspace || $paymentMethod->workspace_id !== $workspace->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$paymentMethod->deactivate();
return response()->json(null, 204);
}
public function setDefaultPaymentMethod(Request $request, PaymentMethod $paymentMethod): JsonResponse
{
$workspace = $this->getWorkspace($request);
if (! $workspace || $paymentMethod->workspace_id !== $workspace->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$paymentMethod->setAsDefault();
return response()->json(['data' => $paymentMethod->fresh()]);
}
/**
* Get usage summary for the workspace.
*

View file

@ -152,6 +152,10 @@ class BTCPayWebhookController extends Controller
return false;
}
if (! $webhookEvent->wasRecentlyCreated) {
return true;
}
// If the webhook event we just created has a different ID than the one
// that already existed in the database, it means this is a duplicate
$existingEvent = WebhookEvent::where('gateway', 'btcpay')

View file

@ -148,6 +148,10 @@ class StripeWebhookController extends Controller
return false;
}
if (! $webhookEvent->wasRecentlyCreated) {
return true;
}
// If the webhook event we just created has a different ID than the one
// that already existed in the database, it means this is a duplicate
$existingEvent = WebhookEvent::where('gateway', 'stripe')

26
DTOs/BundleItem.php Normal file
View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\DTOs;
readonly class BundleItem
{
public function __construct(
public int $productId,
public int $quantity,
public ?float $priceOverride = null,
) {}
/**
* @return array{product_id: int, quantity: int, price_override: float|null}
*/
public function toArray(): array
{
return [
'product_id' => $this->productId,
'quantity' => $this->quantity,
'price_override' => $this->priceOverride,
];
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\DTOs;
readonly class CouponValidationResult
{
public function __construct(
public bool $valid,
public ?string $reason,
public float $discountAmount,
public string $discountType,
) {}
/**
* @return array{valid: bool, reason: string|null, discount_amount: float, discount_type: string}
*/
public function toArray(): array
{
return [
'valid' => $this->valid,
'reason' => $this->reason,
'discount_amount' => $this->discountAmount,
'discount_type' => $this->discountType,
];
}
}

31
DTOs/FraudAssessment.php Normal file
View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\DTOs;
readonly class FraudAssessment
{
/**
* @param array<int, string> $reasons
*/
public function __construct(
public int $score,
public string $riskLevel,
public array $reasons,
public bool $block,
) {}
/**
* @return array{score: int, risk_level: string, reasons: array<int, string>, block: bool}
*/
public function toArray(): array
{
return [
'score' => $this->score,
'risk_level' => $this->riskLevel,
'reasons' => $this->reasons,
'block' => $this->block,
];
}
}

26
DTOs/ParsedItem.php Normal file
View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\DTOs;
readonly class ParsedItem
{
public function __construct(
public string $segment,
public string $type,
public string $value,
) {}
/**
* @return array{segment: string, type: string, value: string}
*/
public function toArray(): array
{
return [
'segment' => $this->segment,
'type' => $this->type,
'value' => $this->value,
];
}
}

29
DTOs/PermissionResult.php Normal file
View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\DTOs;
readonly class PermissionResult
{
/**
* @param array<int, string> $permissions
*/
public function __construct(
public bool $allowed,
public ?string $reason,
public array $permissions,
) {}
/**
* @return array{allowed: bool, reason: string|null, permissions: array<int, string>}
*/
public function toArray(): array
{
return [
'allowed' => $this->allowed,
'reason' => $this->reason,
'permissions' => $this->permissions,
];
}
}

34
DTOs/ProrationResult.php Normal file
View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\DTOs;
use Carbon\Carbon;
readonly class ProrationResult
{
public function __construct(
public float $creditAmount,
public float $chargeAmount,
public Carbon $effectiveDate,
) {}
public function netAmount(): float
{
return round($this->chargeAmount - $this->creditAmount, 2);
}
/**
* @return array{credit_amount: float, charge_amount: float, effective_date: string, net_amount: float}
*/
public function toArray(): array
{
return [
'credit_amount' => $this->creditAmount,
'charge_amount' => $this->chargeAmount,
'effective_date' => $this->effectiveDate->toIso8601String(),
'net_amount' => $this->netAmount(),
];
}
}

26
DTOs/SkuOption.php Normal file
View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\DTOs;
readonly class SkuOption
{
public function __construct(
public string $key,
public string $value,
public int $position,
) {}
/**
* @return array{key: string, value: string, position: int}
*/
public function toArray(): array
{
return [
'key' => $this->key,
'value' => $this->value,
'position' => $this->position,
];
}
}

34
DTOs/SkuParseResult.php Normal file
View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\DTOs;
readonly class SkuParseResult
{
/**
* @param array<int, SkuOption> $options
*/
public function __construct(
public string $baseSku,
public array $options,
public string $entityPrefix,
public bool $valid,
) {}
/**
* @return array{base_sku: string, options: array<int, array{key: string, value: string, position: int}>, entity_prefix: string, valid: bool}
*/
public function toArray(): array
{
return [
'base_sku' => $this->baseSku,
'options' => array_map(
fn (SkuOption $option): array => $option->toArray(),
$this->options
),
'entity_prefix' => $this->entityPrefix,
'valid' => $this->valid,
];
}
}

View file

@ -20,5 +20,15 @@ class OrderPaid
public function __construct(
public Order $order,
public Payment $payment
) {}
) {
$this->orderId = (int) $order->id;
$this->paymentId = (int) $payment->id;
$this->amount = (float) $payment->amount;
}
public int $orderId;
public int $paymentId;
public float $amount;
}

View file

@ -14,6 +14,14 @@ class SubscriptionCancelled
public function __construct(
public Subscription $subscription,
public bool $immediate = false
) {}
public bool $immediate = false,
public string $reason = '',
) {
$this->subscriptionId = (int) $subscription->id;
$this->cancelledAt = $subscription->cancelled_at ?? now();
}
public int $subscriptionId;
public \DateTimeInterface $cancelledAt;
}

View file

@ -14,5 +14,15 @@ class SubscriptionCreated
public function __construct(
public Subscription $subscription
) {}
) {
$this->subscriptionId = (int) $subscription->id;
$this->workspaceId = (int) $subscription->workspace_id;
$this->productId = $subscription->product_id ? (int) $subscription->product_id : null;
}
public int $subscriptionId;
public int $workspaceId;
public ?int $productId;
}

View file

@ -14,6 +14,11 @@ class SubscriptionRenewed
public function __construct(
public Subscription $subscription,
public ?\DateTimeInterface $previousPeriodEnd = null
) {}
public ?\DateTimeInterface $previousPeriodEnd = null,
public ?int $invoiceId = null,
) {
$this->subscriptionId = (int) $subscription->id;
}
public int $subscriptionId;
}

View file

@ -14,6 +14,14 @@ class SubscriptionUpdated
public function __construct(
public Subscription $subscription,
public ?string $previousStatus = null
) {}
public ?string $previousStatus = null,
public ?int $oldProductId = null,
public ?int $newProductId = null,
) {
$this->subscriptionId = (int) $subscription->id;
$this->oldProductId ??= $subscription->getOriginal('product_id') ? (int) $subscription->getOriginal('product_id') : null;
$this->newProductId ??= $subscription->product_id ? (int) $subscription->product_id : null;
}
public int $subscriptionId;
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Jobs;
use Core\Mod\Commerce\Models\WebhookEvent;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
class ProcessWebhookEvent implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
public int $webhookEventId,
) {}
public function handle(): void
{
$webhookEvent = WebhookEvent::find($this->webhookEventId);
if (! $webhookEvent || ! $webhookEvent->isPending()) {
return;
}
try {
Event::dispatch(
"commerce.webhook.{$webhookEvent->gateway}.{$webhookEvent->event_type}",
[$webhookEvent, $webhookEvent->getDecodedPayload()]
);
$webhookEvent->markProcessed();
} catch (\Throwable $e) {
$webhookEvent->markFailed($e->getMessage());
Log::error('Queued webhook event processing failed', [
'webhook_event_id' => $webhookEvent->id,
'gateway' => $webhookEvent->gateway,
'event_type' => $webhookEvent->event_type,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View file

@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('orders')) {
Schema::create('orders', function (Blueprint $table): void {
$table->id();
$table->nullableMorphs('orderable');
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->string('order_number')->unique();
$table->string('status')->default('pending')->index();
$table->string('type')->nullable();
$table->string('billing_cycle')->nullable();
$table->string('currency', 3)->default('GBP');
$table->string('display_currency', 3)->nullable();
$table->decimal('exchange_rate_used', 16, 8)->nullable();
$table->decimal('base_currency_total', 12, 2)->nullable();
$table->decimal('subtotal', 12, 2)->default(0);
$table->decimal('tax_amount', 12, 2)->default(0);
$table->decimal('discount_amount', 12, 2)->default(0);
$table->decimal('total', 12, 2)->default(0);
$table->string('payment_method')->nullable();
$table->unsignedBigInteger('payment_method_id')->nullable()->index();
$table->string('payment_gateway')->nullable();
$table->string('gateway')->nullable();
$table->string('gateway_order_id')->nullable();
$table->string('gateway_session_id')->nullable();
$table->unsignedBigInteger('coupon_id')->nullable()->index();
$table->string('billing_name')->nullable();
$table->string('billing_email')->nullable();
$table->decimal('tax_rate', 8, 4)->nullable();
$table->string('tax_country', 2)->nullable();
$table->json('billing_address')->nullable();
$table->json('metadata')->nullable();
$table->string('idempotency_key')->nullable()->unique();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable('order_items')) {
Schema::create('order_items', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('order_id')->index();
$table->unsignedBigInteger('product_id')->nullable()->index();
$table->string('item_type')->nullable();
$table->unsignedBigInteger('item_id')->nullable();
$table->string('item_code')->nullable();
$table->string('description');
$table->unsignedInteger('quantity')->default(1);
$table->decimal('unit_price', 12, 2)->default(0);
$table->decimal('line_total', 12, 2)->default(0);
$table->decimal('total', 12, 2)->default(0);
$table->string('billing_cycle')->nullable();
$table->json('metadata')->nullable();
$table->timestamp('created_at')->nullable();
});
}
if (! Schema::hasTable('invoices')) {
Schema::create('invoices', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('workspace_id')->nullable()->index();
$table->unsignedBigInteger('order_id')->nullable()->index();
$table->unsignedBigInteger('payment_id')->nullable()->index();
$table->string('invoice_number')->unique();
$table->string('number')->nullable()->index();
$table->string('status')->default('pending')->index();
$table->string('currency', 3)->default('GBP');
$table->string('display_currency', 3)->nullable();
$table->decimal('exchange_rate_used', 16, 8)->nullable();
$table->decimal('base_currency_total', 12, 2)->nullable();
$table->decimal('subtotal', 12, 2)->default(0);
$table->decimal('tax_amount', 12, 2)->default(0);
$table->decimal('tax_rate', 8, 4)->nullable();
$table->string('tax_country', 2)->nullable();
$table->decimal('discount_amount', 12, 2)->default(0);
$table->decimal('total', 12, 2)->default(0);
$table->decimal('amount_paid', 12, 2)->default(0);
$table->decimal('amount_due', 12, 2)->default(0);
$table->date('issue_date')->nullable();
$table->date('due_date')->nullable();
$table->timestamp('due_at')->nullable();
$table->timestamp('paid_at')->nullable();
$table->string('billing_name')->nullable();
$table->string('billing_email')->nullable();
$table->json('billing_address')->nullable();
$table->string('tax_id')->nullable();
$table->string('pdf_path')->nullable();
$table->boolean('auto_charge')->default(true);
$table->unsignedInteger('charge_attempts')->default(0);
$table->timestamp('last_charge_attempt')->nullable();
$table->timestamp('next_charge_attempt')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable('invoice_items')) {
Schema::create('invoice_items', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('invoice_id')->index();
$table->unsignedBigInteger('order_item_id')->nullable()->index();
$table->string('description');
$table->unsignedInteger('quantity')->default(1);
$table->decimal('unit_price', 12, 2)->default(0);
$table->decimal('line_total', 12, 2)->default(0);
$table->decimal('total', 12, 2)->default(0);
$table->boolean('taxable')->default(true);
$table->decimal('tax_rate', 8, 4)->default(0);
$table->decimal('tax_amount', 12, 2)->default(0);
$table->json('metadata')->nullable();
$table->timestamp('created_at')->nullable();
});
}
if (! Schema::hasTable('payments')) {
Schema::create('payments', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('workspace_id')->nullable()->index();
$table->unsignedBigInteger('invoice_id')->nullable()->index();
$table->unsignedBigInteger('order_id')->nullable()->index();
$table->unsignedBigInteger('payment_method_id')->nullable()->index();
$table->string('gateway', 32)->index();
$table->string('gateway_payment_id')->nullable()->index();
$table->string('gateway_customer_id')->nullable();
$table->string('gateway_id')->nullable()->index();
$table->string('currency', 3)->default('GBP');
$table->decimal('amount', 12, 2)->default(0);
$table->decimal('fee', 12, 2)->default(0);
$table->decimal('net_amount', 12, 2)->default(0);
$table->string('status')->default('pending')->index();
$table->string('failure_reason')->nullable();
$table->string('payment_method_type')->nullable();
$table->string('payment_method_last4')->nullable();
$table->string('payment_method_brand')->nullable();
$table->json('gateway_response')->nullable();
$table->decimal('refunded_amount', 12, 2)->default(0);
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable('subscriptions')) {
Schema::create('subscriptions', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('workspace_id')->nullable()->index();
$table->unsignedBigInteger('workspace_package_id')->nullable()->index();
$table->unsignedBigInteger('product_id')->nullable()->index();
$table->string('gateway')->default('btcpay');
$table->string('gateway_subscription_id')->nullable()->index();
$table->string('gateway_customer_id')->nullable();
$table->string('gateway_price_id')->nullable();
$table->string('status')->default('active')->index();
$table->string('billing_cycle')->default('monthly');
$table->timestamp('current_period_start')->nullable();
$table->timestamp('current_period_end')->nullable()->index();
$table->timestamp('trial_ends_at')->nullable();
$table->boolean('cancel_at_period_end')->default(false);
$table->timestamp('cancelled_at')->nullable();
$table->string('cancellation_reason')->nullable();
$table->timestamp('ended_at')->nullable();
$table->timestamp('paused_at')->nullable();
$table->unsignedInteger('pause_count')->default(0);
$table->json('metadata')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable('refunds')) {
Schema::create('refunds', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('payment_id')->index();
$table->string('gateway_refund_id')->nullable()->index();
$table->decimal('amount', 12, 2);
$table->string('currency', 3)->default('GBP');
$table->string('status')->default('pending')->index();
$table->string('reason')->nullable();
$table->text('notes')->nullable();
$table->unsignedBigInteger('initiated_by')->nullable()->index();
$table->json('gateway_response')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable('coupons')) {
Schema::create('coupons', function (Blueprint $table): void {
$table->id();
$table->string('code')->unique();
$table->string('name')->nullable();
$table->text('description')->nullable();
$table->string('type')->default('percent');
$table->decimal('value', 12, 2)->default(0);
$table->decimal('min_amount', 12, 2)->nullable();
$table->decimal('max_discount', 12, 2)->nullable();
$table->string('applies_to')->default('all');
$table->json('package_ids')->nullable();
$table->unsignedInteger('max_uses')->nullable();
$table->unsignedInteger('max_uses_per_workspace')->default(1);
$table->unsignedInteger('used_count')->default(0);
$table->string('duration')->default('once');
$table->unsignedInteger('duration_months')->nullable();
$table->timestamp('valid_from')->nullable();
$table->timestamp('valid_until')->nullable();
$table->timestamp('expires_at')->nullable();
$table->boolean('is_active')->default(true);
$table->string('stripe_coupon_id')->nullable();
$table->string('btcpay_coupon_id')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable('coupon_usages')) {
Schema::create('coupon_usages', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('coupon_id')->index();
$table->unsignedBigInteger('workspace_id')->nullable()->index();
$table->unsignedBigInteger('order_id')->nullable()->index();
$table->decimal('discount_amount', 12, 2)->default(0);
$table->timestamp('created_at')->nullable();
});
}
if (! Schema::hasTable('tax_rates')) {
Schema::create('tax_rates', function (Blueprint $table): void {
$table->id();
$table->string('country_code', 2)->index();
$table->string('country', 2)->nullable()->index();
$table->string('state_code')->nullable();
$table->string('region')->nullable();
$table->string('name');
$table->string('type')->default('vat');
$table->decimal('rate', 8, 4);
$table->boolean('is_digital_services')->default(true);
$table->date('effective_from');
$table->date('effective_until')->nullable();
$table->boolean('is_active')->default(true);
$table->string('stripe_tax_rate_id')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable('commerce_inventory_movements')) {
Schema::create('commerce_inventory_movements', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('inventory_id')->nullable()->index();
$table->unsignedBigInteger('product_id')->index();
$table->unsignedBigInteger('warehouse_id')->index();
$table->string('type')->index();
$table->integer('quantity');
$table->integer('balance_after')->default(0);
$table->string('reference')->nullable()->index();
$table->text('notes')->nullable();
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->integer('unit_cost')->nullable();
$table->timestamp('created_at')->nullable();
});
}
}
public function down(): void
{
Schema::dropIfExists('commerce_inventory_movements');
Schema::dropIfExists('tax_rates');
Schema::dropIfExists('coupon_usages');
Schema::dropIfExists('coupons');
Schema::dropIfExists('refunds');
Schema::dropIfExists('subscriptions');
Schema::dropIfExists('payments');
Schema::dropIfExists('invoice_items');
Schema::dropIfExists('invoices');
Schema::dropIfExists('order_items');
Schema::dropIfExists('orders');
}
};

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$this->addColumn('subscriptions', 'product_id', fn (Blueprint $table) => $table->unsignedBigInteger('product_id')->nullable()->index());
$this->addColumn('orders', 'payment_method_id', fn (Blueprint $table) => $table->unsignedBigInteger('payment_method_id')->nullable()->index());
$this->addColumn('orders', 'gateway', fn (Blueprint $table) => $table->string('gateway')->nullable()->index());
$this->addColumn('orders', 'gateway_session_id', fn (Blueprint $table) => $table->string('gateway_session_id')->nullable()->index());
$this->addColumn('credit_notes', 'invoice_id', fn (Blueprint $table) => $table->unsignedBigInteger('invoice_id')->nullable()->index());
$this->addColumn('permission_matrix', 'target_entity_id', fn (Blueprint $table) => $table->unsignedBigInteger('target_entity_id')->nullable()->index());
$this->addColumn('permission_matrix', 'permissions', fn (Blueprint $table) => $table->json('permissions')->nullable());
$this->addColumn('permission_requests', 'from_entity_id', fn (Blueprint $table) => $table->unsignedBigInteger('from_entity_id')->nullable()->index());
$this->addColumn('permission_requests', 'to_entity_id', fn (Blueprint $table) => $table->unsignedBigInteger('to_entity_id')->nullable()->index());
$this->addColumn('permission_requests', 'permissions', fn (Blueprint $table) => $table->json('permissions')->nullable());
$this->addColumn('commerce_product_prices', 'billing_cycle', fn (Blueprint $table) => $table->string('billing_cycle')->nullable()->index());
$this->addColumn('commerce_product_assignments', 'entity_type', fn (Blueprint $table) => $table->string('entity_type')->nullable()->index());
$this->addColumn('commerce_bundle_hashes', 'product_ids', fn (Blueprint $table) => $table->json('product_ids')->nullable());
$this->addColumn('commerce_warehouses', 'location', fn (Blueprint $table) => $table->string('location')->nullable());
if (Schema::hasTable('commerce_products')) {
$this->addColumn('commerce_products', 'owner_entity_id', fn (Blueprint $table) => $table->unsignedBigInteger('owner_entity_id')->nullable()->index());
$this->addColumn('commerce_products', 'price', fn (Blueprint $table) => $table->integer('price')->default(0));
$this->addColumn('commerce_products', 'is_active', fn (Blueprint $table) => $table->boolean('is_active')->default(true)->index());
$this->addColumn('commerce_products', 'slug', fn (Blueprint $table) => $table->string('slug')->nullable()->index());
}
}
public function down(): void
{
$this->dropColumn('commerce_products', 'slug');
$this->dropColumn('commerce_products', 'is_active');
$this->dropColumn('commerce_products', 'price');
$this->dropColumn('commerce_products', 'owner_entity_id');
$this->dropColumn('commerce_warehouses', 'location');
$this->dropColumn('commerce_bundle_hashes', 'product_ids');
$this->dropColumn('commerce_product_assignments', 'entity_type');
$this->dropColumn('commerce_product_prices', 'billing_cycle');
$this->dropColumn('permission_requests', 'permissions');
$this->dropColumn('permission_requests', 'to_entity_id');
$this->dropColumn('permission_requests', 'from_entity_id');
$this->dropColumn('permission_matrix', 'permissions');
$this->dropColumn('permission_matrix', 'target_entity_id');
$this->dropColumn('credit_notes', 'invoice_id');
$this->dropColumn('orders', 'gateway_session_id');
$this->dropColumn('orders', 'gateway');
$this->dropColumn('orders', 'payment_method_id');
$this->dropColumn('subscriptions', 'product_id');
}
protected function addColumn(string $table, string $column, Closure $callback): void
{
if (! Schema::hasTable($table) || Schema::hasColumn($table, $column)) {
return;
}
Schema::table($table, $callback);
}
protected function dropColumn(string $table, string $column): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
return;
}
Schema::table($table, fn (Blueprint $table) => $table->dropColumn($column));
}
};

View file

@ -24,6 +24,7 @@ class BundleHash extends Model
protected $fillable = [
'hash',
'product_ids',
'base_skus',
'coupon_code',
'fixed_price',
@ -42,6 +43,7 @@ class BundleHash extends Model
protected $casts = [
'fixed_price' => 'decimal:2',
'product_ids' => 'array',
'discount_percent' => 'decimal:2',
'discount_amount' => 'decimal:2',
'min_quantity' => 'integer',

View file

@ -49,6 +49,7 @@ class CreditNote extends Model
protected $fillable = [
'workspace_id',
'user_id',
'invoice_id',
'order_id',
'refund_id',
'reference_number',
@ -93,6 +94,11 @@ class CreditNote extends Model
return $this->belongsTo(Order::class);
}
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
public function refund(): BelongsTo
{
return $this->belongsTo(Refund::class);

View file

@ -73,8 +73,11 @@ class Order extends Model
'discount_amount',
'total',
'payment_method',
'payment_method_id',
'payment_gateway',
'gateway',
'gateway_order_id',
'gateway_session_id',
'coupon_id',
'billing_name',
'billing_email',

View file

@ -45,6 +45,7 @@ class Payment extends Model
'workspace_id',
'invoice_id',
'order_id',
'payment_method_id',
'gateway',
'gateway_payment_id',
'gateway_customer_id',

View file

@ -40,8 +40,10 @@ class PermissionMatrix extends Model
protected $fillable = [
'entity_id',
'target_entity_id',
'key',
'scope',
'permissions',
'allowed',
'locked',
'source',
@ -52,6 +54,7 @@ class PermissionMatrix extends Model
protected $casts = [
'allowed' => 'boolean',
'permissions' => 'array',
'locked' => 'boolean',
'trained_at' => 'datetime',
];
@ -63,6 +66,11 @@ class PermissionMatrix extends Model
return $this->belongsTo(Entity::class, 'entity_id');
}
public function targetEntity(): BelongsTo
{
return $this->belongsTo(Entity::class, 'target_entity_id');
}
public function setByEntity(): BelongsTo
{
return $this->belongsTo(Entity::class, 'set_by_entity_id');

View file

@ -43,9 +43,12 @@ class PermissionRequest extends Model
protected $fillable = [
'entity_id',
'from_entity_id',
'to_entity_id',
'method',
'route',
'action',
'permissions',
'scope',
'request_data',
'user_agent',
@ -58,6 +61,7 @@ class PermissionRequest extends Model
protected $casts = [
'request_data' => 'array',
'permissions' => 'array',
'was_trained' => 'boolean',
'trained_at' => 'datetime',
];
@ -69,6 +73,16 @@ class PermissionRequest extends Model
return $this->belongsTo(Entity::class, 'entity_id');
}
public function fromEntity(): BelongsTo
{
return $this->belongsTo(Entity::class, 'from_entity_id');
}
public function toEntity(): BelongsTo
{
return $this->belongsTo(Entity::class, 'to_entity_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);

View file

@ -42,6 +42,7 @@ class ProductAssignment extends Model
protected $fillable = [
'entity_id',
'entity_type',
'product_id',
'sku_suffix',
'price_override',

View file

@ -28,6 +28,7 @@ class ProductPrice extends Model
'product_id',
'currency',
'amount',
'billing_cycle',
'is_manual',
'exchange_rate_used',
];

View file

@ -59,6 +59,7 @@ class Subscription extends Model
protected $fillable = [
'workspace_id',
'workspace_package_id',
'product_id',
'gateway',
'gateway_subscription_id',
'gateway_customer_id',
@ -101,6 +102,11 @@ class Subscription extends Model
return $this->belongsTo(WorkspacePackage::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function usageRecords(): HasMany
{
return $this->hasMany(SubscriptionUsage::class);
@ -130,7 +136,7 @@ class Subscription extends Model
public function isPaused(): bool
{
return $this->status === 'paused';
return in_array($this->status, ['paused', 'suspended'], true);
}
public function isCancelled(): bool
@ -169,7 +175,7 @@ class Subscription extends Model
public function isValid(): bool
{
return in_array($this->status, ['active', 'trialing', 'past_due']);
return in_array($this->status, ['active', 'trialing', 'past_due', 'suspended'], true);
}
public function onTrial(): bool
@ -230,6 +236,14 @@ class Subscription extends Model
$this->update(['status' => 'paused']);
}
public function suspend(): void
{
$this->update([
'status' => 'suspended',
'paused_at' => $this->paused_at ?? now(),
]);
}
public function markPastDue(): void
{
$this->update(['status' => 'past_due']);

102
Services/BTCPayGateway.php Normal file
View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Core\Mod\Commerce\Contracts\PaymentGatewayContract;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Models\PaymentMethod;
use Core\Mod\Commerce\Models\Refund;
use Core\Mod\Commerce\Services\PaymentGateway\BTCPayGateway as LegacyBTCPayGateway;
use Illuminate\Http\Request;
class BTCPayGateway implements PaymentGatewayContract
{
public function __construct(
protected ?LegacyBTCPayGateway $gateway = null,
) {
$this->gateway ??= new LegacyBTCPayGateway;
}
/**
* @return array<string, mixed>
*/
public function createSession(Order $order, PaymentMethod $paymentMethod): array
{
$successUrl = url('/checkout/success?order='.$order->order_number);
$cancelUrl = url('/checkout/cancel?order='.$order->order_number);
$session = $this->gateway->createCheckoutSession($order, $successUrl, $cancelUrl);
return [
'invoice_id' => $session['session_id'] ?? null,
'checkout_url' => $session['checkout_url'] ?? null,
'session_id' => $session['session_id'] ?? null,
];
}
/**
* @param array<string, mixed> $gatewayData
*/
public function confirmPayment(Payment $payment, array $gatewayData): Payment
{
$payment->update([
'gateway_payment_id' => $gatewayData['invoiceId'] ?? $gatewayData['id'] ?? $payment->gateway_payment_id,
'status' => 'succeeded',
'paid_at' => now(),
'gateway_response' => $gatewayData,
]);
return $payment->fresh();
}
public function refund(Payment $payment, float $amount, string $reason): Refund
{
$refund = Refund::create([
'payment_id' => $payment->id,
'amount' => $amount,
'currency' => $payment->currency,
'status' => 'pending',
'reason' => $reason,
]);
if (! $this->gateway->isEnabled()) {
return $refund;
}
$result = $this->gateway->refund($payment, $amount, $reason);
if (($result['success'] ?? false) === true) {
$refund->markAsSucceeded($result['refund_id'] ?? null);
} else {
$refund->markAsFailed($result);
}
return $refund->fresh();
}
public function validateWebhookSignature(Request $request): bool
{
return $this->gateway->verifyWebhookSignature(
$request->getContent(),
(string) $request->header('BTCPay-Sig', $request->header('BTCPay-Signature', ''))
);
}
/**
* @return array<string, mixed>
*/
public function parseWebhookEvent(Request $request): array
{
$event = $this->gateway->parseWebhookEvent($request->getContent());
return [
'type' => $event['type'] ?? 'unknown',
'id' => $event['id'] ?? null,
'data' => $event['raw'] ?? [],
'raw' => $event['raw'] ?? $event,
];
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Core\Mod\Commerce\Contracts\Orderable;
use Core\Mod\Commerce\Contracts\PaymentGatewayContract as RfcPaymentGatewayContract;
use Core\Mod\Commerce\Data\FraudAssessment;
use Core\Mod\Commerce\Events\OrderPaid;
use Core\Mod\Commerce\Exceptions\CheckoutRateLimitException;
@ -14,6 +15,8 @@ use Core\Mod\Commerce\Models\Invoice;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\OrderItem;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Models\PaymentMethod;
use Core\Mod\Commerce\Models\Product;
use Core\Mod\Commerce\Models\Refund;
use Core\Mod\Commerce\Models\Subscription;
use Core\Mod\Commerce\Services\PaymentGateway\PaymentGatewayContract;
@ -248,6 +251,120 @@ class CommerceService
return $result;
}
/**
* RFC checkout entrypoint for an existing order and stored payment method.
*
* @return array{order: Order, payment: Payment, gateway_session: array<string, mixed>}
*/
public function checkout(Order $order, PaymentMethod $paymentMethod): array
{
$payment = Payment::create([
'workspace_id' => $order->workspace_id,
'order_id' => $order->id,
'payment_method_id' => $paymentMethod->id,
'gateway' => $paymentMethod->gateway,
'amount' => $order->total,
'currency' => $order->currency,
'status' => 'pending',
]);
$gateway = app()->bound("commerce.rfc_gateway.{$paymentMethod->gateway}")
? app("commerce.rfc_gateway.{$paymentMethod->gateway}")
: app(RfcPaymentGatewayContract::class);
$gatewaySession = $gateway->createSession($order, $paymentMethod);
return [
'order' => $order->fresh(),
'payment' => $payment,
'gateway_session' => $gatewaySession,
];
}
public function confirmPayment(Payment $payment, string $gatewayTransactionId): void
{
$payment->update([
'gateway_payment_id' => $gatewayTransactionId,
'status' => 'succeeded',
'paid_at' => now(),
]);
$order = Order::find($payment->order_id);
if ($order && ! $order->isPaid()) {
$this->fulfillOrder($order, $payment->fresh());
}
}
/**
* RFC full checkout flow for cart-style product items.
*
* @param array<int, array{product_id?: int, quantity?: int}> $cartItems
* @return array{order: Order, payment: Payment, gateway_session: array<string, mixed>}
*/
public function processCheckout(
int $workspaceId,
array $cartItems,
string $paymentMethodId,
?string $couponCode = null
): array {
$workspace = Workspace::findOrFail($workspaceId);
$paymentMethod = PaymentMethod::findOrFail($paymentMethodId);
$currency = config('commerce.currency', 'GBP');
$order = DB::transaction(function () use ($workspace, $cartItems, $currency, $couponCode): Order {
$order = Order::create([
'orderable_type' => Workspace::class,
'orderable_id' => $workspace->id,
'user_id' => null,
'order_number' => Order::generateOrderNumber(),
'status' => 'pending',
'type' => 'checkout',
'currency' => $currency,
'subtotal' => 0,
'tax_amount' => 0,
'discount_amount' => 0,
'total' => 0,
'billing_name' => $workspace->billing_name ?? $workspace->name,
'billing_email' => $workspace->billing_email ?? $workspace->owner()?->email,
'billing_address' => method_exists($workspace, 'getBillingAddress') ? $workspace->getBillingAddress() : null,
'metadata' => ['coupon_code' => $couponCode],
]);
$subtotal = 0.0;
foreach ($cartItems as $item) {
$product = Product::findOrFail((int) ($item['product_id'] ?? 0));
$quantity = max(1, (int) ($item['quantity'] ?? 1));
$unitPrice = $this->productUnitPrice($product);
$lineTotal = round($unitPrice * $quantity, 2);
$subtotal += $lineTotal;
OrderItem::create([
'order_id' => $order->id,
'product_id' => $product->id,
'item_type' => 'product',
'item_id' => $product->id,
'item_code' => $product->sku,
'description' => $product->name,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'line_total' => $lineTotal,
'billing_cycle' => $product->isSubscription() ? 'monthly' : 'onetime',
]);
}
$order->update([
'subtotal' => $subtotal,
'total' => $subtotal,
]);
return $order->fresh(['items']);
});
return $this->checkout($order, $paymentMethod);
}
/**
* Assess order for fraud before checkout.
*
@ -737,4 +854,21 @@ class CommerceService
{
return $this->currencyService->convert($amount, $from, $to);
}
protected function productUnitPrice(Product $product): float
{
$price = $product->prices()
->where('currency', config('commerce.currency', 'GBP'))
->first();
if ($price) {
return $price->amount / 100;
}
if (isset($product->price)) {
return ((int) $product->price) / 100;
}
return (float) ($product->base_price ?? 0);
}
}

View file

@ -214,6 +214,24 @@ class CurrencyService
return ExchangeRate::convert($amount, $from, $to);
}
public function rate(string $from, string $to): float
{
return (float) ($this->getExchangeRate($from, $to) ?? 1.0);
}
/**
* @return array<string, array>
*/
public function supported(): array
{
return $this->getSupportedCurrencies();
}
public function refresh(): void
{
$this->refreshExchangeRates();
}
/**
* Convert cents between currencies.
*/

View file

@ -87,8 +87,12 @@ class DunningService
/**
* Retry payment for an overdue invoice.
*/
public function retry(Invoice $invoice): PaymentResult
public function retry(Invoice|Subscription $invoice): PaymentResult|bool
{
if ($invoice instanceof Subscription) {
return $this->retrySubscription($invoice);
}
if ($invoice->isPaid()) {
$subscription = $this->findSubscriptionForInvoice($invoice);
if ($subscription) {
@ -171,6 +175,17 @@ class DunningService
return PaymentResult::failed('Payment retry failed.', $attempts, $nextRetry);
}
public function retrySubscription(Subscription $subscription): bool
{
$invoice = $this->latestDunningInvoice($subscription);
if (! $invoice) {
return false;
}
return $this->retry($invoice)->succeeded();
}
/**
* Suspend a subscription and its workspace after dunning is exhausted.
*/
@ -371,7 +386,7 @@ class DunningService
*/
public function retryPayment(Invoice $invoice): bool
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]);
$maxRetries = count($retryDays);
try {
@ -425,7 +440,7 @@ class DunningService
*/
public function getSubscriptionsForPause(): Collection
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]);
$pauseAfterDays = array_sum($retryDays) + 1; // Day after last retry
return Subscription::query()
@ -516,7 +531,7 @@ class DunningService
$cancelAfterDays = config('commerce.dunning.cancel_after_days', 30);
return Subscription::query()
->where('status', 'paused')
->whereIn('status', ['paused', 'suspended'])
->where('paused_at', '<=', now()->subDays($cancelAfterDays))
->with('workspace')
->get();
@ -573,7 +588,7 @@ class DunningService
*/
public function calculateNextRetry(int $currentAttempts): ?Carbon
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]);
// Account for the initial attempt (attempt 0 used grace period)
$retryIndex = $currentAttempts;
@ -587,6 +602,40 @@ class DunningService
return $daysUntilNext ? now()->addDays($daysUntilNext) : null;
}
public function nextRetryAt(int $attemptCount): Carbon
{
return $this->calculateNextRetry($attemptCount) ?? now()->addDays(14);
}
/**
* Run the RFC dunning pass.
*
* @return array{retried: int, recovered: int, cancelled: int}
*/
public function processAll(): array
{
$results = [
'retried' => 0,
'recovered' => 0,
'cancelled' => 0,
];
foreach ($this->getInvoicesDueForRetry() as $invoice) {
$results['retried']++;
if ($this->retry($invoice)->succeeded()) {
$results['recovered']++;
}
}
foreach ($this->getSubscriptionsForCancellation() as $subscription) {
$this->cancelSubscription($subscription);
$results['cancelled']++;
}
return $results;
}
/**
* Get the dunning status for a subscription.
*
@ -626,7 +675,7 @@ class DunningService
];
}
if ($subscription->status === 'paused') {
if (in_array($subscription->status, ['paused', 'suspended'], true)) {
$pausedDays = $subscription->paused_at
? (int) $subscription->paused_at->diffInDays(now(), false)
: 0;
@ -676,7 +725,7 @@ class DunningService
*/
protected function retryDays(): array
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]);
if (! is_array($retryDays)) {
throw new InvalidArgumentException('Dunning retry days must be configured as an array.');

View file

@ -76,6 +76,30 @@ class InvoiceService
return $invoice;
}
public function generateFromOrder(Order $order): Invoice
{
return $this->createFromOrder($order);
}
public function generateFromSubscription(\Core\Mod\Commerce\Models\Subscription $subscription): Invoice
{
$workspace = $subscription->workspace;
if (! $workspace) {
throw new \InvalidArgumentException('Cannot generate an invoice for a subscription without a workspace.');
}
$package = $subscription->workspacePackage?->package;
$billingCycle = $subscription->billing_cycle ?? 'monthly';
$amount = $package ? (float) $package->getPrice($billingCycle) : 0.0;
return $this->createForRenewal(
$workspace,
$amount,
$package ? "{$package->name} subscription renewal" : 'Subscription renewal'
);
}
/**
* Create an invoice for a subscription renewal.
*/
@ -133,6 +157,16 @@ class InvoiceService
$invoice->markAsPaid($payment);
}
public function markPaid(Invoice $invoice, Payment $payment): void
{
$this->markAsPaid($invoice, $payment);
}
public function markOverdue(Invoice $invoice): void
{
$invoice->update(['status' => 'overdue']);
}
/**
* Mark invoice as void.
*/
@ -221,6 +255,11 @@ class InvoiceService
Mail::to($recipientEmail)->queue(new InvoiceGenerated($invoice));
}
public function sendByEmail(Invoice $invoice): void
{
$this->sendEmail($invoice);
}
/**
* Get invoices for a workspace.
*/

View file

@ -102,20 +102,11 @@ class PermissionMatrixService
$this->logRequest($request, $entity, $action, $scope, $result);
}
// Training mode: undefined permissions become pending for approval
// Training mode records undefined permissions and allows the request.
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,
])
);
return PermissionResult::allowed();
}
// Production mode (strict): undefined = denied
@ -137,6 +128,139 @@ class PermissionMatrixService
return $result;
}
public function check(Entity $entity, Entity $target, string $permission): \Core\Mod\Commerce\DTOs\PermissionResult
{
$matrix = PermissionMatrix::query()
->where('entity_id', $entity->id)
->where('target_entity_id', $target->id)
->where(function ($query) use ($permission): void {
$query->where('key', $permission)
->orWhereJsonContains('permissions', $permission);
})
->first();
if ($this->trainingMode && ! $matrix) {
PermissionRequest::create([
'entity_id' => $entity->id,
'from_entity_id' => $entity->id,
'to_entity_id' => $target->id,
'method' => request()->method(),
'route' => request()->path(),
'action' => $permission,
'permissions' => [$permission],
'request_data' => request()->except([
'password',
'password_confirmation',
'token',
'api_key',
'secret',
'credit_card',
'card_number',
'cvv',
'ssn',
]),
'user_agent' => request()->userAgent(),
'ip_address' => request()->ip(),
'user_id' => auth()->id(),
'status' => PermissionRequest::STATUS_PENDING,
]);
return new \Core\Mod\Commerce\DTOs\PermissionResult(true, 'training', [$permission]);
}
if (! $matrix) {
return new \Core\Mod\Commerce\DTOs\PermissionResult(false, "No permission defined for {$permission}", []);
}
if (! $matrix->allowed) {
return new \Core\Mod\Commerce\DTOs\PermissionResult(false, 'Permission denied', []);
}
$permissions = $matrix->permissions ?: [$matrix->key];
return new \Core\Mod\Commerce\DTOs\PermissionResult(true, null, $permissions);
}
public function grant(Entity $entity, Entity $target, array $permissions): PermissionMatrix
{
$this->assertCanGrant($entity, $target);
return PermissionMatrix::updateOrCreate(
[
'entity_id' => $entity->id,
'target_entity_id' => $target->id,
'key' => 'matrix.grant',
'scope' => (string) $target->id,
],
[
'permissions' => array_values($permissions),
'allowed' => true,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
'set_by_entity_id' => $entity->id,
]
);
}
public function revoke(Entity $entity, Entity $target, array $permissions): void
{
$matrix = PermissionMatrix::query()
->where('entity_id', $entity->id)
->where('target_entity_id', $target->id)
->first();
if (! $matrix) {
return;
}
$remaining = array_values(array_diff($matrix->permissions ?? [], $permissions));
if ($remaining === []) {
$matrix->delete();
return;
}
$matrix->update(['permissions' => $remaining]);
}
public function approveRequest(PermissionRequest $request): void
{
$request->update([
'status' => PermissionRequest::STATUS_ALLOWED,
'was_trained' => true,
'trained_at' => now(),
]);
if ($request->from_entity_id && $request->to_entity_id) {
$from = Entity::find($request->from_entity_id);
$to = Entity::find($request->to_entity_id);
if ($from && $to) {
$this->grant($from, $to, $request->permissions ?: [$request->action]);
}
}
}
public function denyRequest(PermissionRequest $request): void
{
$request->update([
'status' => PermissionRequest::STATUS_DENIED,
'was_trained' => true,
'trained_at' => now(),
]);
}
protected function assertCanGrant(Entity $entity, Entity $target): void
{
$allowed = ($entity->isM1() && $target->isM2())
|| ($entity->isM2() && $target->isM3());
if (! $allowed) {
throw new \InvalidArgumentException('Commerce Matrix permissions may only be granted M1 to M2 or M2 to M3.');
}
}
/**
* Train a permission (dev mode).
*/

View file

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Carbon\Carbon;
use Core\Mod\Commerce\DTOs\ProrationResult;
use Core\Mod\Commerce\Models\CreditNote;
use Core\Mod\Commerce\Models\Invoice;
use Core\Mod\Commerce\Models\InvoiceItem;
use Core\Mod\Commerce\Models\Product;
use Core\Mod\Commerce\Models\Subscription;
use Illuminate\Support\Facades\DB;
class ProrationService
{
public function calculate(Subscription $subscription, Product $newProduct, ?Carbon $effectiveDate = null): ProrationResult
{
$effectiveDate ??= now();
$periodStart = $subscription->current_period_start ?? $effectiveDate->copy();
$periodEnd = $subscription->current_period_end ?? $effectiveDate->copy();
$totalSeconds = max(1, $periodStart->diffInSeconds($periodEnd, absolute: true));
$remainingSeconds = max(0, $effectiveDate->diffInSeconds($periodEnd, absolute: false));
$remainingRatio = min(1, $remainingSeconds / $totalSeconds);
$oldProduct = $subscription->product;
$oldPlanPrice = $oldProduct instanceof Product
? $this->periodPrice($oldProduct, $subscription->billing_cycle ?? 'monthly')
: 0.0;
$newPlanPrice = $this->periodPrice($newProduct, $subscription->billing_cycle ?? 'monthly');
return new ProrationResult(
creditAmount: round($oldPlanPrice * $remainingRatio, 2),
chargeAmount: round($newPlanPrice * $remainingRatio, 2),
effectiveDate: $effectiveDate,
);
}
/**
* Apply the proration by creating a credit note for unused old plan time and
* an invoice for the new plan remainder.
*
* @return array{credit_note: CreditNote|null, invoice: Invoice|null}
*/
public function apply(Subscription $subscription, Product $newProduct, ProrationResult $proration): array
{
return DB::transaction(function () use ($subscription, $newProduct, $proration): array {
$creditNote = $proration->creditAmount > 0
? $this->createCreditNote($subscription, $proration)
: null;
$netCharge = max(0, $proration->chargeAmount - $proration->creditAmount);
$invoice = $netCharge > 0
? $this->createInvoice($subscription, $newProduct, $netCharge, $proration)
: null;
$subscription->update([
'product_id' => $newProduct->id,
'metadata' => array_merge($subscription->metadata ?? [], [
'last_proration' => $proration->toArray(),
]),
]);
return [
'credit_note' => $creditNote,
'invoice' => $invoice,
];
});
}
protected function createCreditNote(Subscription $subscription, ProrationResult $proration): ?CreditNote
{
$workspace = $subscription->workspace;
$user = $workspace?->owner();
if (! $workspace || ! $user) {
return null;
}
return CreditNote::create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'invoice_id' => null,
'reference_number' => CreditNote::generateReferenceNumber(),
'amount' => $proration->creditAmount,
'currency' => config('commerce.currency', 'GBP'),
'reason' => 'subscription_proration',
'description' => 'Credit for unused subscription time',
'status' => 'issued',
'issued_at' => now(),
'metadata' => [
'subscription_id' => $subscription->id,
'proration' => $proration->toArray(),
],
]);
}
protected function createInvoice(
Subscription $subscription,
Product $newProduct,
float $amount,
ProrationResult $proration
): ?Invoice {
$workspace = $subscription->workspace;
if (! $workspace) {
return null;
}
$invoice = Invoice::create([
'workspace_id' => $workspace->id,
'invoice_number' => Invoice::generateInvoiceNumber(),
'status' => 'pending',
'subtotal' => $amount,
'discount_amount' => 0,
'tax_amount' => 0,
'total' => $amount,
'amount_paid' => 0,
'amount_due' => $amount,
'currency' => config('commerce.currency', 'GBP'),
'billing_name' => $workspace->billing_name ?? $workspace->name,
'billing_email' => $workspace->billing_email ?? $workspace->owner()?->email,
'billing_address' => method_exists($workspace, 'getBillingAddress') ? $workspace->getBillingAddress() : null,
'issue_date' => now(),
'due_date' => now()->addDays(config('commerce.billing.invoice_due_days', 14)),
'metadata' => [
'subscription_id' => $subscription->id,
'product_id' => $newProduct->id,
'proration' => $proration->toArray(),
],
]);
InvoiceItem::create([
'invoice_id' => $invoice->id,
'description' => "Prorated subscription change to {$newProduct->name}",
'quantity' => 1,
'unit_price' => $amount,
'line_total' => $amount,
'taxable' => true,
'tax_rate' => 0,
'tax_amount' => 0,
]);
return $invoice;
}
protected function periodPrice(Product $product, string $billingCycle): float
{
$price = $product->prices()
->where('currency', config('commerce.currency', 'GBP'))
->where(function ($query) use ($billingCycle): void {
$query->whereNull('billing_cycle')->orWhere('billing_cycle', $billingCycle);
})
->orderByRaw('billing_cycle IS NULL')
->first();
if ($price) {
return $price->amount / 100;
}
if (isset($product->price)) {
return ((int) $product->price) / 100;
}
return (float) ($product->base_price ?? 0);
}
}

View file

@ -113,6 +113,11 @@ class RefundService
});
}
public function process(Payment $payment, float $amount, string $reason): Refund
{
return $this->refund($payment, $amount, $reason);
}
/**
* Check if a payment can be refunded.
*/

View file

@ -23,8 +23,26 @@ class SkuBuilderService
*
* @param array<array{base_sku: string, options?: array, bundle_group?: string|int}> $lineItems
*/
public function build(array $lineItems): string
public function build(array|string $lineItems, ?string $baseSku = null, array $options = []): string
{
if (is_string($lineItems)) {
$prefix = strtoupper(trim($lineItems, '-'));
$sku = strtoupper((string) $baseSku);
foreach ($options as $key => $value) {
if (is_array($value)) {
$key = $value['key'] ?? $value['code'] ?? $key;
$value = $value['value'] ?? '';
}
if ($value !== '') {
$sku .= '-'.strtoupper((string) $key).strtoupper((string) $value);
}
}
return $prefix === '' ? $sku : "{$prefix}-{$sku}";
}
if (empty($lineItems)) {
return '';
}

View file

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Core\Mod\Commerce\Contracts\PaymentGatewayContract;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Models\PaymentMethod;
use Core\Mod\Commerce\Models\Refund;
use Core\Mod\Commerce\Services\PaymentGateway\StripeGateway as LegacyStripeGateway;
use Illuminate\Http\Request;
class StripeGateway implements PaymentGatewayContract
{
public function __construct(
protected ?LegacyStripeGateway $gateway = null,
) {
$this->gateway ??= new LegacyStripeGateway;
}
/**
* @return array<string, mixed>
*/
public function createSession(Order $order, PaymentMethod $paymentMethod): array
{
$successUrl = url('/checkout/success?order='.$order->order_number);
$cancelUrl = url('/checkout/cancel?order='.$order->order_number);
return $this->gateway->createCheckoutSession($order, $successUrl, $cancelUrl);
}
/**
* @param array<string, mixed> $gatewayData
*/
public function confirmPayment(Payment $payment, array $gatewayData): Payment
{
$payment->update([
'gateway_payment_id' => $gatewayData['payment_intent'] ?? $gatewayData['id'] ?? $payment->gateway_payment_id,
'gateway_customer_id' => $gatewayData['customer'] ?? $payment->gateway_customer_id,
'status' => 'succeeded',
'paid_at' => now(),
'gateway_response' => $gatewayData,
]);
return $payment->fresh();
}
public function refund(Payment $payment, float $amount, string $reason): Refund
{
$refund = Refund::create([
'payment_id' => $payment->id,
'amount' => $amount,
'currency' => $payment->currency,
'status' => 'pending',
'reason' => $reason,
]);
if (! $this->gateway->isEnabled()) {
return $refund;
}
$result = $this->gateway->refund($payment, $amount, $reason);
if (($result['success'] ?? false) === true) {
$refund->markAsSucceeded($result['refund_id'] ?? null);
} else {
$refund->markAsFailed($result);
}
return $refund->fresh();
}
public function validateWebhookSignature(Request $request): bool
{
return $this->gateway->verifyWebhookSignature(
$request->getContent(),
(string) $request->header('Stripe-Signature', '')
);
}
/**
* @return array<string, mixed>
*/
public function parseWebhookEvent(Request $request): array
{
$event = $this->gateway->parseWebhookEvent($request->getContent());
return [
'type' => $event['type'] ?? 'unknown',
'id' => $event['raw']['id'] ?? $event['id'] ?? null,
'data' => $event['raw']['data']['object'] ?? [],
'raw' => $event['raw'] ?? $event,
];
}
}

View file

@ -51,16 +51,38 @@ class SubscriptionService
/**
* Cancel a subscription (set to expire at period end).
*/
public function cancel(Subscription $subscription, ?string $reason = null): Subscription
{
$subscription->update([
public function cancel(
Subscription $subscription,
bool|string|null $immediateOrReason = null,
?string $reason = null
): Subscription {
$immediate = is_bool($immediateOrReason) ? $immediateOrReason : false;
$reason = is_bool($immediateOrReason) ? $reason : $immediateOrReason;
$updates = [
'cancelled_at' => Carbon::now(),
'cancellation_reason' => $reason,
]);
];
if ($immediate) {
$updates['status'] = 'cancelled';
$updates['ended_at'] = Carbon::now();
$updates['current_period_end'] = Carbon::now();
}
$subscription->update($updates);
return $subscription->fresh();
}
public function getDueForRenewal(): Collection
{
return Subscription::query()
->where('status', 'active')
->where('current_period_end', '<=', Carbon::now())
->get();
}
/**
* Resume a cancelled subscription (before it expires).
*/

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Core\Mod\Commerce\Events\SubscriptionCancelled;
use Core\Mod\Commerce\Models\Subscription;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class SubscriptionStateMachine
{
/**
* @var array<string, array<int, string>>
*/
private const TRANSITIONS = [
'active' => ['suspended', 'cancelled'],
'suspended' => ['active', 'cancelled'],
'cancelled' => ['expired'],
'expired' => [],
];
/**
* @return array<int, string>
*/
public function allowedTransitions(string $status): array
{
return self::TRANSITIONS[$status] ?? [];
}
public function canTransition(Subscription $subscription, string $to): bool
{
return in_array($to, $this->allowedTransitions((string) $subscription->status), true);
}
public function transition(Subscription $subscription, string $to, string $reason = ''): Subscription
{
if (! $this->canTransition($subscription, $to)) {
throw new InvalidArgumentException(
"Cannot transition subscription {$subscription->id} from {$subscription->status} to {$to}."
);
}
return DB::transaction(function () use ($subscription, $to, $reason): Subscription {
$updates = [
'status' => $to,
'metadata' => array_merge($subscription->metadata ?? [], [
'state_machine' => [
'from' => $subscription->status,
'to' => $to,
'reason' => $reason,
'changed_at' => now()->toIso8601String(),
],
]),
];
if ($to === 'suspended') {
$updates['paused_at'] = $subscription->paused_at ?? now();
}
if ($to === 'cancelled') {
$updates['cancelled_at'] = now();
$updates['cancellation_reason'] = $reason;
}
if ($to === 'expired') {
$updates['ended_at'] = now();
}
$subscription->update($updates);
if ($to === 'cancelled') {
event(new SubscriptionCancelled($subscription->fresh(), true));
}
return $subscription->fresh();
});
}
public function suspend(Subscription $subscription, string $reason = 'failed_payment'): Subscription
{
return $this->transition($subscription, 'suspended', $reason);
}
public function reactivate(Subscription $subscription, string $reason = 'payment_recovered'): Subscription
{
return $this->transition($subscription, 'active', $reason);
}
public function cancel(Subscription $subscription, string $reason = ''): Subscription
{
return $this->transition($subscription, 'cancelled', $reason);
}
public function expire(Subscription $subscription, string $reason = 'period_ended'): Subscription
{
return $this->transition($subscription, 'expired', $reason);
}
}

109
Services/WebhookService.php Normal file
View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Core\Mod\Commerce\Contracts\PaymentGatewayContract;
use Core\Mod\Commerce\Jobs\ProcessWebhookEvent;
use Core\Mod\Commerce\Models\WebhookEvent;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class WebhookService
{
public function gateway(string $gateway): PaymentGatewayContract
{
return app("commerce.rfc_gateway.{$gateway}");
}
public function dispatch(string $gateway, Request $request): ?WebhookEvent
{
return DB::transaction(function () use ($gateway, $request): ?WebhookEvent {
$paymentGateway = $this->gateway($gateway);
if (! $paymentGateway->validateWebhookSignature($request)) {
Log::warning('Webhook signature rejected', [
'gateway' => $gateway,
'ip' => $request->ip(),
]);
return null;
}
$event = $paymentGateway->parseWebhookEvent($request);
$eventId = $event['id'] ?? null;
$eventType = (string) ($event['type'] ?? 'unknown');
if (is_string($eventId) && $this->exists($gateway, $eventId)) {
return null;
}
try {
$webhookEvent = WebhookEvent::record(
gateway: $gateway,
eventType: $eventType,
payload: $request->getContent(),
eventId: is_string($eventId) ? $eventId : null,
headers: $this->headers($request, $gateway),
);
} catch (QueryException $e) {
if ($this->isDuplicate($e)) {
return null;
}
throw $e;
}
ProcessWebhookEvent::dispatch($webhookEvent->id)->afterCommit();
return $webhookEvent;
});
}
protected function exists(string $gateway, string $eventId): bool
{
return WebhookEvent::query()
->where('gateway', $gateway)
->where('event_id', $eventId)
->exists();
}
/**
* @return array<string, string>
*/
protected function headers(Request $request, string $gateway): array
{
$headers = [
'Content-Type' => (string) $request->header('Content-Type', ''),
'User-Agent' => (string) $request->header('User-Agent', ''),
'X-Forwarded-For' => (string) $request->header('X-Forwarded-For', ''),
];
if ($gateway === 'stripe') {
$headers['Stripe-Signature'] = (string) $request->header('Stripe-Signature', '');
}
if ($gateway === 'btcpay') {
$headers['BTCPay-Sig'] = (string) $request->header('BTCPay-Sig', '');
$headers['BTCPay-Signature'] = (string) $request->header('BTCPay-Signature', '');
}
return array_filter($headers, fn (string $value): bool => $value !== '');
}
protected function isDuplicate(QueryException $e): bool
{
$driverCode = $e->errorInfo[0] ?? null;
$vendorCode = $e->errorInfo[1] ?? null;
$message = $e->getMessage();
return $vendorCode === 1062
|| $vendorCode === 19
|| $driverCode === '23505'
|| str_contains($message, 'webhook_events_idempotency')
|| str_contains($message, 'UNIQUE constraint failed');
}
}

View file

@ -232,9 +232,8 @@ return [
'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],
// Escalating backoff: retry at 1, 3, 7, and 14 days, then cancel.
'retry_days' => [1, 3, 7, 14],
// Days after subscription paused to suspend workspace entitlements
// Paused = billing stopped but workspace accessible
@ -243,7 +242,7 @@ return [
// Days after subscription paused to cancel entirely
// After cancellation, workspace may be downgraded to free tier
'cancel_after_days' => 30,
'cancel_after_days' => 14,
// Grace period before first retry (hours)
// Gives customer time to fix payment method before automated retries

View file

@ -49,3 +49,8 @@ Route::prefix('hub/commerce')->name('hub.commerce.')->group(function () {
Route::get('/credit-notes', CreditNoteManager::class)->name('credit-notes');
Route::get('/referrals', ReferralManager::class)->name('referrals');
});
Route::prefix('admin/commerce')->name('admin.commerce.')->group(function () {
Route::get('/matrix/training', [Core\Mod\Commerce\Controllers\MatrixTrainingController::class, 'pending'])
->name('matrix.training');
});

View file

@ -92,3 +92,25 @@ Route::middleware('auth')->prefix('commerce')->group(function () {
Route::post('/upgrade', [CommerceController::class, 'executeUpgrade'])
->name('api.commerce.upgrade');
});
Route::middleware('auth')->prefix('v1')->name('api.v1.')->group(function () {
Route::post('/checkout', [CommerceController::class, 'checkout'])->name('checkout.store');
Route::get('/checkout/{id}', [CommerceController::class, 'checkoutStatus'])->name('checkout.show');
Route::post('/checkout/{id}/confirm', [CommerceController::class, 'confirmCheckout'])->name('checkout.confirm');
Route::get('/orders', [CommerceController::class, 'orders'])->name('orders.index');
Route::get('/orders/{order}', [CommerceController::class, 'showOrder'])->name('orders.show');
Route::get('/subscriptions', [CommerceController::class, 'subscriptions'])->name('subscriptions.index');
Route::post('/subscriptions/{subscription}/cancel', [CommerceController::class, 'cancelSubscriptionById'])->name('subscriptions.cancel');
Route::post('/subscriptions/{subscription}/change-plan', [CommerceController::class, 'changePlan'])->name('subscriptions.change-plan');
Route::get('/invoices', [CommerceController::class, 'invoices'])->name('invoices.index');
Route::get('/invoices/{invoice}', [CommerceController::class, 'showInvoice'])->name('invoices.show');
Route::get('/invoices/{invoice}/pdf', [CommerceController::class, 'downloadInvoice'])->name('invoices.pdf');
Route::get('/payment-methods', [CommerceController::class, 'paymentMethods'])->name('payment-methods.index');
Route::post('/payment-methods', [CommerceController::class, 'storePaymentMethod'])->name('payment-methods.store');
Route::delete('/payment-methods/{paymentMethod}', [CommerceController::class, 'deletePaymentMethod'])->name('payment-methods.destroy');
Route::post('/payment-methods/{paymentMethod}/default', [CommerceController::class, 'setDefaultPaymentMethod'])->name('payment-methods.default');
});

View file

@ -3,6 +3,8 @@
declare(strict_types=1);
use Core\Mod\Commerce\Controllers\MatrixTrainingController;
use Core\Mod\Commerce\View\Modal\Web\CheckoutCancel;
use Core\Mod\Commerce\View\Modal\Web\CheckoutSuccess;
use Illuminate\Support\Facades\Route;
/*
@ -34,3 +36,6 @@ Route::prefix('commerce')->name('commerce.')->group(function () {
});
});
Route::get('/checkout/success', CheckoutSuccess::class)->name('checkout.success');
Route::get('/checkout/cancel', CheckoutCancel::class)->name('checkout.cancel');

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Carbon\Carbon;
use Core\Mod\Commerce\Contracts\PaymentGatewayContract;
use Core\Mod\Commerce\DTOs\CouponValidationResult;
use Core\Mod\Commerce\DTOs\ProrationResult;
use Core\Mod\Commerce\Services\BTCPayGateway;
use Core\Mod\Commerce\Services\StripeGateway;
it('exposes readonly RFC DTOs', function (): void {
$coupon = new CouponValidationResult(
valid: true,
reason: null,
discountAmount: 5.0,
discountType: 'fixed',
);
$proration = new ProrationResult(
creditAmount: 10.0,
chargeAmount: 25.0,
effectiveDate: Carbon::parse('2026-04-25 12:00:00'),
);
expect($coupon->toArray())->toMatchArray([
'valid' => true,
'discount_amount' => 5.0,
'discount_type' => 'fixed',
])->and($proration->netAmount())->toBe(15.0);
});
it('provides Stripe and BTCPay RFC gateway implementations', function (): void {
expect(is_subclass_of(StripeGateway::class, PaymentGatewayContract::class))->toBeTrue()
->and(is_subclass_of(BTCPayGateway::class, PaymentGatewayContract::class))->toBeTrue();
});