- Add NotifyMe trait with centralized Telegram bot integration - Implement webhook notifications for Polar, OxaPay, and ActivationKey providers - Add subscription lifecycle notifications (create, activate, cancel, pause, resume) - Enhance Polar webhook processing with user context and error handling - Fix subscription.updated and subscription.canceled webhook column errors - Add idempotent webhook processing to prevent duplicate handling
586 lines
20 KiB
PHP
586 lines
20 KiB
PHP
<?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 App\NotifyMe;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
|
|
class ActivationKeyProvider implements PaymentProviderContract
|
|
{
|
|
use NotifyMe;
|
|
|
|
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();
|
|
|
|
// Notify activation key generated
|
|
$this->notifyActivationKeyGenerated($user, $plan, $activationKey);
|
|
|
|
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,
|
|
]);
|
|
|
|
// Notify subscription cancelled
|
|
$this->notifySubscriptionCancelled($subscription, $reason ?: 'Manual cancellation');
|
|
|
|
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 the plan associated with this activation key
|
|
$plan = Plan::where('pricing_id', $keyRecord->price_id)->first();
|
|
|
|
if (! $plan) {
|
|
throw new \Exception('No plan found for activation key with pricing_id: '.$keyRecord->price_id);
|
|
}
|
|
|
|
// Calculate subscription end date based on plan billing cycle
|
|
$endsAt = null;
|
|
if ($plan->billing_cycle_days && $plan->billing_cycle_days > 0) {
|
|
$endsAt = now()->addDays($plan->billing_cycle_days);
|
|
}
|
|
|
|
$subscription = Subscription::create([
|
|
'user_id' => $user->id,
|
|
'plan_id' => $plan->id,
|
|
'type' => 'default',
|
|
'stripe_id' => 'ak_'.$keyRecord->id.'_'.uniqid('', true),
|
|
'stripe_status' => 'active',
|
|
'provider' => $this->getName(),
|
|
'provider_subscription_id' => $keyRecord->id,
|
|
'status' => 'active',
|
|
'starts_at' => now(),
|
|
'ends_at' => $endsAt,
|
|
'provider_data' => [
|
|
'activation_key' => $activationKey,
|
|
'key_id' => $keyRecord->id,
|
|
'redeemed_at' => now()->toISOString(),
|
|
'plan_details' => [
|
|
'name' => $plan->name,
|
|
'price' => $plan->price,
|
|
'billing_cycle_days' => $plan->billing_cycle_days,
|
|
'billing_cycle_display' => $plan->getBillingCycleDisplay(),
|
|
'plan_tier' => $plan->planTier ? $plan->planTier->name : null,
|
|
'features' => $plan->getFeaturesWithLimits(),
|
|
],
|
|
'provider_info' => [
|
|
'name' => $this->getName(),
|
|
'version' => '1.0',
|
|
'processed_at' => now()->toISOString(),
|
|
],
|
|
],
|
|
]);
|
|
|
|
DB::commit();
|
|
|
|
// Notify activation key redeemed and subscription activated
|
|
$this->notifyActivationKeyRedeemed($user, $plan, $activationKey);
|
|
$this->notifySubscriptionActivated($user, $plan, $subscription);
|
|
|
|
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');
|
|
}
|
|
|
|
// Notification methods
|
|
protected function notifyActivationKeyGenerated(User $user, Plan $plan, string $activationKey): void
|
|
{
|
|
$message = "🔑 ACTIVATION KEY GENERATED\n".
|
|
"👤 User: {$user->name} ({$user->email})\n".
|
|
"📋 Plan: {$plan->name}\n".
|
|
'💰 Price: $'.number_format($plan->price, 2)."\n".
|
|
"🏪 Provider: Activation Key\n".
|
|
"🔑 Key: {$activationKey}\n".
|
|
'📅 Generated: '.now()->format('Y-m-d H:i:s');
|
|
|
|
$this->sendTelegramNotification($message);
|
|
}
|
|
|
|
protected function notifyActivationKeyRedeemed(User $user, Plan $plan, string $activationKey): void
|
|
{
|
|
$message = "✅ ACTIVATION KEY REDEEMED\n".
|
|
"👤 User: {$user->name} ({$user->email})\n".
|
|
"📋 Plan: {$plan->name}\n".
|
|
"🏪 Provider: Activation Key\n".
|
|
"🔑 Key: {$activationKey}\n".
|
|
'📅 Redeemed: '.now()->format('Y-m-d H:i:s');
|
|
|
|
$this->sendTelegramNotification($message);
|
|
}
|
|
|
|
protected function notifySubscriptionActivated(User $user, Plan $plan, Subscription $subscription): void
|
|
{
|
|
$message = "🎉 SUBSCRIPTION ACTIVATED\n".
|
|
"👤 User: {$user->name} ({$user->email})\n".
|
|
"📋 Plan: {$plan->name}\n".
|
|
"🏪 Provider: Activation Key\n".
|
|
"🔄 Subscription ID: {$subscription->id}\n".
|
|
'📅 Activated: '.now()->format('Y-m-d H:i:s');
|
|
|
|
$this->sendTelegramNotification($message);
|
|
}
|
|
|
|
protected function notifyProviderError(string $operation, string $error, array $context = []): void
|
|
{
|
|
$contextStr = '';
|
|
if (! empty($context)) {
|
|
$contextStr = '📝 Details: '.json_encode(array_slice($context, 0, 3, true), JSON_UNESCAPED_SLASHES)."\n";
|
|
if (count($context) > 3) {
|
|
$contextStr .= '📝 Additional: '.(count($context) - 3).' more items'."\n";
|
|
}
|
|
}
|
|
|
|
$message = "🚨 PROVIDER ERROR\n".
|
|
"🏪 Provider: Activation Key\n".
|
|
"📡 Operation: {$operation}\n".
|
|
"💥 Error: {$error}\n".
|
|
$contextStr.
|
|
'⏰ Time: '.now()->format('Y-m-d H:i:s');
|
|
|
|
$this->sendTelegramNotification($message);
|
|
}
|
|
|
|
protected function notifySubscriptionCancelled(Subscription $subscription, string $reason): void
|
|
{
|
|
$user = $subscription->user;
|
|
$plan = $subscription->plan;
|
|
|
|
$message = "❌ SUBSCRIPTION CANCELLED\n".
|
|
"👤 User: {$user->name} ({$user->email})\n".
|
|
"📋 Plan: {$plan->name}\n".
|
|
"🏪 Provider: Activation Key\n".
|
|
"💭 Reason: {$reason}\n".
|
|
"🆔 Subscription ID: {$subscription->id}\n".
|
|
($subscription->ends_at ? '📅 Effective: '.$subscription->ends_at->format('Y-m-d')."\n" : '').
|
|
'⏰ Cancelled: '.now()->format('Y-m-d H:i:s');
|
|
|
|
$this->sendTelegramNotification($message);
|
|
}
|
|
}
|