Compare commits

..

2 commits

Author SHA1 Message Date
Claude
b624757c3c
test: add comprehensive tests for InvoiceService
Covers invoice generation from orders and renewals, line item copying,
tax calculations, PDF generation/retrieval, email sending, status
transitions, workspace queries, model scopes, and relationships.

Fixes #5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:25:33 +00:00
Claude
5bce748a0f
security: add CSRF protection to API billing endpoints
- Add `verified` middleware to billing route group so only
  email-verified users can access billing endpoints
- Separate read-only GET routes from state-changing POST routes
- Add `throttle:6,1` rate limiting to state-changing endpoints
  (cancel, resume, upgrade/preview, upgrade) — 6 requests per minute
- Reorganise route group with clear section comments

Fixes #13

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:19:30 +00:00
63 changed files with 1291 additions and 4700 deletions

View file

@ -8,7 +8,6 @@ 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;
@ -28,15 +27,12 @@ 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;
@ -102,9 +98,6 @@ 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) {
@ -115,14 +108,6 @@ 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'
@ -130,14 +115,6 @@ 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 = 'commerce:plant-trees
protected $signature = 'trees:subscriber-monthly
{--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:dunning
protected $signature = 'commerce:process-dunning
{--dry-run : Show what would happen without making changes}
{--stage= : Process only a specific stage (retry, pause, suspend, cancel, expire)}';

View file

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

View file

@ -1,39 +0,0 @@
<?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,7 +7,6 @@ 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;
@ -53,82 +52,6 @@ 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.
*
@ -266,136 +189,6 @@ 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,10 +152,6 @@ 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,10 +148,6 @@ 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')

View file

@ -1,26 +0,0 @@
<?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

@ -1,28 +0,0 @@
<?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,
];
}
}

View file

@ -1,31 +0,0 @@
<?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,
];
}
}

View file

@ -1,26 +0,0 @@
<?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,
];
}
}

View file

@ -1,29 +0,0 @@
<?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,
];
}
}

View file

@ -1,34 +0,0 @@
<?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(),
];
}
}

View file

@ -1,26 +0,0 @@
<?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,
];
}
}

View file

@ -1,34 +0,0 @@
<?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

@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Data;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Core\Mod\Commerce\Models\Coupon as CouponModel;
/**
* Persisted coupon data used by the RFC CouponService API.
*/
readonly class Coupon
{
public function __construct(
public int $id,
public string $code,
public string $type,
public float $value,
public ?int $maxUses,
public ?CarbonImmutable $expiresAt,
public bool $active,
public int $usedCount,
) {}
public static function fromModel(CouponModel $coupon): self
{
return new self(
id: (int) $coupon->id,
code: (string) $coupon->code,
type: in_array((string) $coupon->type, ['percent', 'percentage'], true) ? 'percent' : 'fixed',
value: (float) $coupon->value,
maxUses: $coupon->max_uses === null ? null : (int) $coupon->max_uses,
expiresAt: self::immutableDate($coupon->valid_until),
active: (bool) $coupon->is_active,
usedCount: (int) $coupon->used_count,
);
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'code' => $this->code,
'type' => $this->type,
'value' => $this->value,
'max_uses' => $this->maxUses,
'expires_at' => $this->expiresAt?->toIso8601String(),
'active' => $this->active,
'used_count' => $this->usedCount,
];
}
public function isExpired(): bool
{
return $this->expiresAt?->isPast() ?? false;
}
public function __get(string $name): mixed
{
return match ($name) {
'max_uses' => $this->maxUses,
'expires_at' => $this->expiresAt,
'is_active' => $this->active,
'used_count' => $this->usedCount,
default => null,
};
}
private static function immutableDate(mixed $value): ?CarbonImmutable
{
if (! $value instanceof CarbonInterface) {
return null;
}
return CarbonImmutable::instance($value->toDateTime());
}
}

View file

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Data;
use Carbon\Carbon;
/**
* Failed-payment retry and suspension dates for a subscription.
*/
readonly class DunningSchedule
{
/**
* @param array<int, Carbon> $retryDates
*/
public function __construct(
public array $retryDates,
public Carbon $suspensionDate,
) {}
/**
* @return array{retry_dates: array<int, string>, suspension_date: string}
*/
public function toArray(): array
{
return [
'retry_dates' => array_map(
fn (Carbon $date): string => $date->toISOString(),
$this->retryDates
),
'suspension_date' => $this->suspensionDate->toISOString(),
];
}
}

View file

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Data;
use InvalidArgumentException;
/**
* Order-level fraud score for manual review and blocking decisions.
*/
readonly class FraudScore
{
public function __construct(
public int $score,
public array $signals,
public string $recommendation,
) {
if ($this->score < 0 || $this->score > 100) {
throw new InvalidArgumentException('Fraud score must be between 0 and 100.');
}
if (! in_array($this->recommendation, ['approve', 'review', 'block'], true)) {
throw new InvalidArgumentException('Fraud recommendation must be approve, review, or block.');
}
}
/**
* @return array{score: int, signals: array, recommendation: string}
*/
public function toArray(): array
{
return [
'score' => $this->score,
'signals' => $this->signals,
'recommendation' => $this->recommendation,
];
}
}

View file

@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Data;
use Carbon\Carbon;
use Core\Mod\Commerce\Models\Payment;
/**
* Result from an attempted automatic invoice payment retry.
*/
readonly class PaymentResult
{
public function __construct(
public bool $successful,
public ?Payment $payment = null,
public ?string $reason = null,
public int $attempts = 0,
public ?Carbon $nextRetryAt = null,
) {}
public static function successful(?Payment $payment = null, int $attempts = 0): self
{
return new self(
successful: true,
payment: $payment,
attempts: $attempts,
);
}
public static function failed(string $reason, int $attempts = 0, ?Carbon $nextRetryAt = null): self
{
return new self(
successful: false,
reason: $reason,
attempts: $attempts,
nextRetryAt: $nextRetryAt,
);
}
public function succeeded(): bool
{
return $this->successful;
}
public function isFailed(): bool
{
return ! $this->successful;
}
}

View file

@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Data;
/**
* Coupon validation result for the RFC CouponService API.
*/
readonly class ValidationResult
{
public function __construct(
public bool $valid,
public ?string $reason,
public float $discountAmount,
public string $discountType,
public ?Coupon $coupon = null,
) {}
public static function valid(Coupon $coupon, float $discountAmount, string $discountType): self
{
return new self(
valid: true,
reason: null,
discountAmount: round($discountAmount, 2),
discountType: $discountType,
coupon: $coupon,
);
}
public static function invalid(
string $reason,
string $discountType = 'none',
?Coupon $coupon = null,
): self {
return new self(
valid: false,
reason: $reason,
discountAmount: 0.0,
discountType: $discountType,
coupon: $coupon,
);
}
public function isValid(): bool
{
return $this->valid;
}
public function getMessage(): ?string
{
return $this->reason;
}
public function getCoupon(): ?Coupon
{
return $this->coupon;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'valid' => $this->valid,
'reason' => $this->reason,
'discount_amount' => $this->discountAmount,
'discount_type' => $this->discountType,
'coupon' => $this->coupon?->toArray(),
];
}
public function __get(string $name): mixed
{
return match ($name) {
'discount_amount' => $this->discountAmount,
'discount_type' => $this->discountType,
default => null,
};
}
}

View file

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

View file

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

View file

@ -14,14 +14,6 @@ class SubscriptionUpdated
public function __construct(
public Subscription $subscription,
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;
public ?string $previousStatus = null
) {}
}

View file

@ -1,59 +0,0 @@
<?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

@ -136,15 +136,15 @@ return new class extends Migration
->constrained('users')
->cascadeOnDelete();
// NOTE: `orders` and `invoices` tables are not created by any
// migration in the current codebase. MariaDB silently accepted
// FKs to nonexistent tables (FK checks disabled during the
// migration), but Postgres rejects them. Keeping the columns
// as plain nullable bigint so the schema deploys on both DBs;
// FK constraints can be added in a follow-up migration once
// the orders/invoices tables actually exist.
$table->unsignedBigInteger('order_id')->nullable();
$table->unsignedBigInteger('invoice_id')->nullable();
$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)

View file

@ -1,283 +0,0 @@
<?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

