feat(notifications): implement comprehensive telegram notifications for payment providers

- Add NotifyMe trait with centralized Telegram bot integration
  - Implement webhook notifications for Polar, OxaPay, and ActivationKey providers
  - Add subscription lifecycle notifications (create, activate, cancel, pause, resume)
  - Enhance Polar webhook processing with user context and error handling
  - Fix subscription.updated and subscription.canceled webhook column errors
  - Add idempotent webhook processing to prevent duplicate handling
This commit is contained in:
idevakk
2025-12-08 09:25:19 -08:00
parent 8d8cd44ea5
commit 0d33c57b32
5 changed files with 654 additions and 47 deletions

View File

@@ -65,6 +65,8 @@ class Subscription extends Model
'amount', 'amount',
'currency', 'currency',
'expires_at', 'expires_at',
'uncanceled_at',
'cancel_at_period_end',
]; ];
protected $casts = [ protected $casts = [

View File

@@ -2,13 +2,13 @@
namespace App; namespace App;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Http;
use Exception; use Exception;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
trait NotifyMe trait NotifyMe
{ {
public function sendTelegramNotification($message) public function sendTelegramNotification($message): bool
{ {
$botToken = config('app.notify_tg_bot_token'); $botToken = config('app.notify_tg_bot_token');
$chatId = config('app.notify_tg_chat_id'); $chatId = config('app.notify_tg_chat_id');
@@ -28,9 +28,7 @@ trait NotifyMe
]; ];
try { try {
$response = Http::post($url, $data); return Http::post($url, $data)->successful();
return $response->successful();
} catch (Exception $e) { } catch (Exception $e) {
Log::error('Failed to send Telegram notification: '.$e->getMessage()); Log::error('Failed to send Telegram notification: '.$e->getMessage());

View File

@@ -7,6 +7,7 @@ use App\Models\ActivationKey;
use App\Models\Plan; use App\Models\Plan;
use App\Models\Subscription; use App\Models\Subscription;
use App\Models\User; use App\Models\User;
use App\NotifyMe;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -14,6 +15,8 @@ use Illuminate\Support\Str;
class ActivationKeyProvider implements PaymentProviderContract class ActivationKeyProvider implements PaymentProviderContract
{ {
use NotifyMe;
protected array $config; protected array $config;
public function __construct(array $config = []) public function __construct(array $config = [])
@@ -74,6 +77,9 @@ class ActivationKeyProvider implements PaymentProviderContract
DB::commit(); DB::commit();
// Notify activation key generated
$this->notifyActivationKeyGenerated($user, $plan, $activationKey);
return [ return [
'provider_subscription_id' => $keyRecord->id, 'provider_subscription_id' => $keyRecord->id,
'status' => 'pending_activation', 'status' => 'pending_activation',
@@ -106,6 +112,9 @@ class ActivationKeyProvider implements PaymentProviderContract
'cancellation_reason' => $reason, 'cancellation_reason' => $reason,
]); ]);
// Notify subscription cancelled
$this->notifySubscriptionCancelled($subscription, $reason ?: 'Manual cancellation');
return true; return true;
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -381,6 +390,10 @@ class ActivationKeyProvider implements PaymentProviderContract
DB::commit(); DB::commit();
// Notify activation key redeemed and subscription activated
$this->notifyActivationKeyRedeemed($user, $plan, $activationKey);
$this->notifySubscriptionActivated($user, $plan, $subscription);
return [ return [
'success' => true, 'success' => true,
'subscription_id' => $subscription->id, 'subscription_id' => $subscription->id,
@@ -494,4 +507,79 @@ class ActivationKeyProvider implements PaymentProviderContract
{ {
throw new \Exception('Import to activation keys not implemented'); throw new \Exception('Import to activation keys not implemented');
} }
// Notification methods
protected function notifyActivationKeyGenerated(User $user, Plan $plan, string $activationKey): void
{
$message = "🔑 ACTIVATION KEY GENERATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
'💰 Price: $'.number_format($plan->price, 2)."\n".
"🏪 Provider: Activation Key\n".
"🔑 Key: {$activationKey}\n".
'📅 Generated: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyActivationKeyRedeemed(User $user, Plan $plan, string $activationKey): void
{
$message = "✅ ACTIVATION KEY REDEEMED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"🏪 Provider: Activation Key\n".
"🔑 Key: {$activationKey}\n".
'📅 Redeemed: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionActivated(User $user, Plan $plan, Subscription $subscription): void
{
$message = "🎉 SUBSCRIPTION ACTIVATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"🏪 Provider: Activation Key\n".
"🔄 Subscription ID: {$subscription->id}\n".
'📅 Activated: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyProviderError(string $operation, string $error, array $context = []): void
{
$contextStr = '';
if (! empty($context)) {
$contextStr = '📝 Details: '.json_encode(array_slice($context, 0, 3, true), JSON_UNESCAPED_SLASHES)."\n";
if (count($context) > 3) {
$contextStr .= '📝 Additional: '.(count($context) - 3).' more items'."\n";
}
}
$message = "🚨 PROVIDER ERROR\n".
"🏪 Provider: Activation Key\n".
"📡 Operation: {$operation}\n".
"💥 Error: {$error}\n".
$contextStr.
'⏰ Time: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionCancelled(Subscription $subscription, string $reason): void
{
$user = $subscription->user;
$plan = $subscription->plan;
$message = "❌ SUBSCRIPTION CANCELLED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"🏪 Provider: Activation Key\n".
"💭 Reason: {$reason}\n".
"🆔 Subscription ID: {$subscription->id}\n".
($subscription->ends_at ? '📅 Effective: '.$subscription->ends_at->format('Y-m-d')."\n" : '').
'⏰ Cancelled: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
} }

View File

@@ -7,6 +7,7 @@ use App\Models\PaymentProvider as PaymentProviderModel;
use App\Models\Plan; use App\Models\Plan;
use App\Models\Subscription; use App\Models\Subscription;
use App\Models\User; use App\Models\User;
use App\NotifyMe;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -15,6 +16,8 @@ use Illuminate\Support\Facades\Log;
class OxapayProvider implements PaymentProviderContract class OxapayProvider implements PaymentProviderContract
{ {
use NotifyMe;
protected array $config; protected array $config;
protected bool $sandbox; protected bool $sandbox;
@@ -52,7 +55,6 @@ class OxapayProvider implements PaymentProviderContract
try { try {
$providerModel = PaymentProviderModel::where('name', 'oxapay')->first(); $providerModel = PaymentProviderModel::where('name', 'oxapay')->first();
if ($providerModel && $providerModel->configuration) { if ($providerModel && $providerModel->configuration) {
return $providerModel->configuration; return $providerModel->configuration;
} }
@@ -163,6 +165,9 @@ class OxapayProvider implements PaymentProviderContract
'cancellation_reason' => $reason, 'cancellation_reason' => $reason,
]); ]);
// Notify subscription cancelled
$this->notifySubscriptionCancelled($subscription, $reason ?: 'Manual cancellation');
return true; return true;
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -273,6 +278,13 @@ class OxapayProvider implements PaymentProviderContract
'last_provider_sync' => now(), 'last_provider_sync' => now(),
]); ]);
// Notify payment created
$this->notifyPaymentCreated($user, $plan, [
'amount' => $paymentData['amount'] ?? $amount,
'currency' => $paymentData['currency'] ?? $currency,
'payment_id' => $paymentData['track_id'] ?? null,
]);
Log::info('OxaPay subscription created', [ Log::info('OxaPay subscription created', [
'subscription_id' => $subscription->id, 'subscription_id' => $subscription->id,
'track_id' => $paymentData['track_id'], 'track_id' => $paymentData['track_id'],
@@ -321,11 +333,7 @@ class OxapayProvider implements PaymentProviderContract
{ {
try { try {
$payload = $request->getContent(); $payload = $request->getContent();
$signature = $request->header('HMAC'); $signature = $request->header('hmac');
if (! $this->validateWebhook($request)) {
throw new \Exception('Invalid webhook signature');
}
$data = $request->json()->all(); $data = $request->json()->all();
$status = strtolower($data['status'] ?? 'unknown'); $status = strtolower($data['status'] ?? 'unknown');
@@ -335,6 +343,15 @@ class OxapayProvider implements PaymentProviderContract
if (! $trackId) { if (! $trackId) {
throw new \Exception('Missing track_id in webhook payload'); throw new \Exception('Missing track_id in webhook payload');
} }
Log::debug('before send webhook status');
// Notify webhook received
$this->notifyWebhookReceived($status, $trackId);
Log::debug('before send webhook status with payload'.$status.' '.$trackId);
if (! $this->validateWebhook($request)) {
$this->notifyWebhookError($status, $trackId, 'Invalid webhook signature');
throw new \Exception('Invalid webhook signature');
}
Log::info('OxaPay webhook received', [ Log::info('OxaPay webhook received', [
'track_id' => $trackId, 'track_id' => $trackId,
@@ -345,6 +362,9 @@ class OxapayProvider implements PaymentProviderContract
// Process the webhook based on status // Process the webhook based on status
$result = $this->handleWebhookStatus($status, $data); $result = $this->handleWebhookStatus($status, $data);
// Notify webhook processed successfully
$this->notifyWebhookProcessed($status, $trackId, $data['payment_id'] ?? null);
return [ return [
'success' => true, 'success' => true,
'event_type' => $status, 'event_type' => $status,
@@ -361,6 +381,9 @@ class OxapayProvider implements PaymentProviderContract
'payload' => $request->getContent(), 'payload' => $request->getContent(),
]); ]);
// Notify webhook processing error
$this->notifyWebhookError($data['status'] ?? 'unknown', $data['track_id'] ?? 'unknown', $e->getMessage());
return [ return [
'success' => false, 'success' => false,
'event_type' => 'error', 'event_type' => 'error',
@@ -543,6 +566,15 @@ class OxapayProvider implements PaymentProviderContract
$subscription->update($updateData); $subscription->update($updateData);
// Notify payment success for paid payments
if (in_array($status, ['paid', 'manual_accept'])) {
$this->notifyPaymentSuccess($subscription->user, $subscription->plan, [
'amount' => $data['amount'] ?? 0,
'currency' => $data['currency'] ?? 'USD',
'transaction_id' => $trackId,
]);
}
Log::info('OxaPay subscription updated', [ Log::info('OxaPay subscription updated', [
'subscription_id' => $subscription->id, 'subscription_id' => $subscription->id,
'track_id' => $trackId, 'track_id' => $trackId,
@@ -824,4 +856,109 @@ class OxapayProvider implements PaymentProviderContract
{ {
throw new \Exception('OxaPay does not support recurring subscriptions'); throw new \Exception('OxaPay does not support recurring subscriptions');
} }
// Notification methods
protected function notifyPaymentSuccess(User $user, Plan $plan, array $paymentDetails): void
{
$message = "💰 PAYMENT SUCCESS\n".
"📧 Customer: {$user->name} ({$user->email})\n".
"📦 Plan: {$plan->name}\n".
'💵 Amount: $'.number_format($paymentDetails['amount'] ?? 0, 2).' '.($paymentDetails['currency'] ?? 'USD')."\n".
"🏪 Provider: OxaPay\n".
'🆔 Transaction: '.($paymentDetails['transaction_id'] ?? 'N/A')."\n".
'⏰ Time: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyWebhookReceived(string $eventType, ?string $webhookId): void
{
$message = "🔔 WEBHOOK RECEIVED\n".
"🏪 Provider: OxaPay\n".
"📋 Event: {$eventType}\n".
'🆔 Webhook ID: '.($webhookId ?? 'N/A')."\n".
"📊 Status: Processing...\n".
'⏰ Received: '.now()->format('Y-m-d H:i:s');
Log::warning($message);
$res = $this->sendTelegramNotification($message);
Log::warning('result : '.$res);
}
protected function notifyWebhookProcessed(string $eventType, ?string $webhookId, ?string $paymentId = null): void
{
$message = "✅ WEBHOOK PROCESSED\n".
"🏪 Provider: OxaPay\n".
"📋 Event: {$eventType}\n".
'🆔 Webhook ID: '.($webhookId ?? 'N/A')."\n".
($paymentId ? "💳 Payment ID: {$paymentId}\n" : '').
"📊 Status: Completed\n".
'⏰ Processed: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyWebhookError(string $eventType, ?string $webhookId, string $error): void
{
$message = "❌ WEBHOOK ERROR\n".
"🏪 Provider: OxaPay\n".
"📋 Event: {$eventType}\n".
'🆔 Webhook ID: '.($webhookId ?? 'N/A')."\n".
"💥 Error: {$error}\n".
'⏰ Time: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyProviderError(string $operation, string $error, array $context = []): void
{
$contextStr = '';
if (! empty($context)) {
$contextStr = '📝 Details: '.json_encode(array_slice($context, 0, 3, true), JSON_UNESCAPED_SLASHES)."\n";
if (count($context) > 3) {
$contextStr .= '📝 Additional: '.(count($context) - 3).' more items'."\n";
}
}
$message = "🚨 PROVIDER ERROR\n".
"🏪 Provider: OxaPay\n".
"📡 Operation: {$operation}\n".
"💥 Error: {$error}\n".
$contextStr.
'⏰ Time: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyPaymentCreated(User $user, Plan $plan, array $paymentDetails): void
{
$message = "🆕 PAYMENT CREATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
'💰 Amount: $'.number_format($paymentDetails['amount'] ?? 0, 2).' '.($paymentDetails['currency'] ?? 'USD')."\n".
"🏪 Provider: OxaPay\n".
'🆔 Payment ID: '.($paymentDetails['payment_id'] ?? 'N/A')."\n".
'📅 Created: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionCancelled(Subscription $subscription, string $reason): void
{
$user = $subscription->user;
$plan = $subscription->plan;
$message = "❌ SUBSCRIPTION CANCELLED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"🏪 Provider: OxaPay\n".
"💭 Reason: {$reason}\n".
"🆔 Subscription ID: {$subscription->provider_subscription_id}\n".
($subscription->ends_at ? '📅 Effective: '.$subscription->ends_at->format('Y-m-d')."\n" : '').
'⏰ Cancelled: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
} }

View File

@@ -7,6 +7,7 @@ use App\Models\PaymentProvider as PaymentProviderModel;
use App\Models\Plan; use App\Models\Plan;
use App\Models\Subscription; use App\Models\Subscription;
use App\Models\User; use App\Models\User;
use App\NotifyMe;
use App\Services\Webhooks\WebhookFactory; use App\Services\Webhooks\WebhookFactory;
use App\Services\Webhooks\WebhookVerificationException; use App\Services\Webhooks\WebhookVerificationException;
use Carbon\Carbon; use Carbon\Carbon;
@@ -16,6 +17,8 @@ use Illuminate\Support\Facades\Log;
class PolarProvider implements PaymentProviderContract class PolarProvider implements PaymentProviderContract
{ {
use NotifyMe;
protected array $config; protected array $config;
protected bool $sandbox; protected bool $sandbox;
@@ -136,6 +139,7 @@ class PolarProvider implements PaymentProviderContract
public function makeAuthenticatedRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response public function makeAuthenticatedRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response
{ {
try {
$url = $this->apiBaseUrl.$endpoint; $url = $this->apiBaseUrl.$endpoint;
$headers = [ $headers = [
@@ -155,13 +159,28 @@ class PolarProvider implements PaymentProviderContract
], ],
]); ]);
return match ($method) { $response = match ($method) {
'GET' => $http->get($url, $data), 'GET' => $http->get($url, $data),
'POST' => $http->post($url, $data), 'POST' => $http->post($url, $data),
'PATCH' => $http->patch($url, $data), 'PATCH' => $http->patch($url, $data),
'DELETE' => $http->delete($url, $data), 'DELETE' => $http->delete($url, $data),
default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"), default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"),
}; };
// Log API errors
if (! $response->successful()) {
$this->notifyProviderError("{$method} {$endpoint}", "HTTP {$response->status()}", [
'status_code' => $response->status(),
'response' => $response->json(),
]);
}
return $response;
} catch (\Exception $e) {
$this->notifyProviderError("{$method} {$endpoint}", $e->getMessage());
throw $e;
}
} }
public function createSubscription(User $user, Plan $plan, array $options = []): array public function createSubscription(User $user, Plan $plan, array $options = []): array
@@ -298,6 +317,9 @@ class PolarProvider implements PaymentProviderContract
'expires_at' => $checkout['expires_at'] ?? now()->addHours(24)->toISOString(), 'expires_at' => $checkout['expires_at'] ?? now()->addHours(24)->toISOString(),
]; ];
// Notify checkout created successfully
$this->notifyCheckoutCreated($user, $plan, $checkout);
Log::info('PolarProvider: Returning successful result', [ Log::info('PolarProvider: Returning successful result', [
'result_keys' => array_keys($result), 'result_keys' => array_keys($result),
'checkout_url' => $result['checkout_url'], 'checkout_url' => $result['checkout_url'],
@@ -329,6 +351,9 @@ class PolarProvider implements PaymentProviderContract
'cancellation_reason' => $reason, 'cancellation_reason' => $reason,
]); ]);
// Notify subscription cancelled
$this->notifySubscriptionCancelled($subscription, $reason ?: 'Local cancellation');
return true; return true;
} }
@@ -347,6 +372,9 @@ class PolarProvider implements PaymentProviderContract
'cancellation_reason' => $reason, 'cancellation_reason' => $reason,
]); ]);
// Notify subscription cancelled
$this->notifySubscriptionCancelled($subscription, $reason ?: 'Manual cancellation via API');
return true; return true;
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -594,13 +622,25 @@ class PolarProvider implements PaymentProviderContract
$signature = $request->header('Polar-Signature'); $signature = $request->header('Polar-Signature');
if (! $this->validateWebhook($request)) { if (! $this->validateWebhook($request)) {
$webhookId = $request->header('webhook-id'); // Extract from header for error reporting too
Log::error('Invalid Polar webhook signature'); Log::error('Invalid Polar webhook signature');
$this->notifyWebhookError($webhookData['type'] ?? 'unknown', $webhookId, 'Invalid webhook signature');
throw new \Exception('Invalid webhook signature'); throw new \Exception('Invalid webhook signature');
} }
$webhookData = json_decode($payload, true); $webhookData = json_decode($payload, true);
$eventType = $webhookData['type'] ?? 'unknown'; $eventType = $webhookData['type'] ?? 'unknown';
$webhookId = $webhookData['id'] ?? null; $webhookId = $request->header('webhook-id'); // Extract from header as per Polar documentation
Log::info('Polar webhook received', [
'event_type' => $eventType,
'webhook_id' => $webhookId,
'webhook_id_header' => $request->header('webhook-id'),
'available_headers' => $request->headers->all(),
]);
// Notify webhook received
$this->notifyWebhookReceived($eventType, $webhookId, $webhookData);
// Check for idempotency - prevent duplicate processing // Check for idempotency - prevent duplicate processing
if ($webhookId && $this->isWebhookProcessed($webhookId)) { if ($webhookId && $this->isWebhookProcessed($webhookId)) {
@@ -676,6 +716,10 @@ class PolarProvider implements PaymentProviderContract
// Mark webhook as processed if it has an ID and was successfully processed // Mark webhook as processed if it has an ID and was successfully processed
if ($webhookId && ($result['processed'] ?? false)) { if ($webhookId && ($result['processed'] ?? false)) {
$this->markWebhookAsProcessed($webhookId, $eventType, $result); $this->markWebhookAsProcessed($webhookId, $eventType, $result);
// Notify webhook processed successfully
$subscriptionId = $result['data']['subscription_id'] ?? null;
$this->notifyWebhookProcessed($eventType, $webhookId, $subscriptionId);
} }
return $result; return $result;
@@ -687,6 +731,10 @@ class PolarProvider implements PaymentProviderContract
'event_type' => $eventType ?? 'unknown', 'event_type' => $eventType ?? 'unknown',
'payload' => $request->getContent(), 'payload' => $request->getContent(),
]); ]);
// Notify webhook processing error
$this->notifyWebhookError($eventType ?? 'unknown', $webhookId ?? 'unknown', $e->getMessage());
throw $e; throw $e;
} }
} }
@@ -903,6 +951,325 @@ class PolarProvider implements PaymentProviderContract
return true; return true;
} }
// Notification methods
protected function notifyPaymentSuccess(User $user, Plan $plan, array $paymentDetails): void
{
$message = "💰 PAYMENT SUCCESS\n".
"📧 Customer: {$user->name} ({$user->email})\n".
"📦 Plan: {$plan->name}\n".
'💵 Amount: $'.number_format($paymentDetails['amount'] ?? 0, 2).' '.($paymentDetails['currency'] ?? 'USD')."\n".
"🏪 Provider: Polar\n".
'🆔 Transaction: '.($paymentDetails['transaction_id'] ?? 'N/A')."\n".
'⏰ Time: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionCreated(User $user, Plan $plan, Subscription $subscription): void
{
$amount = $plan->price ? '$'.number_format($plan->price, 2).'/month' : 'Custom pricing';
$message = "🆕 SUBSCRIPTION CREATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"💰 Billing: {$amount}\n".
"🏪 Provider: Polar\n".
"🔄 Subscription ID: {$subscription->provider_subscription_id}\n".
'📅 Created: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyWebhookReceived(string $eventType, ?string $webhookId, ?array $webhookData = null): void
{
$message = "🔔 WEBHOOK RECEIVED\n".
"🏪 Provider: Polar\n".
"📋 Event: {$eventType}\n".
'🆔 Webhook ID: '.($webhookId ?? 'N/A')."\n";
// Try to extract user information safely
try {
$userInfo = $this->extractUserInfoFromWebhook($webhookData);
if ($userInfo['name'] || $userInfo['email']) {
$message .= '👤 User: ';
if ($userInfo['name']) {
$message .= $userInfo['name'];
if ($userInfo['email']) {
$message .= " ({$userInfo['email']})";
}
} elseif ($userInfo['email']) {
$message .= $userInfo['email'];
}
$message .= "\n";
}
} catch (\Exception $e) {
// Silently ignore user info extraction errors to avoid webhook processing failures
Log::debug('Failed to extract user info for webhook notification', [
'event_type' => $eventType,
'webhook_id' => $webhookId,
'error' => $e->getMessage(),
]);
}
$message .= "📊 Status: Processing...\n".
'⏰ Received: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function extractUserInfoFromWebhook(?array $webhookData): array
{
$userInfo = [
'name' => null,
'email' => null,
];
if (! $webhookData) {
return $userInfo;
}
// Try different paths where user info might be stored in webhook payloads
$data = $webhookData['data'] ?? [];
// 1. Check customer object (most common)
if (isset($data['customer'])) {
$customer = $data['customer'];
if (is_array($customer)) {
$userInfo['name'] = $customer['name'] ?? $customer['display_name'] ?? null;
$userInfo['email'] = $customer['email'] ?? null;
}
}
// 2. Check user object (some webhooks use this)
if (! $userInfo['name'] && ! $userInfo['email'] && isset($data['user'])) {
$user = $data['user'];
if (is_array($user)) {
$userInfo['name'] = $user['name'] ?? $user['display_name'] ?? null;
$userInfo['email'] = $user['email'] ?? null;
}
}
// 3. Check direct customer fields
if (! $userInfo['name'] && ! $userInfo['email']) {
$userInfo['name'] = $data['customer_name'] ?? $data['customer_display_name'] ?? null;
$userInfo['email'] = $data['customer_email'] ?? $data['email'] ?? null;
}
// 4. Check metadata for user info
if (! $userInfo['name'] && ! $userInfo['email'] && isset($data['metadata'])) {
$metadata = $data['metadata'];
if (is_array($metadata)) {
$userInfo['name'] = $metadata['user_name'] ?? $metadata['customer_name'] ?? null;
$userInfo['email'] = $metadata['user_email'] ?? $metadata['customer_email'] ?? null;
}
}
// 5. For order-related webhooks, check order data
if (! $userInfo['name'] && ! $userInfo['email'] && isset($data['order'])) {
$order = $data['order'];
if (is_array($order)) {
$userInfo['name'] = $order['customer_name'] ?? $order['user_name'] ?? null;
$userInfo['email'] = $order['customer_email'] ?? $order['user_email'] ?? null;
// Check nested customer in order
if (! $userInfo['name'] && ! $userInfo['email'] && isset($order['customer'])) {
$orderCustomer = $order['customer'];
if (is_array($orderCustomer)) {
$userInfo['name'] = $orderCustomer['name'] ?? $orderCustomer['display_name'] ?? null;
$userInfo['email'] = $orderCustomer['email'] ?? null;
}
}
}
}
// 6. Check subscription data for user info
if (! $userInfo['name'] && ! $userInfo['email'] && isset($data['subscription'])) {
$subscription = $data['subscription'];
if (is_array($subscription)) {
$userInfo['name'] = $subscription['customer_name'] ?? null;
$userInfo['email'] = $subscription['customer_email'] ?? null;
// Check nested customer in subscription
if (! $userInfo['name'] && ! $userInfo['email'] && isset($subscription['customer'])) {
$subCustomer = $subscription['customer'];
if (is_array($subCustomer)) {
$userInfo['name'] = $subCustomer['name'] ?? $subCustomer['display_name'] ?? null;
$userInfo['email'] = $subCustomer['email'] ?? null;
}
}
}
}
return $userInfo;
}
protected function notifyWebhookProcessed(string $eventType, ?string $webhookId, ?string $subscriptionId = null): void
{
$message = "✅ WEBHOOK PROCESSED\n".
"🏪 Provider: Polar\n".
"📋 Event: {$eventType}\n".
'🆔 Webhook ID: '.($webhookId ?? 'N/A')."\n".
($subscriptionId ? "🔄 Subscription ID: {$subscriptionId}\n" : '').
"📊 Status: Completed\n".
'⏰ Processed: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyWebhookError(string $eventType, ?string $webhookId, string $error): void
{
$message = "❌ WEBHOOK ERROR\n".
"🏪 Provider: Polar\n".
"📋 Event: {$eventType}\n".
'🆔 Webhook ID: '.($webhookId ?? 'N/A')."\n".
"💥 Error: {$error}\n".
'⏰ Time: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionUpdated(Subscription $subscription, string $changeType, $details = null): void
{
$user = $subscription->user;
$plan = $subscription->plan;
$message = "🔄 SUBSCRIPTION UPDATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"🏪 Provider: Polar\n".
"🔄 Change: {$changeType}\n".
($details ? "📝 Details: {$details}\n" : '').
"🆔 Subscription ID: {$subscription->provider_subscription_id}\n".
'⏰ Updated: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionCancelled(Subscription $subscription, string $reason): void
{
$user = $subscription->user;
$plan = $subscription->plan;
$message = "❌ SUBSCRIPTION CANCELLED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"🏪 Provider: Polar\n".
"💭 Reason: {$reason}\n".
"🆔 Subscription ID: {$subscription->provider_subscription_id}\n".
($subscription->ends_at ? '📅 Effective: '.$subscription->ends_at->format('Y-m-d')."\n" : '').
'⏰ Cancelled: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyProviderError(string $operation, string $error, array $context = []): void
{
$contextStr = '';
if (! empty($context)) {
$contextStr = '📝 Details: '.json_encode(array_slice($context, 0, 3, true), JSON_UNESCAPED_SLASHES)."\n";
if (count($context) > 3) {
$contextStr .= '📝 Additional: '.(count($context) - 3).' more items'."\n";
}
}
$message = "🚨 PROVIDER ERROR\n".
"🏪 Provider: Polar\n".
"📡 Operation: {$operation}\n".
"💥 Error: {$error}\n".
$contextStr.
'⏰ Time: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionActivated(User $user, Plan $plan, Subscription $subscription): void
{
$amount = $plan->price ? '$'.number_format($plan->price, 2).'/month' : 'Custom pricing';
$message = "✅ SUBSCRIPTION ACTIVATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"💰 Billing: {$amount}\n".
"🏪 Provider: Polar\n".
"🔄 Subscription ID: {$subscription->provider_subscription_id}\n".
'📅 Activated: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionPaused(User $user, Plan $plan, Subscription $subscription, string $reason): void
{
$amount = $plan->price ? '$'.number_format($plan->price, 2).'/month' : 'Custom pricing';
$message = "⏸️ SUBSCRIPTION PAUSED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"💰 Billing: {$amount}\n".
"🏪 Provider: Polar\n".
"🔄 Subscription ID: {$subscription->provider_subscription_id}\n".
"💭 Reason: {$reason}\n".
'📅 Paused: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionResumed(User $user, Plan $plan, Subscription $subscription, string $reason): void
{
$amount = $plan->price ? '$'.number_format($plan->price, 2).'/month' : 'Custom pricing';
$message = "▶️ SUBSCRIPTION RESUMED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"💰 Billing: {$amount}\n".
"🏪 Provider: Polar\n".
"🔄 Subscription ID: {$subscription->provider_subscription_id}\n".
"💭 Reason: {$reason}\n".
'📅 Resumed: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyCheckoutCreated(User $user, Plan $plan, array $checkout): void
{
$amount = $plan->price ? '$'.number_format($plan->price, 2).'/month' : 'Custom pricing';
$customerId = $checkout['customer_id'] ?? 'N/A';
$message = "🛒 CHECKOUT CREATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"💰 Billing: {$amount}\n".
"🏪 Provider: Polar\n".
"🆔 Checkout ID: {$checkout['id']}\n".
"🔗 Checkout URL: {$checkout['url']}\n".
"👤 Customer ID: {$customerId}\n".
'⏰ Created: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyOrderCreated(User $user, Plan $plan, array $order): void
{
$amount = isset($order['amount']) ? '$'.number_format($order['amount'] / 100, 2) : 'N/A';
$currency = $order['currency'] ?? 'USD';
$checkoutId = $order['checkout_id'] ?? 'N/A';
$customerId = $order['customer_id'] ?? 'N/A';
$status = $order['status'] ?? 'unknown';
$message = "📦 ORDER CREATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"💰 Amount: {$amount} {$currency}\n".
"🏪 Provider: Polar\n".
"🆔 Order ID: {$order['id']}\n".
"🛒 Checkout ID: {$checkoutId}\n".
"👤 Customer ID: {$customerId}\n".
"📊 Status: {$status}\n".
'⏰ Created: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
// Helper methods // Helper methods
protected function getOrCreateCustomer(User $user): array protected function getOrCreateCustomer(User $user): array
{ {
@@ -1225,6 +1592,9 @@ class PolarProvider implements PaymentProviderContract
'order_created_at' => now()->toISOString(), 'order_created_at' => now()->toISOString(),
]), ]),
]); ]);
// Notify order created
$this->notifyOrderCreated($subscription->user, $subscription->plan, $order);
} }
return [ return [
@@ -1260,6 +1630,13 @@ class PolarProvider implements PaymentProviderContract
'polar_order' => $order, 'polar_order' => $order,
]), ]),
]); ]);
// Notify payment success
$this->notifyPaymentSuccess($subscription->user, $subscription->plan, [
'amount' => $order['amount'] / 100, // Convert from cents
'currency' => $order['currency'] ?? 'USD',
'transaction_id' => $order['id'],
]);
} }
return [ return [
@@ -1308,6 +1685,9 @@ class PolarProvider implements PaymentProviderContract
$localSubscription->update($updateData); $localSubscription->update($updateData);
// Notify subscription created
$this->notifySubscriptionCreated($localSubscription->user, $localSubscription->plan, $localSubscription);
Log::info('Polar subscription created/updated via webhook', [ Log::info('Polar subscription created/updated via webhook', [
'local_subscription_id' => $localSubscription->id, 'local_subscription_id' => $localSubscription->id,
'polar_subscription_id' => $polarSubscription['id'], 'polar_subscription_id' => $polarSubscription['id'],
@@ -1364,6 +1744,9 @@ class PolarProvider implements PaymentProviderContract
$localSubscription->update($updateData); $localSubscription->update($updateData);
// Notify subscription activated
$this->notifySubscriptionActivated($localSubscription->user, $localSubscription->plan, $localSubscription);
Log::info('Polar subscription activated via webhook', [ Log::info('Polar subscription activated via webhook', [
'local_subscription_id' => $localSubscription->id, 'local_subscription_id' => $localSubscription->id,
'polar_subscription_id' => $polarSubscription['id'], 'polar_subscription_id' => $polarSubscription['id'],
@@ -1426,11 +1809,6 @@ class PolarProvider implements PaymentProviderContract
$updateData['ends_at'] = Carbon::parse($polarSubscription['ends_at']); $updateData['ends_at'] = Carbon::parse($polarSubscription['ends_at']);
} }
// Handle cancellation details
if (isset($polarSubscription['cancel_at_period_end'])) {
$updateData['cancel_at_period_end'] = $polarSubscription['cancel_at_period_end'];
}
// Set cancellation reason if provided // Set cancellation reason if provided
if (! empty($polarSubscription['customer_cancellation_reason'])) { if (! empty($polarSubscription['customer_cancellation_reason'])) {
$updateData['cancellation_reason'] = $polarSubscription['customer_cancellation_reason']; $updateData['cancellation_reason'] = $polarSubscription['customer_cancellation_reason'];
@@ -1445,7 +1823,7 @@ class PolarProvider implements PaymentProviderContract
'updated_fields' => array_keys($updateData), 'updated_fields' => array_keys($updateData),
'cancelled_at_updated' => isset($updateData['cancelled_at']), 'cancelled_at_updated' => isset($updateData['cancelled_at']),
'cancellation_reason_updated' => isset($updateData['cancellation_reason']), 'cancellation_reason_updated' => isset($updateData['cancellation_reason']),
'cancel_at_period_end_updated' => isset($updateData['cancel_at_period_end']), 'cancel_at_period_end_stored' => ($updateData['provider_data']['cancel_at_period_end'] ?? false),
]); ]);
} else { } else {
Log::warning('Subscription not found for Polar subscription.updated webhook', [ Log::warning('Subscription not found for Polar subscription.updated webhook', [
@@ -1503,11 +1881,6 @@ class PolarProvider implements PaymentProviderContract
]), ]),
]; ];
// Set cancel_at_period_end flag if provided
if (isset($polarSubscription['cancel_at_period_end'])) {
$updateData['cancel_at_period_end'] = $polarSubscription['cancel_at_period_end'];
}
// Use Polar's cancellation timestamp if available, otherwise use now // Use Polar's cancellation timestamp if available, otherwise use now
if (! empty($polarSubscription['cancelled_at'])) { if (! empty($polarSubscription['cancelled_at'])) {
$updateData['cancelled_at'] = Carbon::parse($polarSubscription['cancelled_at']); $updateData['cancelled_at'] = Carbon::parse($polarSubscription['cancelled_at']);
@@ -1528,6 +1901,9 @@ class PolarProvider implements PaymentProviderContract
$localSubscription->update($updateData); $localSubscription->update($updateData);
// Notify subscription cancelled
$this->notifySubscriptionCancelled($localSubscription, $cancellationReason);
Log::info('Polar subscription cancellation processed via webhook', [ Log::info('Polar subscription cancellation processed via webhook', [
'local_subscription_id' => $localSubscription->id, 'local_subscription_id' => $localSubscription->id,
'polar_subscription_id' => $polarSubscription['id'], 'polar_subscription_id' => $polarSubscription['id'],
@@ -1597,6 +1973,9 @@ class PolarProvider implements PaymentProviderContract
]), ]),
]); ]);
// Notify subscription paused
$this->notifySubscriptionPaused($localSubscription->user, $localSubscription->plan, $localSubscription, $polarSubscription['pause_reason'] ?? 'Manual pause');
Log::info('Polar subscription paused', [ Log::info('Polar subscription paused', [
'local_subscription_id' => $localSubscription->id, 'local_subscription_id' => $localSubscription->id,
'polar_subscription_id' => $polarSubscription['id'], 'polar_subscription_id' => $polarSubscription['id'],
@@ -1669,6 +2048,9 @@ class PolarProvider implements PaymentProviderContract
]), ]),
]); ]);
// Notify subscription resumed
$this->notifySubscriptionResumed($localSubscription->user, $localSubscription->plan, $localSubscription, $polarSubscription['resume_reason'] ?? 'Manual resume');
Log::info('Polar subscription resumed', [ Log::info('Polar subscription resumed', [
'local_subscription_id' => $localSubscription->id, 'local_subscription_id' => $localSubscription->id,
'polar_subscription_id' => $polarSubscription['id'], 'polar_subscription_id' => $polarSubscription['id'],