- 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
628 lines
20 KiB
PHP
628 lines
20 KiB
PHP
<?php
|
|
|
|
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;
|
|
|
|
class WebhookController extends Controller
|
|
{
|
|
use NotifyMe;
|
|
|
|
public function __construct(
|
|
private PaymentOrchestrator $orchestrator
|
|
) {}
|
|
|
|
/**
|
|
* Unified webhook handler for all payment providers
|
|
*/
|
|
public function handle(Request $request, string $provider): ResponseFactory|Response
|
|
{
|
|
try {
|
|
Log::info("Processing {$provider} webhook", [
|
|
'provider' => $provider,
|
|
'headers' => $request->headers->all(),
|
|
]);
|
|
|
|
$result = $this->orchestrator->processWebhook($provider, $request);
|
|
|
|
// Process Phase 4 specific events
|
|
$this->processPhase4Events($provider, $result);
|
|
|
|
// Send notification for successful payments
|
|
if ($this->isSuccessfulPayment($result)) {
|
|
$this->sendPaymentNotification($provider, $result);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|