@ -1,75 +0,0 @@
<?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,7 +24,6 @@ class BundleHash extends Model
protected $fillable = [
'hash',
'product_ids',
'base_skus',
'coupon_code',
'fixed_price',
@ -43,7 +42,6 @@ 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,7 +49,6 @@ class CreditNote extends Model
protected $fillable = [
'workspace_id',
'user_id',
'invoice_id',
'order_id',
'refund_id',
'reference_number',
@ -94,11 +93,6 @@ 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,11 +73,8 @@ 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,7 +45,6 @@ class Payment extends Model
'workspace_id',
'invoice_id',
'order_id',
'payment_method_id',
'gateway',
'gateway_payment_id',
'gateway_customer_id',

View file

@ -40,10 +40,8 @@ class PermissionMatrix extends Model
protected $fillable = [
'entity_id',
'target_entity_id',
'key',
'scope',
'permissions',
'allowed',
'locked',
'source',
@ -54,7 +52,6 @@ class PermissionMatrix extends Model
protected $casts = [
'allowed' => 'boolean',
'permissions' => 'array',
'locked' => 'boolean',
'trained_at' => 'datetime',
];
@ -66,11 +63,6 @@ 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,12 +43,9 @@ class PermissionRequest extends Model
protected $fillable = [
'entity_id',
'from_entity_id',
'to_entity_id',
'method',
'route',
'action',
'permissions',
'scope',
'request_data',
'user_agent',
@ -61,7 +58,6 @@ class PermissionRequest extends Model
protected $casts = [
'request_data' => 'array',
'permissions' => 'array',
'was_trained' => 'boolean',
'trained_at' => 'datetime',
];
@ -73,16 +69,6 @@ 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,7 +42,6 @@ class ProductAssignment extends Model
protected $fillable = [
'entity_id',
'entity_type',
'product_id',
'sku_suffix',
'price_override',

View file

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

View file

@ -59,7 +59,6 @@ class Subscription extends Model
protected $fillable = [
'workspace_id',
'workspace_package_id',
'product_id',
'gateway',
'gateway_subscription_id',
'gateway_customer_id',
@ -102,11 +101,6 @@ 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);
@ -136,7 +130,7 @@ class Subscription extends Model
public function isPaused(): bool
{
return in_array($this->status, ['paused', 'suspended'], true);
return $this->status === 'paused';
}
public function isCancelled(): bool
@ -175,7 +169,7 @@ class Subscription extends Model
public function isValid(): bool
{
return in_array($this->status, ['active', 'trialing', 'past_due', 'suspended'], true);
return in_array($this->status, ['active', 'trialing', 'past_due']);
}
public function onTrial(): bool
@ -236,14 +230,6 @@ 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']);

View file

@ -1,102 +0,0 @@
<?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,7 +5,6 @@ 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;
@ -15,8 +14,6 @@ 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;
@ -251,120 +248,6 @@ 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.
*
@ -854,21 +737,4 @@ 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

@ -4,23 +4,15 @@ declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Core\Mod\Commerce\Contracts\Orderable;
use Core\Mod\Commerce\Data\Coupon as CouponData;
use Core\Mod\Commerce\Data\CouponValidationResult;
use Core\Mod\Commerce\Data\ValidationResult;
use Core\Mod\Commerce\Models\Coupon as CouponModel;
use Core\Mod\Commerce\Models\Coupon;
use Core\Mod\Commerce\Models\CouponUsage;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\OrderItem;
use Core\Tenant\Models\Package;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use RuntimeException;
/**
* Coupon validation and application service.
@ -53,7 +45,7 @@ class CouponService
*
* Sanitises the code before querying to prevent abuse.
*/
public function findByCode(string $code): ?CouponModel
public function findByCode(string $code): ?Coupon
{
$sanitised = $this->sanitiseCode($code);
@ -61,7 +53,7 @@ class CouponService
return null;
}
return CouponModel::byCode($sanitised)->first();
return Coupon::byCode($sanitised)->first();
}
/**
@ -78,13 +70,16 @@ class CouponService
*/
public function sanitiseCode(string $code): ?string
{
// Trim whitespace and convert to uppercase
$sanitised = strtoupper(trim($code));
// Check length constraints
$length = strlen($sanitised);
if ($length < self::MIN_CODE_LENGTH || $length > self::MAX_CODE_LENGTH) {
return null;
}
// Validate allowed characters (alphanumeric, hyphens, underscores only)
if (! preg_match(self::VALID_CODE_PATTERN, $sanitised)) {
return null;
}
@ -103,209 +98,26 @@ class CouponService
}
/**
* Create a persisted coupon.
*
* The scalar signature is the RFC API and returns a DTO. The array form is
* retained for older module code that passes Eloquent attributes directly.
*
* @param string|array<string, mixed> $code
* Validate a coupon for a workspace and package.
*/
public function create(
string|array $code,
?string $type = null,
float|int|null $value = null,
?int $maxUses = null,
CarbonInterface|string|null $expiresAt = null,
): CouponData|CouponModel {
if (is_array($code)) {
return $this->createModel($code);
}
if ($type === null || $value === null) {
throw new InvalidArgumentException('Coupon type and value are required.');
}
$coupon = $this->createModel([
'code' => $code,
'name' => $code,
'type' => $type,
'value' => $value,
'max_uses' => $maxUses,
'max_uses_per_workspace' => 1,
'duration' => 'once',
'valid_until' => $this->parseExpiresAt($expiresAt),
'is_active' => true,
'applies_to' => 'all',
'used_count' => 0,
]);
return CouponData::fromModel($coupon);
}
/**
* Validate a coupon by code for an order, or use the legacy model/workspace flow.
*/
public function validate(
string|CouponModel $code,
Order|Workspace $order,
?Package $package = null,
): ValidationResult|CouponValidationResult {
if ($code instanceof CouponModel) {
if (! $order instanceof Workspace) {
throw new InvalidArgumentException('Legacy coupon validation requires a workspace.');
}
return $this->validateLegacy($code, $order, $package);
}
if (! $order instanceof Order) {
throw new InvalidArgumentException('Coupon code validation requires an order.');
}
$sanitised = $this->sanitiseCode($code);
if ($sanitised === null) {
return ValidationResult::invalid('Invalid coupon code format');
}
$coupon = CouponModel::byCode($sanitised)->first();
if (! $coupon) {
return ValidationResult::invalid('Coupon not found');
}
return $this->validateCouponForOrder($coupon, $order);
}
/**
* Apply a coupon to an order by mutating eligible line-item totals.
*/
public function apply(CouponData|CouponModel $coupon, Order $order): Order
public function validate(Coupon $coupon, Workspace $workspace, ?Package $package = null): CouponValidationResult
{
$couponModel = $this->resolveCouponModel($coupon);
if (! $order->exists) {
throw new InvalidArgumentException('Coupon application requires a persisted order.');
// Check if coupon is valid (active, within dates, not maxed out)
if (! $coupon->isValid()) {
return CouponValidationResult::invalid('This coupon is no longer valid');
}
return DB::transaction(function () use ($couponModel, $order): Order {
/** @var Order $lockedOrder */
$lockedOrder = Order::query()
->with('items')
->lockForUpdate()
->findOrFail($order->id);
// Check workspace usage limit
if (! $coupon->canBeUsedByWorkspace($workspace->id)) {
return CouponValidationResult::invalid('You have already used this coupon');
}
if ($this->hasAppliedCoupon($couponModel, $lockedOrder)) {
return $lockedOrder->load('items', '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');
}
if ($lockedOrder->coupon_id && (int) $lockedOrder->coupon_id !== (int) $couponModel->id) {
throw new RuntimeException('Order already has a different coupon applied.');
}
$result = $this->validateCouponForOrder($couponModel, $lockedOrder);
if (! $result->valid) {
throw new RuntimeException($result->reason ?? 'Coupon is not valid for this order.');
}
$eligibleItems = $this->eligibleItems($couponModel, $lockedOrder);
$discounts = $this->allocateDiscount($couponModel, $eligibleItems, $result->discountAmount);
foreach ($eligibleItems as $item) {
$baseLineTotal = $this->lineBaseTotal($item);
$lineDiscount = $discounts[(int) $item->id] ?? 0.0;
$metadata = $item->metadata ?? [];
$item->forceFill([
'line_total' => round(max(0.0, $baseLineTotal - $lineDiscount), 2),
'metadata' => array_merge($metadata, [
'original_line_total' => $baseLineTotal,
'coupon_id' => $couponModel->id,
'coupon_code' => $couponModel->code,
'coupon_discount_amount' => round($lineDiscount, 2),
]),
])->save();
}
$lockedOrder->load('items');
$subtotal = round((float) $lockedOrder->items->sum(
fn (OrderItem $item): float => $this->lineBaseTotal($item)
), 2);
$lineTotal = round((float) $lockedOrder->items->sum(
fn (OrderItem $item): float => (float) $item->line_total
), 2);
$discountAmount = round(max(0.0, $subtotal - $lineTotal), 2);
$taxAmount = (float) ($lockedOrder->tax_amount ?? 0);
$lockedOrder->forceFill([
'subtotal' => $subtotal,
'discount_amount' => $discountAmount,
'total' => round($lineTotal + $taxAmount, 2),
'coupon_id' => $couponModel->id,
])->save();
$this->recordOrderUsage($couponModel, $lockedOrder, $discountAmount);
return $lockedOrder->load('items', 'coupon');
});
}
/**
* Expire a coupon immediately.
*/
public function expire(CouponData|CouponModel $coupon): void
{
$couponModel = $this->resolveCouponModel($coupon);
$couponModel->forceFill([
'is_active' => false,
'valid_until' => Carbon::now(),
])->save();
}
/**
* Return redemption statistics for all coupons.
*
* @return array<string, mixed>
*/
public function report(): array
{
$now = Carbon::now();
$couponRows = CouponModel::query()
->withCount('usages')
->withSum('usages as discount_total', 'discount_amount')
->orderByDesc('usages_count')
->orderBy('code')
->get();
return [
'total_coupons' => CouponModel::query()->count(),
'active_coupons' => CouponModel::query()->where('is_active', true)->count(),
'expired_coupons' => CouponModel::query()
->whereNotNull('valid_until')
->where('valid_until', '<', $now)
->count(),
'total_redemptions' => CouponUsage::query()->count(),
'total_discount_amount' => round((float) CouponUsage::query()->sum('discount_amount'), 2),
'by_coupon' => $couponRows->map(function (CouponModel $coupon): array {
$redemptions = (int) ($coupon->getAttribute('usages_count') ?? 0);
return [
'id' => $coupon->id,
'code' => $coupon->code,
'type' => $this->discountType($coupon),
'value' => (float) $coupon->value,
'active' => (bool) $coupon->is_active,
'max_uses' => $coupon->max_uses,
'used_count' => max((int) $coupon->used_count, $redemptions),
'redemptions' => $redemptions,
'discount_total' => round((float) ($coupon->getAttribute('discount_total') ?? 0), 2),
'expires_at' => $coupon->valid_until?->toIso8601String(),
];
})->values()->all(),
];
return CouponValidationResult::valid($coupon);
}
/**
@ -313,16 +125,19 @@ class CouponService
*
* Returns boolean for use in CommerceService order creation.
*/
public function validateForOrderable(CouponModel $coupon, Orderable&Model $orderable, ?Package $package = null): bool
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;
}
@ -338,25 +153,26 @@ class CouponService
*/
public function validateByCode(string $code, Workspace $workspace, ?Package $package = null): CouponValidationResult
{
// Sanitise the code first - reject invalid formats early
$sanitised = $this->sanitiseCode($code);
if ($sanitised === null) {
return CouponValidationResult::invalid('Invalid coupon code format');
}
$coupon = CouponModel::byCode($sanitised)->first();
$coupon = Coupon::byCode($sanitised)->first();
if (! $coupon) {
return CouponValidationResult::invalid('Invalid coupon code');
}
return $this->validateLegacy($coupon, $workspace, $package);
return $this->validate($coupon, $workspace, $package);
}
/**
* Calculate discount for an amount.
*/
public function calculateDiscount(CouponModel $coupon, float $amount): float
public function calculateDiscount(Coupon $coupon, float $amount): float
{
return $coupon->calculateDiscount($amount);
}
@ -364,7 +180,7 @@ class CouponService
/**
* Record coupon usage after successful payment.
*/
public function recordUsage(CouponModel $coupon, Workspace $workspace, Order $order, float $discountAmount): CouponUsage
public function recordUsage(Coupon $coupon, Workspace $workspace, Order $order, float $discountAmount): CouponUsage
{
$usage = CouponUsage::create([
'coupon_id' => $coupon->id,
@ -373,6 +189,7 @@ class CouponService
'discount_amount' => $discountAmount,
]);
// Increment global usage count
$coupon->incrementUsage();
return $usage;
@ -381,12 +198,8 @@ class CouponService
/**
* Record coupon usage for any Orderable entity.
*/
public function recordUsageForOrderable(
CouponModel $coupon,
Orderable&Model $orderable,
Order $order,
float $discountAmount,
): CouponUsage {
public function recordUsageForOrderable(Coupon $coupon, Orderable&Model $orderable, Order $order, float $discountAmount): CouponUsage
{
$workspaceId = $orderable instanceof Workspace ? $orderable->id : null;
$usage = CouponUsage::create([
@ -396,6 +209,7 @@ class CouponService
'discount_amount' => $discountAmount,
]);
// Increment global usage count
$coupon->incrementUsage();
return $usage;
@ -404,7 +218,7 @@ class CouponService
/**
* Get usage history for a coupon.
*/
public function getUsageHistory(CouponModel $coupon, int $limit = 50): Collection
public function getUsageHistory(Coupon $coupon, int $limit = 50): Collection
{
return $coupon->usages()
->with(['workspace', 'order'])
@ -416,7 +230,7 @@ class CouponService
/**
* Get usage count for a workspace.
*/
public function getWorkspaceUsageCount(CouponModel $coupon, Workspace $workspace): int
public function getWorkspaceUsageCount(Coupon $coupon, Workspace $workspace): int
{
return $coupon->usages()
->where('workspace_id', $workspace->id)
@ -426,15 +240,26 @@ class CouponService
/**
* Get total discount amount for a coupon.
*/
public function getTotalDiscountAmount(CouponModel $coupon): float
public function getTotalDiscountAmount(Coupon $coupon): float
{
return (float) $coupon->usages()->sum('discount_amount');
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(CouponModel $coupon): void
public function deactivate(Coupon $coupon): void
{
$coupon->update(['is_active' => false]);
}
@ -451,7 +276,8 @@ class CouponService
$code .= $characters[random_int(0, strlen($characters) - 1)];
}
while (CouponModel::where('code', $code)->exists()) {
// Ensure uniqueness
while (Coupon::where('code', $code)->exists()) {
$code = $this->generateCode($length);
}
@ -462,8 +288,8 @@ class CouponService
* Generate multiple coupons with unique codes.
*
* @param int $count Number of coupons to generate (1-100)
* @param array<string, mixed> $baseData Base coupon data (shared settings for all coupons)
* @return array<CouponModel> Array of created coupons
* @param array $baseData Base coupon data (shared settings for all coupons)
* @return array<Coupon> Array of created coupons
*/
public function generateBulk(int $count, array $baseData): array
{
@ -475,325 +301,9 @@ class CouponService
for ($i = 0; $i < $count; $i++) {
$code = $prefix ? $prefix.'-'.$this->generateCode(6) : $this->generateCode(8);
$data = array_merge($baseData, ['code' => $code]);
$coupons[] = $this->createModel($data);
$coupons[] = $this->create($data);
}
return $coupons;
}
private function validateLegacy(CouponModel $coupon, Workspace $workspace, ?Package $package = null): CouponValidationResult
{
if (! $coupon->isValid()) {
return CouponValidationResult::invalid('This coupon is no longer valid');
}
if (! $coupon->canBeUsedByWorkspace($workspace->id)) {
return CouponValidationResult::invalid('You have already used this coupon');
}
if ($package && ! $coupon->appliesToPackage($package->id)) {
return CouponValidationResult::invalid('This coupon does not apply to the selected plan');
}
return CouponValidationResult::valid($coupon);
}
/**
* @param array<string, mixed> $data
*/
private function createModel(array $data): CouponModel
{
if (! isset($data['code'])) {
throw new InvalidArgumentException('Coupon code is required.');
}
$sanitised = $this->sanitiseCode((string) $data['code']);
if ($sanitised === null) {
throw new InvalidArgumentException('Invalid coupon code format.');
}
if (CouponModel::byCode($sanitised)->exists()) {
throw new InvalidArgumentException('A coupon with this code already exists.');
}
if (! isset($data['type'])) {
throw new InvalidArgumentException('Coupon type is required.');
}
if (! array_key_exists('value', $data)) {
throw new InvalidArgumentException('Coupon value is required.');
}
$modelType = $this->normaliseModelType((string) $data['type']);
$value = round((float) $data['value'], 2);
if ($modelType === 'percentage' && ($value <= 0 || $value > 100)) {
throw new InvalidArgumentException('Percentage coupon value must be between 0 and 100.');
}
if ($modelType === 'fixed_amount' && $value <= 0) {
throw new InvalidArgumentException('Fixed coupon value must be greater than zero.');
}
$maxUses = $data['max_uses'] ?? null;
if ($maxUses !== null && (int) $maxUses < 1) {
throw new InvalidArgumentException('Coupon max uses must be at least one.');
}
$data['code'] = $sanitised;
$data['name'] = $data['name'] ?? $sanitised;
$data['type'] = $modelType;
$data['value'] = $value;
$data['max_uses'] = $maxUses === null ? null : (int) $maxUses;
$data['max_uses_per_workspace'] = (int) ($data['max_uses_per_workspace'] ?? 1);
$data['used_count'] = (int) ($data['used_count'] ?? 0);
$data['duration'] = $data['duration'] ?? 'once';
$data['applies_to'] = $data['applies_to'] ?? 'all';
$data['valid_until'] = $this->parseExpiresAt($data['valid_until'] ?? null);
$data['is_active'] = (bool) ($data['is_active'] ?? true);
return CouponModel::create($data);
}
private function validateCouponForOrder(CouponModel $coupon, Order $order): ValidationResult
{
$discountType = $this->discountType($coupon);
$couponData = CouponData::fromModel($coupon);
if (! $coupon->is_active) {
return ValidationResult::invalid('Coupon is inactive', $discountType, $couponData);
}
if ($coupon->valid_from && $coupon->valid_from->isFuture()) {
return ValidationResult::invalid('Coupon is not active yet', $discountType, $couponData);
}
if ($coupon->valid_until && $coupon->valid_until->isPast()) {
return ValidationResult::invalid('Coupon has expired', $discountType, $couponData);
}
if ($this->usageCount($coupon) >= $this->usageLimit($coupon)) {
return ValidationResult::invalid('Coupon usage limit reached', $discountType, $couponData);
}
$workspaceId = $this->resolveWorkspaceId($order);
if ($workspaceId !== null && $this->workspaceUsageLimitReached($coupon, $workspaceId)) {
return ValidationResult::invalid('Coupon already used by this workspace', $discountType, $couponData);
}
$eligibleItems = $this->eligibleItems($coupon, $order);
if ($eligibleItems->isEmpty()) {
return ValidationResult::invalid('Coupon is not applicable to this order', $discountType, $couponData);
}
$discountAmount = $this->calculateOrderDiscount($coupon, $eligibleItems);
if ($discountAmount <= 0) {
return ValidationResult::invalid('Order has no discountable amount', $discountType, $couponData);
}
return ValidationResult::valid($couponData, $discountAmount, $discountType);
}
private function resolveCouponModel(CouponData|CouponModel $coupon): CouponModel
{
if ($coupon instanceof CouponModel) {
return $coupon;
}
return CouponModel::query()->findOrFail($coupon->id);
}
private function parseExpiresAt(CarbonInterface|string|null $expiresAt): ?Carbon
{
if ($expiresAt === null || $expiresAt === '') {
return null;
}
if ($expiresAt instanceof CarbonInterface) {
return Carbon::instance($expiresAt->toDateTime());
}
return Carbon::parse($expiresAt);
}
private function normaliseModelType(string $type): string
{
return match (strtolower(trim($type))) {
'percent', 'percentage' => 'percentage',
'fixed', 'fixed_amount' => 'fixed_amount',
default => throw new InvalidArgumentException('Coupon type must be percent or fixed.'),
};
}
private function discountType(CouponModel $coupon): string
{
return $this->isPercentCoupon($coupon) ? 'percent' : 'fixed';
}
private function isPercentCoupon(CouponModel $coupon): bool
{
return in_array((string) $coupon->type, ['percent', 'percentage'], true);
}
private function usageLimit(CouponModel $coupon): int
{
return $coupon->max_uses === null ? PHP_INT_MAX : (int) $coupon->max_uses;
}
private function usageCount(CouponModel $coupon): int
{
return max((int) $coupon->used_count, $coupon->usages()->count());
}
private function workspaceUsageLimitReached(CouponModel $coupon, int $workspaceId): bool
{
$limit = (int) ($coupon->max_uses_per_workspace ?? 0);
if ($limit <= 0) {
return false;
}
return $coupon->usages()
->where('workspace_id', $workspaceId)
->count() >= $limit;
}
private function resolveWorkspaceId(Order $order): ?int
{
$rawWorkspaceId = $order->getAttributes()['workspace_id'] ?? null;
if ($rawWorkspaceId !== null) {
return (int) $rawWorkspaceId;
}
return $order->workspace_id;
}
private function eligibleItems(CouponModel $coupon, Order $order): Collection
{
$order->loadMissing('items');
return $order->items
->filter(fn (OrderItem $item): bool => $this->lineBaseTotal($item) > 0
&& $this->couponAppliesToItem($coupon, $item))
->values();
}
private function couponAppliesToItem(CouponModel $coupon, OrderItem $item): bool
{
if ($coupon->applies_to === 'all' || $coupon->applies_to === null) {
return true;
}
$allowedIds = array_map('intval', $coupon->package_ids ?? []);
if ($allowedIds === []) {
return false;
}
if (in_array($coupon->applies_to, ['package', 'packages', 'product', 'products'], true)) {
return $item->item_id !== null && in_array((int) $item->item_id, $allowedIds, true);
}
return false;
}
private function lineBaseTotal(OrderItem $item): float
{
$metadata = $item->metadata ?? [];
if (isset($metadata['original_line_total'])) {
return round((float) $metadata['original_line_total'], 2);
}
return round((float) $item->line_total, 2);
}
private function calculateOrderDiscount(CouponModel $coupon, Collection $eligibleItems): float
{
$subtotal = round((float) $eligibleItems->sum(
fn (OrderItem $item): float => $this->lineBaseTotal($item)
), 2);
if ($subtotal <= 0) {
return 0.0;
}
if ($this->isPercentCoupon($coupon)) {
return round(min($subtotal, $subtotal * ((float) $coupon->value / 100)), 2);
}
return round(min($subtotal, (float) $coupon->value), 2);
}
/**
* @return array<int, float>
*/
private function allocateDiscount(CouponModel $coupon, Collection $eligibleItems, float $discountAmount): array
{
$discountAmount = round($discountAmount, 2);
$allocated = [];
$allocatedTotal = 0.0;
$items = $eligibleItems->values();
$lastIndex = $items->count() - 1;
$eligibleSubtotal = round((float) $items->sum(
fn (OrderItem $item): float => $this->lineBaseTotal($item)
), 2);
foreach ($items as $index => $item) {
$baseLineTotal = $this->lineBaseTotal($item);
if ($index === $lastIndex) {
$lineDiscount = round($discountAmount - $allocatedTotal, 2);
} elseif ($this->isPercentCoupon($coupon)) {
$lineDiscount = round($baseLineTotal * ((float) $coupon->value / 100), 2);
} else {
$lineDiscount = round($discountAmount * ($baseLineTotal / $eligibleSubtotal), 2);
}
$lineDiscount = round(min($baseLineTotal, max(0.0, $lineDiscount)), 2);
$allocated[(int) $item->id] = $lineDiscount;
$allocatedTotal = round($allocatedTotal + $lineDiscount, 2);
}
return $allocated;
}
private function hasAppliedCoupon(CouponModel $coupon, Order $order): bool
{
if ((int) ($order->coupon_id ?? 0) !== (int) $coupon->id) {
return false;
}
return CouponUsage::query()
->where('coupon_id', $coupon->id)
->where('order_id', $order->id)
->exists();
}
private function recordOrderUsage(CouponModel $coupon, Order $order, float $discountAmount): void
{
if (CouponUsage::query()
->where('coupon_id', $coupon->id)
->where('order_id', $order->id)
->exists()) {
return;
}
$workspaceId = $this->resolveWorkspaceId($order);
if ($workspaceId !== null) {
CouponUsage::create([
'coupon_id' => $coupon->id,
'workspace_id' => $workspaceId,
'order_id' => $order->id,
'discount_amount' => $discountAmount,
]);
}
$coupon->incrementUsage();
}
}

View file

@ -214,24 +214,6 @@ 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

@ -5,8 +5,6 @@ declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Carbon\Carbon;
use Core\Mod\Commerce\Data\DunningSchedule;
use Core\Mod\Commerce\Data\PaymentResult;
use Core\Mod\Commerce\Models\Invoice;
use Core\Mod\Commerce\Models\Subscription;
use Core\Mod\Commerce\Notifications\AccountSuspended;
@ -16,9 +14,7 @@ use Core\Mod\Commerce\Notifications\SubscriptionCancelled;
use Core\Mod\Commerce\Notifications\SubscriptionPaused;
use Core\Tenant\Services\EntitlementService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
/**
* Dunning service for failed payment recovery.
@ -38,262 +34,6 @@ class DunningService
protected EntitlementService $entitlements,
) {}
/**
* Build and persist the failed-payment retry schedule for a subscription.
*/
public function schedule(Subscription $subscription): DunningSchedule
{
if (in_array($subscription->status, ['cancelled', 'expired'], true)) {
throw new InvalidArgumentException('Cannot schedule dunning for an ended subscription.');
}
$anchor = $this->dunningAnchor($subscription);
$retryDays = $this->retryDays();
$retryDates = array_map(
fn (int $days): Carbon => $anchor->copy()->addDays($days),
$retryDays
);
$suspensionDate = $anchor->copy()->addDays($this->suspendAfterDays());
$schedule = new DunningSchedule($retryDates, $suspensionDate);
$metadata = $subscription->metadata ?? [];
$metadata['dunning'] = [
'stage' => 'scheduled',
'started_at' => $anchor->toISOString(),
'retry_dates' => array_map(
fn (Carbon $date): string => $date->toISOString(),
$retryDates
),
'suspension_date' => $suspensionDate->toISOString(),
];
$updates = ['metadata' => $metadata];
if (in_array($subscription->status, ['active', 'trialing'], true)) {
$updates['status'] = 'past_due';
}
$subscription->update($updates);
Log::info('Dunning schedule created', [
'subscription_id' => $subscription->id,
'workspace_id' => $subscription->workspace_id,
'retry_dates' => $metadata['dunning']['retry_dates'],
'suspension_date' => $metadata['dunning']['suspension_date'],
]);
return $schedule;
}
/**
* Retry payment for an overdue invoice.
*/
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) {
$this->recover($subscription);
}
return PaymentResult::successful($invoice->payment, $invoice->charge_attempts ?? 0);
}
if (! $invoice->auto_charge) {
return PaymentResult::failed(
'Invoice is not configured for automatic charging.',
$invoice->charge_attempts ?? 0
);
}
$attempts = ($invoice->charge_attempts ?? 0) + 1;
$invoice->update([
'status' => 'overdue',
'charge_attempts' => $attempts,
'last_charge_attempt' => now(),
]);
try {
$successful = $this->commerce->retryInvoicePayment($invoice->fresh());
} catch (\Throwable $e) {
$nextRetry = $this->calculateNextRetry($attempts);
$invoice->update([
'next_charge_attempt' => $nextRetry,
]);
Log::error('Payment retry exception', [
'invoice_id' => $invoice->id,
'attempt' => $attempts,
'error' => $e->getMessage(),
]);
return PaymentResult::failed($e->getMessage(), $attempts, $nextRetry);
}
$invoice->refresh();
$subscription = $this->findSubscriptionForInvoice($invoice);
if ($successful) {
$invoice->update([
'next_charge_attempt' => null,
]);
if ($subscription) {
$this->recover($subscription);
}
Log::info('Payment retry succeeded', [
'invoice_id' => $invoice->id,
'subscription_id' => $subscription?->id,
'attempt' => $attempts,
]);
return PaymentResult::successful($invoice->payment, $attempts);
}
$nextRetry = $this->calculateNextRetry($attempts);
$invoice->update([
'next_charge_attempt' => $nextRetry,
]);
if ($subscription) {
$this->notify($subscription, 'retry');
}
Log::info('Payment retry failed', [
'invoice_id' => $invoice->id,
'subscription_id' => $subscription?->id,
'attempt' => $attempts,
'next_retry' => $nextRetry,
]);
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.
*/
public function suspend(Subscription $subscription): void
{
if (in_array($subscription->status, ['cancelled', 'expired'], true)) {
throw new InvalidArgumentException('Cannot suspend an ended subscription.');
}
$workspace = $subscription->workspace;
if (! $workspace) {
throw new InvalidArgumentException('Cannot suspend a subscription without a workspace.');
}
$metadata = $subscription->metadata ?? [];
$metadata['dunning'] = array_merge($metadata['dunning'] ?? [], [
'stage' => 'suspended',
'suspended_at' => now()->toISOString(),
]);
$subscription->update([
'status' => 'suspended',
'paused_at' => $subscription->paused_at ?? now(),
'metadata' => $metadata,
]);
$this->entitlements->suspendWorkspace($workspace, 'dunning');
$this->notify($subscription->fresh(), 'suspended');
Log::info('Subscription suspended due to dunning', [
'subscription_id' => $subscription->id,
'workspace_id' => $subscription->workspace_id,
]);
}
/**
* Send the notification for a dunning stage and dispatch a lightweight stage event.
*/
public function notify(Subscription $subscription, string $stage): void
{
$stage = $this->normaliseStage($stage);
Event::dispatch('commerce.dunning.notified', [$subscription, $stage]);
if (! config('commerce.dunning.send_notifications', true)) {
return;
}
$workspace = $subscription->workspace;
$owner = $workspace?->owner();
if (! $owner) {
Log::warning('Dunning notification skipped because no workspace owner was found', [
'subscription_id' => $subscription->id,
'workspace_id' => $subscription->workspace_id,
'stage' => $stage,
]);
return;
}
$notification = match ($stage) {
'failed' => new PaymentFailed($subscription),
'retry' => $this->retryNotification($subscription),
'paused' => new SubscriptionPaused($subscription),
'suspended' => new AccountSuspended($subscription),
'cancelled' => new SubscriptionCancelled($subscription),
};
$owner->notify($notification);
}
/**
* Clear dunning state once payment has recovered.
*/
public function recover(Subscription $subscription): void
{
$wasRestricted = in_array($subscription->status, ['paused', 'suspended'], true);
$metadata = $subscription->metadata ?? [];
unset($metadata['dunning']);
$updates = [
'metadata' => $metadata,
];
if (in_array($subscription->status, ['past_due', 'paused', 'suspended'], true)) {
$updates['status'] = 'active';
$updates['paused_at'] = null;
}
$subscription->update($updates);
if ($subscription->workspace_id) {
Invoice::query()
->where('workspace_id', $subscription->workspace_id)
->whereNotNull('next_charge_attempt')
->update(['next_charge_attempt' => null]);
}
if ($wasRestricted && $subscription->workspace) {
$this->entitlements->reactivateWorkspace($subscription->workspace, 'dunning_recovery');
}
Log::info('Dunning state cleared after payment recovery', [
'subscription_id' => $subscription->id,
'workspace_id' => $subscription->workspace_id,
]);
}
/**
* Handle a failed payment for an invoice.
*
@ -386,7 +126,7 @@ class DunningService
*/
public function retryPayment(Invoice $invoice): bool
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]);
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
$maxRetries = count($retryDays);
try {
@ -440,7 +180,7 @@ class DunningService
*/
public function getSubscriptionsForPause(): Collection
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]);
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
$pauseAfterDays = array_sum($retryDays) + 1; // Day after last retry
return Subscription::query()
@ -531,7 +271,7 @@ class DunningService
$cancelAfterDays = config('commerce.dunning.cancel_after_days', 30);
return Subscription::query()
->whereIn('status', ['paused', 'suspended'])
->where('status', 'paused')
->where('paused_at', '<=', now()->subDays($cancelAfterDays))
->with('workspace')
->get();
@ -588,7 +328,7 @@ class DunningService
*/
public function calculateNextRetry(int $currentAttempts): ?Carbon
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]);
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
// Account for the initial attempt (attempt 0 used grace period)
$retryIndex = $currentAttempts;
@ -602,40 +342,6 @@ 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.
*
@ -675,7 +381,7 @@ class DunningService
];
}
if (in_array($subscription->status, ['paused', 'suspended'], true)) {
if ($subscription->status === 'paused') {
$pausedDays = $subscription->paused_at
? (int) $subscription->paused_at->diffInDays(now(), false)
: 0;
@ -716,96 +422,7 @@ class DunningService
return Subscription::query()
->where('workspace_id', $invoice->workspace_id)
->whereIn('status', ['active', 'past_due', 'paused', 'suspended'])
->whereIn('status', ['active', 'past_due', 'paused'])
->first();
}
/**
* @return array<int, int>
*/
protected function retryDays(): array
{
$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.');
}
return array_map(function (mixed $days): int {
if (! is_numeric($days) || (int) $days < 1) {
throw new InvalidArgumentException('Dunning retry days must be positive integers.');
}
return (int) $days;
}, array_values($retryDays));
}
protected function suspendAfterDays(): int
{
$days = config('commerce.dunning.suspend_after_days', 14);
if (! is_numeric($days) || (int) $days < 1) {
throw new InvalidArgumentException('Dunning suspension days must be a positive integer.');
}
return (int) $days;
}
protected function dunningAnchor(Subscription $subscription): Carbon
{
$startedAt = data_get($subscription->metadata, 'dunning.started_at');
if ($startedAt) {
return Carbon::parse($startedAt);
}
$invoice = $this->latestDunningInvoice($subscription);
$anchor = $invoice?->last_charge_attempt
?? $invoice?->due_date
?? now();
return $anchor instanceof Carbon
? $anchor->copy()
: Carbon::parse($anchor);
}
protected function latestDunningInvoice(Subscription $subscription): ?Invoice
{
if (! $subscription->workspace_id) {
return null;
}
return Invoice::query()
->where('workspace_id', $subscription->workspace_id)
->whereIn('status', ['sent', 'pending', 'overdue'])
->orderByRaw('COALESCE(last_charge_attempt, due_date, created_at) DESC')
->first();
}
protected function retryNotification(Subscription $subscription): PaymentRetry|PaymentFailed
{
$invoice = $this->latestDunningInvoice($subscription);
if (! $invoice) {
return new PaymentFailed($subscription);
}
return new PaymentRetry(
$invoice,
$invoice->charge_attempts ?? 0,
count($this->retryDays())
);
}
protected function normaliseStage(string $stage): string
{
return match (strtolower(trim($stage))) {
'failed', 'payment_failed', 'payment-failed' => 'failed',
'retry', 'payment_retry', 'payment-retry' => 'retry',
'pause', 'paused' => 'paused',
'suspend', 'suspended', 'suspension' => 'suspended',
'cancel', 'cancelled', 'cancellation' => 'cancelled',
default => throw new InvalidArgumentException("Unknown dunning stage [{$stage}]."),
};
}
}

