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:
idevakk
2025-12-07 08:20:51 -08:00
parent 5fabec1f9d
commit 9a32511e97
5 changed files with 628 additions and 60 deletions

View 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,
];
}
}

View File

@@ -62,6 +62,9 @@ class Subscription extends Model
'customer_metadata',
'trial_will_end_sent_at',
'pause_reason',
'amount',
'currency',
'expires_at',
];
protected $casts = [

View File

@@ -3,9 +3,11 @@
namespace App\Services\Payments\Providers;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\PaymentProvider as PaymentProviderModel;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
@@ -15,14 +17,75 @@ class OxapayProvider implements PaymentProviderContract
{
protected array $config;
protected bool $sandbox;
protected string $baseUrl;
protected string $merchantApiKey;
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->baseUrl = $config['sandbox'] ?? false
? 'https://api-sandbox.oxapay.com/v1'
: 'https://api.oxapay.com/v1';
$this->sandbox = (bool) ($config['sandbox'] ?? false);
$this->merchantApiKey = $this->sandbox ? ($config['sandbox_merchant_api_key'] ?? '') : ($config['merchant_api_key'] ?? '');
$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
@@ -32,7 +95,7 @@ class OxapayProvider implements PaymentProviderContract
public function isActive(): bool
{
return ! empty($this->config['merchant_api_key']);
return ! empty($this->merchantApiKey);
}
public function supportsRecurring(): bool
@@ -50,7 +113,7 @@ class OxapayProvider implements PaymentProviderContract
return Cache::remember('oxapay_currencies', now()->addHour(), function () {
try {
$response = Http::withHeaders([
'merchant_api_key' => $this->config['merchant_api_key'],
'merchant_api_key' => $this->merchantApiKey,
])->get("{$this->baseUrl}/info/currencies");
if ($response->successful()) {
@@ -91,7 +154,24 @@ class OxapayProvider implements PaymentProviderContract
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
@@ -117,43 +197,105 @@ class OxapayProvider implements PaymentProviderContract
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
{
try {
$amount = $options['amount'] ?? $plan->price;
$currency = $options['currency'] ?? 'USD';
$toCurrency = $options['to_currency'] ?? 'USDT';
$amount = (float) $plan->price;
$currency = (string) ($this->config['currency'] ?? 'USD');
// Build simple invoice request payload
$payload = [
'amount' => $amount,
'currency' => $currency,
'to_currency' => $toCurrency,
'lifetime' => $options['lifetime'] ?? 60, // 60 minutes default
'fee_paid_by_payer' => $options['fee_paid_by_payer'] ?? 0,
'callback_url' => $this->config['webhook_url'] ?? route('webhooks.oxapay'),
'return_url' => $this->config['success_url'] ?? route('payment.success'),
'email' => $user->email,
'order_id' => $options['order_id'] ?? null,
'description' => $options['description'] ?? "Payment for {$plan->name}",
'sandbox' => $this->config['sandbox'] ?? false,
'amount' => $amount, // number · decimal
'currency' => $currency, // string
'lifetime' => (int) ($this->config['lifetime'] ?? 30), // integer · min: 15 · max: 2880
'fee_paid_by_payer' => (int) ($this->config['fee_paid_by_payer'] ?? 1), // number · decimal · max: 1
'under_paid_coverage' => (float) ($this->config['under_paid_coverage'] ?? 2.5), // number · decimal · max: 60
'auto_withdrawal' => (bool) ($this->config['auto_withdrawal'] ?? false), // boolean
'mixed_payment' => (bool) ($this->config['mixed_payment'] ?? false), // boolean
'return_url' => (string) ($this->config['success_url'] ?? route('payment.success')), // string
'callback_url' => (string) ($this->config['callback_url'] ?? route('webhook.oxapay')), // string
'order_id' => (string) ($this->config['order_id'] ?? 'order_'.$user->id.'_'.time()), // string
'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([
'merchant_api_key' => $this->config['merchant_api_key'],
'merchant_api_key' => $this->merchantApiKey,
'Content-Type' => 'application/json',
])->post("{$this->baseUrl}/payment/invoice", $payload);
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();
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 [
'success' => true,
'checkout_url' => $data['data']['payment_url'] ?? null,
'payment_id' => $data['data']['track_id'] ?? null,
'expires_at' => $data['data']['expired_at'] ?? null,
'amount' => $amount,
'currency' => $currency,
'checkout_url' => $paymentData['payment_url'] ?? null,
'payment_id' => $paymentData['track_id'] ?? null,
'track_id' => $paymentData['track_id'] ?? null,
'subscription_id' => $subscription->id,
'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_data' => $paymentData,
];
} catch (\Exception $e) {
@@ -186,10 +328,23 @@ class OxapayProvider implements PaymentProviderContract
}
$data = $request->json()->all();
$status = $data['status'] ?? 'unknown';
$status = strtolower($data['status'] ?? 'unknown');
$trackId = $data['track_id'] ?? null;
$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 [
'success' => true,
'event_type' => $status,
@@ -197,6 +352,7 @@ class OxapayProvider implements PaymentProviderContract
'processed' => true,
'data' => $data,
'type' => $type,
'result' => $result,
];
} catch (\Exception $e) {
@@ -219,16 +375,41 @@ class OxapayProvider implements PaymentProviderContract
{
try {
$payload = $request->getContent();
$signature = $request->header('HMAC');
$apiSecret = $this->config['merchant_api_key'];
$signature = $request->header('hmac'); // Use lowercase 'hmac' as per official implementation
if (empty($signature)) {
Log::warning('OxaPay webhook validation failed: missing HMAC header');
if (empty($signature) || empty($apiSecret)) {
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) {
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
{
return $this->config;
@@ -246,14 +577,127 @@ class OxapayProvider implements PaymentProviderContract
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
{
try {
$response = Http::withHeaders([
'merchant_api_key' => $this->config['merchant_api_key'],
'merchant_api_key' => $this->merchantApiKey,
])->get("{$this->baseUrl}/payment/info", [
'track_id' => $paymentId,
]);
@@ -294,7 +738,7 @@ class OxapayProvider implements PaymentProviderContract
{
try {
$response = Http::withHeaders([
'merchant_api_key' => $this->config['merchant_api_key'],
'merchant_api_key' => $this->merchantApiKey,
])->get("{$this->baseUrl}/payment/history", array_merge([
'email' => $user->email,
], $filters));

View File

@@ -116,7 +116,7 @@
</flux:button>
@endif
@if($latestActiveSubscription->isActive() && in_array($latestActiveSubscription->provider, ['activation_key']))
@if($latestActiveSubscription->isActive() && in_array($latestActiveSubscription->provider, ['activation_key', 'oxapay']))
<flux:button
wire:click="confirmCancelSubscription({{ $latestActiveSubscription->id }})"
variant="danger"

View File

@@ -2,27 +2,6 @@
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\WebhookController;
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::post('/webhook/oxapay', [WebhookController::class, 'oxapay'])->name('webhook.oxapay');
Route::post('/webhook/oxapayLegacy', [WebhookController::class, 'oxapay'])->name('webhook.oxapayLegacy');
// Unified Payment System Routes
require __DIR__.'/payment.php';