Compare commits

..

5 commits

Author SHA1 Message Date
Snider
4e4337e412 feat(commerce): implement RFC.md — billing, subscriptions, Stripe + BTCPay, Commerce Matrix (#845)
Extends prior #860 DunningService with the full RFC.md surface.

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

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

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=845
2026-04-25 22:55:51 +01:00
Snider
51f9595797 feat(commerce): implement DunningService with 5 methods + DunningSchedule DTO (#860)
- schedule(subscription) → DunningSchedule (retry dates + suspension date)
- retry(invoice) → PaymentResult
- suspend(subscription) → void
- notify(subscription, stage) → void (event-driven per dunning stage)
- recover(subscription) → void (clears dunning after payment)

Data/DunningSchedule.php + Data/PaymentResult.php as readonly DTOs.
Pest tests _Good/_Bad/_Ugly per AX-10 for all 5 methods.
pint/pest skipped (vendor binaries missing in sandbox).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=860
2026-04-25 04:57:33 +01:00
Snider
20fb740d61 feat(commerce): implement FraudService with 5 methods + FraudScore DTO (#859)
- score(order) → FraudScore (score 0-100, signals[], recommendation)
- flag(order, reason) → void (marks for review)
- block(order, reason) → void (rejects order)
- reviewQueue() → Collection<Order>
- approve(order) → void

Data/FraudScore.php as readonly DTO. Pest tests _Good/_Bad/_Ugly per AX-10
for all 5 methods. pint/pest skipped (vendor binaries missing).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=859
2026-04-25 04:51:31 +01:00
Snider
cd16c7474e feat(commerce): implement CouponService with 5 methods + DTOs (#858)
- create(code, type, value, maxUses, expiresAt) → Coupon
- validate(code, order) → ValidationResult
- apply(coupon, order) → Order (mutates line-item totals)
- expire(coupon) → void
- report() → array of redemption stats

Data/Coupon.php and Data/ValidationResult.php as readonly DTOs.
Pest unit tests with _Good/_Bad/_Ugly per AX-10 for all 5 methods.
pint/pest skipped (vendor binaries missing in sandbox).
Legacy helpers in CouponService preserved.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=858
2026-04-25 04:41:44 +01:00
Snider
6d83c32114 fix(referral): drop FKs to nonexistent 'orders' + 'invoices' tables
The referral_commissions migration FK-referenced 'orders' and 'invoices'
tables, but neither is ever created by any migration in the codebase.
MariaDB silently accepted the FKs (checks disabled during migration),
Postgres rejects strictly.

Changed both columns to plain nullable unsignedBigInteger — same column
shape, no FK constraint. Data still references orders/invoices by id
via application logic. Proper FKs can be added in a follow-up migration
once orders/invoices migrations land.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-22 22:06:44 +01:00
63 changed files with 4700 additions and 1291 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

26
DTOs/BundleItem.php Normal file
View file

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

View file

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

31
DTOs/FraudAssessment.php Normal file
View file

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

26
DTOs/ParsedItem.php Normal file
View file

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

29
DTOs/PermissionResult.php Normal file
View file

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

34
DTOs/ProrationResult.php Normal file
View file

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

26
DTOs/SkuOption.php Normal file
View file

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

34
DTOs/SkuParseResult.php Normal file
View file

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

82
Data/Coupon.php Normal file
View file

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

35
Data/DunningSchedule.php Normal file
View file

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

39
Data/FraudScore.php Normal file
View file

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

51
Data/PaymentResult.php Normal file
View file

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

82
Data/ValidationResult.php Normal file
View file

@ -0,0 +1,82 @@
<?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,5 +20,15 @@ class OrderPaid
public function __construct(
public Order $order,
public Payment $payment
) {}
) {
$this->orderId = (int) $order->id;
$this->paymentId = (int) $payment->id;
$this->amount = (float) $payment->amount;
}
public int $orderId;
public int $paymentId;
public float $amount;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -136,15 +136,15 @@ return new class extends Migration
->constrained('users')
->cascadeOnDelete();
$table->foreignId('order_id')
->nullable()
->constrained('orders')
->nullOnDelete();
$table->foreignId('invoice_id')
->nullable()
->constrained('invoices')
->nullOnDelete();
// 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();
// Commission calculation
$table->decimal('order_amount', 10, 2); // Net order amount (after tax/discounts)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

102
Services/BTCPayGateway.php Normal file
View file

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

View file

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

View file

@ -4,15 +4,23 @@ 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\Models\Coupon;
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\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.
@ -45,7 +53,7 @@ class CouponService
*
* Sanitises the code before querying to prevent abuse.
*/
public function findByCode(string $code): ?Coupon
public function findByCode(string $code): ?CouponModel
{
$sanitised = $this->sanitiseCode($code);
@ -53,7 +61,7 @@ class CouponService
return null;
}
return Coupon::byCode($sanitised)->first();
return CouponModel::byCode($sanitised)->first();
}
/**
@ -70,16 +78,13 @@ 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;
}
@ -98,26 +103,209 @@ class CouponService
}
/**
* Validate a coupon for a workspace and package.
* 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
*/
public function validate(Coupon $coupon, Workspace $workspace, ?Package $package = null): CouponValidationResult
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
{
// Check if coupon is valid (active, within dates, not maxed out)
if (! $coupon->isValid()) {
return CouponValidationResult::invalid('This coupon is no longer valid');
$couponModel = $this->resolveCouponModel($coupon);
if (! $order->exists) {
throw new InvalidArgumentException('Coupon application requires a persisted order.');
}
// Check workspace usage limit
if (! $coupon->canBeUsedByWorkspace($workspace->id)) {
return CouponValidationResult::invalid('You have already used this coupon');
}
return DB::transaction(function () use ($couponModel, $order): Order {
/** @var Order $lockedOrder */
$lockedOrder = Order::query()
->with('items')
->lockForUpdate()
->findOrFail($order->id);
// 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 ($this->hasAppliedCoupon($couponModel, $lockedOrder)) {
return $lockedOrder->load('items', 'coupon');
}
return CouponValidationResult::valid($coupon);
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(),
];
}
/**
@ -125,19 +313,16 @@ class CouponService
*
* Returns boolean for use in CommerceService order creation.
*/
public function validateForOrderable(Coupon $coupon, Orderable&Model $orderable, ?Package $package = null): bool
public function validateForOrderable(CouponModel $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;
}
@ -153,26 +338,25 @@ 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 = Coupon::byCode($sanitised)->first();
$coupon = CouponModel::byCode($sanitised)->first();
if (! $coupon) {
return CouponValidationResult::invalid('Invalid coupon code');
}
return $this->validate($coupon, $workspace, $package);
return $this->validateLegacy($coupon, $workspace, $package);
}
/**
* Calculate discount for an amount.
*/
public function calculateDiscount(Coupon $coupon, float $amount): float
public function calculateDiscount(CouponModel $coupon, float $amount): float
{
return $coupon->calculateDiscount($amount);
}
@ -180,7 +364,7 @@ class CouponService
/**
* Record coupon usage after successful payment.
*/
public function recordUsage(Coupon $coupon, Workspace $workspace, Order $order, float $discountAmount): CouponUsage
public function recordUsage(CouponModel $coupon, Workspace $workspace, Order $order, float $discountAmount): CouponUsage
{
$usage = CouponUsage::create([
'coupon_id' => $coupon->id,
@ -189,7 +373,6 @@ class CouponService
'discount_amount' => $discountAmount,
]);
// Increment global usage count
$coupon->incrementUsage();
return $usage;
@ -198,8 +381,12 @@ class CouponService
/**
* Record coupon usage for any Orderable entity.
*/
public function recordUsageForOrderable(Coupon $coupon, Orderable&Model $orderable, Order $order, float $discountAmount): CouponUsage
{
public function recordUsageForOrderable(
CouponModel $coupon,
Orderable&Model $orderable,
Order $order,
float $discountAmount,
): CouponUsage {
$workspaceId = $orderable instanceof Workspace ? $orderable->id : null;
$usage = CouponUsage::create([
@ -209,7 +396,6 @@ class CouponService
'discount_amount' => $discountAmount,
]);
// Increment global usage count
$coupon->incrementUsage();
return $usage;
@ -218,7 +404,7 @@ class CouponService
/**
* Get usage history for a coupon.
*/
public function getUsageHistory(Coupon $coupon, int $limit = 50): Collection
public function getUsageHistory(CouponModel $coupon, int $limit = 50): Collection
{
return $coupon->usages()
->with(['workspace', 'order'])
@ -230,7 +416,7 @@ class CouponService
/**
* Get usage count for a workspace.
*/
public function getWorkspaceUsageCount(Coupon $coupon, Workspace $workspace): int
public function getWorkspaceUsageCount(CouponModel $coupon, Workspace $workspace): int
{
return $coupon->usages()
->where('workspace_id', $workspace->id)
@ -240,26 +426,15 @@ class CouponService
/**
* Get total discount amount for a coupon.
*/
public function getTotalDiscountAmount(Coupon $coupon): float
public function getTotalDiscountAmount(CouponModel $coupon): float
{
return $coupon->usages()->sum('discount_amount');
}
/**
* Create a new coupon.
*/
public function create(array $data): Coupon
{
// Normalise code to uppercase
$data['code'] = strtoupper($data['code']);
return Coupon::create($data);
return (float) $coupon->usages()->sum('discount_amount');
}
/**
* Deactivate a coupon.
*/
public function deactivate(Coupon $coupon): void
public function deactivate(CouponModel $coupon): void
{
$coupon->update(['is_active' => false]);
}
@ -276,8 +451,7 @@ class CouponService
$code .= $characters[random_int(0, strlen($characters) - 1)];
}
// Ensure uniqueness
while (Coupon::where('code', $code)->exists()) {
while (CouponModel::where('code', $code)->exists()) {
$code = $this->generateCode($length);
}
@ -288,8 +462,8 @@ class CouponService
* Generate multiple coupons with unique codes.
*
* @param int $count Number of coupons to generate (1-100)
* @param array $baseData Base coupon data (shared settings for all coupons)
* @return array<Coupon> Array of created coupons
* @param array<string, mixed> $baseData Base coupon data (shared settings for all coupons)
* @return array<CouponModel> Array of created coupons
*/
public function generateBulk(int $count, array $baseData): array
{
@ -301,9 +475,325 @@ 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->create($data);
$coupons[] = $this->createModel($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,6 +214,24 @@ class CurrencyService
return ExchangeRate::convert($amount, $from, $to);
}
public function rate(string $from, string $to): float
{
return (float) ($this->getExchangeRate($from, $to) ?? 1.0);
}
/**
* @return array<string, array>
*/
public function supported(): array
{
return $this->getSupportedCurrencies();
}
public function refresh(): void
{
$this->refreshExchangeRates();
}
/**
* Convert cents between currencies.
*/

View file

@ -5,6 +5,8 @@ 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;
@ -14,7 +16,9 @@ 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.
@ -34,6 +38,262 @@ 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.
*
@ -126,7 +386,7 @@ class DunningService
*/
public function retryPayment(Invoice $invoice): bool
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]);
$maxRetries = count($retryDays);
try {
@ -180,7 +440,7 @@ class DunningService
*/
public function getSubscriptionsForPause(): Collection
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]);
$pauseAfterDays = array_sum($retryDays) + 1; // Day after last retry
return Subscription::query()
@ -271,7 +531,7 @@ class DunningService
$cancelAfterDays = config('commerce.dunning.cancel_after_days', 30);
return Subscription::query()
->where('status', 'paused')
->whereIn('status', ['paused', 'suspended'])
->where('paused_at', '<=', now()->subDays($cancelAfterDays))
->with('workspace')
->get();
@ -328,7 +588,7 @@ class DunningService
*/
public function calculateNextRetry(int $currentAttempts): ?Carbon
{
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7, 14]);
// Account for the initial attempt (attempt 0 used grace period)
$retryIndex = $currentAttempts;
@ -342,6 +602,40 @@ class DunningService
return $daysUntilNext ? now()->addDays($daysUntilNext) : null;
}
public function nextRetryAt(int $attemptCount): Carbon
{
return $this->calculateNextRetry($attemptCount) ?? now()->addDays(14);
}
/**
* Run the RFC dunning pass.
*
* @return array{retried: int, recovered: int, cancelled: int}
*/
public function processAll(): array
{
$results = [
'retried' => 0,
'recovered' => 0,
'cancelled' => 0,
];
foreach ($this->getInvoicesDueForRetry() as $invoice) {
$results['retried']++;
if ($this->retry($invoice)->succeeded()) {
$results['recovered']++;
}
}
foreach ($this->getSubscriptionsForCancellation() as $subscription) {
$this->cancelSubscription($subscription);
$results['cancelled']++;
}
return $results;
}
/**
* Get the dunning status for a subscription.
*
@ -381,7 +675,7 @@ class DunningService
];
}
if ($subscription->status === 'paused') {
if (in_array($subscription->status, ['paused', 'suspended'], true)) {
$pausedDays = $subscription->paused_at
? (int) $subscription->paused_at->diffInDays(now(), false)
: 0;
@ -422,7 +716,96 @@ class DunningService
return Subscription::query()
->where('workspace_id', $invoice->workspace_id)
->whereIn('status', ['active', 'past_due', 'paused'])
->whereIn('status', ['active', 'past_due', 'paused', 'suspended'])
->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,10 +5,14 @@ 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.
@ -29,6 +33,145 @@ 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.
*
@ -162,9 +305,9 @@ class FraudService
protected function checkVelocity(Order $order): array
{
$signals = [];
$ip = request()->ip();
$ip = $this->getOrderIp($order);
$email = $order->billing_email;
$workspaceId = $order->orderable_id;
$workspaceId = $this->getOrderWorkspaceId($order);
$maxOrdersPerIpHourly = config('commerce.fraud.velocity.max_orders_per_ip_hourly', 5);
$maxOrdersPerEmailDaily = config('commerce.fraud.velocity.max_orders_per_email_daily', 10);
@ -227,8 +370,8 @@ class FraudService
protected function checkGeoAnomalies(Order $order): array
{
$signals = [];
$billingCountry = $order->billing_address['country'] ?? $order->tax_country ?? null;
$ipCountry = $this->getIpCountry();
$billingCountry = $this->getBillingCountry($order);
$ipCountry = $this->getIpCountry($order);
// Check for country mismatch
if (config('commerce.fraud.geo.flag_country_mismatch', true)) {
@ -241,7 +384,11 @@ class FraudService
}
// Check for high-risk countries
$highRiskCountries = config('commerce.fraud.geo.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 : []
);
if (! empty($highRiskCountries) && $billingCountry) {
if (in_array($billingCountry, $highRiskCountries, true)) {
$signals['high_risk_country'] = $billingCountry;
@ -254,9 +401,21 @@ class FraudService
/**
* Get country code from IP address.
*/
protected function getIpCountry(): ?string
protected function getIpCountry(?Order $order = null): ?string
{
$ip = request()->ip();
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();
if (! $ip || $ip === '127.0.0.1' || str_starts_with($ip, '192.168.')) {
return null;
}
@ -279,6 +438,204 @@ 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.
*/
@ -366,7 +723,7 @@ class FraudService
*/
public function recordFailedPayment(Order $order): void
{
$workspaceId = $order->orderable_id;
$workspaceId = $this->getOrderWorkspaceId($order);
if ($workspaceId) {
$failedKey = "fraud:failed:workspace:{$workspaceId}";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

109
Services/WebhookService.php Normal file
View file

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

View file

@ -232,9 +232,8 @@ return [
'dunning' => [
'enabled' => true,
// Exponential backoff: days after initial failure to schedule each retry
// [1, 3, 7] = retry at day 1, day 3, day 7 (total ~11 days of retries)
'retry_days' => [1, 3, 7],
// Escalating backoff: retry at 1, 3, 7, and 14 days, then cancel.
'retry_days' => [1, 3, 7, 14],
// Days after subscription paused to suspend workspace entitlements
// Paused = billing stopped but workspace accessible
@ -243,7 +242,7 @@ return [
// Days after subscription paused to cancel entirely
// After cancellation, workspace may be downgraded to free tier
'cancel_after_days' => 30,
'cancel_after_days' => 14,
// Grace period before first retry (hours)
// Gives customer time to fix payment method before automated retries

View file

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

View file

@ -52,12 +52,10 @@ Route::prefix('webhooks')->group(function () {
// });
// ─────────────────────────────────────────────────────────────────────────────
// Commerce Billing API (authenticated + verified)
// Commerce Billing API (authenticated)
// ─────────────────────────────────────────────────────────────────────────────
Route::middleware(['auth', 'verified'])->prefix('commerce')->group(function () {
// ── Read-only endpoints ──────────────────────────────────────────────
Route::middleware('auth')->prefix('commerce')->group(function () {
// Billing overview
Route::get('/billing', [CommerceController::class, 'billing'])
->name('api.commerce.billing');
@ -76,27 +74,43 @@ Route::middleware(['auth', 'verified'])->prefix('commerce')->group(function () {
Route::get('/invoices/{invoice}/download', [CommerceController::class, 'downloadInvoice'])
->name('api.commerce.invoices.download');
// Subscription (read)
// Subscription
Route::get('/subscription', [CommerceController::class, 'subscription'])
->name('api.commerce.subscription');
Route::post('/cancel', [CommerceController::class, 'cancelSubscription'])
->name('api.commerce.cancel');
Route::post('/resume', [CommerceController::class, 'resumeSubscription'])
->name('api.commerce.resume');
// Usage
Route::get('/usage', [CommerceController::class, 'usage'])
->name('api.commerce.usage');
// ── 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');
});
// 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');
});

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

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

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