Files
zemailnator/app/Models/Subscription.php
idevakk 9a32511e97 feat(payment): implement OxaPay provider and non-recurring subscription sync
- Add comprehensive OxaPay payment provider with invoice creation, webhook processing, and subscription status sync
  - Implement conditional payload fields (to_currency, callback_url) based on configuration
  - Create universal sync command for all non-recurring payment providers
  - Add subscription model fields for payment tracking
  - Implement proper status mapping between OxaPay and Laravel subscription states
  - Add webhook signature validation using HMAC SHA512
2025-12-07 09:01:53 -08:00

752 lines
22 KiB
PHP

<?php
namespace App\Models;
use App\Services\Payments\PaymentOrchestrator;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
class Subscription extends Model
{
use HasFactory;
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',
'resume_reason',
'trial_ended_at',
'trial_converted_to',
'subscription_id_fetched_at',
'polar_dates',
'customer_state_changed_at',
'polar_subscription_data',
'customer_metadata',
'trial_will_end_sent_at',
'pause_reason',
'amount',
'currency',
'expires_at',
];
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) {
// Parse dates from Polar response
$startsAt = null;
$endsAt = null;
$cancelledAt = null;
// Handle current_period_start
if (isset($matchingSubscription['current_period_start'])) {
$startsAt = \Carbon\Carbon::parse($matchingSubscription['current_period_start']);
}
// Handle current_period_end (renewal date)
if (isset($matchingSubscription['current_period_end'])) {
$endsAt = \Carbon\Carbon::parse($matchingSubscription['current_period_end']);
}
// Handle ends_at (cancellation/expiry date)
elseif (isset($matchingSubscription['ends_at'])) {
$endsAt = \Carbon\Carbon::parse($matchingSubscription['ends_at']);
}
// Handle expires_at (expiry date)
elseif (isset($matchingSubscription['expires_at'])) {
$endsAt = \Carbon\Carbon::parse($matchingSubscription['expires_at']);
}
// Handle cancelled_at
if (isset($matchingSubscription['cancelled_at'])) {
$cancelledAt = \Carbon\Carbon::parse($matchingSubscription['cancelled_at']);
}
$this->update([
'provider_subscription_id' => $matchingSubscription['id'],
'status' => $matchingSubscription['status'],
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'cancelled_at' => $cancelledAt,
'provider_data' => array_merge($this->provider_data ?? [], [
'polar_subscription' => $matchingSubscription,
'subscription_id_fetched_at' => now()->toISOString(),
'polar_dates' => [
'current_period_start' => $matchingSubscription['current_period_start'] ?? null,
'current_period_end' => $matchingSubscription['current_period_end'] ?? null,
'ends_at' => $matchingSubscription['ends_at'] ?? null,
'expires_at' => $matchingSubscription['expires_at'] ?? null,
'cancelled_at' => $matchingSubscription['cancelled_at'] ?? null,
],
]),
]);
Log::info('Polar subscription ID fetched and updated', [
'subscription_id' => $this->id,
'polar_subscription_id' => $matchingSubscription['id'],
'customer_id' => $this->user->polar_cust_id,
'starts_at' => $startsAt?->toISOString(),
'ends_at' => $endsAt?->toISOString(),
'cancelled_at' => $cancelledAt?->toISOString(),
]);
}
}
} 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;
}
/**
* Get plan display name, handles deleted plans gracefully
*/
public function getPlanDisplayName(): string
{
if ($this->plan) {
return $this->plan->name;
}
// Check provider data for stored plan name
if ($this->provider_data) {
// Check for Polar plan name
if (isset($this->provider_data['polar_subscription']['product']['name'])) {
return $this->provider_data['polar_subscription']['product']['name'];
}
// Check for stored plan details
if (isset($this->provider_data['plan_details']['name'])) {
return $this->provider_data['plan_details']['name'];
}
// Check metadata
if (isset($this->provider_data['plan_name'])) {
return $this->provider_data['plan_name'];
}
}
return 'Deleted Plan';
}
/**
* Get plan price, handles deleted plans gracefully
*/
public function getPlanPrice(): float
{
if ($this->plan) {
return $this->plan->price;
}
// Check provider data for stored plan price
if ($this->provider_data) {
// Check for Polar subscription amount
if (isset($this->provider_data['polar_subscription']['amount'])) {
return $this->provider_data['polar_subscription']['amount'] / 100; // Convert from cents
}
// Check for stored plan details
if (isset($this->provider_data['plan_details']['price'])) {
return (float) $this->provider_data['plan_details']['price'];
}
}
return 0.0;
}
}