From 136bfbea47d8ded841861395a668f00e1277e779 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 11 Jun 2026 19:04:44 +0000 Subject: [PATCH] Wire seller push notifications with FCM, inbox API, and payment milestones. Sellers get instant payment alerts on Android; withdrawals respect payout prefs. Co-authored-by: Cursor --- .env.example | 5 + app/Http/Controllers/Api/AuthController.php | 1 + .../Api/Mini/NotificationController.php | 76 ++++++++ .../Api/Mini/PushTokenController.php | 51 +++++ .../Controllers/Api/Mini/WalletController.php | 11 +- app/Models/User.php | 13 +- app/Models/UserPushToken.php | 26 +++ .../Mini/MiniAlertNotification.php | 42 +++++ app/Services/Mini/MiniPaymentService.php | 10 +- app/Services/Notifications/FcmService.php | 151 +++++++++++++++ .../Notifications/MiniNotificationService.php | 177 ++++++++++++++++++ config/notifications.php | 17 ++ config/services.php | 5 + ...1_120000_create_user_push_tokens_table.php | 36 ++++ routes/api.php | 10 + 15 files changed, 626 insertions(+), 5 deletions(-) create mode 100644 app/Http/Controllers/Api/Mini/NotificationController.php create mode 100644 app/Http/Controllers/Api/Mini/PushTokenController.php create mode 100644 app/Models/UserPushToken.php create mode 100644 app/Notifications/Mini/MiniAlertNotification.php create mode 100644 app/Services/Notifications/FcmService.php create mode 100644 app/Services/Notifications/MiniNotificationService.php create mode 100644 config/notifications.php create mode 100644 database/migrations/2026_06_11_120000_create_user_push_tokens_table.php diff --git a/.env.example b/.env.example index 12a2e43..ab817b3 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,11 @@ PAY_API_KEY_MINI= IDENTITY_API_URL=https://ladill.com/api IDENTITY_API_KEY_MINI= +# Firebase Cloud Messaging — instant seller payment alerts on Android. +FIREBASE_PROJECT_ID= +FIREBASE_SERVICE_ACCOUNT_JSON= +NOTIFICATIONS_FCM_ACTIVE_USER_DAYS=60 + AFIA_ENABLED=true AFIA_PRODUCT=mini AFIA_PROVIDER=openai diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php index d3806fd..74cc874 100644 --- a/app/Http/Controllers/Api/AuthController.php +++ b/app/Http/Controllers/Api/AuthController.php @@ -158,6 +158,7 @@ class AuthController extends Controller private function issueToken(User $user, ?string $deviceName): JsonResponse { QrTeamMember::linkPendingInvitesFor($user); + $user->update(['last_app_active_at' => now()]); $token = $user->createToken($deviceName ?: 'Ladill Mini Android', ['mini:read', 'mini:write']); diff --git a/app/Http/Controllers/Api/Mini/NotificationController.php b/app/Http/Controllers/Api/Mini/NotificationController.php new file mode 100644 index 0000000..d8c29db --- /dev/null +++ b/app/Http/Controllers/Api/Mini/NotificationController.php @@ -0,0 +1,76 @@ +notifications() + ->latest() + ->limit(100) + ->get() + ->map(fn ($n) => $this->present($n)); + + $unread = $account->unreadNotifications()->count(); + + return response()->json([ + 'data' => $notifications, + 'unread_count' => $unread, + ]); + } + + public function unreadCount(Request $request): JsonResponse + { + $account = ladill_account(); + + return response()->json([ + 'data' => [ + 'unread_count' => $account->unreadNotifications()->count(), + ], + ]); + } + + public function markAsRead(Request $request, string $id): JsonResponse + { + $notification = ladill_account() + ->notifications() + ->where('id', $id) + ->first(); + + if ($notification) { + $notification->markAsRead(); + } + + return response()->json(['data' => ['success' => true]]); + } + + public function markAllAsRead(Request $request): JsonResponse + { + ladill_account()->unreadNotifications->markAsRead(); + + return response()->json(['data' => ['success' => true]]); + } + + /** @return array */ + private function present(mixed $notification): array + { + return [ + 'id' => $notification->id, + 'type' => class_basename($notification->type), + 'title' => $notification->data['title'] ?? 'Notification', + 'message' => $notification->data['message'] ?? '', + 'icon' => $notification->data['icon'] ?? 'bell', + 'milestone' => $notification->data['milestone'] ?? null, + 'url' => $notification->data['url'] ?? null, + 'read_at' => $notification->read_at?->toIso8601String(), + 'created_at' => $notification->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Mini/PushTokenController.php b/app/Http/Controllers/Api/Mini/PushTokenController.php new file mode 100644 index 0000000..e29ab85 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/PushTokenController.php @@ -0,0 +1,51 @@ +validate([ + 'token' => ['required', 'string', 'max:512'], + 'platform' => ['nullable', 'string', 'max:32'], + 'device_name' => ['nullable', 'string', 'max:120'], + ]); + + $user = $request->user(); + $now = now(); + + UserPushToken::updateOrCreate( + ['token' => $data['token']], + [ + 'user_id' => $user->id, + 'platform' => $data['platform'] ?? 'android', + 'device_name' => $data['device_name'] ?? $user->currentAccessToken()?->name, + 'last_seen_at' => $now, + ], + ); + + $user->update(['last_app_active_at' => $now]); + + return response()->json(['data' => ['registered' => true]]); + } + + public function destroy(Request $request): JsonResponse + { + $data = $request->validate([ + 'token' => ['required', 'string', 'max:512'], + ]); + + $request->user() + ->pushTokens() + ->where('token', $data['token']) + ->delete(); + + return response()->json(['data' => ['removed' => true]]); + } +} diff --git a/app/Http/Controllers/Api/Mini/WalletController.php b/app/Http/Controllers/Api/Mini/WalletController.php index 576362b..e3ce5ec 100644 --- a/app/Http/Controllers/Api/Mini/WalletController.php +++ b/app/Http/Controllers/Api/Mini/WalletController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Mini; use App\Http\Controllers\Api\Concerns\CallsIdentityApi; use App\Http\Controllers\Controller; use App\Services\Billing\BillingClient; +use App\Services\Notifications\MiniNotificationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -14,7 +15,10 @@ class WalletController extends Controller { use CallsIdentityApi; - public function __construct(private BillingClient $billing) {} + public function __construct( + private BillingClient $billing, + private MiniNotificationService $notifications, + ) {} public function show(): JsonResponse { @@ -118,6 +122,11 @@ class WalletController extends Controller ]); $this->rethrowValidation($response, 'amount'); + $this->notifications->withdrawalSubmitted( + ladill_account(), + (float) $data['amount'], + ); + return response()->json(['data' => $response->json('data', [])]); } } diff --git a/app/Models/User.php b/app/Models/User.php index 23dae3b..b158607 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -19,13 +19,17 @@ class User extends Authenticatable /** @use HasFactory */ use HasApiTokens, HasFactory, Notifiable; - protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password']; + protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password', 'last_app_active_at']; protected $hidden = ['password', 'remember_token']; protected function casts(): array { - return ['email_verified_at' => 'datetime', 'password' => 'hashed']; + return [ + 'email_verified_at' => 'datetime', + 'last_app_active_at' => 'datetime', + 'password' => 'hashed', + ]; } public function memberships(): HasMany @@ -63,6 +67,11 @@ class User extends Authenticatable return $this->hasOne(QrSetting::class); } + public function pushTokens(): HasMany + { + return $this->hasMany(UserPushToken::class); + } + public function getOrCreateQrSetting(): QrSetting { return $this->qrSetting()->firstOrCreate([]); diff --git a/app/Models/UserPushToken.php b/app/Models/UserPushToken.php new file mode 100644 index 0000000..ebe33ff --- /dev/null +++ b/app/Models/UserPushToken.php @@ -0,0 +1,26 @@ + 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Notifications/Mini/MiniAlertNotification.php b/app/Notifications/Mini/MiniAlertNotification.php new file mode 100644 index 0000000..954e951 --- /dev/null +++ b/app/Notifications/Mini/MiniAlertNotification.php @@ -0,0 +1,42 @@ + $extra + */ + public function __construct( + private readonly string $title, + private readonly string $message, + private readonly string $icon, + private readonly ?string $url, + private readonly string $milestone, + private readonly array $extra = [], + ) {} + + /** @return list */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return array_merge([ + 'title' => $this->title, + 'message' => $this->message, + 'icon' => $this->icon, + 'url' => $this->url, + 'milestone' => $this->milestone, + ], $this->extra); + } +} diff --git a/app/Services/Mini/MiniPaymentService.php b/app/Services/Mini/MiniPaymentService.php index 320986c..9752685 100644 --- a/app/Services/Mini/MiniPaymentService.php +++ b/app/Services/Mini/MiniPaymentService.php @@ -7,6 +7,7 @@ use App\Models\QrCode; use App\Services\Billing\BillingClient; use App\Services\Billing\PaystackService; use App\Services\Billing\SmsService; +use App\Services\Notifications\MiniNotificationService; use App\Services\Pay\PayClient; use Illuminate\Support\Str; use RuntimeException; @@ -18,6 +19,7 @@ class MiniPaymentService private PaystackService $paystack, private BillingClient $billing, private SmsService $sms, + private MiniNotificationService $notifications, ) {} /** @@ -119,7 +121,9 @@ class MiniPaymentService 'metadata' => array_merge((array) $payment->metadata, ['ladill_pay' => $payOrder]), ]); - $this->notifyPayer($payment->fresh(['qrCode', 'merchant'])); + $payment = $payment->fresh(['qrCode', 'merchant']); + $this->notifyPayer($payment); + $this->notifications->paymentReceived($payment); return $payment; } @@ -162,7 +166,9 @@ class MiniPaymentService sprintf('Payment via %s', $businessName), ); - $this->notifyPayer($payment->fresh(['qrCode', 'merchant'])); + $payment = $payment->fresh(['qrCode', 'merchant']); + $this->notifyPayer($payment); + $this->notifications->paymentReceived($payment); return $payment; } diff --git a/app/Services/Notifications/FcmService.php b/app/Services/Notifications/FcmService.php new file mode 100644 index 0000000..ea3e2ea --- /dev/null +++ b/app/Services/Notifications/FcmService.php @@ -0,0 +1,151 @@ +isConfigured()) { + return self::FAILED; + } + + $projectId = (string) config('services.fcm.project_id'); + $accessToken = $this->accessToken(); + + $response = Http::withToken($accessToken) + ->timeout(15) + ->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", [ + 'message' => [ + 'token' => $fcmToken, + 'notification' => compact('title', 'body'), + 'data' => array_map('strval', $data), + 'android' => [ + 'priority' => 'high', + 'notification' => [ + 'sound' => 'default', + 'channel_id' => 'payments', + ], + ], + ], + ]); + + if ($response->successful()) { + return self::DELIVERED; + } + + $outcome = $this->classifyFailure($response); + + Log::warning('FCM push failed', [ + 'token' => substr($fcmToken, 0, 20).'…', + 'status' => $response->status(), + 'outcome' => $outcome, + 'body' => $response->body(), + ]); + + return $outcome; + } + + /** + * @return self::INVALID_TOKEN|self::FAILED + */ + private function classifyFailure(Response $response): string + { + $statusCode = $response->status(); + $body = strtolower((string) $response->body()); + $apiStatus = strtoupper((string) data_get($response->json(), 'error.status', '')); + + if ($statusCode === 404 + || $apiStatus === 'NOT_FOUND' + || str_contains($body, 'not_found') + || str_contains($body, 'requested entity was not found') + || str_contains($body, 'registration token is not a valid') + || str_contains($body, 'invalid registration') + || str_contains($body, 'unregistered')) { + return self::INVALID_TOKEN; + } + + return self::FAILED; + } + + private function accessToken(): string + { + return Cache::remember('mini_fcm_access_token', 3300, function (): string { + $sa = $this->serviceAccount(); + $jwt = $this->buildJwt($sa); + + $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]); + + if (! $response->successful()) { + throw new \RuntimeException('FCM OAuth2 token exchange failed: '.$response->body()); + } + + return $response->json('access_token'); + }); + } + + private function buildJwt(array $sa): string + { + $header = $this->base64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $now = time(); + $payload = $this->base64url(json_encode([ + 'iss' => $sa['client_email'], + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => 'https://oauth2.googleapis.com/token', + 'iat' => $now, + 'exp' => $now + 3600, + ])); + + $message = "{$header}.{$payload}"; + openssl_sign($message, $signature, $sa['private_key'], 'sha256WithRSAEncryption'); + + return "{$message}.{$this->base64url($signature)}"; + } + + private function base64url(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** @return array */ + private function serviceAccount(): array + { + $value = config('services.fcm.service_account_json'); + + if (blank($value)) { + throw new \RuntimeException('FCM service account JSON is not configured.'); + } + + $json = is_file($value) ? file_get_contents($value) : $value; + $decoded = json_decode((string) $json, true); + + if (! is_array($decoded) || empty($decoded['private_key'])) { + throw new \RuntimeException('FCM service account JSON is invalid or missing private_key.'); + } + + return $decoded; + } +} diff --git a/app/Services/Notifications/MiniNotificationService.php b/app/Services/Notifications/MiniNotificationService.php new file mode 100644 index 0000000..02d96c5 --- /dev/null +++ b/app/Services/Notifications/MiniNotificationService.php @@ -0,0 +1,177 @@ +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 via %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'; + } +} diff --git a/config/notifications.php b/config/notifications.php new file mode 100644 index 0000000..82c6f9b --- /dev/null +++ b/config/notifications.php @@ -0,0 +1,17 @@ + (int) env('NOTIFICATIONS_FCM_ACTIVE_USER_DAYS', 60), + +]; diff --git a/config/services.php b/config/services.php index 2f3ab98..1c3a4f2 100644 --- a/config/services.php +++ b/config/services.php @@ -73,4 +73,9 @@ return [ 'checkout_email' => env('PAYSTACK_CHECKOUT_EMAIL', 'pay@ladill.com'), ], + 'fcm' => [ + 'project_id' => env('FIREBASE_PROJECT_ID'), + 'service_account_json' => env('FIREBASE_SERVICE_ACCOUNT_JSON'), + ], + ]; diff --git a/database/migrations/2026_06_11_120000_create_user_push_tokens_table.php b/database/migrations/2026_06_11_120000_create_user_push_tokens_table.php new file mode 100644 index 0000000..d785369 --- /dev/null +++ b/database/migrations/2026_06_11_120000_create_user_push_tokens_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('token', 512)->unique(); + $table->string('platform', 32)->default('android'); + $table->string('device_name')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'updated_at']); + }); + + Schema::table('users', function (Blueprint $table) { + $table->timestamp('last_app_active_at')->nullable()->after('remember_token'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('last_app_active_at'); + }); + + Schema::dropIfExists('user_push_tokens'); + } +}; diff --git a/routes/api.php b/routes/api.php index e9a2b1d..a0e2450 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,7 +4,9 @@ use App\Http\Controllers\Api\AuthController; use App\Http\Controllers\Api\MeController; use App\Http\Controllers\Api\Mini\AccountController as MiniAccountController; use App\Http\Controllers\Api\Mini\AfiaController as MiniAfiaController; +use App\Http\Controllers\Api\Mini\NotificationController as MiniNotificationController; use App\Http\Controllers\Api\Mini\OverviewController as MiniOverviewController; +use App\Http\Controllers\Api\Mini\PushTokenController as MiniPushTokenController; use App\Http\Controllers\Api\Mini\PaymentQrController as MiniPaymentQrController; use App\Http\Controllers\Api\Mini\PaymentsController as MiniPaymentsController; use App\Http\Controllers\Api\Mini\PayoutsController as MiniPayoutsController; @@ -31,6 +33,14 @@ Route::prefix('v1')->group(function () { // Ladill Mini — payments product. Route::get('/mini/overview', MiniOverviewController::class)->name('api.mini.overview'); + + Route::get('/mini/notifications', [MiniNotificationController::class, 'index'])->name('api.mini.notifications.index'); + Route::get('/mini/notifications/unread-count', [MiniNotificationController::class, 'unreadCount'])->name('api.mini.notifications.unread-count'); + Route::post('/mini/notifications/{id}/read', [MiniNotificationController::class, 'markAsRead'])->name('api.mini.notifications.read'); + Route::post('/mini/notifications/mark-all-read', [MiniNotificationController::class, 'markAllAsRead'])->name('api.mini.notifications.mark-all-read'); + + Route::post('/mini/push-token', [MiniPushTokenController::class, 'store'])->name('api.mini.push-token.store'); + Route::delete('/mini/push-token', [MiniPushTokenController::class, 'destroy'])->name('api.mini.push-token.destroy'); Route::post('/mini/afia/chat', [MiniAfiaController::class, 'chat'])->middleware('throttle:30,1')->name('api.mini.afia.chat'); Route::get('/mini/payment-qrs', [MiniPaymentQrController::class, 'index'])->name('api.mini.payment-qrs.index'); Route::post('/mini/payment-qrs', [MiniPaymentQrController::class, 'store'])->name('api.mini.payment-qrs.store');