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:
240
app/Http/Controllers/PaymentController.php
Normal file
240
app/Http/Controllers/PaymentController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
297
app/Http/Controllers/PaymentProviderController.php
Normal file
297
app/Http/Controllers/PaymentProviderController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user