Add mobile API for Ladill Mini Android app.
Deploy Ladill Mini / deploy (push) Successful in 47s

Token login via OAuth password grant against auth.ladill.com (AuthController),
plus /api/v1/mini endpoints (overview, payment-qrs CRUD + preview, payments,
payouts) mirroring the web Mini controllers. Sanctum abilities mini:read/write.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-10 22:13:30 +00:00
co-authored by Claude Opus 4.8
parent 70ded0e668
commit 307248749b
7 changed files with 501 additions and 7 deletions
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
/**
* Token login for the Ladill Mini mobile app.
*
* Mirrors the web "Sign in with Ladill" flow (SsoLoginController) but uses the
* OAuth Resource Owner Password grant so the native app can collect the
* credentials directly. On success we provision the same thin local user
* mirror keyed by the OIDC `sub` and hand back a Sanctum personal access token
* the app stores for `auth:sanctum` requests.
*/
class AuthController extends Controller
{
public function login(Request $request): JsonResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'device_name' => ['nullable', 'string', 'max:120'],
]);
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'password',
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'username' => $credentials['email'],
'password' => $credentials['password'],
'scope' => 'openid profile email',
]);
if ($tokenRes->failed()) {
throw ValidationException::withMessages([
'email' => ['The email or password is incorrect.'],
]);
}
$user = $this->provisionFromAccessToken($issuer, (string) $tokenRes->json('access_token'));
if (! $user) {
throw ValidationException::withMessages([
'email' => ['We could not verify your Ladill account. Please try again.'],
]);
}
QrTeamMember::linkPendingInvitesFor($user);
$deviceName = $credentials['device_name'] ?? 'Ladill Mini Android';
$token = $user->createToken($deviceName, ['mini:read', 'mini:write']);
return response()->json([
'data' => [
'token' => $token->plainTextToken,
'user' => $this->presentUser($user),
],
]);
}
public function logout(Request $request): JsonResponse
{
$token = $request->user()?->currentAccessToken();
$token?->delete();
return response()->json(['data' => ['message' => 'Signed out.']]);
}
public function me(Request $request): JsonResponse
{
return response()->json(['data' => $this->presentUser($request->user())]);
}
private function provisionFromAccessToken(string $issuer, string $accessToken): ?User
{
if ($accessToken === '') {
return null;
}
$claims = Http::withToken($accessToken)->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return null;
}
$email = (string) ($claims->json('email') ?: '');
return User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
}
/** @return array<string, mixed> */
private function presentUser(User $user): array
{
$account = ladill_account();
return [
'id' => $user->id,
'public_id' => $user->public_id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $user->avatarUrl(),
'acting_account' => [
'id' => $account->id,
'public_id' => $account->public_id,
'name' => $account->name,
'email' => $account->email,
],
'accessible_account_ids' => $user->accessibleAccounts()->pluck('id')->values(),
];
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Controller;
use App\Models\MiniPayment;
use App\Models\QrCode;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Throwable;
class OverviewController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function __invoke(): JsonResponse
{
$account = ladill_account();
$qrIds = $account->qrCodes()
->where('type', QrCode::TYPE_PAYMENT)
->pluck('id');
$todayPayments = MiniPayment::query()
->whereIn('qr_code_id', $qrIds)
->where('status', MiniPayment::STATUS_PAID)
->where('paid_at', '>=', now()->startOfDay());
$recentPayments = MiniPayment::query()
->whereIn('qr_code_id', $qrIds)
->where('status', MiniPayment::STATUS_PAID)
->with('qrCode')
->latest('paid_at')
->limit(8)
->get();
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Mini API overview could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return response()->json([
'data' => [
'currency' => 'GHS',
'today_takings_minor' => (int) (clone $todayPayments)->sum('merchant_amount_minor'),
'today_count' => (clone $todayPayments)->count(),
'payment_qr_count' => $qrIds->count(),
'wallet_balance_minor' => $balanceMinor,
'recent_payments' => $recentPayments->map(fn (MiniPayment $p) => PaymentPresenter::present($p))->values(),
],
]);
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Models\MiniPayment;
use App\Models\QrCode;
/**
* Shared JSON shape for Mini payments and payment QRs so the Android app and
* the web views stay describing the same records.
*/
class PaymentPresenter
{
/** @return array<string, mixed> */
public static function present(MiniPayment $payment): array
{
return [
'id' => $payment->id,
'reference' => $payment->reference,
'amount_minor' => $payment->amount_minor,
'merchant_amount_minor' => $payment->merchant_amount_minor,
'platform_fee_minor' => $payment->platform_fee_minor,
'currency' => $payment->currency,
'status' => $payment->status,
'payer_name' => $payment->payer_name,
'payer_email' => $payment->payer_email,
'payer_phone' => $payment->payer_phone,
'payer_note' => $payment->payer_note,
'qr_code_id' => $payment->qr_code_id,
'qr_label' => $payment->qrCode?->label,
'paid_at' => $payment->paid_at?->toIso8601String(),
'created_at' => $payment->created_at?->toIso8601String(),
];
}
/** @return array<string, mixed> */
public static function presentQr(QrCode $qr, ?string $previewUrl = null): array
{
$content = $qr->content();
return [
'id' => $qr->id,
'label' => $qr->label,
'business_name' => $content['business_name'] ?? null,
'branch_label' => $content['branch_label'] ?? null,
'currency' => $content['currency'] ?? 'GHS',
'short_code' => $qr->short_code,
'public_url' => $qr->publicUrl(),
'is_active' => $qr->is_active,
'scans_total' => $qr->scans_total,
'preview_url' => $previewUrl,
'created_at' => $qr->created_at?->toIso8601String(),
'updated_at' => $qr->updated_at?->toIso8601String(),
];
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
class PaymentQrController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrImageGeneratorService $imageGenerator,
) {}
public function index(): JsonResponse
{
$qrCodes = ladill_account()->qrCodes()
->where('type', QrCode::TYPE_PAYMENT)
->latest()
->get();
return response()->json([
'data' => $qrCodes->map(fn (QrCode $qr) => PaymentPresenter::presentQr($qr, $this->previewUrl($qr)))->values(),
]);
}
public function store(Request $request): JsonResponse
{
abort_unless($request->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.');
$data = $request->validate([
'label' => ['required', 'string', 'max:120'],
'business_name' => ['required', 'string', 'max:120'],
'branch_label' => ['nullable', 'string', 'max:80'],
]);
$data['type'] = QrCode::TYPE_PAYMENT;
$data['currency'] = 'GHS';
try {
$qrCode = $this->manager->create(ladill_account(), $data);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json([
'data' => PaymentPresenter::presentQr($qrCode->fresh(), $this->previewUrl($qrCode)),
], 201);
}
public function show(QrCode $paymentQr): JsonResponse
{
$this->authorizePaymentQr($paymentQr);
return response()->json([
'data' => PaymentPresenter::presentQr($paymentQr, $this->previewUrl($paymentQr)),
]);
}
public function update(Request $request, QrCode $paymentQr): JsonResponse
{
abort_unless($request->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.');
$this->authorizePaymentQr($paymentQr);
$data = $request->validate([
'label' => ['sometimes', 'string', 'max:120'],
'business_name' => ['sometimes', 'string', 'max:120'],
'branch_label' => ['nullable', 'string', 'max:80'],
'is_active' => ['sometimes', 'boolean'],
]);
if ($request->has('is_active')) {
$data['is_active'] = $request->boolean('is_active');
}
try {
$this->manager->update($paymentQr, $data);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json([
'data' => PaymentPresenter::presentQr($paymentQr->fresh(), $this->previewUrl($paymentQr)),
]);
}
public function destroy(QrCode $paymentQr): JsonResponse
{
abort_unless(request()->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.');
$this->authorizePaymentQr($paymentQr);
$this->manager->delete($paymentQr);
return response()->json(['data' => ['message' => 'Payment QR deleted.']]);
}
public function preview(QrCode $paymentQr): Response
{
$this->authorizePaymentQr($paymentQr);
$qrCode = $this->imageGenerator->ensureValidImages($paymentQr);
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response($bytes, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'private, max-age=3600',
]);
}
private function previewUrl(QrCode $qr): string
{
return route('api.mini.payment-qrs.preview', $qr);
}
private function authorizePaymentQr(QrCode $paymentQr): void
{
abort_unless($paymentQr->type === QrCode::TYPE_PAYMENT, 404);
abort_unless($paymentQr->user_id === ladill_account()->id, 403);
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Controller;
use App\Models\MiniPayment;
use App\Models\QrCode;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaymentsController extends Controller
{
public function index(Request $request): JsonResponse
{
$search = trim((string) $request->query('q', ''));
$qrIds = ladill_account()->qrCodes()
->where('type', QrCode::TYPE_PAYMENT)
->pluck('id');
$payments = MiniPayment::query()
->whereIn('qr_code_id', $qrIds)
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
$query->where(function ($inner) use ($like) {
$inner->where('payer_name', 'like', $like)
->orWhere('payer_email', 'like', $like)
->orWhere('payer_note', 'like', $like)
->orWhere('reference', 'like', $like)
->orWhere('payment_reference', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
});
})
->with('qrCode')
->latest('created_at')
->paginate(25)
->withQueryString();
return response()->json([
'data' => collect($payments->items())->map(fn (MiniPayment $p) => PaymentPresenter::present($p))->values(),
'meta' => [
'current_page' => $payments->currentPage(),
'last_page' => $payments->lastPage(),
'per_page' => $payments->perPage(),
'total' => $payments->total(),
],
]);
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Controller;
use App\Models\MiniPayment;
use App\Models\QrCode;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Throwable;
class PayoutsController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function __invoke(): JsonResponse
{
$account = ladill_account();
$qrIds = $account->qrCodes()
->where('type', QrCode::TYPE_PAYMENT)
->pluck('id');
$revenueMinor = (int) MiniPayment::query()
->whereIn('qr_code_id', $qrIds)
->where('status', MiniPayment::STATUS_PAID)
->sum('merchant_amount_minor');
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Mini API payouts could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return response()->json([
'data' => [
'currency' => 'GHS',
'total_revenue_minor' => $revenueMinor,
'wallet_balance_minor' => $balanceMinor,
'account_wallet_url' => 'https://'.config('app.account_domain').'/wallet',
],
]);
}
}
+30 -7
View File
@@ -1,15 +1,38 @@
<?php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\MeController;
use App\Http\Controllers\Api\Mini\OverviewController as MiniOverviewController;
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;
use App\Http\Controllers\Api\QrCodeController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->prefix('v1')->group(function () {
Route::get('/me', MeController::class);
Route::prefix('v1')->group(function () {
// Mobile token login (Ladill Mini Android). Public — issues a Sanctum token.
Route::post('/auth/login', [AuthController::class, 'login'])->name('api.auth.login');
Route::get('/qr-codes', [QrCodeController::class, 'index']);
Route::post('/qr-codes', [QrCodeController::class, 'store']);
Route::get('/qr-codes/{qrCode}', [QrCodeController::class, 'show'])->whereNumber('qrCode');
Route::patch('/qr-codes/{qrCode}', [QrCodeController::class, 'update'])->whereNumber('qrCode');
Route::get('/qr-codes/{qrCode}/analytics', [QrCodeController::class, 'analytics'])->whereNumber('qrCode');
Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout'])->name('api.auth.logout');
Route::get('/me', MeController::class);
Route::get('/qr-codes', [QrCodeController::class, 'index']);
Route::post('/qr-codes', [QrCodeController::class, 'store']);
Route::get('/qr-codes/{qrCode}', [QrCodeController::class, 'show'])->whereNumber('qrCode');
Route::patch('/qr-codes/{qrCode}', [QrCodeController::class, 'update'])->whereNumber('qrCode');
Route::get('/qr-codes/{qrCode}/analytics', [QrCodeController::class, 'analytics'])->whereNumber('qrCode');
// Ladill Mini — payments product.
Route::get('/mini/overview', MiniOverviewController::class)->name('api.mini.overview');
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::get('/mini/payment-qrs/{paymentQr}', [MiniPaymentQrController::class, 'show'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.show');
Route::patch('/mini/payment-qrs/{paymentQr}', [MiniPaymentQrController::class, 'update'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.update');
Route::delete('/mini/payment-qrs/{paymentQr}', [MiniPaymentQrController::class, 'destroy'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.destroy');
Route::get('/mini/payment-qrs/{paymentQr}/preview.png', [MiniPaymentQrController::class, 'preview'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.preview');
Route::get('/mini/payments', [MiniPaymentsController::class, 'index'])->name('api.mini.payments.index');
Route::get('/mini/payouts', MiniPayoutsController::class)->name('api.mini.payouts');
});
});