2026-01-27 00:24:22 +00:00
|
|
|
<?php
|
|
|
|
|
|
2026-01-27 16:23:12 +00:00
|
|
|
namespace Core\Mod\Commerce\Services;
|
2026-01-27 00:24:22 +00:00
|
|
|
|
2026-01-27 17:39:12 +00:00
|
|
|
use Core\Tenant\Models\Package;
|
|
|
|
|
use Core\Tenant\Models\Workspace;
|
|
|
|
|
use Core\Tenant\Services\EntitlementService;
|
2026-01-27 00:24:22 +00:00
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
2026-01-27 16:23:12 +00:00
|
|
|
use Core\Mod\Commerce\Contracts\Orderable;
|
|
|
|
|
use Core\Mod\Commerce\Models\Coupon;
|
|
|
|
|
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\Subscription;
|
|
|
|
|
use Core\Mod\Commerce\Services\PaymentGateway\PaymentGatewayContract;
|
2026-01-27 00:24:22 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Main commerce orchestration service.
|
|
|
|
|
*
|
|
|
|
|
* Handles order creation, checkout flow, and payment processing.
|
|
|
|
|
*/
|
|
|
|
|
class CommerceService
|
|
|
|
|
{
|
|
|
|
|
public function __construct(
|
|
|
|
|
protected EntitlementService $entitlements,
|
|
|
|
|
protected TaxService $taxService,
|
|
|
|
|
protected CouponService $couponService,
|
|
|
|
|
protected InvoiceService $invoiceService,
|
|
|
|
|
protected CurrencyService $currencyService,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the active payment gateway.
|
|
|
|
|
*/
|
|
|
|
|
public function gateway(?string $name = null): PaymentGatewayContract
|
|
|
|
|
{
|
|
|
|
|
$name = $name ?? $this->getDefaultGateway();
|
|
|
|
|
|
|
|
|
|
return app("commerce.gateway.{$name}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the default gateway name.
|
|
|
|
|
*/
|
|
|
|
|
public function getDefaultGateway(): string
|
|
|
|
|
{
|
|
|
|
|
// BTCPay is primary, Stripe is fallback
|
|
|
|
|
if (config('commerce.gateways.btcpay.enabled')) {
|
|
|
|
|
return 'btcpay';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'stripe';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get all enabled gateways.
|
|
|
|
|
*
|
|
|
|
|
* @return array<string, PaymentGatewayContract>
|
|
|
|
|
*/
|
|
|
|
|
public function getEnabledGateways(): array
|
|
|
|
|
{
|
|
|
|
|
$gateways = [];
|
|
|
|
|
|
|
|
|
|
foreach (config('commerce.gateways') as $name => $config) {
|
|
|
|
|
if ($config['enabled'] ?? false) {
|
|
|
|
|
$gateways[$name] = $this->gateway($name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $gateways;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Order Creation
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create an order for a package purchase.
|
|
|
|
|
*
|
|
|
|
|
* @param string|null $idempotencyKey Optional idempotency key to prevent duplicate orders
|
|
|
|
|
*/
|
|
|
|
|
public function createOrder(
|
|
|
|
|
Orderable&Model $orderable,
|
|
|
|
|
Package $package,
|
|
|
|
|
string $billingCycle = 'monthly',
|
|
|
|
|
?Coupon $coupon = null,
|
|
|
|
|
array $metadata = [],
|
|
|
|
|
?string $idempotencyKey = null
|
|
|
|
|
): Order {
|
|
|
|
|
// Check for existing order with same idempotency key
|
|
|
|
|
if ($idempotencyKey) {
|
|
|
|
|
$existingOrder = Order::where('idempotency_key', $idempotencyKey)->first();
|
|
|
|
|
if ($existingOrder) {
|
|
|
|
|
return $existingOrder;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return DB::transaction(function () use ($orderable, $package, $billingCycle, $coupon, $metadata, $idempotencyKey) {
|
|
|
|
|
// Calculate pricing
|
|
|
|
|
$subtotal = $package->getPrice($billingCycle);
|
|
|
|
|
$setupFee = $package->setup_fee ?? 0;
|
|
|
|
|
|
|
|
|
|
// Apply coupon if valid
|
|
|
|
|
$discountAmount = 0;
|
|
|
|
|
if ($coupon && $this->couponService->validateForOrderable($coupon, $orderable, $package)) {
|
|
|
|
|
$discountAmount = $coupon->calculateDiscount($subtotal);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate tax
|
|
|
|
|
$taxableAmount = $subtotal - $discountAmount + $setupFee;
|
|
|
|
|
$taxResult = $this->taxService->calculateForOrderable($orderable, $taxableAmount);
|
|
|
|
|
|
|
|
|
|
// Create order
|
|
|
|
|
$order = Order::create([
|
|
|
|
|
'orderable_type' => get_class($orderable),
|
|
|
|
|
'orderable_id' => $orderable->id,
|
2026-01-27 17:39:12 +00:00
|
|
|
'user_id' => $orderable instanceof \Core\Tenant\Models\User ? $orderable->id : null,
|
2026-01-27 00:24:22 +00:00
|
|
|
'order_number' => Order::generateOrderNumber(),
|
|
|
|
|
'status' => 'pending',
|
|
|
|
|
'billing_cycle' => $billingCycle,
|
|
|
|
|
'subtotal' => $subtotal + $setupFee,
|
|
|
|
|
'discount_amount' => $discountAmount,
|
|
|
|
|
'tax_amount' => $taxResult->taxAmount,
|
|
|
|
|
'tax_rate' => $taxResult->taxRate,
|
|
|
|
|
'tax_country' => $taxResult->jurisdiction,
|
|
|
|
|
'total' => $subtotal - $discountAmount + $setupFee + $taxResult->taxAmount,
|
|
|
|
|
'currency' => config('commerce.currency', 'GBP'),
|
|
|
|
|
'coupon_id' => $coupon?->id,
|
|
|
|
|
'billing_name' => $orderable->getBillingName(),
|
|
|
|
|
'billing_email' => $orderable->getBillingEmail(),
|
|
|
|
|
'billing_address' => $orderable->getBillingAddress(),
|
|
|
|
|
'metadata' => $metadata,
|
|
|
|
|
'idempotency_key' => $idempotencyKey,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Create line items
|
|
|
|
|
$lineTotal = $subtotal - $discountAmount;
|
|
|
|
|
OrderItem::create([
|
|
|
|
|
'order_id' => $order->id,
|
|
|
|
|
'item_type' => 'package',
|
|
|
|
|
'item_id' => $package->id,
|
|
|
|
|
'item_code' => $package->code,
|
|
|
|
|
'description' => "{$package->name} - ".ucfirst($billingCycle),
|
|
|
|
|
'quantity' => 1,
|
|
|
|
|
'unit_price' => $subtotal,
|
|
|
|
|
'line_total' => $lineTotal,
|
|
|
|
|
'billing_cycle' => $billingCycle,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Add setup fee as separate line item if applicable
|
|
|
|
|
if ($setupFee > 0) {
|
|
|
|
|
OrderItem::create([
|
|
|
|
|
'order_id' => $order->id,
|
|
|
|
|
'item_type' => 'setup_fee',
|
|
|
|
|
'item_id' => $package->id,
|
|
|
|
|
'item_code' => 'setup-fee',
|
|
|
|
|
'description' => "One-time setup fee for {$package->name}",
|
|
|
|
|
'quantity' => 1,
|
|
|
|
|
'unit_price' => $setupFee,
|
|
|
|
|
'line_total' => $setupFee,
|
|
|
|
|
'billing_cycle' => 'onetime',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $order;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a checkout session for an order.
|
|
|
|
|
*
|
|
|
|
|
* @return array{order: Order, session_id: string, checkout_url: string}
|
|
|
|
|
*/
|
|
|
|
|
public function createCheckout(
|
|
|
|
|
Order $order,
|
|
|
|
|
?string $gateway = null,
|
|
|
|
|
?string $successUrl = null,
|
|
|
|
|
?string $cancelUrl = null
|
|
|
|
|
): array {
|
|
|
|
|
$gateway = $gateway ?? $this->getDefaultGateway();
|
|
|
|
|
$successUrl = $successUrl ?? route('checkout.success', ['order' => $order->order_number]);
|
|
|
|
|
$cancelUrl = $cancelUrl ?? route('checkout.cancel', ['order' => $order->order_number]);
|
|
|
|
|
|
|
|
|
|
// Ensure customer exists in gateway (only for Workspace orderables)
|
|
|
|
|
if ($order->orderable instanceof Workspace) {
|
|
|
|
|
$this->ensureCustomer($order->orderable, $gateway);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update order with gateway info
|
|
|
|
|
$order->update([
|
|
|
|
|
'gateway' => $gateway,
|
|
|
|
|
'status' => 'processing',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Create checkout session
|
|
|
|
|
$session = $this->gateway($gateway)->createCheckoutSession($order, $successUrl, $cancelUrl);
|
|
|
|
|
|
|
|
|
|
$order->update([
|
|
|
|
|
'gateway_session_id' => $session['session_id'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'order' => $order->fresh(),
|
|
|
|
|
'session_id' => $session['session_id'],
|
|
|
|
|
'checkout_url' => $session['checkout_url'],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ensure workspace has a customer ID in the gateway.
|
|
|
|
|
*/
|
|
|
|
|
public function ensureCustomer(Workspace $workspace, string $gateway): string
|
|
|
|
|
{
|
|
|
|
|
$field = "{$gateway}_customer_id";
|
|
|
|
|
|
|
|
|
|
if ($workspace->{$field}) {
|
|
|
|
|
return $workspace->{$field};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$customerId = $this->gateway($gateway)->createCustomer($workspace);
|
|
|
|
|
|
|
|
|
|
$workspace->update([$field => $customerId]);
|
|
|
|
|
|
|
|
|
|
return $customerId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create an order for a one-time boost purchase.
|
|
|
|
|
*/
|
|
|
|
|
public function createBoostOrder(
|
|
|
|
|
Orderable&Model $orderable,
|
|
|
|
|
string $boostCode,
|
|
|
|
|
string $boostName,
|
|
|
|
|
int $price,
|
|
|
|
|
?Coupon $coupon = null,
|
|
|
|
|
array $metadata = []
|
|
|
|
|
): Order {
|
|
|
|
|
return DB::transaction(function () use ($orderable, $boostCode, $boostName, $price, $coupon, $metadata) {
|
|
|
|
|
// Calculate pricing
|
|
|
|
|
$subtotal = $price;
|
|
|
|
|
|
|
|
|
|
// Apply coupon if valid
|
|
|
|
|
$discountAmount = 0;
|
|
|
|
|
if ($coupon) {
|
|
|
|
|
$discountAmount = $coupon->calculateDiscount($subtotal);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate tax
|
|
|
|
|
$taxableAmount = $subtotal - $discountAmount;
|
|
|
|
|
$taxResult = $this->taxService->calculateForOrderable($orderable, $taxableAmount);
|
|
|
|
|
|
|
|
|
|
// Create order
|
|
|
|
|
$order = Order::create([
|
|
|
|
|
'orderable_type' => get_class($orderable),
|
|
|
|
|
'orderable_id' => $orderable->id,
|
2026-01-27 17:39:12 +00:00
|
|
|
'user_id' => $orderable instanceof \Core\Tenant\Models\User ? $orderable->id : null,
|
2026-01-27 00:24:22 +00:00
|
|
|
'order_number' => Order::generateOrderNumber(),
|
|
|
|
|
'status' => 'pending',
|
|
|
|
|
'billing_cycle' => 'onetime',
|
|
|
|
|
'subtotal' => $subtotal,
|
|
|
|
|
'discount_amount' => $discountAmount,
|
|
|
|
|
'tax_amount' => $taxResult->taxAmount,
|
|
|
|
|
'tax_rate' => $taxResult->taxRate,
|
|
|
|
|
'tax_country' => $taxResult->jurisdiction,
|
|
|
|
|
'total' => $subtotal - $discountAmount + $taxResult->taxAmount,
|
|
|
|
|
'currency' => config('commerce.currency', 'GBP'),
|
|
|
|
|
'coupon_id' => $coupon?->id,
|
|
|
|
|
'billing_name' => $orderable->getBillingName(),
|
|
|
|
|
'billing_email' => $orderable->getBillingEmail(),
|
|
|
|
|
'billing_address' => $orderable->getBillingAddress(),
|
|
|
|
|
'metadata' => array_merge($metadata, ['boost_code' => $boostCode]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Create line item
|
|
|
|
|
OrderItem::create([
|
|
|
|
|
'order_id' => $order->id,
|
|
|
|
|
'item_type' => 'boost',
|
|
|
|
|
'item_id' => null,
|
|
|
|
|
'item_code' => $boostCode,
|
|
|
|
|
'description' => $boostName,
|
|
|
|
|
'quantity' => 1,
|
|
|
|
|
'unit_price' => $subtotal,
|
|
|
|
|
'line_total' => $subtotal - $discountAmount,
|
|
|
|
|
'billing_cycle' => 'onetime',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $order;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Order Fulfilment
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Process a successful payment and provision entitlements.
|
|
|
|
|
*/
|
|
|
|
|
public function fulfillOrder(Order $order, Payment $payment): void
|
|
|
|
|
{
|
|
|
|
|
DB::transaction(function () use ($order, $payment) {
|
|
|
|
|
// Mark order as paid
|
|
|
|
|
$order->markAsPaid();
|
|
|
|
|
|
|
|
|
|
// Create invoice
|
|
|
|
|
$invoice = $this->invoiceService->createFromOrder($order, $payment);
|
|
|
|
|
|
|
|
|
|
// Record coupon usage if applicable
|
|
|
|
|
if ($order->coupon_id && $order->orderable) {
|
|
|
|
|
$this->couponService->recordUsageForOrderable(
|
|
|
|
|
$order->coupon,
|
|
|
|
|
$order->orderable,
|
|
|
|
|
$order,
|
|
|
|
|
$order->discount_amount
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Provision entitlements for each package item (only for Workspace orderables)
|
|
|
|
|
if ($order->orderable instanceof Workspace) {
|
|
|
|
|
foreach ($order->items as $item) {
|
|
|
|
|
if ($item->item_type === 'package' && $item->item_id) {
|
|
|
|
|
$this->entitlements->provisionPackage(
|
|
|
|
|
$order->orderable,
|
|
|
|
|
$item->package->code,
|
|
|
|
|
[
|
|
|
|
|
'order_id' => $order->id,
|
|
|
|
|
'source' => $order->gateway,
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Provision boosts for user-level orders
|
2026-01-27 17:39:12 +00:00
|
|
|
if ($order->orderable instanceof \Core\Tenant\Models\User) {
|
2026-01-27 00:24:22 +00:00
|
|
|
foreach ($order->items as $item) {
|
|
|
|
|
if ($item->item_type === 'boost') {
|
|
|
|
|
$quantity = $item->metadata['quantity'] ?? $item->quantity ?? 1;
|
|
|
|
|
$this->provisionBoostForUser($order->orderable, $item->item_code, $quantity, [
|
|
|
|
|
'order_id' => $order->id,
|
|
|
|
|
'source' => $order->gateway,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dispatch OrderPaid event for referral tracking and other listeners
|
2026-01-27 16:23:12 +00:00
|
|
|
event(new \Core\Mod\Commerce\Events\OrderPaid($order, $payment));
|
2026-01-27 00:24:22 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Provision a boost for a user.
|
|
|
|
|
*/
|
2026-01-27 17:39:12 +00:00
|
|
|
public function provisionBoostForUser(\Core\Tenant\Models\User $user, string $featureCode, int $quantity = 1, array $metadata = []): \Core\Tenant\Models\Boost
|
2026-01-27 00:24:22 +00:00
|
|
|
{
|
|
|
|
|
// Use ADD_LIMIT for quantity-based boosts, ENABLE for boolean boosts
|
|
|
|
|
$boostType = $quantity > 1 || $this->isQuantityBasedFeature($featureCode)
|
2026-01-27 17:39:12 +00:00
|
|
|
? \Core\Tenant\Models\Boost::BOOST_TYPE_ADD_LIMIT
|
|
|
|
|
: \Core\Tenant\Models\Boost::BOOST_TYPE_ENABLE;
|
2026-01-27 00:24:22 +00:00
|
|
|
|
2026-01-27 17:39:12 +00:00
|
|
|
return \Core\Tenant\Models\Boost::create([
|
2026-01-27 00:24:22 +00:00
|
|
|
'user_id' => $user->id,
|
|
|
|
|
'workspace_id' => null,
|
|
|
|
|
'feature_code' => $featureCode,
|
|
|
|
|
'boost_type' => $boostType,
|
2026-01-27 17:39:12 +00:00
|
|
|
'duration_type' => \Core\Tenant\Models\Boost::DURATION_PERMANENT,
|
|
|
|
|
'limit_value' => $boostType === \Core\Tenant\Models\Boost::BOOST_TYPE_ADD_LIMIT ? $quantity : null,
|
|
|
|
|
'status' => \Core\Tenant\Models\Boost::STATUS_ACTIVE,
|
2026-01-27 00:24:22 +00:00
|
|
|
'starts_at' => now(),
|
|
|
|
|
'metadata' => $metadata,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if a feature code is quantity-based (needs ADD_LIMIT).
|
|
|
|
|
*/
|
|
|
|
|
protected function isQuantityBasedFeature(string $featureCode): bool
|
|
|
|
|
{
|
|
|
|
|
return in_array($featureCode, [
|
|
|
|
|
'bio.pages',
|
|
|
|
|
'bio.blocks',
|
|
|
|
|
'bio.shortened_links',
|
|
|
|
|
'bio.qr_codes',
|
|
|
|
|
'bio.file_downloads',
|
|
|
|
|
'bio.events',
|
|
|
|
|
'bio.vcard',
|
|
|
|
|
'bio.splash_pages',
|
|
|
|
|
'bio.pixels',
|
|
|
|
|
'bio.static_sites',
|
|
|
|
|
'bio.custom_domains',
|
|
|
|
|
'bio.web3_domains',
|
|
|
|
|
'ai.credits',
|
|
|
|
|
'webpage.sub_pages',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle a failed order.
|
|
|
|
|
*/
|
|
|
|
|
public function failOrder(Order $order, ?string $reason = null): void
|
|
|
|
|
{
|
|
|
|
|
$order->markAsFailed($reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Subscription Management
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a subscription for a workspace.
|
|
|
|
|
*/
|
|
|
|
|
public function createSubscription(
|
|
|
|
|
Workspace $workspace,
|
|
|
|
|
Package $package,
|
|
|
|
|
string $billingCycle = 'monthly',
|
|
|
|
|
?string $gateway = null
|
|
|
|
|
): Subscription {
|
|
|
|
|
$gateway = $gateway ?? $this->getDefaultGateway();
|
|
|
|
|
$priceId = $package->getGatewayPriceId($gateway, $billingCycle);
|
|
|
|
|
|
|
|
|
|
if (! $priceId) {
|
|
|
|
|
throw new \InvalidArgumentException(
|
|
|
|
|
"Package {$package->code} has no {$gateway} price ID for {$billingCycle} billing"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure customer exists
|
|
|
|
|
$this->ensureCustomer($workspace, $gateway);
|
|
|
|
|
|
|
|
|
|
// Create subscription in gateway
|
|
|
|
|
$subscription = $this->gateway($gateway)->createSubscription($workspace, $priceId, [
|
|
|
|
|
'trial_days' => $package->trial_days,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $subscription;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Upgrade or downgrade a subscription.
|
|
|
|
|
*/
|
|
|
|
|
public function changeSubscription(
|
|
|
|
|
Subscription $subscription,
|
|
|
|
|
Package $newPackage,
|
|
|
|
|
?string $billingCycle = null
|
|
|
|
|
): Subscription {
|
|
|
|
|
$billingCycle = $billingCycle ?? $this->guessBillingCycleFromSubscription($subscription);
|
|
|
|
|
$priceId = $newPackage->getGatewayPriceId($subscription->gateway, $billingCycle);
|
|
|
|
|
|
|
|
|
|
if (! $priceId) {
|
|
|
|
|
throw new \InvalidArgumentException(
|
|
|
|
|
"Package {$newPackage->code} has no {$subscription->gateway} price ID"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->gateway($subscription->gateway)->updateSubscription($subscription, [
|
|
|
|
|
'price_id' => $priceId,
|
|
|
|
|
'prorate' => config('commerce.subscriptions.allow_proration', true),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cancel a subscription.
|
|
|
|
|
*/
|
|
|
|
|
public function cancelSubscription(Subscription $subscription, bool $immediately = false): void
|
|
|
|
|
{
|
|
|
|
|
$this->gateway($subscription->gateway)->cancelSubscription($subscription, $immediately);
|
|
|
|
|
|
|
|
|
|
if ($immediately) {
|
|
|
|
|
// Revoke entitlements immediately
|
|
|
|
|
$workspacePackage = $subscription->workspacePackage;
|
|
|
|
|
if ($workspacePackage) {
|
|
|
|
|
$this->entitlements->revokePackage($subscription->workspace, $workspacePackage->package->code);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resume a cancelled subscription.
|
|
|
|
|
*/
|
|
|
|
|
public function resumeSubscription(Subscription $subscription): void
|
|
|
|
|
{
|
|
|
|
|
if (! $subscription->onGracePeriod()) {
|
|
|
|
|
throw new \InvalidArgumentException('Cannot resume subscription outside grace period');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->gateway($subscription->gateway)->resumeSubscription($subscription);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Refunds
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Process a refund.
|
|
|
|
|
*/
|
|
|
|
|
public function refund(
|
|
|
|
|
Payment $payment,
|
|
|
|
|
?float $amount = null,
|
|
|
|
|
?string $reason = null
|
2026-01-27 16:23:12 +00:00
|
|
|
): \Core\Mod\Commerce\Models\Refund {
|
2026-01-27 00:24:22 +00:00
|
|
|
$amountCents = $amount
|
|
|
|
|
? (int) ($amount * 100)
|
|
|
|
|
: (int) (($payment->amount - $payment->amount_refunded) * 100);
|
|
|
|
|
|
|
|
|
|
return $this->gateway($payment->gateway)->refund($payment, $amountCents, $reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Invoice Retries
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Retry payment for an invoice.
|
|
|
|
|
*/
|
|
|
|
|
public function retryInvoicePayment(Invoice $invoice): bool
|
|
|
|
|
{
|
|
|
|
|
if ($invoice->isPaid()) {
|
|
|
|
|
return true; // Already paid
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$workspace = $invoice->workspace;
|
|
|
|
|
if (! $workspace) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get default payment method
|
|
|
|
|
$paymentMethod = $workspace->paymentMethods()
|
|
|
|
|
->where('is_active', true)
|
|
|
|
|
->where('is_default', true)
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
if (! $paymentMethod) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$gateway = $this->gateway($paymentMethod->gateway);
|
|
|
|
|
|
|
|
|
|
// Convert total to cents and charge via gateway
|
|
|
|
|
$amountCents = (int) ($invoice->total * 100);
|
|
|
|
|
$payment = $gateway->chargePaymentMethod(
|
|
|
|
|
$paymentMethod,
|
|
|
|
|
$amountCents,
|
|
|
|
|
$invoice->currency,
|
|
|
|
|
[
|
|
|
|
|
'description' => "Invoice {$invoice->invoice_number}",
|
|
|
|
|
'invoice_id' => $invoice->id,
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Gateway returns a Payment model - check if it succeeded
|
|
|
|
|
if ($payment->status === 'succeeded') {
|
|
|
|
|
// Link payment to invoice
|
|
|
|
|
$payment->update(['invoice_id' => $invoice->id]);
|
|
|
|
|
$invoice->markAsPaid($payment);
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For BTCPay, payment will be 'pending' as it requires customer action
|
|
|
|
|
// This is expected - automatic retry won't work for crypto payments
|
|
|
|
|
if ($payment->status === 'pending' && $paymentMethod->gateway === 'btcpay') {
|
|
|
|
|
\Illuminate\Support\Facades\Log::info('BTCPay invoice created for retry - requires customer payment', [
|
|
|
|
|
'invoice_id' => $invoice->id,
|
|
|
|
|
'payment_id' => $payment->id,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
\Illuminate\Support\Facades\Log::error('Invoice payment retry failed', [
|
|
|
|
|
'invoice_id' => $invoice->id,
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get gateway by name (alias for gateway method).
|
|
|
|
|
*/
|
|
|
|
|
public function getGateway(string $name): PaymentGatewayContract
|
|
|
|
|
{
|
|
|
|
|
return $this->gateway($name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helpers
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Guess billing cycle from subscription metadata.
|
|
|
|
|
*/
|
|
|
|
|
protected function guessBillingCycleFromSubscription(Subscription $subscription): string
|
|
|
|
|
{
|
|
|
|
|
// Try to determine from current period length
|
|
|
|
|
$periodDays = $subscription->current_period_start->diffInDays($subscription->current_period_end);
|
|
|
|
|
|
|
|
|
|
return $periodDays > 32 ? 'yearly' : 'monthly';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get currency symbol.
|
|
|
|
|
*/
|
|
|
|
|
public function getCurrencySymbol(?string $currency = null): string
|
|
|
|
|
{
|
|
|
|
|
$currency = $currency ?? config('commerce.currency', 'GBP');
|
|
|
|
|
|
|
|
|
|
return $this->currencyService->getSymbol($currency);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format money for display.
|
|
|
|
|
*/
|
|
|
|
|
public function formatMoney(float $amount, ?string $currency = null): string
|
|
|
|
|
{
|
|
|
|
|
$currency = $currency ?? config('commerce.currency', 'GBP');
|
|
|
|
|
|
|
|
|
|
return $this->currencyService->format($amount, $currency);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the currency service.
|
|
|
|
|
*/
|
|
|
|
|
public function getCurrencyService(): CurrencyService
|
|
|
|
|
{
|
|
|
|
|
return $this->currencyService;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert an amount between currencies.
|
|
|
|
|
*/
|
|
|
|
|
public function convertCurrency(float $amount, string $from, string $to): ?float
|
|
|
|
|
{
|
|
|
|
|
return $this->currencyService->convert($amount, $from, $to);
|
|
|
|
|
}
|
|
|
|
|
}
|