Align commerce module with the monorepo module structure by updating all namespaces to use the Core\Mod\Commerce convention. This change supports the recent monorepo separation and ensures consistency with other modules. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
335 lines
11 KiB
PHP
335 lines
11 KiB
PHP
<?php
|
|
|
|
namespace Core\Mod\Commerce\Services;
|
|
|
|
use Core\Mod\Tenant\Models\User;
|
|
use Core\Mod\Tenant\Models\Workspace;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Core\Mod\Commerce\Models\PaymentMethod;
|
|
use Core\Mod\Commerce\Services\PaymentGateway\StripeGateway;
|
|
|
|
/**
|
|
* Service for managing payment methods.
|
|
*
|
|
* Handles adding, removing, and managing saved payment methods
|
|
* for workspaces with full gateway integration.
|
|
*/
|
|
class PaymentMethodService
|
|
{
|
|
public function __construct(
|
|
protected StripeGateway $stripeGateway,
|
|
) {}
|
|
|
|
/**
|
|
* Get all active payment methods for a workspace.
|
|
*/
|
|
public function getPaymentMethods(Workspace $workspace): Collection
|
|
{
|
|
return $workspace->paymentMethods()
|
|
->where('is_active', true)
|
|
->orderByDesc('is_default')
|
|
->orderByDesc('created_at')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* Get the default payment method for a workspace.
|
|
*/
|
|
public function getDefaultPaymentMethod(Workspace $workspace): ?PaymentMethod
|
|
{
|
|
return $workspace->paymentMethods()
|
|
->where('is_active', true)
|
|
->where('is_default', true)
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* Add a new payment method to a workspace.
|
|
*
|
|
* @param string $gatewayPaymentMethodId The payment method ID from the gateway (e.g., pm_xxx for Stripe)
|
|
*/
|
|
public function addPaymentMethod(
|
|
Workspace $workspace,
|
|
string $gatewayPaymentMethodId,
|
|
?User $user = null,
|
|
string $gateway = 'stripe'
|
|
): PaymentMethod {
|
|
return DB::transaction(function () use ($workspace, $gatewayPaymentMethodId, $user, $gateway) {
|
|
// For Stripe, attach and get details from the gateway
|
|
if ($gateway === 'stripe') {
|
|
return $this->addStripePaymentMethod($workspace, $gatewayPaymentMethodId, $user);
|
|
}
|
|
|
|
throw new \InvalidArgumentException("Unsupported payment gateway: {$gateway}");
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add a Stripe payment method.
|
|
*/
|
|
protected function addStripePaymentMethod(
|
|
Workspace $workspace,
|
|
string $gatewayPaymentMethodId,
|
|
?User $user = null
|
|
): PaymentMethod {
|
|
// Check if payment method already exists
|
|
$existing = PaymentMethod::where('workspace_id', $workspace->id)
|
|
->where('gateway', 'stripe')
|
|
->where('gateway_payment_method_id', $gatewayPaymentMethodId)
|
|
->first();
|
|
|
|
if ($existing) {
|
|
// Reactivate if it was deactivated
|
|
if (! $existing->is_active) {
|
|
$existing->update(['is_active' => true]);
|
|
}
|
|
|
|
return $existing;
|
|
}
|
|
|
|
// Attach to Stripe customer
|
|
$paymentMethod = $this->stripeGateway->attachPaymentMethod($workspace, $gatewayPaymentMethodId);
|
|
|
|
// Update with user info
|
|
if ($user) {
|
|
$paymentMethod->update(['user_id' => $user->id]);
|
|
}
|
|
|
|
// If this is the first payment method, make it the default
|
|
$hasOtherMethods = $workspace->paymentMethods()
|
|
->where('is_active', true)
|
|
->where('id', '!=', $paymentMethod->id)
|
|
->exists();
|
|
|
|
if (! $hasOtherMethods) {
|
|
$this->setDefaultPaymentMethod($workspace, $paymentMethod);
|
|
}
|
|
|
|
Log::info('Payment method added', [
|
|
'workspace_id' => $workspace->id,
|
|
'payment_method_id' => $paymentMethod->id,
|
|
'type' => $paymentMethod->type,
|
|
'brand' => $paymentMethod->brand,
|
|
]);
|
|
|
|
return $paymentMethod;
|
|
}
|
|
|
|
/**
|
|
* Remove a payment method from a workspace.
|
|
*
|
|
* @throws \RuntimeException If the payment method cannot be removed
|
|
*/
|
|
public function removePaymentMethod(Workspace $workspace, PaymentMethod $paymentMethod): void
|
|
{
|
|
// Verify ownership
|
|
if ($paymentMethod->workspace_id !== $workspace->id) {
|
|
throw new \RuntimeException('Payment method does not belong to this workspace.');
|
|
}
|
|
|
|
// Check if this is the last active payment method
|
|
$activeCount = $workspace->paymentMethods()
|
|
->where('is_active', true)
|
|
->count();
|
|
|
|
if ($activeCount === 1) {
|
|
// Check for active subscriptions
|
|
$hasActiveSubscription = $workspace->subscriptions()
|
|
->active()
|
|
->exists();
|
|
|
|
if ($hasActiveSubscription) {
|
|
throw new \RuntimeException(
|
|
'Cannot remove the last payment method while you have an active subscription.'
|
|
);
|
|
}
|
|
}
|
|
|
|
DB::transaction(function () use ($workspace, $paymentMethod) {
|
|
// Detach from gateway (Stripe)
|
|
if ($paymentMethod->gateway === 'stripe' && $paymentMethod->gateway_payment_method_id) {
|
|
try {
|
|
$this->stripeGateway->detachPaymentMethod($paymentMethod);
|
|
} catch (\Exception $e) {
|
|
Log::warning('Failed to detach payment method from Stripe', [
|
|
'payment_method_id' => $paymentMethod->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
// Continue with local removal even if gateway fails
|
|
}
|
|
}
|
|
|
|
// If this was the default, make another one the default
|
|
if ($paymentMethod->is_default) {
|
|
$newDefault = $workspace->paymentMethods()
|
|
->where('is_active', true)
|
|
->where('id', '!=', $paymentMethod->id)
|
|
->first();
|
|
|
|
if ($newDefault) {
|
|
$this->setDefaultPaymentMethod($workspace, $newDefault);
|
|
}
|
|
}
|
|
|
|
// Soft-delete by marking as inactive
|
|
$paymentMethod->update(['is_active' => false]);
|
|
|
|
Log::info('Payment method removed', [
|
|
'workspace_id' => $workspace->id,
|
|
'payment_method_id' => $paymentMethod->id,
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set a payment method as the default for a workspace.
|
|
*/
|
|
public function setDefaultPaymentMethod(Workspace $workspace, PaymentMethod $paymentMethod): void
|
|
{
|
|
// Verify ownership
|
|
if ($paymentMethod->workspace_id !== $workspace->id) {
|
|
throw new \RuntimeException('Payment method does not belong to this workspace.');
|
|
}
|
|
|
|
DB::transaction(function () use ($workspace, $paymentMethod) {
|
|
// Update gateway default (for Stripe)
|
|
if ($paymentMethod->gateway === 'stripe') {
|
|
try {
|
|
$this->stripeGateway->setDefaultPaymentMethod($paymentMethod);
|
|
} catch (\Exception $e) {
|
|
Log::warning('Failed to set default payment method in Stripe', [
|
|
'payment_method_id' => $paymentMethod->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
// Continue with local update even if gateway fails
|
|
}
|
|
}
|
|
|
|
// Remove default from all other methods
|
|
$workspace->paymentMethods()
|
|
->where('id', '!=', $paymentMethod->id)
|
|
->update(['is_default' => false]);
|
|
|
|
// Set this one as default
|
|
$paymentMethod->update(['is_default' => true]);
|
|
|
|
Log::info('Default payment method updated', [
|
|
'workspace_id' => $workspace->id,
|
|
'payment_method_id' => $paymentMethod->id,
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sync payment methods from Stripe to local database.
|
|
*
|
|
* This is useful when payment methods are added via Stripe's
|
|
* hosted checkout or customer portal.
|
|
*/
|
|
public function syncPaymentMethodsFromStripe(Workspace $workspace): Collection
|
|
{
|
|
if (! $workspace->stripe_customer_id) {
|
|
return collect();
|
|
}
|
|
|
|
// This would need the Stripe SDK to list payment methods
|
|
// For now, we rely on webhooks to keep data in sync
|
|
Log::info('Payment method sync requested', [
|
|
'workspace_id' => $workspace->id,
|
|
'stripe_customer_id' => $workspace->stripe_customer_id,
|
|
]);
|
|
|
|
return $this->getPaymentMethods($workspace);
|
|
}
|
|
|
|
/**
|
|
* Check if a payment method is expiring soon.
|
|
*/
|
|
public function isExpiringSoon(PaymentMethod $paymentMethod, int $monthsThreshold = 2): bool
|
|
{
|
|
if (! $paymentMethod->exp_month || ! $paymentMethod->exp_year) {
|
|
return false;
|
|
}
|
|
|
|
$expiry = \Carbon\Carbon::createFromDate(
|
|
$paymentMethod->exp_year,
|
|
$paymentMethod->exp_month
|
|
)->endOfMonth();
|
|
|
|
return $expiry->isBefore(now()->addMonths($monthsThreshold));
|
|
}
|
|
|
|
/**
|
|
* Get all workspaces with expiring payment methods.
|
|
*
|
|
* Useful for sending expiry warning notifications.
|
|
*/
|
|
public function getExpiringPaymentMethods(int $monthsThreshold = 2): Collection
|
|
{
|
|
$thresholdDate = now()->addMonths($monthsThreshold);
|
|
|
|
return PaymentMethod::query()
|
|
->where('is_active', true)
|
|
->where('is_default', true)
|
|
->where('type', 'card')
|
|
->whereNotNull('exp_month')
|
|
->whereNotNull('exp_year')
|
|
->whereRaw("STR_TO_DATE(CONCAT(exp_year, '-', exp_month, '-01'), '%Y-%m-%d') <= ?", [
|
|
$thresholdDate->format('Y-m-d'),
|
|
])
|
|
->with('workspace')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* Update payment method details from gateway.
|
|
*
|
|
* Called when card details are updated (e.g., new expiry date from card networks).
|
|
*/
|
|
public function updateFromGateway(PaymentMethod $paymentMethod, array $gatewayData): PaymentMethod
|
|
{
|
|
$updates = [];
|
|
|
|
if (isset($gatewayData['card'])) {
|
|
$card = $gatewayData['card'];
|
|
$updates['brand'] = $card['brand'] ?? $paymentMethod->brand;
|
|
$updates['last_four'] = $card['last4'] ?? $paymentMethod->last_four;
|
|
$updates['exp_month'] = $card['exp_month'] ?? $paymentMethod->exp_month;
|
|
$updates['exp_year'] = $card['exp_year'] ?? $paymentMethod->exp_year;
|
|
}
|
|
|
|
if (! empty($updates)) {
|
|
$paymentMethod->update($updates);
|
|
}
|
|
|
|
return $paymentMethod->fresh();
|
|
}
|
|
|
|
/**
|
|
* Create a setup session for adding a payment method.
|
|
*
|
|
* Returns the URL to redirect the user to Stripe's hosted setup page.
|
|
*/
|
|
public function createSetupSession(Workspace $workspace, string $returnUrl): array
|
|
{
|
|
if (! $this->stripeGateway->isEnabled()) {
|
|
throw new \RuntimeException('Stripe payments are not currently available.');
|
|
}
|
|
|
|
return $this->stripeGateway->createSetupSession($workspace, $returnUrl);
|
|
}
|
|
|
|
/**
|
|
* Get the billing portal URL for full payment management.
|
|
*/
|
|
public function getBillingPortalUrl(Workspace $workspace, string $returnUrl): ?string
|
|
{
|
|
if (! $this->stripeGateway->isEnabled()) {
|
|
return null;
|
|
}
|
|
|
|
return $this->stripeGateway->getPortalUrl($workspace, $returnUrl);
|
|
}
|
|
}
|