feat(payment): implement OxaPay provider and non-recurring subscription sync
- Add comprehensive OxaPay payment provider with invoice creation, webhook processing, and subscription status sync - Implement conditional payload fields (to_currency, callback_url) based on configuration - Create universal sync command for all non-recurring payment providers - Add subscription model fields for payment tracking - Implement proper status mapping between OxaPay and Laravel subscription states - Add webhook signature validation using HMAC SHA512
This commit is contained in:
142
app/Console/Commands/Payment/SyncNonRecurringSubscriptions.php
Normal file
142
app/Console/Commands/Payment/SyncNonRecurringSubscriptions.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Payment;
|
||||||
|
|
||||||
|
use App\Models\PaymentProvider;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class SyncNonRecurringSubscriptions extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'payment:sync-non-recurring-subscriptions {--dry-run}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Sync status of subscriptions for non-recurring payment providers (activation_key, oxapay, etc.) based on ends_at timestamp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
$this->info('🔄 Syncing non-recurring payment provider subscriptions...');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('📋 DRY RUN MODE - No actual updates will be made');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all active non-recurring payment providers
|
||||||
|
$nonRecurringProviders = PaymentProvider::where('is_active', true)
|
||||||
|
->where('supports_recurring', false)
|
||||||
|
->where('supports_one_time', true)
|
||||||
|
->pluck('name')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$this->info('📋 Non-recurring providers found: '.implode(', ', $nonRecurringProviders));
|
||||||
|
|
||||||
|
$totalProcessed = 0;
|
||||||
|
$totalExpired = 0;
|
||||||
|
|
||||||
|
foreach ($nonRecurringProviders as $providerName) {
|
||||||
|
$this->info("🔍 Processing provider: {$providerName}");
|
||||||
|
|
||||||
|
$result = $this->syncProviderSubscriptions($providerName, $dryRun);
|
||||||
|
$totalProcessed += $result['processed'];
|
||||||
|
$totalExpired += $result['expired'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('✅ Sync completed!');
|
||||||
|
$this->info("📊 Total subscriptions processed: {$totalProcessed}");
|
||||||
|
$this->info("⏰ Total subscriptions expired: {$totalExpired}");
|
||||||
|
|
||||||
|
if (! $dryRun && $totalExpired > 0) {
|
||||||
|
$this->info('💡 Tip: Set up a cron job to run this command every few hours:');
|
||||||
|
$this->info(' */4 * * * * php artisan app:sync-non-recurring-subscriptions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync subscriptions for a specific provider
|
||||||
|
*/
|
||||||
|
protected function syncProviderSubscriptions(string $providerName, bool $dryRun): array
|
||||||
|
{
|
||||||
|
$query = Subscription::where('provider', $providerName)
|
||||||
|
->where('status', '!=', 'expired')
|
||||||
|
->where('status', '!=', 'cancelled')
|
||||||
|
->whereNotNull('ends_at');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$subscriptions = $query->get();
|
||||||
|
$expiredCount = $subscriptions->filter(function ($sub) {
|
||||||
|
return $sub->ends_at->isPast();
|
||||||
|
})->count();
|
||||||
|
|
||||||
|
$this->line(" 📊 Found {$subscriptions->count()} subscriptions");
|
||||||
|
$this->line(" ⏰ Would expire {$expiredCount} subscriptions");
|
||||||
|
|
||||||
|
return [
|
||||||
|
'processed' => $subscriptions->count(),
|
||||||
|
'expired' => $expiredCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subscriptions that should be expired
|
||||||
|
$expiredSubscriptions = $query->where('ends_at', '<', now())->get();
|
||||||
|
|
||||||
|
$expiredCount = 0;
|
||||||
|
foreach ($expiredSubscriptions as $subscription) {
|
||||||
|
try {
|
||||||
|
$subscription->update([
|
||||||
|
'status' => 'expired',
|
||||||
|
'unified_status' => 'expired',
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$expiredCount++;
|
||||||
|
|
||||||
|
$this->line(" ✅ Expired subscription #{$subscription->id} (User: {$subscription->user_id})");
|
||||||
|
|
||||||
|
Log::info('Non-recurring subscription expired via sync command', [
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'provider' => $providerName,
|
||||||
|
'user_id' => $subscription->user_id,
|
||||||
|
'ends_at' => $subscription->ends_at,
|
||||||
|
'command' => 'app:sync-non-recurring-subscriptions',
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error(" ❌ Failed to expire subscription #{$subscription->id}: {$e->getMessage()}");
|
||||||
|
|
||||||
|
Log::error('Failed to expire non-recurring subscription', [
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'provider' => $providerName,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalProcessed = $query->count();
|
||||||
|
|
||||||
|
if ($expiredCount > 0) {
|
||||||
|
$this->info(" ✅ Expired {$expiredCount} subscriptions for {$providerName}");
|
||||||
|
} else {
|
||||||
|
$this->info(" ℹ️ No expired subscriptions found for {$providerName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'processed' => $totalProcessed,
|
||||||
|
'expired' => $expiredCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,9 @@ class Subscription extends Model
|
|||||||
'customer_metadata',
|
'customer_metadata',
|
||||||
'trial_will_end_sent_at',
|
'trial_will_end_sent_at',
|
||||||
'pause_reason',
|
'pause_reason',
|
||||||
|
'amount',
|
||||||
|
'currency',
|
||||||
|
'expires_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
namespace App\Services\Payments\Providers;
|
namespace App\Services\Payments\Providers;
|
||||||
|
|
||||||
use App\Contracts\Payments\PaymentProviderContract;
|
use App\Contracts\Payments\PaymentProviderContract;
|
||||||
|
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 Carbon\Carbon;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
@@ -15,14 +17,75 @@ class OxapayProvider implements PaymentProviderContract
|
|||||||
{
|
{
|
||||||
protected array $config;
|
protected array $config;
|
||||||
|
|
||||||
|
protected bool $sandbox;
|
||||||
|
|
||||||
protected string $baseUrl;
|
protected string $baseUrl;
|
||||||
|
|
||||||
|
protected string $merchantApiKey;
|
||||||
|
|
||||||
public function __construct(array $config = [])
|
public function __construct(array $config = [])
|
||||||
{
|
{
|
||||||
|
// Load configuration from PaymentProvider model to get the latest data
|
||||||
|
$dbConfig = $this->loadConfigurationFromModel();
|
||||||
|
|
||||||
|
// Merge with any passed config (passed config takes precedence)
|
||||||
|
$config = array_merge($dbConfig, $config);
|
||||||
|
|
||||||
$this->config = $config;
|
$this->config = $config;
|
||||||
$this->baseUrl = $config['sandbox'] ?? false
|
$this->sandbox = (bool) ($config['sandbox'] ?? false);
|
||||||
? 'https://api-sandbox.oxapay.com/v1'
|
$this->merchantApiKey = $this->sandbox ? ($config['sandbox_merchant_api_key'] ?? '') : ($config['merchant_api_key'] ?? '');
|
||||||
: 'https://api.oxapay.com/v1';
|
|
||||||
|
$this->baseUrl = 'https://api.oxapay.com/v1';
|
||||||
|
|
||||||
|
Log::info('OxaPayProvider configuration loaded', [
|
||||||
|
'sandbox' => $this->sandbox,
|
||||||
|
'has_merchant_api_key' => ! empty($this->merchantApiKey),
|
||||||
|
'base_url' => $this->baseUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration from PaymentProvider model
|
||||||
|
*/
|
||||||
|
protected function loadConfigurationFromModel(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$providerModel = PaymentProviderModel::where('name', 'oxapay')->first();
|
||||||
|
|
||||||
|
|
||||||
|
if ($providerModel && $providerModel->configuration) {
|
||||||
|
return $providerModel->configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('OxaPayProvider configuration not found in database, using defaults');
|
||||||
|
|
||||||
|
return $this->getDefaultConfiguration();
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('OxaPayProvider failed to load configuration from database', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->getDefaultConfiguration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default configuration
|
||||||
|
*/
|
||||||
|
protected function getDefaultConfiguration(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'merchant_api_key' => '',
|
||||||
|
'sandbox_merchant_api_key' => '',
|
||||||
|
'sandbox' => false,
|
||||||
|
'webhook_url' => route('webhook.payment', 'oxapay'),
|
||||||
|
'success_url' => route('payment.success'),
|
||||||
|
'cancel_url' => route('payment.cancel'),
|
||||||
|
'default_lifetime' => 60, // minutes
|
||||||
|
'default_under_paid_coverage' => 5, // percentage
|
||||||
|
'fee_paid_by_payer' => 0, // merchant pays by default
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getName(): string
|
public function getName(): string
|
||||||
@@ -32,7 +95,7 @@ class OxapayProvider implements PaymentProviderContract
|
|||||||
|
|
||||||
public function isActive(): bool
|
public function isActive(): bool
|
||||||
{
|
{
|
||||||
return ! empty($this->config['merchant_api_key']);
|
return ! empty($this->merchantApiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function supportsRecurring(): bool
|
public function supportsRecurring(): bool
|
||||||
@@ -50,7 +113,7 @@ class OxapayProvider implements PaymentProviderContract
|
|||||||
return Cache::remember('oxapay_currencies', now()->addHour(), function () {
|
return Cache::remember('oxapay_currencies', now()->addHour(), function () {
|
||||||
try {
|
try {
|
||||||
$response = Http::withHeaders([
|
$response = Http::withHeaders([
|
||||||
'merchant_api_key' => $this->config['merchant_api_key'],
|
'merchant_api_key' => $this->merchantApiKey,
|
||||||
])->get("{$this->baseUrl}/info/currencies");
|
])->get("{$this->baseUrl}/info/currencies");
|
||||||
|
|
||||||
if ($response->successful()) {
|
if ($response->successful()) {
|
||||||
@@ -91,7 +154,24 @@ class OxapayProvider implements PaymentProviderContract
|
|||||||
|
|
||||||
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
|
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
|
||||||
{
|
{
|
||||||
throw new \Exception('OxaPay does not support recurring subscriptions');
|
try {
|
||||||
|
// For oxapay, we don't actually cancel since it's a one-time activation
|
||||||
|
// We can deactivate the subscription if needed
|
||||||
|
$subscription->update([
|
||||||
|
'status' => 'cancelled',
|
||||||
|
'cancelled_at' => now(),
|
||||||
|
'cancellation_reason' => $reason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Oxapay subscription cancellation failed', [
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
|
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
|
||||||
@@ -117,43 +197,105 @@ class OxapayProvider implements PaymentProviderContract
|
|||||||
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
|
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$amount = $options['amount'] ?? $plan->price;
|
$amount = (float) $plan->price;
|
||||||
$currency = $options['currency'] ?? 'USD';
|
$currency = (string) ($this->config['currency'] ?? 'USD');
|
||||||
$toCurrency = $options['to_currency'] ?? 'USDT';
|
|
||||||
|
|
||||||
|
// Build simple invoice request payload
|
||||||
$payload = [
|
$payload = [
|
||||||
'amount' => $amount,
|
'amount' => $amount, // number · decimal
|
||||||
'currency' => $currency,
|
'currency' => $currency, // string
|
||||||
'to_currency' => $toCurrency,
|
'lifetime' => (int) ($this->config['lifetime'] ?? 30), // integer · min: 15 · max: 2880
|
||||||
'lifetime' => $options['lifetime'] ?? 60, // 60 minutes default
|
'fee_paid_by_payer' => (int) ($this->config['fee_paid_by_payer'] ?? 1), // number · decimal · max: 1
|
||||||
'fee_paid_by_payer' => $options['fee_paid_by_payer'] ?? 0,
|
'under_paid_coverage' => (float) ($this->config['under_paid_coverage'] ?? 2.5), // number · decimal · max: 60
|
||||||
'callback_url' => $this->config['webhook_url'] ?? route('webhooks.oxapay'),
|
'auto_withdrawal' => (bool) ($this->config['auto_withdrawal'] ?? false), // boolean
|
||||||
'return_url' => $this->config['success_url'] ?? route('payment.success'),
|
'mixed_payment' => (bool) ($this->config['mixed_payment'] ?? false), // boolean
|
||||||
'email' => $user->email,
|
'return_url' => (string) ($this->config['success_url'] ?? route('payment.success')), // string
|
||||||
'order_id' => $options['order_id'] ?? null,
|
'callback_url' => (string) ($this->config['callback_url'] ?? route('webhook.oxapay')), // string
|
||||||
'description' => $options['description'] ?? "Payment for {$plan->name}",
|
'order_id' => (string) ($this->config['order_id'] ?? 'order_'.$user->id.'_'.time()), // string
|
||||||
'sandbox' => $this->config['sandbox'] ?? false,
|
'thanks_message' => (string) ($this->config['thanks_message'] ?? 'Thank you for your payment!'), // string
|
||||||
|
'description' => (string) ($this->config['description'] ?? "Payment for plan: {$plan->name}"), // string
|
||||||
|
'sandbox' => $this->sandbox, // boolean
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add to_currency only if it's properly configured
|
||||||
|
$configuredToCurrency = $this->config['to_currency'] ?? null;
|
||||||
|
if (! empty($configuredToCurrency)) {
|
||||||
|
$toCurrency = (string) $configuredToCurrency;
|
||||||
|
$payload['to_currency'] = $toCurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Creating OxaPay invoice', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
'payload_keys' => array_keys($payload),
|
||||||
|
]);
|
||||||
|
|
||||||
$response = Http::withHeaders([
|
$response = Http::withHeaders([
|
||||||
'merchant_api_key' => $this->config['merchant_api_key'],
|
'merchant_api_key' => $this->merchantApiKey,
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
])->post("{$this->baseUrl}/payment/invoice", $payload);
|
])->post("{$this->baseUrl}/payment/invoice", $payload);
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
throw new \Exception('Failed to create OxaPay invoice: '.$response->body());
|
$errorData = $response->json();
|
||||||
|
$errorMessage = $errorData['message'] ?? 'Unknown error';
|
||||||
|
|
||||||
|
Log::error('OxaPay invoice creation failed', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'response' => $errorData,
|
||||||
|
'payload' => $payload,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new \Exception("Failed to create OxaPay invoice: {$errorMessage}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $response->json();
|
$data = $response->json();
|
||||||
|
|
||||||
|
if (! isset($data['data']) || $data['status'] !== 200) {
|
||||||
|
throw new \Exception('Invalid response from OxaPay API');
|
||||||
|
}
|
||||||
|
|
||||||
|
$paymentData = $data['data'];
|
||||||
|
|
||||||
|
// Create local subscription record
|
||||||
|
$subscription = Subscription::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
'provider' => 'oxapay',
|
||||||
|
'type' => 'one_time', // OxaPay doesn't support recurring
|
||||||
|
'provider_subscription_id' => $paymentData['track_id'],
|
||||||
|
'provider_checkout_id' => $paymentData['track_id'],
|
||||||
|
'status' => 'pending_payment',
|
||||||
|
'unified_status' => 'pending_payment',
|
||||||
|
'quantity' => 1,
|
||||||
|
'provider_data' => $paymentData,
|
||||||
|
'starts_at' => now(),
|
||||||
|
'ends_at' => $this->calculateSubscriptionEndDate($plan),
|
||||||
|
'last_provider_sync' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('OxaPay subscription created', [
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'track_id' => $paymentData['track_id'],
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'checkout_url' => $data['data']['payment_url'] ?? null,
|
'checkout_url' => $paymentData['payment_url'] ?? null,
|
||||||
'payment_id' => $data['data']['track_id'] ?? null,
|
'payment_id' => $paymentData['track_id'] ?? null,
|
||||||
'expires_at' => $data['data']['expired_at'] ?? null,
|
'track_id' => $paymentData['track_id'] ?? null,
|
||||||
'amount' => $amount,
|
'subscription_id' => $subscription->id,
|
||||||
'currency' => $currency,
|
'amount' => $paymentData['amount'] ?? $amount,
|
||||||
|
'currency' => $paymentData['currency'] ?? $currency,
|
||||||
|
'pay_amount' => $paymentData['pay_amount'] ?? null,
|
||||||
|
'pay_currency' => $paymentData['pay_currency'] ?? null,
|
||||||
|
'expires_at' => isset($paymentData['expired_at']) ? Carbon::createFromTimestamp($paymentData['expired_at']) : null,
|
||||||
|
'lifetime' => $paymentData['lifetime'] ?? $payload['lifetime'],
|
||||||
|
'under_paid_coverage' => $paymentData['under_paid_coverage'] ?? $payload['under_paid_coverage'],
|
||||||
|
'fee_paid_by_payer' => $paymentData['fee_paid_by_payer'] ?? $payload['fee_paid_by_payer'],
|
||||||
'provider' => 'oxapay',
|
'provider' => 'oxapay',
|
||||||
|
'provider_data' => $paymentData,
|
||||||
];
|
];
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -186,10 +328,23 @@ class OxapayProvider implements PaymentProviderContract
|
|||||||
}
|
}
|
||||||
|
|
||||||
$data = $request->json()->all();
|
$data = $request->json()->all();
|
||||||
$status = $data['status'] ?? 'unknown';
|
$status = strtolower($data['status'] ?? 'unknown');
|
||||||
$trackId = $data['track_id'] ?? null;
|
$trackId = $data['track_id'] ?? null;
|
||||||
$type = $data['type'] ?? 'payment';
|
$type = $data['type'] ?? 'payment';
|
||||||
|
|
||||||
|
if (! $trackId) {
|
||||||
|
throw new \Exception('Missing track_id in webhook payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('OxaPay webhook received', [
|
||||||
|
'track_id' => $trackId,
|
||||||
|
'status' => $status,
|
||||||
|
'type' => $type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Process the webhook based on status
|
||||||
|
$result = $this->handleWebhookStatus($status, $data);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'event_type' => $status,
|
'event_type' => $status,
|
||||||
@@ -197,6 +352,7 @@ class OxapayProvider implements PaymentProviderContract
|
|||||||
'processed' => true,
|
'processed' => true,
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
|
'result' => $result,
|
||||||
];
|
];
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -219,16 +375,41 @@ class OxapayProvider implements PaymentProviderContract
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$payload = $request->getContent();
|
$payload = $request->getContent();
|
||||||
$signature = $request->header('HMAC');
|
$signature = $request->header('hmac'); // Use lowercase 'hmac' as per official implementation
|
||||||
$apiSecret = $this->config['merchant_api_key'];
|
|
||||||
|
if (empty($signature)) {
|
||||||
|
Log::warning('OxaPay webhook validation failed: missing HMAC header');
|
||||||
|
|
||||||
if (empty($signature) || empty($apiSecret)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedSignature = hash_hmac('sha512', $payload, $apiSecret);
|
// Get webhook data to determine type
|
||||||
|
$data = $request->json()->all();
|
||||||
|
$type = $data['type'] ?? '';
|
||||||
|
|
||||||
return hash_equals($expectedSignature, $signature);
|
// Resolve API key based on webhook type (following official implementation)
|
||||||
|
$apiKey = $this->resolveApiKeyByType($type);
|
||||||
|
|
||||||
|
if (empty($apiKey)) {
|
||||||
|
Log::warning('OxaPay webhook validation failed: no API key available for type', [
|
||||||
|
'type' => $type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedSignature = hash_hmac('sha512', $payload, $apiKey);
|
||||||
|
|
||||||
|
$isValid = hash_equals($expectedSignature, $signature);
|
||||||
|
|
||||||
|
Log::info('OxaPay webhook validation', [
|
||||||
|
'type' => $type,
|
||||||
|
'signature_provided' => ! empty($signature),
|
||||||
|
'api_key_available' => ! empty($apiKey),
|
||||||
|
'valid' => $isValid,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $isValid;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('OxaPay webhook validation failed', [
|
Log::error('OxaPay webhook validation failed', [
|
||||||
@@ -239,6 +420,156 @@ class OxapayProvider implements PaymentProviderContract
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve API key based on webhook type (following official OxaPay implementation)
|
||||||
|
*/
|
||||||
|
private function resolveApiKeyByType(string $type): string
|
||||||
|
{
|
||||||
|
// Map webhook types to API key groups per official implementation
|
||||||
|
$merchantTypes = ['invoice', 'white_label', 'static_address', 'payment_link', 'donation'];
|
||||||
|
$payoutTypes = ['payout'];
|
||||||
|
|
||||||
|
if (in_array($type, $merchantTypes, true)) {
|
||||||
|
return $this->config['merchant_api_key'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($type, $payoutTypes, true)) {
|
||||||
|
return $this->config['payout_api_key'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to merchant API key for unknown types
|
||||||
|
return $this->config['merchant_api_key'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle different webhook statuses
|
||||||
|
*/
|
||||||
|
protected function handleWebhookStatus(string $status, array $data): array
|
||||||
|
{
|
||||||
|
$trackId = $data['track_id'];
|
||||||
|
$orderId = $data['order_id'] ?? null;
|
||||||
|
|
||||||
|
switch ($status) {
|
||||||
|
case 'paying':
|
||||||
|
// Payment is being processed, show as pending
|
||||||
|
return $this->updatePaymentStatus($trackId, 'paying', $data);
|
||||||
|
|
||||||
|
case 'paid':
|
||||||
|
// Payment completed successfully
|
||||||
|
return $this->updatePaymentStatus($trackId, 'paid', $data);
|
||||||
|
|
||||||
|
case 'underpaid':
|
||||||
|
// Partial payment received
|
||||||
|
return $this->updatePaymentStatus($trackId, 'underpaid', $data);
|
||||||
|
|
||||||
|
case 'expired':
|
||||||
|
// Payment expired
|
||||||
|
return $this->updatePaymentStatus($trackId, 'expired', $data);
|
||||||
|
|
||||||
|
case 'refunded':
|
||||||
|
// Payment was refunded
|
||||||
|
return $this->updatePaymentStatus($trackId, 'refunded', $data);
|
||||||
|
|
||||||
|
case 'manual_accept':
|
||||||
|
// Manually accepted
|
||||||
|
return $this->updatePaymentStatus($trackId, 'manual_accept', $data);
|
||||||
|
|
||||||
|
default:
|
||||||
|
Log::warning('Unknown OxaPay webhook status', [
|
||||||
|
'status' => $status,
|
||||||
|
'track_id' => $trackId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['status' => 'unknown', 'message' => "Unknown status: {$status}"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update payment status in local system
|
||||||
|
*/
|
||||||
|
protected function updatePaymentStatus(string $trackId, string $status, array $data): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Find subscription by provider subscription ID (track_id)
|
||||||
|
$subscription = Subscription::where('provider', 'oxapay')
|
||||||
|
->where('provider_subscription_id', $trackId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $subscription) {
|
||||||
|
Log::warning('OxaPay webhook: Subscription not found', [
|
||||||
|
'track_id' => $trackId,
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'not_found',
|
||||||
|
'message' => 'Subscription not found for track_id: '.$trackId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update subscription based on payment status
|
||||||
|
$updateData = [
|
||||||
|
'provider_data' => array_merge($subscription->provider_data ?? [], $data),
|
||||||
|
'last_provider_sync' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map OxaPay statuses to Laravel subscription statuses
|
||||||
|
switch ($status) {
|
||||||
|
case 'paying':
|
||||||
|
case 'underpaid':
|
||||||
|
$updateData['status'] = 'pending_payment';
|
||||||
|
$updateData['unified_status'] = 'pending_payment';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'paid':
|
||||||
|
case 'manual_accept':
|
||||||
|
$updateData['status'] = 'active';
|
||||||
|
$updateData['unified_status'] = 'active';
|
||||||
|
$updateData['starts_at'] = now();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'refunded':
|
||||||
|
$updateData['status'] = 'cancelled';
|
||||||
|
$updateData['unified_status'] = 'cancelled';
|
||||||
|
$updateData['cancelled_at'] = now();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'expired':
|
||||||
|
$updateData['status'] = 'expired';
|
||||||
|
$updateData['unified_status'] = 'expired';
|
||||||
|
$updateData['ends_at'] = now();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription->update($updateData);
|
||||||
|
|
||||||
|
Log::info('OxaPay subscription updated', [
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'track_id' => $trackId,
|
||||||
|
'status' => $status,
|
||||||
|
'new_status' => $updateData['status'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'updated',
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'new_status' => $updateData['status'],
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('OxaPay payment status update failed', [
|
||||||
|
'track_id' => $trackId,
|
||||||
|
'status' => $status,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function getConfiguration(): array
|
public function getConfiguration(): array
|
||||||
{
|
{
|
||||||
return $this->config;
|
return $this->config;
|
||||||
@@ -246,14 +577,127 @@ class OxapayProvider implements PaymentProviderContract
|
|||||||
|
|
||||||
public function syncSubscriptionStatus(Subscription $subscription): array
|
public function syncSubscriptionStatus(Subscription $subscription): array
|
||||||
{
|
{
|
||||||
throw new \Exception('OxaPay does not support recurring subscriptions');
|
try {
|
||||||
|
$trackId = $subscription->provider_subscription_id;
|
||||||
|
|
||||||
|
if (empty($trackId)) {
|
||||||
|
throw new \Exception('No track_id found for subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Syncing OxaPay subscription status', [
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'track_id' => $trackId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'merchant_api_key' => $this->merchantApiKey,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])->get("{$this->baseUrl}/payment/{$trackId}");
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
throw new \Exception('Failed to fetch payment status from OxaPay');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
if (! isset($data['data'])) {
|
||||||
|
throw new \Exception('Invalid response from OxaPay API');
|
||||||
|
}
|
||||||
|
|
||||||
|
$paymentData = $data['data'];
|
||||||
|
$oxapayStatus = $paymentData['status'] ?? 'unknown';
|
||||||
|
|
||||||
|
// Map OxaPay status to Laravel subscription status
|
||||||
|
$statusMapping = [
|
||||||
|
'paying' => 'pending_payment',
|
||||||
|
'underpaid' => 'pending_payment',
|
||||||
|
'paid' => 'active',
|
||||||
|
'manual_accept' => 'active',
|
||||||
|
'expired' => 'expired',
|
||||||
|
'refunded' => 'cancelled',
|
||||||
|
];
|
||||||
|
|
||||||
|
$newStatus = $statusMapping[$oxapayStatus] ?? 'pending_payment';
|
||||||
|
|
||||||
|
// Update subscription with latest data
|
||||||
|
$updateData = [
|
||||||
|
'status' => $newStatus,
|
||||||
|
'unified_status' => $newStatus,
|
||||||
|
'provider_data' => array_merge($subscription->provider_data ?? [], $paymentData),
|
||||||
|
'last_provider_sync' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add timestamps based on status
|
||||||
|
switch ($newStatus) {
|
||||||
|
case 'active':
|
||||||
|
if (! $subscription->starts_at) {
|
||||||
|
$updateData['starts_at'] = now();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'expired':
|
||||||
|
$updateData['ends_at'] = now();
|
||||||
|
break;
|
||||||
|
case 'cancelled':
|
||||||
|
$updateData['cancelled_at'] = now();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription->update($updateData);
|
||||||
|
|
||||||
|
Log::info('OxaPay subscription synced successfully', [
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'track_id' => $trackId,
|
||||||
|
'oxapay_status' => $oxapayStatus,
|
||||||
|
'new_status' => $newStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'status' => $newStatus,
|
||||||
|
'oxapay_status' => $oxapayStatus,
|
||||||
|
'provider_data' => $paymentData,
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('OxaPay subscription sync failed', [
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate subscription end date based on plan billing cycle days
|
||||||
|
*/
|
||||||
|
protected function calculateSubscriptionEndDate(Plan $plan): ?Carbon
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Use billing_cycle_days from plans table
|
||||||
|
$billingCycleDays = $plan->billing_cycle_days ?? 30;
|
||||||
|
|
||||||
|
return now()->addDays($billingCycleDays);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to calculate subscription end date', [
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Safe fallback: 30 days
|
||||||
|
return now()->addDays(30);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPaymentMethodDetails(string $paymentId): array
|
public function getPaymentMethodDetails(string $paymentId): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$response = Http::withHeaders([
|
$response = Http::withHeaders([
|
||||||
'merchant_api_key' => $this->config['merchant_api_key'],
|
'merchant_api_key' => $this->merchantApiKey,
|
||||||
])->get("{$this->baseUrl}/payment/info", [
|
])->get("{$this->baseUrl}/payment/info", [
|
||||||
'track_id' => $paymentId,
|
'track_id' => $paymentId,
|
||||||
]);
|
]);
|
||||||
@@ -294,7 +738,7 @@ class OxapayProvider implements PaymentProviderContract
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$response = Http::withHeaders([
|
$response = Http::withHeaders([
|
||||||
'merchant_api_key' => $this->config['merchant_api_key'],
|
'merchant_api_key' => $this->merchantApiKey,
|
||||||
])->get("{$this->baseUrl}/payment/history", array_merge([
|
])->get("{$this->baseUrl}/payment/history", array_merge([
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
], $filters));
|
], $filters));
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
</flux:button>
|
</flux:button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if($latestActiveSubscription->isActive() && in_array($latestActiveSubscription->provider, ['activation_key']))
|
@if($latestActiveSubscription->isActive() && in_array($latestActiveSubscription->provider, ['activation_key', 'oxapay']))
|
||||||
<flux:button
|
<flux:button
|
||||||
wire:click="confirmCancelSubscription({{ $latestActiveSubscription->id }})"
|
wire:click="confirmCancelSubscription({{ $latestActiveSubscription->id }})"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -266,4 +266,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
</x-settings.layout>
|
</x-settings.layout>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,27 +2,6 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\AppController;
|
use App\Http\Controllers\AppController;
|
||||||
|
|
||||||
// DEBUG: Test route to check PolarProvider
|
|
||||||
Route::get('/debug-polar', function () {
|
|
||||||
try {
|
|
||||||
$provider = new \App\Services\Payments\Providers\PolarProvider;
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'status' => 'success',
|
|
||||||
'provider_class' => get_class($provider),
|
|
||||||
'is_active' => $provider->isActive(),
|
|
||||||
'config' => $provider->getConfiguration(),
|
|
||||||
'sandbox' => $provider->config['sandbox'] ?? 'unknown',
|
|
||||||
'timestamp' => '2025-12-04-17-15-00',
|
|
||||||
]);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
return response()->json([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
use App\Http\Controllers\ImpersonationController;
|
use App\Http\Controllers\ImpersonationController;
|
||||||
use App\Http\Controllers\WebhookController;
|
use App\Http\Controllers\WebhookController;
|
||||||
use App\Http\Middleware\CheckPageSlug;
|
use App\Http\Middleware\CheckPageSlug;
|
||||||
@@ -180,7 +159,7 @@ Route::middleware(['auth'])->group(function (): void {
|
|||||||
Route::get('settings/appearance', Appearance::class)->name('settings.appearance');
|
Route::get('settings/appearance', Appearance::class)->name('settings.appearance');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::post('/webhook/oxapay', [WebhookController::class, 'oxapay'])->name('webhook.oxapay');
|
Route::post('/webhook/oxapayLegacy', [WebhookController::class, 'oxapay'])->name('webhook.oxapayLegacy');
|
||||||
|
|
||||||
// Unified Payment System Routes
|
// Unified Payment System Routes
|
||||||
require __DIR__.'/payment.php';
|
require __DIR__.'/payment.php';
|
||||||
|
|||||||
Reference in New Issue
Block a user