Add Ladill POS v1 — register, Pay checkout, and commerce links.
Deploy Ladill Mini / deploy (push) Successful in 23s
Deploy Ladill Mini / deploy (push) Successful in 23s
Staff-facing counter register at pos.ladill.com with catalog cart, cash and MoMo/card checkout via Ladill Pay, CRM timeline/import, invoice prefill, and Merchant catalog import. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 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<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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user