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:
idevakk
2025-11-19 09:37:00 -08:00
parent 0560016f33
commit 27ac13948c
83 changed files with 15613 additions and 103 deletions

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Http\Controllers;
use App\Models\Plan;
use App\Models\User;
use App\Services\Payments\PaymentOrchestrator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class PaymentController extends Controller
{
public function __construct(
private PaymentOrchestrator $orchestrator
) {}
/**
* Create a checkout session for a plan
*/
public function createCheckout(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'plan_id' => 'required|exists:plans,id',
'provider' => 'nullable|string|in:stripe,lemon_squeezy,polar,oxapay,crypto,activation_key',
'options' => 'nullable|array',
]);
$user = $request->user();
$plan = Plan::findOrFail($validated['plan_id']);
$provider = $validated['provider'] ?? null;
$options = $validated['options'] ?? [];
$result = $this->orchestrator->createCheckoutSession($user, $plan, $provider, $options);
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Create a new subscription
*/
public function createSubscription(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'plan_id' => 'required|exists:plans,id',
'provider' => 'nullable|string|in:stripe,lemon_squeezy,polar',
'options' => 'nullable|array',
]);
$user = $request->user();
$plan = Plan::findOrFail($validated['plan_id']);
$provider = $validated['provider'] ?? null;
$options = $validated['options'] ?? [];
// Only recurring providers can create subscriptions
if (! $plan->monthly_billing) {
return response()->json([
'success' => false,
'error' => 'This plan does not support recurring subscriptions. Use checkout instead.',
], 400);
}
$result = $this->orchestrator->createSubscription($user, $plan, $provider, $options);
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Get available payment methods for a plan
*/
public function getPaymentMethods(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'plan_id' => 'required|exists:plans,id',
]);
$plan = Plan::findOrFail($validated['plan_id']);
$providers = $this->orchestrator->getActiveProvidersForPlan($plan);
$methods = $providers->map(function ($provider) use ($plan) {
return [
'provider' => $provider->getName(),
'name' => $provider->getName(),
'supports_recurring' => $provider->supportsRecurring(),
'supports_one_time' => $provider->supportsOneTime(),
'supported_currencies' => $provider->getSupportedCurrencies(),
'fees' => $provider->calculateFees($plan->price),
'active' => $provider->isActive(),
];
})->values()->toArray();
return response()->json([
'success' => true,
'data' => [
'plan' => [
'id' => $plan->id,
'name' => $plan->name,
'price' => $plan->price,
'monthly_billing' => $plan->monthly_billing,
],
'payment_methods' => $methods,
],
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Get user's payment/subscription history
*/
public function getHistory(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'limit' => 'nullable|integer|min:1|max:100',
'offset' => 'nullable|integer|min:0',
'filters' => 'nullable|array',
]);
$user = $request->user();
$limit = $validated['limit'] ?? 20;
$filters = $validated['filters'] ?? [];
$history = $this->orchestrator->getTransactionHistory($user, $filters);
// Apply pagination
$offset = $validated['offset'] ?? 0;
$paginatedHistory = array_slice($history, $offset, $limit);
return response()->json([
'success' => true,
'data' => [
'transactions' => $paginatedHistory,
'pagination' => [
'total' => count($history),
'limit' => $limit,
'offset' => $offset,
'has_more' => $offset + $limit < count($history),
],
],
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Handle successful payment redirect
*/
public function success(Request $request): JsonResponse
{
return response()->json([
'status' => 'success',
'message' => 'Payment completed successfully',
]);
}
/**
* Handle cancelled payment redirect
*/
public function cancel(Request $request): JsonResponse
{
return response()->json([
'status' => 'cancelled',
'message' => 'Payment was cancelled',
]);
}
/**
* Handle payment provider webhooks
*/
public function webhook(Request $request, string $provider): JsonResponse
{
try {
$result = $this->orchestrator->processWebhook($provider, $request);
return response()->json([
'status' => 'processed',
'result' => $result,
]);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
], 400);
}
}
}

View File

