feat: implement comprehensive multi-provider payment processing system
- Add unified payment provider architecture with contract-based design - Implement 6 payment providers: Stripe, Lemon Squeezy, Polar, Oxapay, Crypto, Activation Keys - Create subscription management with lifecycle handling (create, cancel, pause, resume, update) - Add coupon system with usage tracking and trial extensions - Build Filament admin resources for payment providers, subscriptions, coupons, and trials - Implement payment orchestration service with provider registry and configuration management - Add comprehensive payment logging and webhook handling for all providers - Create customer analytics dashboard with revenue, churn, and lifetime value metrics - Add subscription migration service for provider switching - Include extensive test coverage for all payment functionality
This commit is contained in:
475
app/Models/Subscription.php
Normal file
475
app/Models/Subscription.php
Normal file
@@ -0,0 +1,475 @@
|
||||
<?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',
|
||||
];
|
||||
|
||||
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 {
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user