php-commerce/Services/SubscriptionService.php
Snider a774f4e285 refactor: migrate namespace from Core\Commerce to Core\Mod\Commerce
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>
2026-01-27 16:23:12 +00:00

456 lines
15 KiB
PHP

<?php
namespace Core\Mod\Commerce\Services;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Core\Mod\Commerce\Exceptions\PauseLimitExceededException;
use Core\Mod\Commerce\Models\Subscription;
use Core\Mod\Tenant\Models\Package;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Models\WorkspacePackage;
use Core\Mod\Tenant\Services\EntitlementService;
class SubscriptionService
{
public function __construct(
protected CommerceService $commerce,
protected EntitlementService $entitlements,
) {}
/**
* Create a new subscription for a workspace package.
*/
public function create(
WorkspacePackage $workspacePackage,
string $billingCycle = 'monthly',
?string $gateway = null,
?string $gatewaySubscriptionId = null
): Subscription {
// Use fixed days for predictable billing periods
$periodEnd = $billingCycle === 'yearly'
? Carbon::now()->addDays(365)
: Carbon::now()->addDays(30);
return Subscription::create([
'workspace_id' => $workspacePackage->workspace_id,
'workspace_package_id' => $workspacePackage->id,
'status' => 'active',
'gateway' => $gateway ?? config('commerce.default_gateway', 'btcpay'),
'gateway_subscription_id' => $gatewaySubscriptionId,
'current_period_start' => Carbon::now(),
'current_period_end' => $periodEnd,
'billing_cycle' => $billingCycle,
]);
}
/**
* Cancel a subscription (set to expire at period end).
*/
public function cancel(Subscription $subscription, ?string $reason = null): Subscription
{
$subscription->update([
'cancelled_at' => Carbon::now(),
'cancellation_reason' => $reason,
]);
return $subscription->fresh();
}
/**
* Resume a cancelled subscription (before it expires).
*/
public function resume(Subscription $subscription): Subscription
{
if (! $subscription->cancelled_at) {
return $subscription;
}
// Only resume if still within billing period
if ($subscription->current_period_end && $subscription->current_period_end->isFuture()) {
$subscription->update([
'cancelled_at' => null,
'cancellation_reason' => null,
]);
}
return $subscription->fresh();
}
/**
* Renew a subscription for another billing period.
*/
public function renew(Subscription $subscription): Subscription
{
$billingCycle = $subscription->billing_cycle ?? 'monthly';
$newPeriodStart = $subscription->current_period_end ?? Carbon::now();
// Use fixed days for predictable billing periods
$newPeriodEnd = $billingCycle === 'yearly'
? $newPeriodStart->copy()->addDays(365)
: $newPeriodStart->copy()->addDays(30);
$subscription->update([
'current_period_start' => $newPeriodStart,
'current_period_end' => $newPeriodEnd,
'cancelled_at' => null,
'cancellation_reason' => null,
]);
return $subscription->fresh();
}
/**
* Expire a subscription (end it immediately or at period end).
*/
public function expire(Subscription $subscription): Subscription
{
$subscription->update([
'status' => 'expired',
'ended_at' => Carbon::now(),
]);
// Revoke the associated workspace package if configured
if ($subscription->workspacePackage) {
$subscription->workspacePackage->update([
'status' => 'expired',
'expires_at' => Carbon::now(),
]);
}
return $subscription->fresh();
}
/**
* Pause a subscription (for dunning/failed payments).
*
* @param bool $force Skip pause limit check (for dunning/system use)
*
* @throws PauseLimitExceededException When pause limit exceeded and not forced
*/
public function pause(Subscription $subscription, bool $force = false): Subscription
{
// Cannot pause a subscription that is not active
if ($subscription->status !== 'active') {
return $subscription;
}
// Check if pause is allowed by config
if (! config('commerce.subscriptions.allow_pause', true)) {
throw new \InvalidArgumentException('Subscription pausing is not enabled.');
}
// Check pause limit unless forced (e.g., by dunning service)
if (! $force && ! $subscription->canPause()) {
$maxPauseCycles = config('commerce.subscriptions.max_pause_cycles', 3);
throw new PauseLimitExceededException($subscription, $maxPauseCycles);
}
$subscription->update([
'status' => 'paused',
'paused_at' => Carbon::now(),
'pause_count' => ($subscription->pause_count ?? 0) + 1,
]);
Log::info('Subscription paused', [
'subscription_id' => $subscription->id,
'pause_count' => $subscription->fresh()->pause_count,
'forced' => $force,
]);
return $subscription->fresh();
}
/**
* Unpause a subscription.
*/
public function unpause(Subscription $subscription): Subscription
{
if ($subscription->status !== 'paused') {
return $subscription;
}
$subscription->update([
'status' => 'active',
'paused_at' => null,
]);
return $subscription->fresh();
}
/**
* Change subscription to a different package (upgrade/downgrade).
*
* @param bool $prorate Whether to prorate (charge/credit difference immediately)
* @param bool $immediate Whether to apply change immediately or at period end
*/
public function changePlan(
Subscription $subscription,
Package $newPackage,
bool $prorate = true,
bool $immediate = true
): array {
return DB::transaction(function () use ($subscription, $newPackage, $prorate, $immediate) {
$workspace = $subscription->workspace;
$currentPackage = $subscription->workspacePackage?->package;
$billingCycle = $subscription->billing_cycle ?? 'monthly';
// Calculate proration if enabled
$proration = null;
if ($prorate && $currentPackage && $immediate) {
$proration = $this->calculateProration(
$subscription,
$currentPackage,
$newPackage,
$billingCycle
);
}
if ($immediate) {
// Provision new package immediately
$newWorkspacePackage = $this->entitlements->provisionPackage(
$workspace,
$newPackage->code,
[
'subscription_id' => $subscription->id,
'source' => $subscription->gateway,
'prorated_from' => $currentPackage?->code,
]
);
// Update subscription to point to new package
$subscription->update([
'workspace_package_id' => $newWorkspacePackage->id,
'metadata' => array_merge($subscription->metadata ?? [], [
'plan_change' => [
'from' => $currentPackage?->code,
'to' => $newPackage->code,
'changed_at' => now()->toISOString(),
'proration' => $proration?->toArray(),
],
]),
]);
// Revoke old package entitlements
if ($currentPackage) {
$this->entitlements->revokePackage($workspace, $currentPackage->code);
}
Log::info('Subscription plan changed immediately', [
'subscription_id' => $subscription->id,
'from_package' => $currentPackage?->code,
'to_package' => $newPackage->code,
'proration' => $proration?->toArray(),
]);
} else {
// Schedule change for end of billing period
$subscription->update([
'metadata' => array_merge($subscription->metadata ?? [], [
'pending_plan_change' => [
'to_package_id' => $newPackage->id,
'to_package_code' => $newPackage->code,
'scheduled_for' => $subscription->current_period_end?->toISOString(),
],
]),
]);
Log::info('Subscription plan change scheduled', [
'subscription_id' => $subscription->id,
'to_package' => $newPackage->code,
'scheduled_for' => $subscription->current_period_end,
]);
}
return [
'subscription' => $subscription->fresh(),
'proration' => $proration,
'immediate' => $immediate,
];
});
}
/**
* Calculate proration for a plan change.
*/
public function calculateProration(
Subscription $subscription,
Package $currentPackage,
Package $newPackage,
string $billingCycle = 'monthly'
): ProrationResult {
$now = Carbon::now();
$periodStart = $subscription->current_period_start;
$periodEnd = $subscription->current_period_end;
// Calculate days in period and days remaining
// Note: diffInDays returns absolute value when using absolute: true (default in Carbon 2)
// In Carbon 3, we need to ensure we get positive values
$totalPeriodDays = (int) $periodStart->diffInDays($periodEnd, absolute: true);
$daysUsed = (int) $periodStart->diffInDays($now, absolute: true);
$daysRemaining = (int) max(0, $now->diffInDays($periodEnd, absolute: true));
// Avoid division by zero
if ($totalPeriodDays <= 0) {
$totalPeriodDays = $billingCycle === 'yearly' ? 365 : 30;
}
$usedPercentage = $daysUsed / $totalPeriodDays;
$remainingPercentage = 1 - $usedPercentage;
// Get prices for the billing cycle
$currentPrice = $currentPackage->getPrice($billingCycle);
$newPrice = $newPackage->getPrice($billingCycle);
// Calculate credit from unused current plan time
$creditAmount = round($currentPrice * $remainingPercentage, 2);
// Calculate prorated cost for new plan for remaining period
$proratedNewCost = round($newPrice * $remainingPercentage, 2);
// Net amount: positive = customer pays, negative = credit
$netAmount = round($proratedNewCost - $creditAmount, 2);
return new ProrationResult(
daysRemaining: $daysRemaining,
totalPeriodDays: $totalPeriodDays,
usedPercentage: round($usedPercentage, 4),
currentPlanPrice: $currentPrice,
newPlanPrice: $newPrice,
creditAmount: $creditAmount,
proratedNewPlanCost: $proratedNewCost,
netAmount: $netAmount,
currency: config('commerce.currency', 'GBP'),
);
}
/**
* Preview proration without making changes.
*/
public function previewPlanChange(
Subscription $subscription,
Package $newPackage,
?string $billingCycle = null
): ProrationResult {
$currentPackage = $subscription->workspacePackage?->package;
if (! $currentPackage) {
throw new \InvalidArgumentException('Subscription has no current package');
}
$billingCycle = $billingCycle ?? $subscription->billing_cycle ?? 'monthly';
return $this->calculateProration(
$subscription,
$currentPackage,
$newPackage,
$billingCycle
);
}
/**
* Apply scheduled plan change (called when period ends).
*/
public function applyScheduledPlanChange(Subscription $subscription): ?Subscription
{
$pendingChange = $subscription->metadata['pending_plan_change'] ?? null;
if (! $pendingChange) {
return null;
}
$newPackage = Package::find($pendingChange['to_package_id']);
if (! $newPackage) {
Log::warning('Scheduled plan change failed: package not found', [
'subscription_id' => $subscription->id,
'package_id' => $pendingChange['to_package_id'],
]);
return null;
}
// Apply the change without proration (since it's at period end)
$result = $this->changePlan($subscription, $newPackage, prorate: false, immediate: true);
// Clear the pending change
$metadata = $subscription->metadata ?? [];
unset($metadata['pending_plan_change']);
$subscription->update(['metadata' => $metadata]);
return $result['subscription'];
}
/**
* Cancel a pending plan change.
*/
public function cancelScheduledPlanChange(Subscription $subscription): Subscription
{
$metadata = $subscription->metadata ?? [];
unset($metadata['pending_plan_change']);
$subscription->update(['metadata' => $metadata]);
return $subscription->fresh();
}
/**
* Check if subscription has a pending plan change.
*/
public function hasPendingPlanChange(Subscription $subscription): bool
{
return isset($subscription->metadata['pending_plan_change']);
}
/**
* Get pending plan change details.
*/
public function getPendingPlanChange(Subscription $subscription): ?array
{
return $subscription->metadata['pending_plan_change'] ?? null;
}
/**
* Get subscriptions expiring soon (for renewal reminders).
*/
public function getExpiringSoon(int $days = 7): \Illuminate\Database\Eloquent\Collection
{
return Subscription::query()
->active()
->whereNull('cancelled_at')
->where('current_period_end', '<=', Carbon::now()->addDays($days))
->where('current_period_end', '>', Carbon::now())
->with('workspace', 'workspacePackage.package')
->get();
}
/**
* Get subscriptions that have failed payment and need dunning.
*/
public function getFailedPayments(): \Illuminate\Database\Eloquent\Collection
{
return Subscription::query()
->where('status', 'past_due')
->with('workspace', 'workspacePackage.package')
->get();
}
/**
* Process expired subscriptions (called by scheduler).
*/
public function processExpired(): int
{
$expired = Subscription::query()
->active()
->whereNotNull('cancelled_at')
->where('current_period_end', '<=', Carbon::now())
->get();
foreach ($expired as $subscription) {
$this->expire($subscription);
}
return $expired->count();
}
}