Wire seller push notifications with FCM, inbox API, and payment milestones.
Deploy Ladill Mini / deploy (push) Successful in 52s
Deploy Ladill Mini / deploy (push) Successful in 52s
Sellers get instant payment alerts on Android; withdrawals respect payout prefs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,6 +40,11 @@ PAY_API_KEY_MINI=
|
|||||||
IDENTITY_API_URL=https://ladill.com/api
|
IDENTITY_API_URL=https://ladill.com/api
|
||||||
IDENTITY_API_KEY_MINI=
|
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_ENABLED=true
|
||||||
AFIA_PRODUCT=mini
|
AFIA_PRODUCT=mini
|
||||||
AFIA_PROVIDER=openai
|
AFIA_PROVIDER=openai
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ class AuthController extends Controller
|
|||||||
private function issueToken(User $user, ?string $deviceName): JsonResponse
|
private function issueToken(User $user, ?string $deviceName): JsonResponse
|
||||||
{
|
{
|
||||||
QrTeamMember::linkPendingInvitesFor($user);
|
QrTeamMember::linkPendingInvitesFor($user);
|
||||||
|
$user->update(['last_app_active_at' => now()]);
|
||||||
|
|
||||||
$token = $user->createToken($deviceName ?: 'Ladill Mini Android', ['mini:read', 'mini:write']);
|
$token = $user->createToken($deviceName ?: 'Ladill Mini Android', ['mini:read', 'mini:write']);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class NotificationController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$notifications = $account->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<string, mixed> */
|
||||||
|
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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\UserPushToken;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PushTokenController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->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]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Mini;
|
|||||||
use App\Http\Controllers\Api\Concerns\CallsIdentityApi;
|
use App\Http\Controllers\Api\Concerns\CallsIdentityApi;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\Billing\BillingClient;
|
use App\Services\Billing\BillingClient;
|
||||||
|
use App\Services\Notifications\MiniNotificationService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -14,7 +15,10 @@ class WalletController extends Controller
|
|||||||
{
|
{
|
||||||
use CallsIdentityApi;
|
use CallsIdentityApi;
|
||||||
|
|
||||||
public function __construct(private BillingClient $billing) {}
|
public function __construct(
|
||||||
|
private BillingClient $billing,
|
||||||
|
private MiniNotificationService $notifications,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function show(): JsonResponse
|
public function show(): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -118,6 +122,11 @@ class WalletController extends Controller
|
|||||||
]);
|
]);
|
||||||
$this->rethrowValidation($response, 'amount');
|
$this->rethrowValidation($response, 'amount');
|
||||||
|
|
||||||
|
$this->notifications->withdrawalSubmitted(
|
||||||
|
ladill_account(),
|
||||||
|
(float) $data['amount'],
|
||||||
|
);
|
||||||
|
|
||||||
return response()->json(['data' => $response->json('data', [])]);
|
return response()->json(['data' => $response->json('data', [])]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -19,13 +19,17 @@ class User extends Authenticatable
|
|||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
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 $hidden = ['password', 'remember_token'];
|
||||||
|
|
||||||
protected function casts(): array
|
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
|
public function memberships(): HasMany
|
||||||
@@ -63,6 +67,11 @@ class User extends Authenticatable
|
|||||||
return $this->hasOne(QrSetting::class);
|
return $this->hasOne(QrSetting::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function pushTokens(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserPushToken::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function getOrCreateQrSetting(): QrSetting
|
public function getOrCreateQrSetting(): QrSetting
|
||||||
{
|
{
|
||||||
return $this->qrSetting()->firstOrCreate([]);
|
return $this->qrSetting()->firstOrCreate([]);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserPushToken extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'token',
|
||||||
|
'platform',
|
||||||
|
'device_name',
|
||||||
|
'last_seen_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'last_seen_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications\Mini;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class MiniAlertNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $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<string> */
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use App\Models\QrCode;
|
|||||||
use App\Services\Billing\BillingClient;
|
use App\Services\Billing\BillingClient;
|
||||||
use App\Services\Billing\PaystackService;
|
use App\Services\Billing\PaystackService;
|
||||||
use App\Services\Billing\SmsService;
|
use App\Services\Billing\SmsService;
|
||||||
|
use App\Services\Notifications\MiniNotificationService;
|
||||||
use App\Services\Pay\PayClient;
|
use App\Services\Pay\PayClient;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@@ -18,6 +19,7 @@ class MiniPaymentService
|
|||||||
private PaystackService $paystack,
|
private PaystackService $paystack,
|
||||||
private BillingClient $billing,
|
private BillingClient $billing,
|
||||||
private SmsService $sms,
|
private SmsService $sms,
|
||||||
|
private MiniNotificationService $notifications,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,7 +121,9 @@ class MiniPaymentService
|
|||||||
'metadata' => array_merge((array) $payment->metadata, ['ladill_pay' => $payOrder]),
|
'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;
|
return $payment;
|
||||||
}
|
}
|
||||||
@@ -162,7 +166,9 @@ class MiniPaymentService
|
|||||||
sprintf('Payment via %s', $businessName),
|
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;
|
return $payment;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class FcmService
|
||||||
|
{
|
||||||
|
public const DELIVERED = 'ok';
|
||||||
|
|
||||||
|
public const INVALID_TOKEN = 'invalid_token';
|
||||||
|
|
||||||
|
public const FAILED = 'failed';
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return filled(config('services.fcm.project_id'))
|
||||||
|
&& filled(config('services.fcm.service_account_json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return self::DELIVERED|self::INVALID_TOKEN|self::FAILED
|
||||||
|
*/
|
||||||
|
public function send(string $fcmToken, string $title, string $body, array $data = []): string
|
||||||
|
{
|
||||||
|
if (! $this->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<string, mixed> */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Notifications;
|
||||||
|
|
||||||
|
use App\Models\MiniPayment;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\Mini\MiniAlertNotification;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class MiniNotificationService
|
||||||
|
{
|
||||||
|
public function __construct(private FcmService $fcm) {}
|
||||||
|
|
||||||
|
public function paymentReceived(MiniPayment $payment): void
|
||||||
|
{
|
||||||
|
$payment->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<string, mixed> $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<string, mixed> $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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| FCM push for Ladill Mini Android
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Sellers receive instant payment alerts when a device token is registered
|
||||||
|
| and the account was active in the app within this window.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'fcm_active_user_within_days' => (int) env('NOTIFICATIONS_FCM_ACTIVE_USER_DAYS', 60),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -73,4 +73,9 @@ return [
|
|||||||
'checkout_email' => env('PAYSTACK_CHECKOUT_EMAIL', 'pay@ladill.com'),
|
'checkout_email' => env('PAYSTACK_CHECKOUT_EMAIL', 'pay@ladill.com'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'fcm' => [
|
||||||
|
'project_id' => env('FIREBASE_PROJECT_ID'),
|
||||||
|
'service_account_json' => env('FIREBASE_SERVICE_ACCOUNT_JSON'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_push_tokens', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,7 +4,9 @@ use App\Http\Controllers\Api\AuthController;
|
|||||||
use App\Http\Controllers\Api\MeController;
|
use App\Http\Controllers\Api\MeController;
|
||||||
use App\Http\Controllers\Api\Mini\AccountController as MiniAccountController;
|
use App\Http\Controllers\Api\Mini\AccountController as MiniAccountController;
|
||||||
use App\Http\Controllers\Api\Mini\AfiaController as MiniAfiaController;
|
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\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\PaymentQrController as MiniPaymentQrController;
|
||||||
use App\Http\Controllers\Api\Mini\PaymentsController as MiniPaymentsController;
|
use App\Http\Controllers\Api\Mini\PaymentsController as MiniPaymentsController;
|
||||||
use App\Http\Controllers\Api\Mini\PayoutsController as MiniPayoutsController;
|
use App\Http\Controllers\Api\Mini\PayoutsController as MiniPayoutsController;
|
||||||
@@ -31,6 +33,14 @@ Route::prefix('v1')->group(function () {
|
|||||||
|
|
||||||
// Ladill Mini — payments product.
|
// Ladill Mini — payments product.
|
||||||
Route::get('/mini/overview', MiniOverviewController::class)->name('api.mini.overview');
|
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::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::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');
|
Route::post('/mini/payment-qrs', [MiniPaymentQrController::class, 'store'])->name('api.mini.payment-qrs.store');
|
||||||
|
|||||||
Reference in New Issue
Block a user