loadMissing(['qrCode', 'merchant']); $merchant = $payment->merchant; if (! $merchant) { return; } $businessName = $payment->qrCode?->content()['business_name'] ?? $payment->qrCode?->label ?? 'Payment QR'; $amount = $this->formatMoney($payment->currency, $payment->merchant_amount_minor ?: $payment->amount_minor); $title = 'Payment received'; $message = sprintf('%s paid %s to %s.', $this->payerLabel($payment), $amount, $businessName); $this->alert( $merchant, $title, $message, 'payment', route('mini.payments.index'), 'payment_received', [ 'payment_id' => $payment->id, 'reference' => $payment->reference, 'amount_minor' => $payment->amount_minor, 'currency' => $payment->currency, ], respectPayoutPref: false, ); } public function withdrawalSubmitted(User $user, float $amountMajor, string $currency = 'GHS'): void { if (! $this->payoutAlertsEnabled($user)) { return; } $amount = sprintf('%s %s', $currency, number_format($amountMajor, 2)); $this->alert( $user, 'Withdrawal requested', sprintf('Your withdrawal of %s has been submitted and is being processed.', $amount), 'payout', route('mini.payouts'), 'withdrawal_submitted', ['amount_major' => $amountMajor, 'currency' => $currency], respectPayoutPref: false, ); } public function walletCredited(User $user, int $amountMinor, string $currency, string $description): void { if (! $this->payoutAlertsEnabled($user)) { return; } $amount = $this->formatMoney($currency, $amountMinor); $this->alert( $user, 'Wallet credited', sprintf('%s added to your wallet. %s', $amount, $description), 'wallet', route('mini.payouts'), 'wallet_credited', ['amount_minor' => $amountMinor, 'currency' => $currency], respectPayoutPref: false, ); } /** * @param array $extra */ private function alert( User $user, string $title, string $message, string $icon, ?string $url, string $milestone, array $extra = [], bool $respectPayoutPref = true, ): void { if ($respectPayoutPref && ! $this->payoutAlertsEnabled($user)) { return; } $user->notify(new MiniAlertNotification($title, $message, $icon, $url, $milestone, $extra)); $this->pushToDevices($user, $title, $message, array_merge($extra, [ 'milestone' => $milestone, 'url' => $url, ])); } /** @param array $data */ private function pushToDevices(User $user, string $title, string $body, array $data = []): void { if (! $this->fcm->isConfigured() || ! $this->shouldAttemptFcm($user)) { return; } $windowDays = (int) config('notifications.fcm_active_user_within_days', 60); $cutoff = now()->subDays($windowDays); $tokens = $user->pushTokens() ->where(function ($query) use ($cutoff) { $query->where('last_seen_at', '>=', $cutoff) ->orWhere('updated_at', '>=', $cutoff); }) ->get(); foreach ($tokens as $device) { try { $outcome = $this->fcm->send($device->token, $title, $body, $data); if ($outcome === FcmService::INVALID_TOKEN) { $device->delete(); } } catch (\Throwable $e) { Log::warning('Mini push failed', [ 'user_id' => $user->id, 'error' => $e->getMessage(), ]); } } } private function shouldAttemptFcm(User $user): bool { if ($user->pushTokens()->doesntExist()) { return false; } $windowDays = (int) config('notifications.fcm_active_user_within_days', 60); if ($user->last_app_active_at === null) { return false; } return $user->last_app_active_at->gte(now()->subDays($windowDays)); } private function payoutAlertsEnabled(User $user): bool { return (bool) ($user->getOrCreateQrSetting()->notify_payouts ?? true); } private function formatMoney(string $currency, int $amountMinor): string { return sprintf('%s %s', $currency, number_format($amountMinor / 100, 2)); } private function payerLabel(MiniPayment $payment): string { if ($payment->payer_name) { return $payment->payer_name; } if ($payment->payer_phone) { return $payment->payer_phone; } return 'A customer'; } }