Compare commits
5 commits
feat/test-
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e4337e412 | ||
|
|
51f9595797 | ||
|
|
20fb740d61 | ||
|
|
cd16c7474e | ||
|
|
6d83c32114 |
63 changed files with 4700 additions and 1291 deletions
23
Boot.php
23
Boot.php
|
|
@ -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}");
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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}';
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
39
Contracts/PaymentGatewayContract.php
Normal file
39
Contracts/PaymentGatewayContract.php
Normal 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
26
DTOs/BundleItem.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
28
DTOs/CouponValidationResult.php
Normal file
28
DTOs/CouponValidationResult.php
Normal 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
31
DTOs/FraudAssessment.php
Normal 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
26
DTOs/ParsedItem.php
Normal 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
29
DTOs/PermissionResult.php
Normal 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
34
DTOs/ProrationResult.php
Normal 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
26
DTOs/SkuOption.php
Normal 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
34
DTOs/SkuParseResult.php
Normal 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
82
Data/Coupon.php
Normal 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
35
Data/DunningSchedule.php
Normal 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
39
Data/FraudScore.php
Normal 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
51
Data/PaymentResult.php
Normal 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
82
Data/ValidationResult.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
59
Jobs/ProcessWebhookEvent.php
Normal file
59
Jobs/ProcessWebhookEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
283
Migrations/2026_04_25_000001_create_rfc_billing_tables.php
Normal file
283
Migrations/2026_04_25_000001_create_rfc_billing_tables.php
Normal 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');
|
||||
}
|
||||
};
|
||||
75
Migrations/2026_04_25_000002_align_rfc_columns.php
Normal file
75
Migrations/2026_04_25_000002_align_rfc_columns.php
Normal 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));
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class Payment extends Model
|
|||
'workspace_id',
|
||||
'invoice_id',
|
||||
'order_id',
|
||||
'payment_method_id',
|
||||
'gateway',
|
||||
'gateway_payment_id',
|
||||
'gateway_customer_id',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class ProductAssignment extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'entity_id',
|
||||
'entity_type',
|
||||
'product_id',
|
||||
'sku_suffix',
|
||||
'price_override',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class ProductPrice extends Model
|
|||
'product_id',
|
||||
'currency',
|
||||
'amount',
|
||||
'billing_cycle',
|
||||
'is_manual',
|
||||
'exchange_rate_used',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
102
Services/BTCPayGateway.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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}]."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
169
Services/ProrationService.php
Normal file
169
Services/ProrationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
|
|
|
|||
97
Services/StripeGateway.php
Normal file
97
Services/StripeGateway.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
100
Services/SubscriptionStateMachine.php
Normal file
100
Services/SubscriptionStateMachine.php
Normal 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
109
Services/WebhookService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
36
tests/Feature/RfcSurfaceTest.php
Normal file
36
tests/Feature/RfcSurfaceTest.php
Normal 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();
|
||||
});
|
||||
295
tests/Unit/Services/CouponServiceTest.php
Normal file
295
tests/Unit/Services/CouponServiceTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
453
tests/Unit/Services/DunningServiceTest.php
Normal file
453
tests/Unit/Services/DunningServiceTest.php
Normal 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([]);
|
||||
});
|
||||
});
|
||||
288
tests/Unit/Services/FraudServiceTest.php
Normal file
288
tests/Unit/Services/FraudServiceTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue