Files
zemailnator/app/Models/Subscription.php
idevakk 0724e6da43 feat(payments): implement smart Polar subscription sync with checkout tracking
- Add provider_checkout_id column to separate checkout ID from subscription ID
   - Update Polar provider to store checkout ID separately and set subscription ID to null initially
   - Implement smart sync logic that queries Polar API when subscription ID is missing
   - Add fetchPolarSubscriptionId method to find active subscriptions via customer ID
   - Update webhook handlers to use provider_checkout_id for subscription lookup
   - Make makeAuthenticatedRequest public to enable Subscription model API access
   - Support plan metadata matching for accurate subscription identification
   - Add fallback to most recent active subscription when no exact match found

   This resolves sync button issues by properly tracking checkout vs subscription IDs
   and enables automatic subscription ID recovery when webhooks fail.
2025-12-06 02:37:52 -08:00

646 lines
18 KiB
PHP

<?php
namespace App\Models;
use App\Services\Payments\PaymentOrchestrator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
class Subscription extends Model
{
protected $fillable = [
'user_id',
'type',
'stripe_id',
'stripe_status',
'stripe_price',
'quantity',
'trial_ends_at',
'ends_at',
'provider',
'provider_subscription_id',
'unified_status',
'cancelled_at',
'cancellation_reason',
'paused_at',
'resumed_at',
'migration_batch_id',
'is_migrated',
'legacy_data',
'synced_at',
'provider_data',
'last_provider_sync',
'plan_id',
'starts_at',
'status',
'updated_at',
'polar_subscription',
'metadata',
'migration_source',
'migration_date',
'migration_reason',
'created_at',
'activated_at',
'checkout_id',
'customer_id',
'polar_checkout',
'order_id',
'polar_order',
'order_created_at',
'order_paid_at',
'provider_checkout_id',
];
protected $casts = [
'is_migrated' => 'boolean',
'legacy_data' => 'array',
'provider_data' => 'array',
'metadata' => 'array',
'trial_ends_at' => 'datetime',
'ends_at' => 'datetime',
'cancelled_at' => 'datetime',
'paused_at' => 'datetime',
'resumed_at' => 'datetime',
'synced_at' => 'datetime',
'last_provider_sync' => 'datetime',
'starts_at' => 'datetime',
];
/**
* Accessor for total coupon discount
*/
protected function getTotalCouponDiscountAttribute(): float
{
// Use preloaded sum if available, otherwise calculate it
if (array_key_exists('total_coupon_discount', $this->attributes)) {
return (float) $this->attributes['total_coupon_discount'];
}
return $this->couponUsages()->sum('discount_amount');
}
protected $dates = [
'trial_ends_at',
'ends_at',
'cancelled_at',
'paused_at',
'resumed_at',
'synced_at',
'last_provider_sync',
'starts_at',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function plan()
{
return $this->belongsTo(Plan::class);
}
public function trialExtensions()
{
return $this->hasMany(TrialExtension::class);
}
public function subscriptionChanges()
{
return $this->hasMany(SubscriptionChange::class);
}
public function couponUsages()
{
return $this->hasMany(CouponUsage::class);
}
/**
* Check if subscription is active
*/
public function isActive(): bool
{
return in_array($this->status, ['active', 'trialing']) &&
(! $this->ends_at || $this->ends_at->isFuture());
}
/**
* Check if subscription is on trial
*/
public function isOnTrial(): bool
{
return $this->status === 'trialing' &&
$this->trial_ends_at &&
$this->trial_ends_at->isFuture();
}
/**
* Check if subscription is cancelled
*/
public function isCancelled(): bool
{
return $this->status === 'cancelled' ||
($this->ends_at && $this->ends_at->isPast());
}
/**
* Check if subscription supports recurring payments
*/
public function isRecurring(): bool
{
return $this->plan && $this->plan->monthly_billing;
}
/**
* Get the display name for the provider
*/
public function getProviderDisplayName(): string
{
$displayNames = [
'stripe' => 'Stripe',
'lemon_squeezy' => 'Lemon Squeezy',
'polar' => 'Polar.sh',
'oxapay' => 'OxaPay',
'crypto' => 'Crypto',
'activation_key' => 'Activation Key',
];
return $displayNames[$this->provider] ?? ucfirst($this->provider);
}
/**
* Get provider-specific data
*/
public function getProviderData(?string $key = null, $default = null)
{
if ($key) {
return data_get($this->provider_data, $key, $default);
}
return $this->provider_data;
}
/**
* Set provider-specific data
*/
public function setProviderData(string $key, $value): void
{
$data = $this->provider_data ?? [];
data_set($data, $key, $value);
$this->provider_data = $data;
}
/**
* Sync subscription status with provider
*/
public function syncWithProvider(): bool
{
try {
// For Polar provider, check if we need to fetch subscription ID first
if ($this->provider === 'polar' && empty($this->provider_subscription_id) && !empty($this->user->polar_cust_id)) {
$this->fetchPolarSubscriptionId();
}
$orchestrator = app(PaymentOrchestrator::class);
$result = $orchestrator->syncSubscriptionStatus($this);
$this->update([
'status' => $result['status'] ?? $this->status,
'provider_data' => array_merge($this->provider_data ?? [], $result),
'last_provider_sync' => now(),
]);
Log::info('Subscription synced with provider', [
'subscription_id' => $this->id,
'provider' => $this->provider,
'status' => $result['status'] ?? 'unknown',
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to sync subscription with provider', [
'subscription_id' => $this->id,
'provider' => $this->provider,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Fetch Polar subscription ID using customer ID if missing
*/
protected function fetchPolarSubscriptionId(): void
{
if ($this->provider !== 'polar' || empty($this->user->polar_cust_id)) {
return;
}
try {
$polarProvider = app(\App\Services\Payments\Providers\PolarProvider::class);
// Get active subscriptions for this customer
$response = $polarProvider->makeAuthenticatedRequest('GET', '/subscriptions', [
'customer_id' => $this->user->polar_cust_id,
'status' => 'active',
'limit' => 10,
]);
if ($response->successful()) {
$data = $response->json();
$subscriptions = $data['items'] ?? [];
if (!empty($subscriptions)) {
// Find the subscription that matches our plan or take the most recent active one
$matchingSubscription = null;
foreach ($subscriptions as $sub) {
// Check if this subscription matches our plan (via metadata or other criteria)
if (isset($sub['metadata']['plan_id']) && $sub['metadata']['plan_id'] == $this->plan_id) {
$matchingSubscription = $sub;
break;
}
}
// If no exact match, take the most recent active subscription
if (!$matchingSubscription && !empty($subscriptions)) {
$matchingSubscription = $subscriptions[0];
}
if ($matchingSubscription) {
$this->update([
'provider_subscription_id' => $matchingSubscription['id'],
'status' => $matchingSubscription['status'],
'starts_at' => isset($matchingSubscription['current_period_start'])
? \Carbon\Carbon::parse($matchingSubscription['current_period_start'])
: null,
'ends_at' => isset($matchingSubscription['current_period_end'])
? \Carbon\Carbon::parse($matchingSubscription['current_period_end'])
: null,
'provider_data' => array_merge($this->provider_data ?? [], [
'polar_subscription' => $matchingSubscription,
'subscription_id_fetched_at' => now()->toISOString(),
]),
]);
Log::info('Polar subscription ID fetched and updated', [
'subscription_id' => $this->id,
'polar_subscription_id' => $matchingSubscription['id'],
'customer_id' => $this->user->polar_cust_id,
]);
}
}
} else {
Log::warning('Failed to fetch Polar subscriptions for sync', [
'customer_id' => $this->user->polar_cust_id,
'status_code' => $response->status(),
'response' => $response->json(),
]);
}
} catch (\Exception $e) {
Log::error('Error fetching Polar subscription ID', [
'subscription_id' => $this->id,
'customer_id' => $this->user->polar_cust_id ?? 'none',
'error' => $e->getMessage(),
]);
}
}
/**
* Cancel the subscription
*/
public function cancel(string $reason = ''): bool
{
try {
$orchestrator = app(PaymentOrchestrator::class);
$result = $orchestrator->cancelSubscription($this, $reason);
if ($result) {
$this->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
}
return $result;
} catch (\Exception $e) {
Log::error('Failed to cancel subscription', [
'subscription_id' => $this->id,
'provider' => $this->provider,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Update subscription plan
*/
public function updatePlan(Plan $newPlan): bool
{
try {
$orchestrator = app(PaymentOrchestrator::class);
$result = $orchestrator->updateSubscription($this, $newPlan);
if ($result['success']) {
$this->update([
'plan_id' => $newPlan->id,
'updated_at' => now(),
]);
}
return $result['success'];
} catch (\Exception $e) {
Log::error('Failed to update subscription plan', [
'subscription_id' => $this->id,
'provider' => $this->provider,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get subscription metadata
*/
public function getMetadata(?string $key = null, $default = null)
{
if ($key) {
return data_get($this->metadata, $key, $default);
}
return $this->metadata;
}
/**
* Set subscription metadata
*/
public function setMetadata(string $key, $value): void
{
$data = $this->metadata ?? [];
data_set($data, $key, $value);
$this->metadata = $data;
}
/**
* Scope: Active subscriptions
*/
public function scopeActive($query)
{
return $query->where('status', 'active')
->where(function ($q) {
$q->whereNull('ends_at')->orWhere('ends_at', '>', now());
});
}
/**
* Scope: Cancelled subscriptions
*/
public function scopeCancelled($query)
{
return $query->where('status', 'cancelled')
->where(function ($q) {
$q->whereNull('ends_at')->orWhere('ends_at', '<=', now());
});
}
/**
* Scope: On trial subscriptions
*/
public function scopeOnTrial($query)
{
return $query->where('status', 'trialing')
->where('trial_ends_at', '>', now());
}
/**
* Scope: By provider
*/
public function scopeByProvider($query, string $provider)
{
return $query->where('provider', $provider);
}
/**
* Scope: By user
*/
public function scopeByUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope: With total coupon discount
*/
public function scopeWithTotalCouponDiscount($query)
{
return $query->withSum('couponUsages as total_coupon_discount', 'discount_amount');
}
/**
* Extend trial period
*/
public function extendTrial(int $days, string $reason = '', string $extensionType = 'manual', ?User $grantedBy = null): TrialExtension
{
$originalEnd = $this->trial_ends_at;
$newEnd = $originalEnd ? $originalEnd->copy()->addDays($days) : now()->addDays($days);
$extension = $this->trialExtensions()->create([
'user_id' => $this->user_id,
'extension_days' => $days,
'reason' => $reason,
'extension_type' => $extensionType,
'original_trial_ends_at' => $originalEnd,
'new_trial_ends_at' => $newEnd,
'granted_at' => now(),
'granted_by_admin_id' => $grantedBy?->id,
]);
// Update the subscription's trial end date
$this->update(['trial_ends_at' => $newEnd]);
// Record the change
SubscriptionChange::createRecord(
$this,
'pause',
"Trial extended by {$days} days",
['trial_ends_at' => $originalEnd?->format('Y-m-d H:i:s')],
['trial_ends_at' => $newEnd->format('Y-m-d H:i:s')],
$reason
);
return $extension;
}
/**
* Get total trial extensions granted
*/
public function getTotalTrialExtensionsDays(): int
{
return $this->trialExtensions()->sum('extension_days');
}
/**
* Get latest trial extension
*/
public function getLatestTrialExtension(): ?TrialExtension
{
return $this->trialExtensions()->latest()->first();
}
/**
* Check if trial was extended
*/
public function hasExtendedTrial(): bool
{
return $this->trialExtensions()->exists();
}
/**
* Apply coupon to subscription
*/
public function applyCoupon(Coupon $coupon, float $amount): CouponUsage
{
if (! $coupon->isValid($this->user)) {
throw new \Exception('Coupon is not valid for this user');
}
return $coupon->applyToSubscription($this, $amount);
}
/**
* Get total discount from coupons
*/
public function getTotalCouponDiscount(): float
{
return $this->couponUsages()->sum('discount_amount');
}
/**
* Record subscription change
*/
public function recordChange(
string $changeType,
string $description,
?array $oldValues = null,
?array $newValues = null,
?string $reason = null
): SubscriptionChange {
return SubscriptionChange::createRecord(
$this,
$changeType,
$description,
$oldValues,
$newValues,
$reason
);
}
/**
* Get pending changes
*/
public function getPendingChanges(): \Illuminate\Database\Eloquent\Collection
{
return $this->subscriptionChanges()->pending()->get();
}
/**
* Process pending changes
*/
public function processPendingChanges(): int
{
$pending = $this->getPendingChanges();
$processedCount = 0;
foreach ($pending as $change) {
$change->markAsProcessed();
$processedCount++;
}
return $processedCount;
}
/**
* Calculate Monthly Recurring Revenue (MRR) for this subscription
*/
public function calculateMRR(): float
{
// Only active and trialing subscriptions contribute to MRR
if (! in_array($this->status, ['active', 'trialing'])) {
return 0;
}
// Check if subscription has ended
if ($this->ends_at && $this->ends_at->isPast()) {
return 0;
}
// Get the plan's MRR calculation
if ($this->plan) {
return $this->plan->calculateMRR();
}
// Fallback: try to calculate from legacy data or provider data
$price = $this->getLegacyPrice();
if ($price > 0) {
$cycleDays = $this->getLegacyBillingCycleDays();
return ($price / $cycleDays) * 30;
}
return 0;
}
/**
* Get price from legacy data or provider data
*/
private function getLegacyPrice(): float
{
// Try to get price from plan first
if ($this->plan && $this->plan->price) {
return (float) $this->plan->price;
}
// Try provider data
if ($this->provider_data && isset($this->provider_data['plan_details']['price'])) {
return (float) $this->provider_data['plan_details']['price'];
}
// Try legacy stripe_price field
if ($this->stripe_price) {
// This would need additional logic to get price from Stripe
// For now, return 0 as we can't easily get the amount
return 0;
}
return 0;
}
/**
* Get billing cycle days from legacy data
*/
private function getLegacyBillingCycleDays(): int
{
// Try to get from plan first
if ($this->plan && $this->plan->billing_cycle_days) {
return (int) $this->plan->billing_cycle_days;
}
// Try provider data
if ($this->provider_data && isset($this->provider_data['plan_details']['billing_cycle_days'])) {
return (int) $this->provider_data['plan_details']['billing_cycle_days'];
}
// Fallback to legacy monthly_billing
return $this->plan && $this->plan->monthly_billing ? 30 : 365;
}
}