- Add unified payment provider architecture with contract-based design - Implement 6 payment providers: Stripe, Lemon Squeezy, Polar, Oxapay, Crypto, Activation Keys - Create subscription management with lifecycle handling (create, cancel, pause, resume, update) - Add coupon system with usage tracking and trial extensions - Build Filament admin resources for payment providers, subscriptions, coupons, and trials - Implement payment orchestration service with provider registry and configuration management - Add comprehensive payment logging and webhook handling for all providers - Create customer analytics dashboard with revenue, churn, and lifetime value metrics - Add subscription migration service for provider switching - Include extensive test coverage for all payment functionality
432 lines
13 KiB
PHP
432 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Payments;
|
|
|
|
use App\Models\PaymentEvent;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class PaymentLogger
|
|
{
|
|
protected array $context = [];
|
|
|
|
public function __construct()
|
|
{
|
|
$this->context = [
|
|
'request_id' => uniqid('pay_', true),
|
|
'timestamp' => now()->toISOString(),
|
|
'user_agent' => $this->getUserAgent(),
|
|
'ip_address' => $this->getIpAddress(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Log a payment event
|
|
*/
|
|
public function logEvent(string $eventType, array $data = [], ?string $level = 'info'): void
|
|
{
|
|
$eventData = array_merge($this->context, [
|
|
'event_type' => $eventType,
|
|
'user_id' => $this->getUserId(),
|
|
'data' => $data,
|
|
'level' => $level,
|
|
]);
|
|
|
|
// Log to Laravel logs
|
|
$this->logToFile("Payment event: {$eventType}", $eventData, $level);
|
|
|
|
// Store in database for audit trail
|
|
$this->storeEvent($eventType, $data, $level);
|
|
}
|
|
|
|
/**
|
|
* Log an error event
|
|
*/
|
|
public function logError(string $eventType, array $data = [], ?\Exception $exception = null): void
|
|
{
|
|
$errorData = $data;
|
|
|
|
if ($exception) {
|
|
$errorData['exception'] = [
|
|
'message' => $exception->getMessage(),
|
|
'file' => $exception->getFile(),
|
|
'line' => $exception->getLine(),
|
|
'trace' => $exception->getTraceAsString(),
|
|
];
|
|
}
|
|
|
|
$this->logEvent($eventType, $errorData, 'error');
|
|
}
|
|
|
|
/**
|
|
* Log a security event
|
|
*/
|
|
public function logSecurityEvent(string $eventType, array $data = []): void
|
|
{
|
|
$securityData = array_merge($data, [
|
|
'security_level' => 'high',
|
|
'requires_review' => true,
|
|
]);
|
|
|
|
$this->logEvent("security_{$eventType}", $securityData, 'warning');
|
|
|
|
// Additional security logging
|
|
$this->logToFile("Security payment event: {$eventType}", array_merge($this->context, $securityData), 'warning');
|
|
}
|
|
|
|
/**
|
|
* Log webhook events
|
|
*/
|
|
public function logWebhook(string $provider, string $eventType, array $payload, bool $success = true): void
|
|
{
|
|
$webhookData = [
|
|
'provider' => $provider,
|
|
'webhook_event_type' => $eventType,
|
|
'payload_size' => strlen(json_encode($payload)),
|
|
'payload_hash' => hash('sha256', json_encode($payload)),
|
|
'success' => $success,
|
|
];
|
|
|
|
// Don't store full payload in logs for security/size reasons
|
|
$this->logEvent('webhook_received', $webhookData, $success ? 'info' : 'error');
|
|
|
|
// Store full payload in database for debugging (with retention policy)
|
|
$this->storeWebhookPayload($provider, $eventType, $payload, $success);
|
|
}
|
|
|
|
/**
|
|
* Log subscription lifecycle events
|
|
*/
|
|
public function logSubscriptionEvent(string $action, int $subscriptionId, array $data = []): void
|
|
{
|
|
$subscriptionData = array_merge($data, [
|
|
'subscription_id' => $subscriptionId,
|
|
'action' => $action,
|
|
]);
|
|
|
|
$this->logEvent("subscription_{$action}", $subscriptionData);
|
|
}
|
|
|
|
/**
|
|
* Log payment method events
|
|
*/
|
|
public function logPaymentMethodEvent(string $action, array $data = []): void
|
|
{
|
|
$this->logEvent("payment_method_{$action}", $data);
|
|
}
|
|
|
|
/**
|
|
* Log provider events
|
|
*/
|
|
public function logProviderEvent(string $provider, string $action, array $data = []): void
|
|
{
|
|
$providerData = array_merge($data, [
|
|
'provider' => $provider,
|
|
'provider_action' => $action,
|
|
]);
|
|
|
|
$this->logEvent("provider_{$action}", $providerData);
|
|
}
|
|
|
|
/**
|
|
* Log admin actions
|
|
*/
|
|
public function logAdminAction(string $action, array $data = []): void
|
|
{
|
|
$adminData = array_merge($data, [
|
|
'admin_user_id' => Auth::id(),
|
|
'admin_action' => $action,
|
|
'requires_review' => in_array($action, ['refund', 'subscription_override', 'provider_config_change']),
|
|
]);
|
|
|
|
$this->logEvent("admin_{$action}", $adminData);
|
|
}
|
|
|
|
/**
|
|
* Log migration events
|
|
*/
|
|
public function logMigrationEvent(string $action, array $data = []): void
|
|
{
|
|
$migrationData = array_merge($data, [
|
|
'migration_action' => $action,
|
|
'batch_id' => $data['batch_id'] ?? null,
|
|
]);
|
|
|
|
$this->logEvent("migration_{$action}", $migrationData);
|
|
}
|
|
|
|
/**
|
|
* Log compliance events
|
|
*/
|
|
public function logComplianceEvent(string $type, array $data = []): void
|
|
{
|
|
$complianceData = array_merge($data, [
|
|
'compliance_type' => $type,
|
|
'retention_required' => true,
|
|
'gdpr_relevant' => in_array($type, ['data_access', 'data_deletion', 'consent_withdrawn']),
|
|
]);
|
|
|
|
$this->logEvent("compliance_{$type}", $complianceData);
|
|
}
|
|
|
|
/**
|
|
* Get audit trail for a specific user
|
|
*/
|
|
public function getUserAuditTrail(int $userId, array $filters = []): array
|
|
{
|
|
$query = PaymentEvent::where('user_id', $userId);
|
|
|
|
if (! empty($filters['event_type'])) {
|
|
$query->where('event_type', $filters['event_type']);
|
|
}
|
|
|
|
if (! empty($filters['date_from'])) {
|
|
$query->where('created_at', '>=', $filters['date_from']);
|
|
}
|
|
|
|
if (! empty($filters['date_to'])) {
|
|
$query->where('created_at', '<=', $filters['date_to']);
|
|
}
|
|
|
|
if (! empty($filters['level'])) {
|
|
$query->where('level', $filters['level']);
|
|
}
|
|
|
|
return $query->orderBy('created_at', 'desc')
|
|
->limit($filters['limit'] ?? 1000)
|
|
->get()
|
|
->toArray();
|
|
}
|
|
|
|
/**
|
|
* Get audit trail for a subscription
|
|
*/
|
|
public function getSubscriptionAuditTrail(int $subscriptionId): array
|
|
{
|
|
return PaymentEvent::whereJsonContains('data->subscription_id', $subscriptionId)
|
|
->orderBy('created_at', 'desc')
|
|
->get()
|
|
->toArray();
|
|
}
|
|
|
|
/**
|
|
* Get provider audit trail
|
|
*/
|
|
public function getProviderAuditTrail(string $provider, array $filters = []): array
|
|
{
|
|
$query = PaymentEvent::whereJsonContains('data->provider', $provider);
|
|
|
|
if (! empty($filters['date_from'])) {
|
|
$query->where('created_at', '>=', $filters['date_from']);
|
|
}
|
|
|
|
if (! empty($filters['date_to'])) {
|
|
$query->where('created_at', '<=', $filters['date_to']);
|
|
}
|
|
|
|
return $query->orderBy('created_at', 'desc')
|
|
->limit($filters['limit'] ?? 1000)
|
|
->get()
|
|
->toArray();
|
|
}
|
|
|
|
/**
|
|
* Generate compliance report
|
|
*/
|
|
public function generateComplianceReport(array $criteria = []): array
|
|
{
|
|
$query = PaymentEvent::query();
|
|
|
|
if (! empty($criteria['date_from'])) {
|
|
$query->where('created_at', '>=', $criteria['date_from']);
|
|
}
|
|
|
|
if (! empty($criteria['date_to'])) {
|
|
$query->where('created_at', '<=', $criteria['date_to']);
|
|
}
|
|
|
|
if (! empty($criteria['event_types'])) {
|
|
$query->whereIn('event_type', $criteria['event_types']);
|
|
}
|
|
|
|
$events = $query->get();
|
|
|
|
return [
|
|
'report_generated_at' => now()->toISOString(),
|
|
'criteria' => $criteria,
|
|
'total_events' => $events->count(),
|
|
'events_by_type' => $events->groupBy('event_type')->map->count(),
|
|
'events_by_level' => $events->groupBy('level')->map->count(),
|
|
'security_events' => $events->filter(fn ($e) => str_contains($e->event_type, 'security'))->count(),
|
|
'compliance_events' => $events->filter(fn ($e) => str_contains($e->event_type, 'compliance'))->count(),
|
|
'retention_summary' => $this->getRetentionSummary($events),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Store event in database
|
|
*/
|
|
protected function storeEvent(string $eventType, array $data, string $level): void
|
|
{
|
|
try {
|
|
PaymentEvent::create([
|
|
'event_type' => $eventType,
|
|
'user_id' => Auth::id(),
|
|
'level' => $level,
|
|
'data' => array_merge($this->context, $data),
|
|
'ip_address' => $this->context['ip_address'],
|
|
'user_agent' => $this->context['user_agent'],
|
|
'request_id' => $this->context['request_id'],
|
|
]);
|
|
} catch (\Exception $e) {
|
|
// Fallback to file logging if database fails
|
|
$this->logToFile('Failed to store payment event in database', [
|
|
'event_type' => $eventType,
|
|
'error' => $e->getMessage(),
|
|
'data' => $data,
|
|
], 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Store webhook payload
|
|
*/
|
|
protected function storeWebhookPayload(string $provider, string $eventType, array $payload, bool $success): void
|
|
{
|
|
try {
|
|
PaymentEvent::create([
|
|
'event_type' => 'webhook_payload',
|
|
'level' => $success ? 'info' : 'error',
|
|
'data' => [
|
|
'provider' => $provider,
|
|
'webhook_event_type' => $eventType,
|
|
'payload' => $payload,
|
|
'success' => $success,
|
|
'stored_at' => now()->toISOString(),
|
|
] + $this->context,
|
|
'ip_address' => $this->context['ip_address'],
|
|
'user_agent' => $this->context['user_agent'],
|
|
'request_id' => $this->context['request_id'],
|
|
'expires_at' => now()->addDays(30), // Webhook payloads expire after 30 days
|
|
]);
|
|
} catch (\Exception $e) {
|
|
$this->logToFile('Failed to store webhook payload', [
|
|
'provider' => $provider,
|
|
'event_type' => $eventType,
|
|
'error' => $e->getMessage(),
|
|
], 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get retention summary for compliance
|
|
*/
|
|
protected function getRetentionSummary($events): array
|
|
{
|
|
$now = now();
|
|
$retentionPeriods = [
|
|
'30_days' => $now->copy()->subDays(30),
|
|
'90_days' => $now->copy()->subDays(90),
|
|
'1_year' => $now->copy()->subYear(),
|
|
'7_years' => $now->copy()->subYears(7),
|
|
];
|
|
|
|
return array_map(static function ($date) use ($events) {
|
|
return $events->where('created_at', '>=', $date)->count();
|
|
}, $retentionPeriods);
|
|
}
|
|
|
|
/**
|
|
* Clean up old events based on retention policy
|
|
*/
|
|
public function cleanupOldEvents(): array
|
|
{
|
|
$cleanupResults = [];
|
|
|
|
// Clean up webhook payloads after 30 days
|
|
$webhookCleanup = PaymentEvent::where('event_type', 'webhook_payload')
|
|
->where('expires_at', '<', now())
|
|
->delete();
|
|
|
|
$cleanupResults['webhook_payloads'] = $webhookCleanup;
|
|
|
|
// Clean up debug events after 90 days
|
|
$debugCleanup = PaymentEvent::where('level', 'debug')
|
|
->where('created_at', '<', now()->subDays(90))
|
|
->delete();
|
|
|
|
$cleanupResults['debug_events'] = $debugCleanup;
|
|
|
|
// Keep compliance and security events for 7 years
|
|
// This is handled by database retention policies
|
|
|
|
$this->logToFile('Payment event cleanup completed', $cleanupResults, 'info');
|
|
|
|
return $cleanupResults;
|
|
}
|
|
|
|
/**
|
|
* Set additional context for logging
|
|
*/
|
|
public function setContext(array $context): void
|
|
{
|
|
$this->context = array_merge($this->context, $context);
|
|
}
|
|
|
|
/**
|
|
* Get current context
|
|
*/
|
|
public function getContext(): array
|
|
{
|
|
return $this->context;
|
|
}
|
|
|
|
/**
|
|
* Get user agent safely
|
|
*/
|
|
protected function getUserAgent(): ?string
|
|
{
|
|
try {
|
|
return request()->userAgent();
|
|
} catch (\Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get IP address safely
|
|
*/
|
|
protected function getIpAddress(): ?string
|
|
{
|
|
try {
|
|
return request()->ip();
|
|
} catch (\Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user ID safely
|
|
*/
|
|
protected function getUserId(): ?int
|
|
{
|
|
try {
|
|
return Auth::id();
|
|
} catch (\Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log to file safely
|
|
*/
|
|
protected function logToFile(string $message, array $context, string $level): void
|
|
{
|
|
try {
|
|
Log::{$level}($message, $context);
|
|
} catch (\Exception $e) {
|
|
// Silently fail if logging isn't available
|
|
}
|
|
}
|
|
}
|