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:
474
app/Services/Payments/Providers/ActivationKeyProvider.php
Normal file
474
app/Services/Payments/Providers/ActivationKeyProvider.php
Normal file
@@ -0,0 +1,474 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments\Providers;
|
||||
|
||||
use App\Contracts\Payments\PaymentProviderContract;
|
||||
use App\Models\ActivationKey;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ActivationKeyProvider implements PaymentProviderContract
|
||||
{
|
||||
protected array $config;
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge([
|
||||
'key_prefix' => 'AK-',
|
||||
'key_length' => 32,
|
||||
'expiration_days' => null, // null means no expiration
|
||||
'success_url' => route('payment.success'),
|
||||
'cancel_url' => route('payment.cancel'),
|
||||
], $config);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'activation_key';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return true; // Activation keys are always available
|
||||
}
|
||||
|
||||
public function createSubscription(User $user, Plan $plan, array $options = []): array
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Generate a unique activation key
|
||||
$activationKey = $this->generateUniqueActivationKey();
|
||||
|
||||
// Create activation key record
|
||||
$keyRecord = ActivationKey::create([
|
||||
'user_id' => $user->id,
|
||||
'activation_key' => $activationKey,
|
||||
'price_id' => $plan->id,
|
||||
'is_activated' => false,
|
||||
]);
|
||||
|
||||
// Create subscription record
|
||||
$subscription = Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'type' => 'activation_key',
|
||||
'stripe_id' => 'ak_'.$keyRecord->id.'_'.uniqid(more_entropy: true), // Use activation key ID + unique ID for compatibility
|
||||
'stripe_status' => 'pending_activation',
|
||||
'provider' => $this->getName(),
|
||||
'provider_subscription_id' => $keyRecord->id,
|
||||
'status' => 'pending_activation',
|
||||
'starts_at' => null,
|
||||
'ends_at' => null,
|
||||
'provider_data' => [
|
||||
'activation_key' => $activationKey,
|
||||
'key_id' => $keyRecord->id,
|
||||
'created_at' => now()->toISOString(),
|
||||
],
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return [
|
||||
'provider_subscription_id' => $keyRecord->id,
|
||||
'status' => 'pending_activation',
|
||||
'activation_key' => $activationKey,
|
||||
'plan_name' => $plan->name,
|
||||
'plan_price' => $plan->price,
|
||||
'type' => 'activation_key',
|
||||
'message' => 'Activation key generated. User needs to redeem the key to activate the subscription.',
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Activation key subscription creation failed', [
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
|
||||
{
|
||||
try {
|
||||
// For activation keys, we don't actually cancel since it's a one-time activation
|
||||
// We can deactivate the subscription if needed
|
||||
$subscription->update([
|
||||
'status' => 'cancelled',
|
||||
'cancelled_at' => now(),
|
||||
'cancellation_reason' => $reason,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Activation key subscription cancellation failed', [
|
||||
'subscription_id' => $subscription->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
|
||||
{
|
||||
try {
|
||||
// Activation keys don't support plan updates
|
||||
// User would need a new activation key for a different plan
|
||||
throw new \Exception('Activation keys do not support plan updates');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Activation key subscription update failed', [
|
||||
'subscription_id' => $subscription->id,
|
||||
'new_plan_id' => $newPlan->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function pauseSubscription(Subscription $subscription): bool
|
||||
{
|
||||
// Activation keys can't be paused
|
||||
return false;
|
||||
}
|
||||
|
||||
public function resumeSubscription(Subscription $subscription): bool
|
||||
{
|
||||
// Activation keys can't be paused, so can't be resumed
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getSubscriptionDetails(string $providerSubscriptionId): array
|
||||
{
|
||||
try {
|
||||
$activationKey = ActivationKey::findOrFail($providerSubscriptionId);
|
||||
|
||||
return [
|
||||
'id' => $activationKey->id,
|
||||
'activation_key' => $activationKey->activation_key,
|
||||
'user_id' => $activationKey->user_id,
|
||||
'price_id' => $activationKey->price_id,
|
||||
'is_activated' => $activationKey->is_activated,
|
||||
'created_at' => $activationKey->created_at->toISOString(),
|
||||
'updated_at' => $activationKey->updated_at->toISOString(),
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Activation key subscription details retrieval failed', [
|
||||
'subscription_id' => $providerSubscriptionId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
|
||||
{
|
||||
return $this->createSubscription($user, $plan, $options);
|
||||
}
|
||||
|
||||
public function createCustomerPortalSession(User $user): array
|
||||
{
|
||||
return [
|
||||
'portal_url' => route('dashboard'),
|
||||
'message' => 'Activation keys are managed through your dashboard',
|
||||
];
|
||||
}
|
||||
|
||||
public function processWebhook(Request $request): array
|
||||
{
|
||||
// Activation keys don't have webhooks
|
||||
return [
|
||||
'event_type' => 'not_applicable',
|
||||
'processed' => false,
|
||||
'data' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function validateWebhook(Request $request): bool
|
||||
{
|
||||
// No webhooks to validate
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
public function syncSubscriptionStatus(Subscription $subscription): array
|
||||
{
|
||||
return $this->getSubscriptionDetails($subscription->provider_subscription_id);
|
||||
}
|
||||
|
||||
public function getPaymentMethodDetails(string $paymentMethodId): array
|
||||
{
|
||||
try {
|
||||
$activationKey = ActivationKey::findOrFail($paymentMethodId);
|
||||
|
||||
return [
|
||||
'id' => $activationKey->id,
|
||||
'type' => 'activation_key',
|
||||
'activation_key' => $activationKey->activation_key,
|
||||
'is_activated' => $activationKey->is_activated,
|
||||
'created_at' => $activationKey->created_at->toISOString(),
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Activation key payment method details retrieval failed', [
|
||||
'payment_method_id' => $paymentMethodId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function processRefund(string $paymentId, float $amount, string $reason = ''): array
|
||||
{
|
||||
// Activation keys are not refundable
|
||||
throw new \Exception('Activation keys are not refundable');
|
||||
}
|
||||
|
||||
public function getTransactionHistory(User $user, array $filters = []): array
|
||||
{
|
||||
try {
|
||||
$query = ActivationKey::where('user_id', $user->id);
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['status'])) {
|
||||
if ($filters['status'] === 'activated') {
|
||||
$query->where('is_activated', true);
|
||||
} elseif ($filters['status'] === 'unactivated') {
|
||||
$query->where('is_activated', false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($filters['date_from'])) {
|
||||
$query->where('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (isset($filters['date_to'])) {
|
||||
$query->where('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
$activationKeys = $query->orderBy('created_at', 'desc')->get();
|
||||
|
||||
$transactions = [];
|
||||
foreach ($activationKeys as $key) {
|
||||
$transactions[] = [
|
||||
'id' => $key->id,
|
||||
'activation_key' => $key->activation_key,
|
||||
'plan_id' => $key->price_id,
|
||||
'is_activated' => $key->is_activated,
|
||||
'created_at' => $key->created_at->toISOString(),
|
||||
'updated_at' => $key->updated_at->toISOString(),
|
||||
];
|
||||
}
|
||||
|
||||
return $transactions;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Activation key transaction history retrieval failed', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function calculateFees(float $amount): array
|
||||
{
|
||||
// Activation keys have no fees
|
||||
return [
|
||||
'fixed_fee' => 0,
|
||||
'percentage_fee' => 0,
|
||||
'total_fee' => 0,
|
||||
'net_amount' => $amount,
|
||||
];
|
||||
}
|
||||
|
||||
public function getSupportedCurrencies(): array
|
||||
{
|
||||
return ['USD']; // Activation keys are currency-agnostic
|
||||
}
|
||||
|
||||
public function supportsRecurring(): bool
|
||||
{
|
||||
return false; // Activation keys are one-time
|
||||
}
|
||||
|
||||
public function supportsOneTime(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
protected function generateUniqueActivationKey(): string
|
||||
{
|
||||
do {
|
||||
$key = $this->config['key_prefix'].strtoupper(Str::random($this->config['key_length']));
|
||||
} while (ActivationKey::where('activation_key', $key)->exists());
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
// Public method for redeeming activation keys
|
||||
public function redeemActivationKey(string $activationKey, User $user): array
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$keyRecord = ActivationKey::where('activation_key', $activationKey)
|
||||
->where('is_activated', false)
|
||||
->firstOrFail();
|
||||
|
||||
// Mark key as activated and assign to user
|
||||
$keyRecord->update([
|
||||
'user_id' => $user->id,
|
||||
'is_activated' => true,
|
||||
]);
|
||||
|
||||
// Find or create subscription
|
||||
$plan = Plan::findOrFail($keyRecord->price_id);
|
||||
|
||||
$subscription = Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'type' => 'activation_key',
|
||||
'stripe_id' => 'ak_'.$keyRecord->id.'_'.uniqid(),
|
||||
'stripe_status' => 'active',
|
||||
'provider' => $this->getName(),
|
||||
'provider_subscription_id' => $keyRecord->id,
|
||||
'status' => 'active',
|
||||
'starts_at' => now(),
|
||||
'ends_at' => null, // No expiration for activation keys
|
||||
'provider_data' => [
|
||||
'activation_key' => $activationKey,
|
||||
'key_id' => $keyRecord->id,
|
||||
'redeemed_at' => now()->toISOString(),
|
||||
],
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'subscription_id' => $subscription->id,
|
||||
'plan_name' => $plan->name,
|
||||
'message' => 'Activation key redeemed successfully',
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
Log::error('Activation key redemption failed', [
|
||||
'activation_key' => $activationKey,
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// Additional interface methods
|
||||
public function getSubscriptionMetadata(Subscription $subscription): array
|
||||
{
|
||||
$keyDetails = $this->getSubscriptionDetails($subscription->provider_subscription_id);
|
||||
|
||||
return $keyDetails + [
|
||||
'redeemed_at' => $subscription->provider_data['redeemed_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
|
||||
{
|
||||
try {
|
||||
$subscription->update([
|
||||
'provider_data' => array_merge($subscription->provider_data ?? [], $metadata),
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to update activation key subscription metadata', [
|
||||
'subscription_id' => $subscription->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function startTrial(Subscription $subscription, int $trialDays): bool
|
||||
{
|
||||
// Activation keys don't support trials
|
||||
return false;
|
||||
}
|
||||
|
||||
public function applyCoupon(Subscription $subscription, string $couponCode): array
|
||||
{
|
||||
// Activation keys don't support coupons
|
||||
throw new \Exception('Coupons not supported for activation keys');
|
||||
}
|
||||
|
||||
public function removeCoupon(Subscription $subscription): bool
|
||||
{
|
||||
return false; // No coupons to remove
|
||||
}
|
||||
|
||||
public function getUpcomingInvoice(Subscription $subscription): array
|
||||
{
|
||||
// Activation keys don't have invoices
|
||||
return [
|
||||
'amount_due' => 0,
|
||||
'currency' => 'USD',
|
||||
'next_payment_date' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function retryFailedPayment(Subscription $subscription): array
|
||||
{
|
||||
// No payments to retry for activation keys
|
||||
return $this->syncSubscriptionStatus($subscription);
|
||||
}
|
||||
|
||||
public function canModifySubscription(Subscription $subscription): bool
|
||||
{
|
||||
try {
|
||||
$details = $this->getSubscriptionDetails($subscription->provider_subscription_id);
|
||||
|
||||
return ! $details['is_activated']; // Can only modify before activation
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function getCancellationTerms(Subscription $subscription): array
|
||||
{
|
||||
return [
|
||||
'immediate_cancellation' => true,
|
||||
'refund_policy' => 'non_refundable',
|
||||
'cancellation_effective' => 'immediately',
|
||||
'billing_cycle_proration' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function exportSubscriptionData(Subscription $subscription): array
|
||||
{
|
||||
return [
|
||||
'provider' => 'activation_key',
|
||||
'provider_subscription_id' => $subscription->provider_subscription_id,
|
||||
'data' => $subscription->provider_data,
|
||||
];
|
||||
}
|
||||
|
||||
public function importSubscriptionData(User $user, array $subscriptionData): array
|
||||
{
|
||||
throw new \Exception('Import to activation keys not implemented');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user