From ccffa1433d706c3b316d5114cc06e7f9ab6b571f Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 20 Jun 2026 02:23:18 +0000 Subject: [PATCH] Route SMS voice uploads through Transfer with wallet billing. Add a service API for the Ladill SMS platform to store voice recordings in Transfer and charge monthly storage from the customer wallet. Co-authored-by: Cursor --- .env.example | 3 + .../Api/SmsVoiceTransferController.php | 81 +++++++++++++ .../Identity/PublicIdUserResolver.php | 57 +++++++++ app/Support/DirectTransferFileUrl.php | 21 ++++ config/transfer.php | 4 + deploy/shared.env.example | 3 + routes/api.php | 2 + tests/Feature/SmsVoiceTransferTest.php | 110 ++++++++++++++++++ 8 files changed, 281 insertions(+) create mode 100644 app/Http/Controllers/Api/SmsVoiceTransferController.php create mode 100644 app/Services/Identity/PublicIdUserResolver.php create mode 100644 app/Support/DirectTransferFileUrl.php create mode 100644 tests/Feature/SmsVoiceTransferTest.php diff --git a/.env.example b/.env.example index 8e7f105..95bfc03 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,9 @@ TRANSFER_MAIL_RETENTION_DAYS=30 # Ladill Mail — large attachment uploads (service-to-service). TRANSFER_API_KEY_WEBMAIL= +# Ladill SMS — voice message uploads (must match SMS_TRANSFER_API_KEY on platform). +TRANSFER_API_KEY_SMS= +TRANSFER_SMS_VOICE_MAX_UPLOAD_KB=5120 AFIA_ENABLED=true AFIA_PRODUCT=transfer diff --git a/app/Http/Controllers/Api/SmsVoiceTransferController.php b/app/Http/Controllers/Api/SmsVoiceTransferController.php new file mode 100644 index 0000000..150a98d --- /dev/null +++ b/app/Http/Controllers/Api/SmsVoiceTransferController.php @@ -0,0 +1,81 @@ +attributes->get('service_caller') !== 'sms') { + return response()->json(['message' => 'Forbidden.'], 403); + } + + $maxKb = max(256, (int) config('transfer.sms_voice_max_upload_kb', 5120)); + + $data = $request->validate([ + 'user' => ['required', 'string', 'max:64'], + 'title' => ['nullable', 'string', 'max:120'], + 'voice' => ['required', 'file', 'mimes:mp3,wav', 'max:'.$maxKb], + ]); + + try { + $user = $this->users->resolve((string) $data['user']); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + $voice = $request->file('voice'); + $filename = $voice?->getClientOriginalName() ?: 'voice.mp3'; + $title = trim((string) ($data['title'] ?? '')); + if ($title === '') { + $title = Str::limit('SMS voice: '.$filename, 120, ''); + } + + try { + $transfer = $this->transfers->create($user, [ + 'title' => $title, + ], [$voice]); + } catch (RuntimeException $e) { + $payload = ['message' => $e->getMessage()]; + if (TransferWalletErrors::isInsufficientBalance($e)) { + $payload['error_code'] = TransferWalletErrors::CODE_INSUFFICIENT_WALLET; + } + + return response()->json($payload, 422); + } + + $transfer->load(['files', 'qrCode']); + $file = $transfer->files->first(); + if ($file === null) { + return response()->json(['message' => 'Voice upload failed.'], 422); + } + + return response()->json([ + 'data' => [ + 'direct_file_url' => DirectTransferFileUrl::for($transfer, $file), + 'public_url' => $transfer->qrCode?->publicUrl(), + 'transfer_id' => $transfer->id, + 'file_id' => $file->id, + 'paid_until' => $transfer->paid_until?->toIso8601String(), + 'storage_gb' => $transfer->storageGb(), + 'monthly_cost_ghs' => $transfer->monthlyCostGhs(), + ], + ], 201); + } +} diff --git a/app/Services/Identity/PublicIdUserResolver.php b/app/Services/Identity/PublicIdUserResolver.php new file mode 100644 index 0000000..1368434 --- /dev/null +++ b/app/Services/Identity/PublicIdUserResolver.php @@ -0,0 +1,57 @@ +where('public_id', $publicId)->first(); + if ($existing) { + return $existing; + } + + $base = rtrim((string) config('identity.api_url'), '/'); + $key = (string) config('identity.api_key'); + if ($base === '' || $key === '') { + throw new RuntimeException('Identity API is not configured.'); + } + + $res = Http::withToken($key) + ->acceptJson() + ->timeout(10) + ->get("{$base}/identity/profile", ['user' => $publicId]); + + if ($res->failed()) { + throw new RuntimeException('Could not resolve the Ladill account.'); + } + + $data = $res->json('data'); + if (! is_array($data)) { + throw new RuntimeException('Could not resolve the Ladill account.'); + } + + $email = trim((string) ($data['email'] ?? '')); + if ($email === '') { + $email = 'sms+'.$publicId.'@accounts.ladill.local'; + } + + return User::updateOrCreate( + ['public_id' => $publicId], + [ + 'name' => trim((string) ($data['name'] ?? '')) ?: 'Ladill user', + 'email' => $email, + ], + ); + } +} diff --git a/app/Support/DirectTransferFileUrl.php b/app/Support/DirectTransferFileUrl.php new file mode 100644 index 0000000..59d2140 --- /dev/null +++ b/app/Support/DirectTransferFileUrl.php @@ -0,0 +1,21 @@ +qrCode?->short_code; + if ($shortCode === null || $shortCode === '') { + throw new RuntimeException('Transfer file URL is unavailable.'); + } + + return QrCode::publicBaseUrl().'/q/'.$shortCode.'/transfer/files/'.$file->id; + } +} diff --git a/config/transfer.php b/config/transfer.php index 401cbcf..2aa48f8 100644 --- a/config/transfer.php +++ b/config/transfer.php @@ -6,6 +6,7 @@ return [ */ 'service_api_keys' => array_filter([ 'webmail' => env('TRANSFER_API_KEY_WEBMAIL'), + 'sms' => env('TRANSFER_API_KEY_SMS'), ]), // GHS per GB per month of retention (see qr-suite-decomposition.md §7.3). @@ -35,4 +36,7 @@ return [ // Legacy env keys (billing is monthly until cancelled by non-payment + grace). 'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30), 'mail_retention_days' => (int) env('TRANSFER_MAIL_RETENTION_DAYS', 30), + + // Voice SMS uploads from sms.ladill.com (via platform API). + 'sms_voice_max_upload_kb' => (int) env('TRANSFER_SMS_VOICE_MAX_UPLOAD_KB', 5120), ]; diff --git a/deploy/shared.env.example b/deploy/shared.env.example index c907bec..00d588a 100644 --- a/deploy/shared.env.example +++ b/deploy/shared.env.example @@ -58,6 +58,9 @@ TRANSFER_MAIL_RETENTION_DAYS=30 # Ladill Mail — large attachment uploads (must match WEBMAIL_TRANSFER_API_KEY). TRANSFER_API_KEY_WEBMAIL= +# Ladill SMS — voice message uploads (must match SMS_TRANSFER_API_KEY on platform). +TRANSFER_API_KEY_SMS= +TRANSFER_SMS_VOICE_MAX_UPLOAD_KB=5120 AFIA_ENABLED=true AFIA_PRODUCT=transfer diff --git a/routes/api.php b/routes/api.php index dfaf7df..abcbcdb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,11 +3,13 @@ use App\Http\Controllers\Api\ChunkedUploadController; use App\Http\Controllers\Api\MeController; use App\Http\Controllers\Api\QrCodeController; +use App\Http\Controllers\Api\SmsVoiceTransferController; use App\Http\Controllers\Api\TransferController; use Illuminate\Support\Facades\Route; Route::middleware('auth.service:transfer')->prefix('v1')->group(function () { Route::post('/transfers', [TransferController::class, 'store']); + Route::post('/transfers/voice', [SmsVoiceTransferController::class, 'store']); Route::get('/transfers/files', [\App\Http\Controllers\Api\TransferFilesController::class, 'index']); Route::post('/transfers/upload/init', [ChunkedUploadController::class, 'init']); Route::post('/transfers/upload/chunk', [ChunkedUploadController::class, 'chunk']); diff --git a/tests/Feature/SmsVoiceTransferTest.php b/tests/Feature/SmsVoiceTransferTest.php new file mode 100644 index 0000000..d861acd --- /dev/null +++ b/tests/Feature/SmsVoiceTransferTest.php @@ -0,0 +1,110 @@ +fakeTransferBillingApi(); + config([ + 'transfer.service_api_keys' => ['sms' => 'test-sms-key'], + 'identity.api_url' => 'https://ladill.com/api', + 'identity.api_key' => 'test-identity-key', + ]); + } + + public function test_sms_service_can_upload_voice_recording(): void + { + $publicId = (string) Str::uuid(); + + Http::fake([ + rtrim((string) config('identity.api_url'), '/').'/identity/profile*' => Http::response([ + 'data' => [ + 'public_id' => $publicId, + 'name' => 'SMS User', + 'email' => 'sms@acme.com', + 'picture' => null, + ], + ]), + ]); + + $voice = UploadedFile::fake()->create('greeting.mp3', 120, 'audio/mpeg'); + + $this->withToken('test-sms-key') + ->post('/api/v1/transfers/voice', [ + 'user' => $publicId, + 'title' => 'SMS voice: greeting.mp3', + 'voice' => $voice, + ]) + ->assertCreated() + ->assertJsonPath('data.direct_file_url', fn (string $url) => str_contains($url, '/transfer/files/')) + ->assertJsonStructure([ + 'data' => [ + 'direct_file_url', + 'public_url', + 'transfer_id', + 'file_id', + 'paid_until', + 'storage_gb', + 'monthly_cost_ghs', + ], + ]); + + $this->assertDatabaseHas('users', ['public_id' => $publicId]); + $this->assertDatabaseCount('transfers', 1); + } + + public function test_voice_upload_rejects_non_sms_callers(): void + { + config(['transfer.service_api_keys' => ['webmail' => 'test-webmail-key']]); + + $this->withToken('test-webmail-key') + ->post('/api/v1/transfers/voice', [ + 'user' => (string) Str::uuid(), + 'voice' => UploadedFile::fake()->create('greeting.mp3', 120, 'audio/mpeg'), + ]) + ->assertForbidden(); + } + + public function test_voice_upload_returns_insufficient_wallet_error_code(): void + { + $publicId = (string) Str::uuid(); + User::factory()->create(['public_id' => $publicId]); + + $this->mock(TransferBillingService::class, function ($mock): void { + $mock->shouldReceive('chargeInitialPeriod') + ->once() + ->andThrow(new \RuntimeException( + 'Insufficient wallet balance for the first month of storage. Top up your Ladill wallet and try again.' + )); + }); + + $voice = UploadedFile::fake()->create('greeting.mp3', 120, 'audio/mpeg'); + + $this->withToken('test-sms-key') + ->post('/api/v1/transfers/voice', [ + 'user' => $publicId, + 'voice' => $voice, + ]) + ->assertStatus(422) + ->assertJsonPath('error_code', 'insufficient_wallet'); + + $this->assertDatabaseCount('transfers', 0); + } +}