@@ -0,0 +1,297 @@
<?php
namespace App\Http\Controllers;
use App\Models\ActivationKey;
use App\Services\Payments\PaymentConfigurationManager;
use App\Services\Payments\PaymentOrchestrator;
use App\Services\Payments\Providers\ActivationKeyProvider;
use App\Services\Payments\Providers\CryptoProvider;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class PaymentProviderController extends Controller
{
public function __construct(
private PaymentConfigurationManager $configManager,
private PaymentOrchestrator $orchestrator
) {}
/**
* Get all payment providers and their status
*/
public function index(): JsonResponse
{
try {
$status = $this->configManager->getProviderStatus();
$stats = $this->orchestrator->getRegistry()->getProviderStats();
return response()->json([
'providers' => $status,
'statistics' => $stats,
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Failed to retrieve provider status',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Get specific provider details
*/
public function show(string $provider): JsonResponse
{
try {
$providerInstance = $this->orchestrator->getRegistry()->get($provider);
$config = $this->configManager->getProviderConfig($provider);
return response()->json([
'provider' => $provider,
'name' => $providerInstance->getName(),
'active' => $providerInstance->isActive(),
'configuration' => $this->configManager->sanitizeConfig($config),
'capabilities' => [
'supports_recurring' => $providerInstance->supportsRecurring(),
'supports_one_time' => $providerInstance->supportsOneTime(),
'supported_currencies' => $providerInstance->getSupportedCurrencies(),
],
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Provider not found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* Test provider connectivity
*/
public function test(string $provider): JsonResponse
{
try {
$result = $this->configManager->testProviderConnectivity($provider);
return response()->json($result);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Failed to test provider',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Enable/disable a provider
*/
public function toggle(Request $request, string $provider): JsonResponse
{
$validated = $request->validate([
'enabled' => 'required|boolean',
]);
try {
$result = $this->configManager->toggleProvider($provider, $validated['enabled']);
if ($result) {
return response()->json([
'success' => true,
'message' => "Provider {$provider} has been ".
($validated['enabled'] ? 'enabled' : 'disabled'),
]);
}
return response()->json([
'success' => false,
'message' => "Failed to toggle provider {$provider}",
], 400);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Failed to toggle provider',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Update provider configuration
*/
public function updateConfig(Request $request, string $provider): JsonResponse
{
$config = $request->all();
// Validate configuration
$validation = $this->configManager->validateProviderConfig($provider, $config);
if (! $validation['valid']) {
throw ValidationException::withMessages([
'config' => $validation['errors'],
]);
}
try {
$this->configManager->updateProviderConfig($provider, $config);
return response()->json([
'success' => true,
'message' => "Configuration updated for provider {$provider}",
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Failed to update configuration',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Refresh provider configurations
*/
public function refresh(): JsonResponse
{
try {
$this->configManager->refreshConfigurations();
return response()->json([
'success' => true,
'message' => 'Provider configurations refreshed',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Failed to refresh configurations',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Redeem an activation key
*/
public function redeemActivationKey(Request $request): JsonResponse
{
$validated = $request->validate([
'activation_key' => 'required|string',
]);
try {
$user = $request->user();
if (! $user) {
return response()->json([
'success' => false,
'error' => 'Authentication required',
], 401);
}
$provider = new ActivationKeyProvider;
$result = $provider->redeemActivationKey($validated['activation_key'], $user);
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Failed to redeem activation key',
'message' => $e->getMessage(),
], 400);
}
}
/**
* Validate an activation key
*/
public function validateActivationKey(string $key): JsonResponse
{
try {
$activationKey = ActivationKey::where('activation_key', $key)->first();
if (! $activationKey) {
return response()->json([
'valid' => false,
'reason' => 'Activation key not found',
]);
}
return response()->json([
'valid' => true,
'is_activated' => $activationKey->is_activated,
'created_at' => $activationKey->created_at,
'plan_id' => $activationKey->price_id,
]);
} catch (\Exception $e) {
return response()->json([
'valid' => false,
'error' => 'Failed to validate activation key',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Get cryptocurrency exchange rate
*/
public function getCryptoRate(string $crypto): JsonResponse
{
try {
$provider = new CryptoProvider;
// Test conversion with $1 USD to get rate
$amount = $provider->convertUsdToCrypto(1.00, strtoupper($crypto));
$rate = 1 / $amount; // Invert to get USD per crypto
return response()->json([
'crypto' => strtoupper($crypto),
'rate_usd_per_crypto' => $rate,
'rate_crypto_per_usd' => $amount,
'updated_at' => now()->toISOString(),
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Failed to get crypto rate',
'message' => $e->getMessage(),
], 400);
}
}
/**
* Convert USD to cryptocurrency
*/
public function convertUsdToCrypto(Request $request): JsonResponse
{
$validated = $request->validate([
'usd_amount' => 'required|numeric|min:0.01',
'crypto' => 'required|string|in:BTC,ETH,USDT,USDC,LTC',
]);
try {
$provider = new CryptoProvider;
$cryptoAmount = $provider->convertUsdToCrypto(
$validated['usd_amount'],
strtoupper($validated['crypto'])
);
$fees = $provider->calculateFees($validated['usd_amount']);
return response()->json([
'usd_amount' => $validated['usd_amount'],
'crypto' => strtoupper($validated['crypto']),
'crypto_amount' => $cryptoAmount,
'fees' => $fees,
'net_crypto_amount' => $cryptoAmount, // Crypto providers typically don't deduct fees from amount
'updated_at' => now()->toISOString(),
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Failed to convert USD to crypto',
'message' => $e->getMessage(),
], 400);
}
}
}

View File

@@ -2,118 +2,626 @@
namespace App\Http\Controllers;
use App\Models\Coupon;
use App\Models\Subscription;
use App\NotifyMe;
use App\Services\Payments\PaymentOrchestrator;
use Exception;
use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Date;
use App\NotifyMe;
use Exception;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
use NotifyMe;
public function oxapay(Request $request): ResponseFactory|Response
public function __construct(
private PaymentOrchestrator $orchestrator
) {}
/**
* Unified webhook handler for all payment providers
*/
public function handle(Request $request, string $provider): ResponseFactory|Response
{
// Get the request data
$postData = $request->getContent();
$data = json_decode($postData, true);
try {
Log::info("Processing {$provider} webhook", [
'provider' => $provider,
'headers' => $request->headers->all(),
]);
// Validate request data
if (! $data || ! isset($data['type']) || ! in_array($data['type'], ['invoice', 'payment_link', 'payout'])) {
Log::warning('Invalid Oxapay webhook data', ['data' => $data]);
$result = $this->orchestrator->processWebhook($provider, $request);
return response('Invalid data.type', 400);
}
// Process Phase 4 specific events
$this->processPhase4Events($provider, $result);
// Determine API secret key based on type
$apiSecretKey = $data['type'] === 'invoice'
? config('services.oxapay.merchant_api_key')
: config('services.oxapay.payout_api_key');
// Validate HMAC signature
$hmacHeader = $request->header('HMAC');
$calculatedHmac = hash_hmac('sha512', $postData, (string) $apiSecretKey);
if (hash_equals($calculatedHmac, $hmacHeader)) {
// HMAC signature is valid
try {
if ($data['type'] === 'invoice' || $data['type'] === 'payment_link') {
// Process invoice payment data
$email = $data['email'] ?? 'Unknown';
$amount = $data['amount'] ?? 'Unknown';
$currency = $data['currency'] ?? 'Unknown';
$trackId = $data['track_id'] ?? 'Unknown';
$orderId = $data['order_id'] ?? 'N/A';
$date = isset($data['date']) ? Date::createFromTimestamp($data['date'])->toDateTimeString() : now()->toDateTimeString();
Log::info('Received Oxapay invoice payment callback', [
'track_id' => $trackId,
'email' => $email,
'amount' => $amount,
'currency' => $currency,
'order_id' => $orderId,
'date' => $date,
]);
$message = "✅ Oxapay Invoice Payment Success\n".
"Track ID: {$trackId}\n".
"Email: {$email}\n".
"Amount: {$amount} {$currency}\n".
"Order ID: {$orderId}\n".
"Time: {$date}";
self::sendTelegramNotification($message);
} elseif ($data['type'] === 'payout') {
// Process payout data
$trackId = $data['track_id'] ?? 'Unknown';
$amount = $data['amount'] ?? 'Unknown';
$currency = $data['currency'] ?? 'Unknown';
$network = $data['network'] ?? 'Unknown';
$address = $data['address'] ?? 'Unknown';
$txHash = $data['tx_hash'] ?? 'Unknown';
$description = $data['description'] ?? 'N/A';
$date = isset($data['date']) ? Date::createFromTimestamp($data['date'])->toDateTimeString() : now()->toDateTimeString();
Log::info('Received Oxapay payout callback', [
'track_id' => $trackId,
'status' => $data['status'] ?? 'Unknown',
'amount' => $amount,
'currency' => $currency,
'network' => $network,
'address' => $address,
'tx_hash' => $txHash,
'description' => $description,
'date' => $date,
]);
$message = "📤 Oxapay Payout Confirmed\n".
"Track ID: {$trackId}\n".
"Amount: {$amount} {$currency}\n".
"Network: {$network}\n".
"Address: {$address}\n".
"Transaction Hash: {$txHash}\n".
"Description: {$description}\n".
"Date: {$date}";
self::sendTelegramNotification($message);
}
return response('OK', 200);
} catch (Exception $e) {
Log::error('Oxapay webhook processing error', ['error' => $e->getMessage(), 'data' => $data]);
self::sendTelegramNotification("
Failed to process Oxapay webhook\n
Type: {$data['type']}\n
Email/Track ID: ".($data['type'] === 'invoice' ? ($data['email'] ?? 'Unknown') : ($data['track_id'] ?? 'Unknown'))."\n
Error: {$e->getMessage()}
");
return response('Processing error', 400);
// Send notification for successful payments
if ($this->isSuccessfulPayment($result)) {
$this->sendPaymentNotification($provider, $result);
}
} else {
Log::warning('Invalid Oxapay HMAC signature', ['hmac_header' => $hmacHeader, 'calculated_hmac' => $calculatedHmac]);
return response('Invalid HMAC signature', 400);
// Send notifications for Phase 4 events
$this->sendPhase4Notifications($provider, $result);
return response('OK', 200);
} catch (Exception $e) {
Log::error("{$provider} webhook processing error", [
'provider' => $provider,
'error' => $e->getMessage(),
'request_data' => $request->getContent(),
]);
$this->sendErrorNotification($provider, $e);
return response('Processing error', 400);
}
}
/**
* Legacy Oxapay webhook handler (for backward compatibility)
*/
public function oxapay(Request $request): ResponseFactory|Response
{
return $this->handle($request, 'oxapay');
}
/**
* Stripe webhook handler
*/
public function stripe(Request $request): ResponseFactory|Response
{
return $this->handle($request, 'stripe');
}
/**
* Lemon Squeezy webhook handler
*/
public function lemonSqueezy(Request $request): ResponseFactory|Response
{
return $this->handle($request, 'lemon_squeezy');
}
/**
* Polar webhook handler
*/
public function polar(Request $request): ResponseFactory|Response
{
return $this->handle($request, 'polar');
}
/**
* Crypto webhook handler
*/
public function crypto(Request $request): ResponseFactory|Response
{
return $this->handle($request, 'crypto');
}
/**
* Check if webhook result indicates a successful payment
*/
protected function isSuccessfulPayment(array $result): bool
{
if (! ($result['success'] ?? false)) {
return false;
}
$eventType = $result['event_type'] ?? '';
$status = $result['status'] ?? '';
// Check for successful payment events
$successfulEvents = [
'payment.succeeded',
'invoice.payment_succeeded',
'checkout.session.completed',
'subscription.created',
'subscription.updated',
'customer.subscription.created',
'charge.succeeded',
'payment_intent.succeeded',
'invoicepaid',
'Paid', // OxaPay status
];
return in_array($eventType, $successfulEvents) ||
in_array($status, ['paid', 'succeeded', 'completed', 'active']);
}
/**
* Send notification for successful payment
*/
protected function sendPaymentNotification(string $provider, array $result): void
{
$eventType = $result['event_type'] ?? $result['status'] ?? 'unknown';
$subscriptionId = $result['subscription_id'] ?? null;
$amount = $result['amount'] ?? 'Unknown';
$currency = $result['currency'] ?? 'Unknown';
$email = $result['email'] ?? 'Unknown';
$message = "{$this->getProviderDisplayName($provider)} Payment Success\n".
"Event: {$eventType}\n".
"Amount: {$amount} {$currency}\n".
($subscriptionId ? "Subscription ID: {$subscriptionId}\n" : '').
($email !== 'Unknown' ? "Email: {$email}\n" : '').
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Send error notification
*/
protected function sendErrorNotification(string $provider, Exception $e): void
{
$message = "{$this->getProviderDisplayName($provider)} Webhook Error\n".
"Error: {$e->getMessage()}\n".
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Process Phase 4 specific events
*/
protected function processPhase4Events(string $provider, array $result): void
{
$eventType = $result['event_type'] ?? '';
$subscriptionId = $result['subscription_id'] ?? null;
if (! $subscriptionId) {
return;
}
$subscription = Subscription::find($subscriptionId);
if (! $subscription) {
Log::warning('Subscription not found for Phase 4 processing', [
'subscription_id' => $subscriptionId,
'provider' => $provider,
]);
return;
}
// Handle coupon usage events
if ($this->isCouponUsageEvent($result)) {
$this->processCouponUsage($subscription, $result);
}
// Handle trial events
if ($this->isTrialEvent($result)) {
$this->processTrialEvent($subscription, $result);
}
// Handle subscription change events
if ($this->isSubscriptionChangeEvent($result)) {
$this->processSubscriptionChangeEvent($subscription, $result);
}
// Handle migration events
if ($this->isMigrationEvent($result)) {
$this->processMigrationEvent($subscription, $result);
}
}
/**
* Process coupon usage
*/
protected function processCouponUsage(Subscription $subscription, array $result): void
{
try {
$couponCode = $result['coupon_code'] ?? null;
$discountAmount = $result['discount_amount'] ?? 0;
if (! $couponCode) {
return;
}
$coupon = Coupon::where('code', $couponCode)->first();
if (! $coupon) {
Log::warning('Coupon not found', ['coupon_code' => $couponCode]);
return;
}
// Apply coupon to subscription if not already applied
$existingUsage = $subscription->couponUsages()
->where('coupon_id', $coupon->id)
->first();
if (! $existingUsage) {
$subscription->applyCoupon($coupon, $discountAmount);
Log::info('Coupon applied via webhook', [
'subscription_id' => $subscription->id,
'coupon_id' => $coupon->id,
'discount_amount' => $discountAmount,
]);
}
} catch (Exception $e) {
Log::error('Failed to process coupon usage', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Process trial events
*/
protected function processTrialEvent(Subscription $subscription, array $result): void
{
try {
$eventType = $result['event_type'] ?? '';
$newTrialEnd = $result['trial_ends_at'] ?? null;
if (! $newTrialEnd) {
return;
}
switch ($eventType) {
case 'trial.will_end':
case 'trial.ending':
// Send reminder notification
$this->sendTrialEndingNotification($subscription);
break;
case 'trial.extended':
// Record trial extension
$daysExtended = $result['trial_extension_days'] ?? 7;
$reason = $result['extension_reason'] ?? 'Extended by provider';
$subscription->extendTrial($daysExtended, $reason, 'automatic');
break;
case 'trial.ended':
// Record trial completion
$this->recordTrialCompletion($subscription, $result);
break;
}
} catch (Exception $e) {
Log::error('Failed to process trial event', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Process subscription change events
*/
protected function processSubscriptionChangeEvent(Subscription $subscription, array $result): void
{
try {
$eventType = $result['event_type'] ?? '';
$oldPlanId = $result['old_plan_id'] ?? null;
$newPlanId = $result['new_plan_id'] ?? null;
switch ($eventType) {
case 'plan.changed':
case 'subscription.updated':
if ($oldPlanId && $newPlanId) {
$changeType = $this->determinePlanChangeType($oldPlanId, $newPlanId);
$this->orchestrator->recordSubscriptionChange(
$subscription,
$changeType,
"Plan changed from {$oldPlanId} to {$newPlanId}",
['plan_id' => $oldPlanId],
['plan_id' => $newPlanId],
'Plan change via webhook'
);
}
break;
case 'subscription.paused':
$this->orchestrator->recordSubscriptionChange(
$subscription,
'pause',
'Subscription paused via webhook',
null,
['status' => 'paused'],
'Paused by provider'
);
break;
case 'subscription.resumed':
$this->orchestrator->recordSubscriptionChange(
$subscription,
'resume',
'Subscription resumed via webhook',
['status' => 'paused'],
['status' => 'active'],
'Resumed by provider'
);
break;
case 'subscription.cancelled':
$reason = $result['cancellation_reason'] ?? 'Cancelled by provider';
$this->orchestrator->recordSubscriptionChange(
$subscription,
'cancel',
'Subscription cancelled via webhook',
null,
['status' => 'cancelled', 'reason' => $reason],
$reason
);
break;
}
} catch (Exception $e) {
Log::error('Failed to process subscription change event', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Process migration events
*/
protected function processMigrationEvent(Subscription $subscription, array $result): void
{
try {
$targetProvider = $result['target_provider'] ?? null;
$migrationBatchId = $result['migration_batch_id'] ?? null;
if (! $targetProvider || ! $migrationBatchId) {
return;
}
$subscription->update([
'migration_batch_id' => $migrationBatchId,
'is_migrated' => true,
'legacy_data' => array_merge($subscription->legacy_data ?? [], [
'migration_source' => $result['source_provider'] ?? $subscription->provider,
'migration_date' => now()->toDateTimeString(),
'migration_reason' => $result['migration_reason'] ?? 'Provider migration',
]),
]);
Log::info('Subscription migration recorded', [
'subscription_id' => $subscription->id,
'migration_batch_id' => $migrationBatchId,
'target_provider' => $targetProvider,
]);
} catch (Exception $e) {
Log::error('Failed to process migration event', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Send Phase 4 specific notifications
*/
protected function sendPhase4Notifications(string $provider, array $result): void
{
$eventType = $result['event_type'] ?? '';
switch ($eventType) {
case 'coupon.applied':
$this->sendCouponAppliedNotification($provider, $result);
break;
case 'trial.ending':
$this->sendTrialEndingNotification($result);
break;
case 'trial.extended':
$this->sendTrialExtendedNotification($provider, $result);
break;
case 'plan.changed':
$this->sendPlanChangedNotification($provider, $result);
break;
case 'subscription.migrated':
$this->sendMigrationNotification($provider, $result);
break;
}
}
/**
* Send coupon applied notification
*/
protected function sendCouponAppliedNotification(string $provider, array $result): void
{
$couponCode = $result['coupon_code'] ?? 'Unknown';
$discountAmount = $result['discount_amount'] ?? 0;
$email = $result['email'] ?? 'Unknown';
$message = "🎫 Coupon Applied\n".
"Provider: {$this->getProviderDisplayName($provider)}\n".
"Coupon: {$couponCode}\n".
"Discount: {$discountAmount}\n".
"Email: {$email}\n".
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Send trial ending notification
*/
protected function sendTrialEndingNotification($subscriptionOrResult): void
{
if ($subscriptionOrResult instanceof Subscription) {
$subscription = $subscriptionOrResult;
$email = $subscription->user?->email ?? 'Unknown';
$trialEndsAt = $subscription->trial_ends_at?->toDateTimeString() ?? 'Unknown';
} else {
$email = $subscriptionOrResult['email'] ?? 'Unknown';
$trialEndsAt = $subscriptionOrResult['trial_ends_at'] ?? 'Unknown';
}
$message = "⏰ Trial Ending Soon\n".
"Email: {$email}\n".
"Trial ends: {$trialEndsAt}\n".
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Send trial extended notification
*/
protected function sendTrialExtendedNotification(string $provider, array $result): void
{
$daysExtended = $result['trial_extension_days'] ?? 0;
$newTrialEnd = $result['new_trial_ends_at'] ?? 'Unknown';
$reason = $result['extension_reason'] ?? 'Extended';
$message = "✅ Trial Extended\n".
"Provider: {$this->getProviderDisplayName($provider)}\n".
"Days extended: {$daysExtended}\n".
"New trial end: {$newTrialEnd}\n".
"Reason: {$reason}\n".
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Send plan changed notification
*/
protected function sendPlanChangedNotification(string $provider, array $result): void
{
$oldPlan = $result['old_plan_name'] ?? 'Unknown';
$newPlan = $result['new_plan_name'] ?? 'Unknown';
$email = $result['email'] ?? 'Unknown';
$message = "🔄 Plan Changed\n".
"Provider: {$this->getProviderDisplayName($provider)}\n".
"Email: {$email}\n".
"Old plan: {$oldPlan}\n".
"New plan: {$newPlan}\n".
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Send migration notification
*/
protected function sendMigrationNotification(string $provider, array $result): void
{
$sourceProvider = $result['source_provider'] ?? 'Unknown';
$targetProvider = $result['target_provider'] ?? 'Unknown';
$migrationBatchId = $result['migration_batch_id'] ?? 'Unknown';
$message = "🔄 Subscription Migration\n".
"Source: {$this->getProviderDisplayName($sourceProvider)}\n".
"Target: {$this->getProviderDisplayName($targetProvider)}\n".
"Batch ID: {$migrationBatchId}\n".
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Record trial completion
*/
protected function recordTrialCompletion(Subscription $subscription, array $result): void
{
$this->orchestrator->recordSubscriptionChange(
$subscription,
'trial_completed',
'Trial period completed',
['status' => 'trialing'],
['status' => $subscription->status],
'Trial ended naturally'
);
}
/**
* Check if event is a coupon usage event
*/
protected function isCouponUsageEvent(array $result): bool
{
$eventType = $result['event_type'] ?? '';
return in_array($eventType, [
'coupon.applied',
'discount.applied',
'coupon.redeemed',
]) || isset($result['coupon_code']);
}
/**
* Check if event is a trial event
*/
protected function isTrialEvent(array $result): bool
{
$eventType = $result['event_type'] ?? '';
return in_array($eventType, [
'trial.started',
'trial.will_end',
'trial.ending',
'trial.ended',
'trial.extended',
]) || isset($result['trial_ends_at']);
}
/**
* Check if event is a subscription change event
*/
protected function isSubscriptionChangeEvent(array $result): bool
{
$eventType = $result['event_type'] ?? '';
return in_array($eventType, [
'plan.changed',
'subscription.updated',
'subscription.paused',
'subscription.resumed',
'subscription.cancelled',
]) || isset($result['new_plan_id']);
}
/**
* Check if event is a migration event
*/
protected function isMigrationEvent(array $result): bool
{
$eventType = $result['event_type'] ?? '';
return in_array($eventType, [
'subscription.migrated',
'provider.migrated',
]) || isset($result['migration_batch_id']);
}
/**
* Determine plan change type
*/
protected function determinePlanChangeType(?int $oldPlanId, ?int $newPlanId): string
{
if (! $oldPlanId || ! $newPlanId) {
return 'plan_change';
}
// This is a simplified determination - in practice you'd compare plan prices/features
return $newPlanId > $oldPlanId ? 'plan_upgrade' : 'plan_downgrade';
}
/**
* Get display name for provider
*/
protected function getProviderDisplayName(string $provider): string
{
$displayNames = [
'stripe' => 'Stripe',
'lemon_squeezy' => 'Lemon Squeezy',
'polar' => 'Polar.sh',
'oxapay' => 'OxaPay',
'crypto' => 'Crypto',
'activation_key' => 'Activation Key',
];
return $displayNames[$provider] ?? ucfirst($provider);
}
}