View file

@ -5,14 +5,10 @@ declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Core\Mod\Commerce\Data\FraudAssessment;
use Core\Mod\Commerce\Data\FraudScore;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\Payment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use RuntimeException;
/**
* Fraud detection and scoring service.
@ -33,145 +29,6 @@ class FraudService
public const RISK_NOT_ASSESSED = 'not_assessed';
public const RECOMMENDATION_APPROVE = 'approve';
public const RECOMMENDATION_REVIEW = 'review';
public const RECOMMENDATION_BLOCK = 'block';
public const ORDER_STATUS_PENDING_REVIEW = 'pending_review';
private const FRAUD_REVIEW_PENDING = 'pending';
private const FRAUD_REVIEW_APPROVED = 'approved';
private const FRAUD_REVIEW_BLOCKED = 'blocked';
private const MAX_REASON_LENGTH = 500;
private const SIGNAL_WEIGHTS = [
'velocity_ip_exceeded' => 35,
'velocity_email_exceeded' => 25,
'velocity_failed_exceeded' => 35,
'geo_country_mismatch' => 20,
'high_risk_country' => 60,
'card_bin_country_mismatch' => 25,
'network_declined' => 15,
];
/**
* Score an order for fraud risk.
*/
public function score(Order $order): FraudScore
{
if (! config('commerce.fraud.enabled', true)) {
return new FraudScore(
score: 0,
signals: [],
recommendation: self::RECOMMENDATION_APPROVE
);
}
$score = 0;
$signals = [];
if (config('commerce.fraud.velocity.enabled', true)) {
$this->addSignalsToScore($signals, $score, $this->checkVelocity($order));
}
if (config('commerce.fraud.geo.enabled', true)) {
$this->addSignalsToScore($signals, $score, $this->checkGeoAnomalies($order));
}
$this->addSignalsToScore($signals, $score, $this->checkCardBinMismatch($order));
$score = max($score, $this->scoreStripeRadarSignals($order, $signals));
$score = $this->clampScore($score);
return new FraudScore(
score: $score,
signals: $signals,
recommendation: $this->recommendationForScore($score)
);
}
/**
* Mark an order for manual fraud review.
*/
public function flag(Order $order, string $reason): void
{
$reason = $this->normaliseReason($reason);
$metadata = $this->metadataWithFraudState($order, [
'review_status' => self::FRAUD_REVIEW_PENDING,
'review_reason' => $reason,
'previous_status' => $this->previousOrderStatus($order),
'flagged_at' => now()->toIso8601String(),
'approved_at' => null,
'blocked_at' => null,
]);
$order->update([
'status' => self::ORDER_STATUS_PENDING_REVIEW,
'metadata' => $metadata,
]);
}
/**
* Reject an order due to confirmed fraud.
*/
public function block(Order $order, string $reason): void
{
$reason = $this->normaliseReason($reason);
$metadata = $this->metadataWithFraudState($order, [
'review_status' => self::FRAUD_REVIEW_BLOCKED,
'block_reason' => $reason,
'blocked_at' => now()->toIso8601String(),
'failure_reason' => $reason,
]);
$metadata['failure_reason'] = $reason;
$metadata['failed_at'] = now()->toIso8601String();
$order->update([
'status' => 'failed',
'metadata' => $metadata,
]);
}
/**
* Orders waiting for manual fraud review.
*
* @return Collection<int, Order>
*/
public function reviewQueue(): Collection
{
return Order::query()
->where('status', self::ORDER_STATUS_PENDING_REVIEW)
->oldest()
->get()
->filter(fn (Order $order): bool => data_get($order->metadata, 'fraud.review_status') === self::FRAUD_REVIEW_PENDING)
->values();
}
/**
* Approve an order that was held for fraud review.
*/
public function approve(Order $order): void
{
if (data_get($order->metadata, 'fraud.review_status') !== self::FRAUD_REVIEW_PENDING) {
throw new RuntimeException('Only orders pending fraud review can be approved.');
}
$metadata = $this->metadataWithFraudState($order, [
'review_status' => self::FRAUD_REVIEW_APPROVED,
'approved_at' => now()->toIso8601String(),
]);
$order->update([
'status' => data_get($metadata, 'fraud.previous_status', 'pending'),
'metadata' => $metadata,
]);
}
/**
* Assess fraud risk for an order before checkout.
*
@ -305,9 +162,9 @@ class FraudService
protected function checkVelocity(Order $order): array
{
$signals = [];
$ip = $this->getOrderIp($order);
$ip = request()->ip();
$email = $order->billing_email;
$workspaceId = $this->getOrderWorkspaceId($order);
$workspaceId = $order->orderable_id;
$maxOrdersPerIpHourly = config('commerce.fraud.velocity.max_orders_per_ip_hourly', 5);
$maxOrdersPerEmailDaily = config('commerce.fraud.velocity.max_orders_per_email_daily', 10);
@ -370,8 +227,8 @@ class FraudService
protected function checkGeoAnomalies(Order $order): array
{
$signals = [];
$billingCountry = $this->getBillingCountry($order);
$ipCountry = $this->getIpCountry($order);
$billingCountry = $order->billing_address['country'] ?? $order->tax_country ?? null;
$ipCountry = $this->getIpCountry();
// Check for country mismatch
if (config('commerce.fraud.geo.flag_country_mismatch', true)) {
@ -384,11 +241,7 @@ class FraudService
}
// Check for high-risk countries
$configuredHighRiskCountries = config('commerce.fraud.geo.high_risk_countries', []);
$highRiskCountries = array_map(
fn (mixed $country): ?string => $this->normaliseCountry($country),
is_array($configuredHighRiskCountries) ? $configuredHighRiskCountries : []
);
$highRiskCountries = config('commerce.fraud.geo.high_risk_countries', []);
if (! empty($highRiskCountries) && $billingCountry) {
if (in_array($billingCountry, $highRiskCountries, true)) {
$signals['high_risk_country'] = $billingCountry;
@ -401,21 +254,9 @@ class FraudService
/**
* Get country code from IP address.
*/
protected function getIpCountry(?Order $order = null): ?string
protected function getIpCountry(): ?string
{
if ($order) {
$metadata = $order->metadata ?? [];
$metadataCountry = data_get($metadata, 'ip_country')
?? data_get($metadata, 'ip_country_code')
?? data_get($metadata, 'geo.country')
?? data_get($metadata, 'ip.country');
if ($metadataCountry) {
return $this->normaliseCountry($metadataCountry);
}
}
$ip = $order ? $this->getOrderIp($order) : request()->ip();
$ip = request()->ip();
if (! $ip || $ip === '127.0.0.1' || str_starts_with($ip, '192.168.')) {
return null;
}
@ -438,204 +279,6 @@ class FraudService
});
}
/**
* Check for card issuing country mismatch against billing country.
*/
protected function checkCardBinMismatch(Order $order): array
{
$billingCountry = $this->getBillingCountry($order);
$metadata = $order->metadata ?? [];
$cardCountry = $this->normaliseCountry(
data_get($metadata, 'card_bin_country')
?? data_get($metadata, 'card.bin_country')
?? data_get($metadata, 'payment_method.card_country')
?? data_get($metadata, 'payment_method_details.card.country')
?? data_get($metadata, 'stripe.payment_method_details.card.country')
);
if (! $billingCountry || ! $cardCountry || $billingCountry === $cardCountry) {
return [];
}
return [
'card_bin_country_mismatch' => [
'billing_country' => $billingCountry,
'card_country' => $cardCountry,
],
];
}
/**
* Fold weighted signals into the running fraud score.
*
* @param array<string, mixed> $signals
* @param array<string, mixed> $newSignals
*/
protected function addSignalsToScore(array &$signals, int &$score, array $newSignals): void
{
foreach ($newSignals as $key => $value) {
$signals[$key] = $value;
$score += self::SIGNAL_WEIGHTS[$key] ?? 10;
}
}
/**
* Convert Stripe Radar metadata into score and signals.
*
* @param array<string, mixed> $signals
*/
protected function scoreStripeRadarSignals(Order $order, array &$signals): int
{
$radar = $this->getStripeRadarMetadata($order);
if ($radar === []) {
return 0;
}
$score = 0;
$riskLevel = data_get($radar, 'risk_level') ?? data_get($radar, 'riskLevel');
$riskScore = data_get($radar, 'risk_score') ?? data_get($radar, 'stripe_risk_score');
if ($riskLevel === self::RISK_HIGHEST) {
$signals['stripe_risk_highest'] = true;
$score = max($score, 90);
} elseif ($riskLevel === self::RISK_ELEVATED) {
$signals['stripe_risk_elevated'] = true;
$score = max($score, 60);
}
if (is_numeric($riskScore)) {
$signals['stripe_risk_score'] = (int) $riskScore;
$score = max($score, (int) $riskScore);
}
$ruleAction = data_get($radar, 'rule.action') ?? data_get($radar, 'stripe_rule_action');
if ($ruleAction) {
$signals['stripe_rule_action'] = $ruleAction;
}
if ($ruleAction === 'block') {
$score = 100;
}
$networkStatus = data_get($radar, 'network_status');
if ($networkStatus === 'declined_by_network') {
$signals['network_declined'] = true;
$score += self::SIGNAL_WEIGHTS['network_declined'];
}
return $this->clampScore($score);
}
/**
* Extract Stripe Radar metadata from known order metadata locations.
*
* @return array<string, mixed>
*/
protected function getStripeRadarMetadata(Order $order): array
{
$metadata = $order->metadata ?? [];
$radar = data_get($metadata, 'stripe_radar')
?? data_get($metadata, 'stripe.outcome')
?? data_get($metadata, 'payment.outcome')
?? data_get($metadata, 'fraud_assessment');
return is_array($radar) ? $radar : [];
}
protected function clampScore(int $score): int
{
return max(0, min(100, $score));
}
protected function recommendationForScore(int $score): string
{
$blockThreshold = (int) config('commerce.fraud.score.block_threshold', 80);
$reviewThreshold = (int) config('commerce.fraud.score.review_threshold', 50);
if ($score >= $blockThreshold) {
return self::RECOMMENDATION_BLOCK;
}
if ($score >= $reviewThreshold) {
return self::RECOMMENDATION_REVIEW;
}
return self::RECOMMENDATION_APPROVE;
}
protected function getBillingCountry(Order $order): ?string
{
return $this->normaliseCountry(
data_get($order->billing_address, 'country')
?? data_get($order->metadata, 'billing_country')
?? $order->tax_country
);
}
protected function normaliseCountry(mixed $country): ?string
{
if (! is_string($country) || trim($country) === '') {
return null;
}
return strtoupper(substr(trim($country), 0, 2));
}
protected function getOrderIp(Order $order): ?string
{
$metadata = $order->metadata ?? [];
$ip = data_get($metadata, 'ip_address')
?? data_get($metadata, 'ip')
?? data_get($metadata, 'customer_ip')
?? request()->ip();
return is_string($ip) && trim($ip) !== '' ? trim($ip) : null;
}
protected function getOrderWorkspaceId(Order $order): ?int
{
$workspaceId = $order->getAttribute('workspace_id')
?? $order->getAttribute('workspaceId')
?? $order->workspace_id
?? $order->orderable_id;
return $workspaceId === null ? null : (int) $workspaceId;
}
protected function normaliseReason(string $reason): string
{
$reason = trim((string) preg_replace('/[[:cntrl:]]+/', ' ', $reason));
if ($reason === '') {
throw new InvalidArgumentException('Fraud reason is required.');
}
return substr($reason, 0, self::MAX_REASON_LENGTH);
}
/**
* @param array<string, mixed> $fraudState
* @return array<string, mixed>
*/
protected function metadataWithFraudState(Order $order, array $fraudState): array
{
$metadata = $order->metadata ?? [];
$fraud = is_array($metadata['fraud'] ?? null) ? $metadata['fraud'] : [];
$metadata['fraud'] = array_merge($fraud, $fraudState);
return $metadata;
}
protected function previousOrderStatus(Order $order): string
{
if ($order->status !== self::ORDER_STATUS_PENDING_REVIEW) {
return $order->status;
}
return data_get($order->metadata, 'fraud.previous_status', 'pending');
}
/**
* Determine if order should be blocked based on risk level.
*/
@ -723,7 +366,7 @@ class FraudService
*/
public function recordFailedPayment(Order $order): void
{
$workspaceId = $this->getOrderWorkspaceId($order);
$workspaceId = $order->orderable_id;
if ($workspaceId) {
$failedKey = "fraud:failed:workspace:{$workspaceId}";

View file

@ -76,30 +76,6 @@ 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.
*/
@ -157,16 +133,6 @@ 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.
*/
@ -255,11 +221,6 @@ 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,11 +102,20 @@ class PermissionMatrixService
$this->logRequest($request, $entity, $action, $scope, $result);
}
// Training mode records undefined permissions and allows the request.
// 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::allowed();
return PermissionResult::pending(
key: $action,
scope: $scope,
trainingUrl: route('commerce.matrix.train', [
'entity' => $entity->id,
'key' => $action,
'scope' => $scope,
])
);
}
// Production mode (strict): undefined = denied
@ -128,139 +137,6 @@ 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

