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

@@ -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);
}
}