@ -1,169 +0,0 @@
<?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,11 +113,6 @@ 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,26 +23,8 @@ class SkuBuilderService
*
* @param array<array{base_sku: string, options?: array, bundle_group?: string|int}> $lineItems
*/
public function build(array|string $lineItems, ?string $baseSku = null, array $options = []): string
public function build(array $lineItems): 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

@ -1,97 +0,0 @@
<?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,38 +51,16 @@ class SubscriptionService
/**
* Cancel a subscription (set to expire at period end).
*/
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 = [
public function cancel(Subscription $subscription, ?string $reason = null): Subscription
{
$subscription->update([
'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

@ -1,100 +0,0 @@
<?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);
}
}

View file

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

View file

@ -49,8 +49,3 @@ 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

@ -52,10 +52,12 @@ Route::prefix('webhooks')->group(function () {
// });
// ─────────────────────────────────────────────────────────────────────────────
// Commerce Billing API (authenticated)
// Commerce Billing API (authenticated + verified)
// ─────────────────────────────────────────────────────────────────────────────
Route::middleware('auth')->prefix('commerce')->group(function () {
Route::middleware(['auth', 'verified'])->prefix('commerce')->group(function () {
// ── Read-only endpoints ──────────────────────────────────────────────
// Billing overview
Route::get('/billing', [CommerceController::class, 'billing'])
->name('api.commerce.billing');
@ -74,43 +76,27 @@ Route::middleware('auth')->prefix('commerce')->group(function () {
Route::get('/invoices/{invoice}/download', [CommerceController::class, 'downloadInvoice'])
->name('api.commerce.invoices.download');
// Subscription
// Subscription (read)
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');
});
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');
// ── State-changing endpoints (rate-limited) ──────────────────────────
Route::middleware('throttle:6,1')->group(function () {
// Subscription management
Route::post('/cancel', [CommerceController::class, 'cancelSubscription'])
->name('api.commerce.cancel');
Route::post('/resume', [CommerceController::class, 'resumeSubscription'])
->name('api.commerce.resume');
// Plan changes
Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade'])
->name('api.commerce.upgrade.preview');
Route::post('/upgrade', [CommerceController::class, 'executeUpgrade'])
->name('api.commerce.upgrade');
});
});

View file

@ -3,8 +3,6 @@
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;
/*
@ -36,6 +34,3 @@ 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');

File diff suppressed because it is too large Load diff

View file

@ -1,36 +0,0 @@
<?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();
});

View file

@ -1,295 +0,0 @@
<?php
declare(strict_types=1);
use Carbon\Carbon;
use Core\Mod\Commerce\Data\Coupon as CouponData;
use Core\Mod\Commerce\Data\ValidationResult;
use Core\Mod\Commerce\Models\Coupon as CouponModel;
use Core\Mod\Commerce\Models\CouponUsage;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\OrderItem;
use Core\Mod\Commerce\Services\CouponService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
beforeEach(function (): void {
Schema::dropIfExists('coupon_usages');
Schema::dropIfExists('order_items');
Schema::dropIfExists('orders');
Schema::dropIfExists('coupons');
Schema::create('coupons', function (Blueprint $table): void {
$table->id();
$table->string('code')->index();
$table->string('name');
$table->text('description')->nullable();
$table->string('type');
$table->decimal('value', 10, 2);
$table->decimal('min_amount', 10, 2)->nullable();
$table->decimal('max_discount', 10, 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->boolean('is_active')->default(true);
$table->string('stripe_coupon_id')->nullable();
$table->string('btcpay_coupon_id')->nullable();
$table->timestamps();
});
Schema::create('orders', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('workspace_id')->nullable();
$table->string('orderable_type')->nullable();
$table->unsignedBigInteger('orderable_id')->nullable();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('order_number')->unique();
$table->string('status')->default('pending');
$table->string('type')->default('new');
$table->string('billing_cycle')->nullable();
$table->string('currency', 3)->default('GBP');
$table->decimal('subtotal', 10, 2)->default(0);
$table->decimal('tax_amount', 10, 2)->default(0);
$table->decimal('discount_amount', 10, 2)->default(0);
$table->decimal('total', 10, 2)->default(0);
$table->unsignedBigInteger('coupon_id')->nullable();
$table->json('billing_address')->nullable();
$table->json('metadata')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
Schema::create('order_items', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('order_id');
$table->string('item_type');
$table->unsignedBigInteger('item_id')->nullable();
$table->string('item_code')->nullable();
$table->string('description');
$table->unsignedInteger('quantity')->default(1);
$table->decimal('unit_price', 10, 2);
$table->decimal('line_total', 10, 2);
$table->string('billing_cycle')->default('onetime');
$table->json('metadata')->nullable();
$table->timestamp('created_at')->nullable();
});
Schema::create('coupon_usages', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('coupon_id');
$table->unsignedBigInteger('workspace_id');
$table->unsignedBigInteger('order_id');
$table->decimal('discount_amount', 10, 2);
$table->timestamp('created_at')->nullable();
});
CouponModel::unsetEventDispatcher();
CouponUsage::unsetEventDispatcher();
Order::unsetEventDispatcher();
OrderItem::unsetEventDispatcher();
$this->service = new CouponService();
});
afterEach(function (): void {
Schema::dropIfExists('coupon_usages');
Schema::dropIfExists('order_items');
Schema::dropIfExists('orders');
Schema::dropIfExists('coupons');
});
function couponServiceTestOrder(array $lineTotals = [100.00], int $workspaceId = 10): Order
{
$order = Order::forceCreate([
'workspace_id' => $workspaceId,
'order_number' => 'ORD-'.uniqid(),
'status' => 'pending',
'type' => 'new',
'currency' => 'GBP',
'subtotal' => array_sum($lineTotals),
'tax_amount' => 0,
'discount_amount' => 0,
'total' => array_sum($lineTotals),
]);
foreach ($lineTotals as $index => $lineTotal) {
OrderItem::create([
'order_id' => $order->id,
'item_type' => 'package',
'item_id' => $index + 1,
'item_code' => 'PKG-'.$index,
'description' => 'Package '.$index,
'quantity' => 1,
'unit_price' => $lineTotal,
'line_total' => $lineTotal,
'billing_cycle' => 'monthly',
]);
}
return $order->load('items');
}
describe('CouponService create()', function (): void {
it('Good: creates and persists a percent coupon DTO', function (): void {
$coupon = $this->service->create(' save20 ', 'percent', 20, 5, Carbon::now()->addMonth());
expect($coupon)->toBeInstanceOf(CouponData::class)
->and($coupon->code)->toBe('SAVE20')
->and($coupon->type)->toBe('percent')
->and($coupon->maxUses)->toBe(5)
->and(CouponModel::byCode('SAVE20')->exists())->toBeTrue();
});
it('Bad: rejects an invalid discount type', function (): void {
$this->service->create('SAVE20', 'bogus', 20, 5, null);
})->throws(InvalidArgumentException::class);
it('Ugly: rejects duplicate sanitised codes', function (): void {
$this->service->create('SAVE20', 'percent', 20, 5, null);
$this->service->create(' save20 ', 'percent', 25, 5, null);
})->throws(InvalidArgumentException::class);
});
describe('CouponService validate()', function (): void {
it('Good: validates a live coupon and calculates the order discount', function (): void {
$this->service->create('SAVE20', 'percent', 20, 5, Carbon::now()->addMonth());
$order = couponServiceTestOrder([100.00]);
$result = $this->service->validate('SAVE20', $order);
expect($result)->toBeInstanceOf(ValidationResult::class)
->and($result->valid)->toBeTrue()
->and($result->discountAmount)->toBe(20.00)
->and($result->discountType)->toBe('percent');
});
it('Bad: rejects an expired coupon', function (): void {
$this->service->create('OLD10', 'fixed', 10, 5, Carbon::now()->subDay());
$order = couponServiceTestOrder([50.00]);
$result = $this->service->validate('OLD10', $order);
expect($result->valid)->toBeFalse()
->and($result->reason)->toBe('Coupon has expired');
});
it('Ugly: rejects hostile coupon code input before lookup', function (): void {
$order = couponServiceTestOrder([50.00]);
$result = $this->service->validate("'; DROP TABLE coupons; --", $order);
expect($result->valid)->toBeFalse()
->and($result->reason)->toBe('Invalid coupon code format');
});
});
describe('CouponService apply()', function (): void {
it('Good: applies a fixed coupon across line items and records usage', function (): void {
$coupon = $this->service->create('FLAT30', 'fixed', 30, 5, Carbon::now()->addMonth());
$order = couponServiceTestOrder([100.00, 50.00], 22);
$applied = $this->service->apply($coupon, $order);
expect((float) $applied->discount_amount)->toBe(30.00)
->and((float) $applied->total)->toBe(120.00)
->and($applied->items->pluck('line_total')->map(fn (mixed $value): float => (float) $value)->all())
->toBe([80.00, 40.00])
->and(CouponUsage::query()->count())->toBe(1)
->and((float) CouponUsage::query()->first()->discount_amount)->toBe(30.00);
});
it('Bad: refuses to apply an inactive coupon', function (): void {
$coupon = $this->service->create('PAUSED', 'fixed', 10, 5, Carbon::now()->addMonth());
CouponModel::byCode('PAUSED')->firstOrFail()->update(['is_active' => false]);
$order = couponServiceTestOrder([100.00]);
$this->service->apply($coupon, $order);
})->throws(RuntimeException::class, 'Coupon is inactive');
it('Ugly: caps a large fixed coupon at the order subtotal', function (): void {
$coupon = $this->service->create('FREEBIE', 'fixed', 999, 5, Carbon::now()->addMonth());
$order = couponServiceTestOrder([20.00]);
$applied = $this->service->apply($coupon, $order);
expect((float) $applied->discount_amount)->toBe(20.00)
->and((float) $applied->total)->toBe(0.00)
->and((float) $applied->items->first()->line_total)->toBe(0.00);
});
});
describe('CouponService expire()', function (): void {
it('Good: expires an active coupon immediately', function (): void {
$coupon = $this->service->create('SPRING', 'percent', 15, 5, Carbon::now()->addMonth());
$this->service->expire($coupon);
$model = CouponModel::byCode('SPRING')->firstOrFail();
expect($model->is_active)->toBeFalse()
->and($model->valid_until?->isPast() || $model->valid_until?->isCurrentSecond())->toBeTrue();
});
it('Bad: fails when the DTO no longer points to a persisted coupon', function (): void {
$coupon = $this->service->create('GONE', 'fixed', 10, 5, null);
CouponModel::byCode('GONE')->firstOrFail()->delete();
$this->service->expire($coupon);
})->throws(ModelNotFoundException::class);
it('Ugly: can expire an already expired coupon without reactivating it', function (): void {
$coupon = $this->service->create('ANCIENT', 'fixed', 10, 5, Carbon::now()->subMonth());
$this->service->expire($coupon);
$model = CouponModel::byCode('ANCIENT')->firstOrFail();
expect($model->is_active)->toBeFalse()
->and($model->valid_until?->isPast() || $model->valid_until?->isCurrentSecond())->toBeTrue();
});
});
describe('CouponService report()', function (): void {
it('Good: reports redemption totals by coupon', function (): void {
$coupon = $this->service->create('SAVE10', 'fixed', 10, 5, Carbon::now()->addMonth());
$this->service->apply($coupon, couponServiceTestOrder([50.00], 44));
$report = $this->service->report();
expect($report['total_coupons'])->toBe(1)
->and($report['total_redemptions'])->toBe(1)
->and($report['total_discount_amount'])->toBe(10.00)
->and($report['by_coupon'][0]['code'])->toBe('SAVE10')
->and($report['by_coupon'][0]['redemptions'])->toBe(1);
});
it('Bad: reports zero redemption stats when nothing has been applied', function (): void {
$this->service->create('UNUSED', 'percent', 5, 5, Carbon::now()->addMonth());
$report = $this->service->report();
expect($report['total_coupons'])->toBe(1)
->and($report['total_redemptions'])->toBe(0)
->and($report['total_discount_amount'])->toBe(0.00)
->and($report['by_coupon'][0]['redemptions'])->toBe(0);
});
it('Ugly: includes expired coupon counts alongside active redemption data', function (): void {
$coupon = $this->service->create('LIVE10', 'fixed', 10, 5, Carbon::now()->addMonth());
$this->service->create('EXPIRED', 'fixed', 10, 5, Carbon::now()->subMonth());
$this->service->apply($coupon, couponServiceTestOrder([40.00], 55));
$report = $this->service->report();
expect($report['total_coupons'])->toBe(2)
->and($report['active_coupons'])->toBe(2)
->and($report['expired_coupons'])->toBe(1)
->and($report['by_coupon'][0]['code'])->toBe('LIVE10');
});
});

View file

@ -1,453 +0,0 @@
<?php
declare(strict_types=1);
use Carbon\Carbon;
use Core\Mod\Commerce\Data\DunningSchedule;
use Core\Mod\Commerce\Data\PaymentResult;
use Core\Mod\Commerce\Models\Invoice;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Models\Subscription;
use Core\Mod\Commerce\Notifications\AccountSuspended;
use Core\Mod\Commerce\Services\CommerceService;
use Core\Mod\Commerce\Services\DunningService;
use Core\Mod\Commerce\Services\SubscriptionService;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Services\EntitlementService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Schema;
beforeEach(function (): void {
Schema::dropIfExists('payments');
Schema::dropIfExists('invoices');
Schema::dropIfExists('subscriptions');
Schema::dropIfExists('user_workspace');
Schema::dropIfExists('users');
Schema::dropIfExists('workspaces');
Schema::create('workspaces', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->string('password')->nullable();
$table->timestamps();
});
Schema::create('user_workspace', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('workspace_id');
$table->string('role');
$table->boolean('is_default')->default(false);
$table->unsignedBigInteger('team_id')->nullable();
$table->json('custom_permissions')->nullable();
$table->timestamp('joined_at')->nullable();
$table->unsignedBigInteger('invited_by')->nullable();
$table->timestamps();
});
Schema::create('subscriptions', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('workspace_id')->nullable();
$table->unsignedBigInteger('workspace_package_id')->nullable();
$table->string('gateway')->default('stripe');
$table->string('gateway_subscription_id')->nullable();
$table->string('gateway_customer_id')->nullable();
$table->string('gateway_price_id')->nullable();
$table->string('status')->default('active');
$table->string('billing_cycle')->default('monthly');
$table->timestamp('current_period_start')->nullable();
$table->timestamp('current_period_end')->nullable();
$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();
});
Schema::create('invoices', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('workspace_id')->nullable();
$table->unsignedBigInteger('order_id')->nullable();
$table->unsignedBigInteger('payment_id')->nullable();
$table->string('invoice_number')->unique();
$table->string('status')->default('sent');
$table->string('currency', 3)->default('GBP');
$table->decimal('subtotal', 10, 2)->default(0);
$table->decimal('tax_amount', 10, 2)->default(0);
$table->decimal('discount_amount', 10, 2)->default(0);
$table->decimal('total', 10, 2)->default(0);
$table->decimal('amount_paid', 10, 2)->default(0);
$table->decimal('amount_due', 10, 2)->default(0);
$table->date('issue_date')->nullable();
$table->date('due_date')->nullable();
$table->timestamp('paid_at')->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();
});
Schema::create('payments', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('workspace_id')->nullable();
$table->unsignedBigInteger('invoice_id')->nullable();
$table->string('gateway')->default('stripe');
$table->string('currency', 3)->default('GBP');
$table->decimal('amount', 10, 2)->default(0);
$table->decimal('fee', 10, 2)->default(0);
$table->decimal('net_amount', 10, 2)->default(0);
$table->string('status')->default('pending');
$table->string('failure_reason')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
Subscription::unsetEventDispatcher();
Invoice::unsetEventDispatcher();
Payment::unsetEventDispatcher();
config([
'commerce.dunning.retry_days' => [1, 3, 7],
'commerce.dunning.suspend_after_days' => 14,
'commerce.dunning.send_notifications' => true,
]);
$this->commerce = Mockery::mock(CommerceService::class);
$this->subscriptions = Mockery::mock(SubscriptionService::class);
$this->entitlements = Mockery::mock(EntitlementService::class);
$this->service = new DunningService(
$this->commerce,
$this->subscriptions,
$this->entitlements,
);
});
afterEach(function (): void {
Carbon::setTestNow();
Mockery::close();
Schema::dropIfExists('payments');
Schema::dropIfExists('invoices');
Schema::dropIfExists('subscriptions');
Schema::dropIfExists('user_workspace');
Schema::dropIfExists('users');
Schema::dropIfExists('workspaces');
});
function dunningServiceTestWorkspace(bool $withOwner = true): Workspace
{
$workspaceId = DB::table('workspaces')->insertGetId([
'name' => 'Dunning Test Workspace',
'slug' => 'dunning-test-'.uniqid(),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
if ($withOwner) {
$userId = DB::table('users')->insertGetId([
'name' => 'Dunning Owner',
'email' => 'dunning-'.uniqid().'@example.test',
'password' => 'secret',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('user_workspace')->insert([
'user_id' => $userId,
'workspace_id' => $workspaceId,
'role' => 'owner',
'is_default' => true,
'joined_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
return Workspace::query()->findOrFail($workspaceId);
}
function dunningServiceTestSubscription(array $overrides = [], ?Workspace $workspace = null): Subscription
{
if (! array_key_exists('workspace_id', $overrides)) {
$workspace ??= dunningServiceTestWorkspace();
$overrides['workspace_id'] = $workspace->id;
}
return Subscription::forceCreate(array_merge([
'workspace_package_id' => null,
'status' => 'active',
'gateway' => 'stripe',
'billing_cycle' => 'monthly',
'current_period_start' => now(),
'current_period_end' => now()->addDays(30),
'metadata' => null,
], $overrides));
}
function dunningServiceTestInvoice(array $overrides = [], ?Workspace $workspace = null): Invoice
{
if (! array_key_exists('workspace_id', $overrides)) {
$workspace ??= dunningServiceTestWorkspace();
$overrides['workspace_id'] = $workspace->id;
}
return Invoice::forceCreate(array_merge([
'invoice_number' => 'INV-DUN-'.uniqid(),
'status' => 'overdue',
'currency' => 'GBP',
'subtotal' => 20.00,
'total' => 20.00,
'amount_due' => 20.00,
'issue_date' => now(),
'due_date' => now()->subDay(),
'auto_charge' => true,
'charge_attempts' => 0,
], $overrides));
}
describe('DunningService schedule()', function (): void {
it('Good: stores retry dates and marks an active subscription past due', function (): void {
Carbon::setTestNow('2026-01-01 09:00:00');
$subscription = dunningServiceTestSubscription();
$schedule = $this->service->schedule($subscription);
expect($schedule)->toBeInstanceOf(DunningSchedule::class)
->and(array_map(fn (Carbon $date): string => $date->toDateString(), $schedule->retryDates))
->toBe(['2026-01-02', '2026-01-04', '2026-01-08'])
->and($schedule->suspensionDate->toDateString())->toBe('2026-01-15')
->and($subscription->fresh()->status)->toBe('past_due')
->and(data_get($subscription->fresh()->metadata, 'dunning.stage'))->toBe('scheduled');
});
it('Bad: rejects ended subscriptions', function (): void {
$subscription = dunningServiceTestSubscription(['status' => 'cancelled']);
$this->service->schedule($subscription);
})->throws(InvalidArgumentException::class);
it('Ugly: preserves unrelated subscription metadata when scheduling', function (): void {
$subscription = dunningServiceTestSubscription([
'metadata' => ['customer_note' => 'preserve'],
]);
$this->service->schedule($subscription);
expect($subscription->fresh()->metadata['customer_note'])->toBe('preserve')
->and(data_get($subscription->fresh()->metadata, 'dunning.retry_dates'))->toHaveCount(3);
});
});
describe('DunningService retry()', function (): void {
it('Good: records a successful payment retry and clears dunning', function (): void {
$workspace = dunningServiceTestWorkspace();
$subscription = dunningServiceTestSubscription([
'status' => 'past_due',
'metadata' => ['dunning' => ['stage' => 'scheduled']],
], $workspace);
$invoice = dunningServiceTestInvoice([
'next_charge_attempt' => now()->subMinute(),
], $workspace);
$this->commerce
->shouldReceive('retryInvoicePayment')
->once()
->andReturnUsing(function (Invoice $invoice): bool {
$payment = Payment::forceCreate([
'workspace_id' => $invoice->workspace_id,
'invoice_id' => $invoice->id,
'gateway' => 'stripe',
'currency' => 'GBP',
'amount' => 20.00,
'net_amount' => 20.00,
'status' => 'succeeded',
'paid_at' => now(),
]);
$invoice->markAsPaid($payment);
return true;
});
$result = $this->service->retry($invoice);
expect($result)->toBeInstanceOf(PaymentResult::class)
->and($result->successful)->toBeTrue()
->and($result->attempts)->toBe(1)
->and($invoice->fresh()->status)->toBe('paid')
->and($invoice->fresh()->next_charge_attempt)->toBeNull()
->and(data_get($subscription->fresh()->metadata, 'dunning'))->toBeNull();
});
it('Bad: refuses invoices that are not configured for automatic charging', function (): void {
$invoice = dunningServiceTestInvoice(['auto_charge' => false]);
$this->commerce->shouldNotReceive('retryInvoicePayment');
$result = $this->service->retry($invoice);
expect($result->successful)->toBeFalse()
->and($result->reason)->toBe('Invoice is not configured for automatic charging.')
->and($invoice->fresh()->charge_attempts)->toBe(0);
});
it('Ugly: captures gateway exceptions and schedules the next retry', function (): void {
Carbon::setTestNow('2026-01-01 09:00:00');
$workspace = dunningServiceTestWorkspace();
dunningServiceTestSubscription(['status' => 'past_due'], $workspace);
$invoice = dunningServiceTestInvoice([], $workspace);
$this->commerce
->shouldReceive('retryInvoicePayment')
->once()
->andThrow(new RuntimeException('gateway offline'));
$result = $this->service->retry($invoice);
expect($result->successful)->toBeFalse()
->and($result->reason)->toBe('gateway offline')
->and($result->attempts)->toBe(1)
->and($result->nextRetryAt?->toDateString())->toBe('2026-01-04')
->and($invoice->fresh()->next_charge_attempt?->toDateString())->toBe('2026-01-04');
});
});
describe('DunningService suspend()', function (): void {
it('Good: marks the subscription suspended and suspends workspace entitlements', function (): void {
Notification::fake();
Event::fake();
$workspace = dunningServiceTestWorkspace();
$subscription = dunningServiceTestSubscription(['status' => 'past_due'], $workspace);
$this->entitlements
->shouldReceive('suspendWorkspace')
->once()
->with(Mockery::type(Workspace::class), 'dunning');
$this->service->suspend($subscription);
expect($subscription->fresh()->status)->toBe('suspended')
->and($subscription->fresh()->paused_at)->not->toBeNull()
->and(data_get($subscription->fresh()->metadata, 'dunning.stage'))->toBe('suspended');
Event::assertDispatched('commerce.dunning.notified');
});
it('Bad: rejects ended subscriptions', function (): void {
$subscription = dunningServiceTestSubscription(['status' => 'expired']);
$this->service->suspend($subscription);
})->throws(InvalidArgumentException::class);
it('Ugly: refuses to suspend a subscription with no workspace', function (): void {
$subscription = dunningServiceTestSubscription(['workspace_id' => null]);
$this->entitlements->shouldNotReceive('suspendWorkspace');
$this->service->suspend($subscription);
})->throws(InvalidArgumentException::class);
});
describe('DunningService notify()', function (): void {
it('Good: sends the notification mapped to the requested dunning stage', function (): void {
Notification::fake();
Event::fake();
$workspace = dunningServiceTestWorkspace();
$subscription = dunningServiceTestSubscription([], $workspace);
$owner = User::query()->findOrFail($workspace->owner()->id);
$this->service->notify($subscription, 'suspended');
Notification::assertSentTo($owner, AccountSuspended::class);
Event::assertDispatched('commerce.dunning.notified');
});
it('Bad: rejects unknown stages', function (): void {
$subscription = dunningServiceTestSubscription();
$this->service->notify($subscription, 'mystery');
})->throws(InvalidArgumentException::class);
it('Ugly: dispatches the stage event even when no owner can receive email', function (): void {
Notification::fake();
Event::fake();
$workspace = dunningServiceTestWorkspace(withOwner: false);
$subscription = dunningServiceTestSubscription([], $workspace);
$this->service->notify($subscription, 'failed');
Event::assertDispatched('commerce.dunning.notified');
Notification::assertNothingSent();
});
});
describe('DunningService recover()', function (): void {
it('Good: clears dunning metadata, retry dates, and workspace suspension', function (): void {
$workspace = dunningServiceTestWorkspace();
$subscription = dunningServiceTestSubscription([
'status' => 'suspended',
'paused_at' => now()->subDays(2),
'metadata' => ['dunning' => ['stage' => 'suspended']],
], $workspace);
$invoice = dunningServiceTestInvoice([
'next_charge_attempt' => now()->addDay(),
], $workspace);
$this->entitlements
->shouldReceive('reactivateWorkspace')
->once()
->with(Mockery::type(Workspace::class), 'dunning_recovery');
$this->service->recover($subscription);
expect($subscription->fresh()->status)->toBe('active')
->and($subscription->fresh()->paused_at)->toBeNull()
->and(data_get($subscription->fresh()->metadata, 'dunning'))->toBeNull()
->and($invoice->fresh()->next_charge_attempt)->toBeNull();
});
it('Bad: does not reactivate an ended subscription', function (): void {
$subscription = dunningServiceTestSubscription([
'status' => 'cancelled',
'metadata' => ['dunning' => ['stage' => 'scheduled']],
]);
$this->entitlements->shouldNotReceive('reactivateWorkspace');
$this->service->recover($subscription);
expect($subscription->fresh()->status)->toBe('cancelled')
->and(data_get($subscription->fresh()->metadata, 'dunning'))->toBeNull();
});
it('Ugly: tolerates missing workspace and missing dunning metadata', function (): void {
$subscription = dunningServiceTestSubscription([
'workspace_id' => null,
'status' => 'past_due',
'metadata' => null,
]);
$this->entitlements->shouldNotReceive('reactivateWorkspace');
$this->service->recover($subscription);
expect($subscription->fresh()->status)->toBe('active')
->and($subscription->fresh()->metadata)->toBe([]);
});
});

View file

@ -1,288 +0,0 @@
<?php
declare(strict_types=1);
use Core\Mod\Commerce\Data\FraudScore;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Services\FraudService;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
beforeEach(function (): void {
Schema::dropIfExists('orders');
Schema::create('orders', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('workspace_id')->nullable();
$table->string('orderable_type')->nullable();
$table->unsignedBigInteger('orderable_id')->nullable();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('order_number')->unique();
$table->string('status')->default('pending');
$table->string('type')->default('new');
$table->string('billing_cycle')->nullable();
$table->string('currency', 3)->default('GBP');
$table->decimal('subtotal', 10, 2)->default(0);
$table->decimal('tax_amount', 10, 2)->default(0);
$table->decimal('discount_amount', 10, 2)->default(0);
$table->decimal('total', 10, 2)->default(0);
$table->string('billing_name')->nullable();
$table->string('billing_email')->nullable();
$table->decimal('tax_rate', 6, 4)->nullable();
$table->string('tax_country', 2)->nullable();
$table->json('billing_address')->nullable();
$table->json('metadata')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
Order::unsetEventDispatcher();
Cache::flush();
config([
'commerce.fraud.enabled' => true,
'commerce.fraud.score.review_threshold' => 50,
'commerce.fraud.score.block_threshold' => 80,
'commerce.fraud.velocity.enabled' => true,
'commerce.fraud.velocity.max_orders_per_ip_hourly' => 1,
'commerce.fraud.velocity.max_orders_per_email_daily' => 1,
'commerce.fraud.velocity.max_failed_payments_hourly' => 1,
'commerce.fraud.geo.enabled' => true,
'commerce.fraud.geo.flag_country_mismatch' => true,
'commerce.fraud.geo.high_risk_countries' => ['IR'],
'commerce.fraud.actions.log' => false,
'commerce.fraud.actions.auto_block' => true,
'commerce.fraud.stripe_radar.enabled' => true,
'commerce.fraud.stripe_radar.block_threshold' => 'highest',
'commerce.fraud.stripe_radar.review_threshold' => 'elevated',
]);
$this->service = new FraudService();
});
afterEach(function (): void {
Schema::dropIfExists('orders');
});
function fraudServiceTestOrder(array $overrides = []): Order
{
return Order::forceCreate(array_merge([
'workspace_id' => 10,
'orderable_id' => 10,
'user_id' => null,
'order_number' => 'ORD-'.uniqid(),
'status' => 'pending',
'type' => 'new',
'currency' => 'GBP',
'subtotal' => 100,
'tax_amount' => 20,
'discount_amount' => 0,
'total' => 120,
'billing_name' => 'Ada Lovelace',
'billing_email' => 'ada@example.test',
'tax_country' => 'GB',
'billing_address' => ['country' => 'GB'],
'metadata' => [
'ip_address' => '203.0.113.10',
'ip_country' => 'GB',
],
], $overrides));
}
describe('FraudService score()', function (): void {
it('Good: approves a clean order with no risk signals', function (): void {
$score = $this->service->score(fraudServiceTestOrder());
expect($score)->toBeInstanceOf(FraudScore::class)
->and($score->score)->toBe(0)
->and($score->signals)->toBe([])
->and($score->recommendation)->toBe('approve');
});
it('Bad: recommends review for velocity and geo mismatch signals', function (): void {
Cache::put('fraud:orders:ip:203.0.113.20', 1, now()->addHour());
$score = $this->service->score(fraudServiceTestOrder([
'metadata' => [
'ip_address' => '203.0.113.20',
'ip_country' => 'US',
],
]));
expect($score->recommendation)->toBe('review')
->and($score->score)->toBeGreaterThanOrEqual(50)
->and($score->signals)->toHaveKeys(['velocity_ip_exceeded', 'geo_country_mismatch']);
});
it('Ugly: clamps severe Stripe Radar and BIN signals at a block recommendation', function (): void {
$score = $this->service->score(fraudServiceTestOrder([
'metadata' => [
'ip_address' => '203.0.113.30',
'ip_country' => 'US',
'card_bin_country' => 'CA',
'stripe_radar' => [
'risk_level' => 'highest',
'risk_score' => 97,
'rule' => ['action' => 'block'],
],
],
]));
expect($score->score)->toBe(100)
->and($score->recommendation)->toBe('block')
->and($score->signals)->toHaveKeys([
'geo_country_mismatch',
'card_bin_country_mismatch',
'stripe_risk_highest',
'stripe_risk_score',
'stripe_rule_action',
]);
});
});
describe('FraudService flag()', function (): void {
it('Good: marks an order as pending fraud review', function (): void {
$order = fraudServiceTestOrder();
$this->service->flag($order, 'Velocity threshold exceeded');
$order->refresh();
expect($order->status)->toBe(FraudService::ORDER_STATUS_PENDING_REVIEW)
->and(data_get($order->metadata, 'fraud.review_status'))->toBe('pending')
->and(data_get($order->metadata, 'fraud.review_reason'))->toBe('Velocity threshold exceeded');
});
it('Bad: rejects a blank review reason without changing the order', function (): void {
$order = fraudServiceTestOrder();
$this->service->flag($order, " \n\t ");
})->throws(InvalidArgumentException::class, 'Fraud reason is required.');
it('Ugly: preserves existing metadata and truncates oversized reasons', function (): void {
$order = fraudServiceTestOrder([
'metadata' => [
'ip_address' => '203.0.113.40',
'ip_country' => 'GB',
'checkout_reference' => 'abc123',
],
]);
$this->service->flag($order, str_repeat('x', 700));
$order->refresh();
expect(data_get($order->metadata, 'checkout_reference'))->toBe('abc123')
->and(strlen(data_get($order->metadata, 'fraud.review_reason')))->toBe(500);
});
});
describe('FraudService block()', function (): void {
it('Good: rejects an unpaid order with fraud metadata', function (): void {
$order = fraudServiceTestOrder();
$this->service->block($order, 'Confirmed card testing');
$order->refresh();
expect($order->status)->toBe('failed')
->and(data_get($order->metadata, 'failure_reason'))->toBe('Confirmed card testing')
->and(data_get($order->metadata, 'fraud.review_status'))->toBe('blocked')
->and(data_get($order->metadata, 'fraud.block_reason'))->toBe('Confirmed card testing');
});
it('Bad: rejects a blank block reason', function (): void {
$order = fraudServiceTestOrder();
$this->service->block($order, '');
})->throws(InvalidArgumentException::class, 'Fraud reason is required.');
it('Ugly: removes a previously flagged order from the review queue', function (): void {
$order = fraudServiceTestOrder();
$this->service->flag($order, 'Manual review');
$this->service->block($order->refresh(), 'Confirmed fraud');
$order->refresh();
expect($this->service->reviewQueue())->toHaveCount(0)
->and($order->status)->toBe('failed')
->and(data_get($order->metadata, 'fraud.review_status'))->toBe('blocked');
});
});
describe('FraudService reviewQueue()', function (): void {
it('Good: returns pending fraud review orders oldest first', function (): void {
$newest = fraudServiceTestOrder(['order_number' => 'ORD-NEW']);
$oldest = fraudServiceTestOrder(['order_number' => 'ORD-OLD']);
$this->service->flag($newest, 'Second review');
$newest->update(['created_at' => now()->addMinute()]);
$this->service->flag($oldest, 'First review');
$oldest->update(['created_at' => now()->subMinute()]);
$queue = $this->service->reviewQueue();
expect($queue)->toBeInstanceOf(Collection::class)
->and($queue->pluck('order_number')->all())->toBe(['ORD-OLD', 'ORD-NEW']);
});
it('Bad: excludes blocked and approved orders', function (): void {
$blocked = fraudServiceTestOrder(['order_number' => 'ORD-BLOCKED']);
$approved = fraudServiceTestOrder(['order_number' => 'ORD-APPROVED']);
$pending = fraudServiceTestOrder(['order_number' => 'ORD-PENDING']);
$this->service->block($blocked, 'Confirmed fraud');
$this->service->flag($approved, 'Manual check');
$this->service->approve($approved->refresh());
$this->service->flag($pending, 'Manual check');
expect($this->service->reviewQueue()->pluck('order_number')->all())->toBe(['ORD-PENDING']);
});
it('Ugly: excludes stale pending-review statuses without fraud metadata', function (): void {
fraudServiceTestOrder([
'order_number' => 'ORD-STALE',
'status' => FraudService::ORDER_STATUS_PENDING_REVIEW,
'metadata' => ['note' => 'legacy status only'],
]);
expect($this->service->reviewQueue())->toHaveCount(0);
});
});
describe('FraudService approve()', function (): void {
it('Good: approves a flagged order and restores its prior status', function (): void {
$order = fraudServiceTestOrder(['status' => 'processing']);
$this->service->flag($order, 'Manual check');
$this->service->approve($order->refresh());
$order->refresh();
expect($order->status)->toBe('processing')
->and(data_get($order->metadata, 'fraud.review_status'))->toBe('approved')
->and(data_get($order->metadata, 'fraud.approved_at'))->not->toBeNull();
});
it('Bad: refuses to approve an order that is not pending review', function (): void {
$order = fraudServiceTestOrder();
$this->service->approve($order);
})->throws(RuntimeException::class, 'Only orders pending fraud review can be approved.');
it('Ugly: removes the approved order from the review queue without dropping metadata', function (): void {
$order = fraudServiceTestOrder([
'metadata' => [
'ip_address' => '203.0.113.50',
'ip_country' => 'GB',
'checkout_reference' => 'keep-me',
],
]);
$this->service->flag($order, 'Manual check');
$this->service->approve($order->refresh());
$order->refresh();
expect($this->service->reviewQueue())->toHaveCount(0)
->and(data_get($order->metadata, 'checkout_reference'))->toBe('keep-me')
->and(data_get($order->metadata, 'fraud.review_reason'))->toBe('Manual check');
});
});