From 7a276add15393bb6ff5c6b6bd70125dde18b3844 Mon Sep 17 00:00:00 2001 From: Obinna Ikeh Date: Thu, 5 Mar 2026 17:01:30 +0100 Subject: [PATCH 1/7] Feature: support bi-directional sms messaging using available gateways. --- docs/integrations-guide/textbee.md | 13 ++ docs/usage-guide/sms.md | 1 + src/backend/app/Events/SmsStoredEvent.php | 7 +- .../app/Http/Controllers/SmsController.php | 2 +- .../AfricasTalkingCallbackController.php | 6 +- .../AfricasTalking/Listeners/SmsListener.php | 25 ++++ .../Providers/EventServiceProvider.php | 6 + .../SparkMeter/Listeners/SmsListener.php | 7 +- .../SparkMeter/Services/CustomerService.php | 2 +- .../SteamaMeter/Listeners/SmsListener.php | 6 +- .../Services/SteamaCustomerService.php | 2 +- .../Controllers/TextbeeCallbackController.php | 55 ++++++++ .../TextbeeCredentialController.php | 2 + .../Listeners/SmsListener.php | 25 ++++ .../Models/TextbeeCredential.php | 1 + .../Providers/EventServiceProvider.php | 6 + .../Services/TextbeeCredentialService.php | 7 +- .../Plugins/TextbeeSmsGateway/routes/api.php | 3 + .../ApiResolvers/Data/ApiResolverMap.php | 4 + .../TextbeeSmsGatewayApiResolver.php | 22 ++++ src/backend/bootstrap/providers.php | 2 +- ..._webhook_secret_to_textbee_credentials.php | 19 +++ .../tests/Feature/TextbeeIncomingSmsTest.php | 120 ++++++++++++++++++ .../modules/Overview/Credential.vue | 53 ++++++++ .../services/CredentialService.js | 1 + 25 files changed, 378 insertions(+), 19 deletions(-) create mode 100644 src/backend/app/Plugins/AfricasTalking/Listeners/SmsListener.php create mode 100644 src/backend/app/Plugins/TextbeeSmsGateway/Http/Controllers/TextbeeCallbackController.php create mode 100644 src/backend/app/Plugins/TextbeeSmsGateway/Listeners/SmsListener.php create mode 100644 src/backend/app/Services/ApiResolvers/TextbeeSmsGatewayApiResolver.php create mode 100644 src/backend/database/migrations/tenant/2026_03_05_000000_add_webhook_secret_to_textbee_credentials.php create mode 100644 src/backend/tests/Feature/TextbeeIncomingSmsTest.php diff --git a/docs/integrations-guide/textbee.md b/docs/integrations-guide/textbee.md index 528601c50..0aea71201 100644 --- a/docs/integrations-guide/textbee.md +++ b/docs/integrations-guide/textbee.md @@ -35,6 +35,7 @@ TextBee allows you to use your own Android device as an SMS gateway, providing u 3. Click **Generate API Key** 4. Copy the API key - you'll need this for MPM configuration 5. Note your **Device ID** from the dashboard or app +6. (Optional) To enable incoming SMS, create a **Webhook Subscription** for the `MESSAGE_RECEIVED` event and copy the **Webhook Secret** ## Step 4: Enable TextBee Plugin in MPM @@ -51,9 +52,21 @@ TextBee allows you to use your own Android device as an SMS gateway, providing u 3. Enter the following credentials: - **API Key**: Paste the API key from Step 3 - **Device ID**: Enter your device ID from Step 3 + - **Webhook Secret** (optional): Paste the webhook secret from Step 3 to enable incoming SMS 4. Click **Save** to store the configuration 5. The plugin will validate your credentials automatically +### Incoming SMS (Optional) + +To receive incoming SMS from customers, you need to configure a webhook in the TextBee dashboard: + +1. In the TextBee dashboard, navigate to **Webhooks** +2. Create a new webhook subscription for the `MESSAGE_RECEIVED` event +3. Set the webhook URL to: `https://your-mpm-domain/api/textbee-sms-gateway/callback/{company-id}/incoming-messages` + - Replace `{company-id}` with your MPM company ID +4. Copy the webhook secret and paste it in the MPM TextBee configuration +5. Incoming SMS will now be stored and processed by MPM + ## Step 6: Select TextBee as Your SMS Gateway 1. Navigate to **Settings** → **Configuration** → **Main Settings** diff --git a/docs/usage-guide/sms.md b/docs/usage-guide/sms.md index 88fa5fa1f..747469ead 100644 --- a/docs/usage-guide/sms.md +++ b/docs/usage-guide/sms.md @@ -34,6 +34,7 @@ TextBee allows you to use your own Android device as an SMS gateway, providing u - Up to 98% cost savings - Uses your own Android device +- Bidirectional SMS (send and receive) - Free plan: 300 messages/month - Pro plan: 5,000 messages/month diff --git a/src/backend/app/Events/SmsStoredEvent.php b/src/backend/app/Events/SmsStoredEvent.php index 5553a00e9..547094401 100644 --- a/src/backend/app/Events/SmsStoredEvent.php +++ b/src/backend/app/Events/SmsStoredEvent.php @@ -2,6 +2,7 @@ namespace App\Events; +use App\Models\Sms; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -11,8 +12,9 @@ * Dispatch this event to asynchronously store an SMS. * A corresponding listener will send the event. * - * @property string $sender The sender of the SMS. - * @property string $message The message content of the SMS. + * @property string $sender The sender of the SMS. + * @property string $message The message content of the SMS. + * @property Sms|null $sms The SMS model instance, if available. */ class SmsStoredEvent { use Dispatchable; @@ -21,5 +23,6 @@ class SmsStoredEvent { public function __construct( public string $sender, public string $message, + public ?Sms $sms = null, ) {} } diff --git a/src/backend/app/Http/Controllers/SmsController.php b/src/backend/app/Http/Controllers/SmsController.php index fd871b4fc..af4dceb66 100644 --- a/src/backend/app/Http/Controllers/SmsController.php +++ b/src/backend/app/Http/Controllers/SmsController.php @@ -183,7 +183,7 @@ public function store(StoreSmsRequest $request): ApiResource { $sms = $this->smsService->createSms($smsData); match ($this->smsService->checkMessageType($message)) { - $this->smsService::FEEDBACK => event(new SmsStoredEvent($sender, $message)), + $this->smsService::FEEDBACK => event(new SmsStoredEvent($sender, $message, $sms)), $this->smsService::TICKET => $this->commentService->storeComment($sender, $message), default => new ApiResource($sms), }; diff --git a/src/backend/app/Plugins/AfricasTalking/Http/Controllers/AfricasTalkingCallbackController.php b/src/backend/app/Plugins/AfricasTalking/Http/Controllers/AfricasTalkingCallbackController.php index 53029542f..136aa00ed 100644 --- a/src/backend/app/Plugins/AfricasTalking/Http/Controllers/AfricasTalkingCallbackController.php +++ b/src/backend/app/Plugins/AfricasTalking/Http/Controllers/AfricasTalkingCallbackController.php @@ -29,15 +29,15 @@ public function incoming(Request $request): JsonResponse { $senderId = $sender ? $sender->id : null; $smsData = [ - 'receiver' => $phoneNumber, + 'receiver' => $address->phone ?? $phoneNumber, 'body' => $message, 'sender_id' => $senderId, 'direction' => Sms::DIRECTION_INCOMING, 'status' => Sms::STATUS_DELIVERED, ]; - $this->smsService->createSms($smsData); - event(new SmsStoredEvent($phoneNumber, $message)); + $sms = $this->smsService->createSms($smsData); + event(new SmsStoredEvent($phoneNumber, $message, $sms)); return response()->json(['status' => 'success']); } diff --git a/src/backend/app/Plugins/AfricasTalking/Listeners/SmsListener.php b/src/backend/app/Plugins/AfricasTalking/Listeners/SmsListener.php new file mode 100644 index 000000000..1e22d1137 --- /dev/null +++ b/src/backend/app/Plugins/AfricasTalking/Listeners/SmsListener.php @@ -0,0 +1,25 @@ +mainSettingsService->getAll()->first(); + + if ($mainSettings?->sms_gateway_id !== MpmPlugin::AFRICAS_TALKING) { + return; + } + + $this->globalSmsListener->handle($event); + } +} diff --git a/src/backend/app/Plugins/AfricasTalking/Providers/EventServiceProvider.php b/src/backend/app/Plugins/AfricasTalking/Providers/EventServiceProvider.php index 72b19901b..33363b2b0 100644 --- a/src/backend/app/Plugins/AfricasTalking/Providers/EventServiceProvider.php +++ b/src/backend/app/Plugins/AfricasTalking/Providers/EventServiceProvider.php @@ -2,9 +2,15 @@ namespace App\Plugins\AfricasTalking\Providers; +use App\Events\SmsStoredEvent; +use App\Plugins\AfricasTalking\Listeners\SmsListener; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; class EventServiceProvider extends ServiceProvider { + protected $listen = [ + SmsStoredEvent::class => [SmsListener::class], + ]; + /** * Register any events for your application. */ diff --git a/src/backend/app/Plugins/SparkMeter/Listeners/SmsListener.php b/src/backend/app/Plugins/SparkMeter/Listeners/SmsListener.php index 4134cf158..321357404 100644 --- a/src/backend/app/Plugins/SparkMeter/Listeners/SmsListener.php +++ b/src/backend/app/Plugins/SparkMeter/Listeners/SmsListener.php @@ -2,7 +2,7 @@ namespace App\Plugins\SparkMeter\Listeners; -use App\Models\Meter\Meter; +use App\Events\SmsStoredEvent; use App\Plugins\SparkMeter\Exceptions\SparkAPIResponseException; use App\Plugins\SparkMeter\Models\SmCustomer; use App\Plugins\SparkMeter\Services\CustomerService; @@ -56,8 +56,7 @@ public function onSmsStored(string $sender, string $message): void { } } - public function handle(string $sender, string $message): void { - // TODO: Uncomment this when spark-meter package is refactored with device->meter approach - // $this->onSmsStored($sender, $message); + public function handle(SmsStoredEvent $event): void { + $this->onSmsStored($event->sender, $event->message); } } diff --git a/src/backend/app/Plugins/SparkMeter/Services/CustomerService.php b/src/backend/app/Plugins/SparkMeter/Services/CustomerService.php index edf1f83d5..1a61dbfaf 100644 --- a/src/backend/app/Plugins/SparkMeter/Services/CustomerService.php +++ b/src/backend/app/Plugins/SparkMeter/Services/CustomerService.php @@ -529,7 +529,7 @@ static function ($q) use ($phoneNumber) { return $this->smCustomer->newQuery()->with(['site', 'mpmPerson.devices.device'])->where( 'mpm_customer_id', - $person->id + $person?->id )->first(); } } diff --git a/src/backend/app/Plugins/SteamaMeter/Listeners/SmsListener.php b/src/backend/app/Plugins/SteamaMeter/Listeners/SmsListener.php index 4670800c1..e97303599 100644 --- a/src/backend/app/Plugins/SteamaMeter/Listeners/SmsListener.php +++ b/src/backend/app/Plugins/SteamaMeter/Listeners/SmsListener.php @@ -2,6 +2,7 @@ namespace App\Plugins\SteamaMeter\Listeners; +use App\Events\SmsStoredEvent; use App\Plugins\SteamaMeter\Models\SteamaCustomer; use App\Plugins\SteamaMeter\Services\SteamaCustomerService; use App\Plugins\SteamaMeter\Services\SteamaSmsFeedbackWordService; @@ -35,8 +36,7 @@ public function onSmsStored(string $sender, string $message): void { } } - public function handle(string $sender, string $message): void { - // TODO: Uncomment this when steamaco-meter package is refactored with device->meter approach - // $this->onSmsStored($sender, $message); + public function handle(SmsStoredEvent $event): void { + $this->onSmsStored($event->sender, $event->message); } } diff --git a/src/backend/app/Plugins/SteamaMeter/Services/SteamaCustomerService.php b/src/backend/app/Plugins/SteamaMeter/Services/SteamaCustomerService.php index 8fe80d8b0..b8d795e14 100644 --- a/src/backend/app/Plugins/SteamaMeter/Services/SteamaCustomerService.php +++ b/src/backend/app/Plugins/SteamaMeter/Services/SteamaCustomerService.php @@ -506,6 +506,6 @@ static function ($q) use ($phoneNumber) { return $this->customer->newQuery() ->with(['site', 'mpmPerson.devices.device']) - ->where('mpm_customer_id', $person->id)->first(); + ->where('mpm_customer_id', $person?->id)->first(); } } diff --git a/src/backend/app/Plugins/TextbeeSmsGateway/Http/Controllers/TextbeeCallbackController.php b/src/backend/app/Plugins/TextbeeSmsGateway/Http/Controllers/TextbeeCallbackController.php new file mode 100644 index 000000000..84c1a26ff --- /dev/null +++ b/src/backend/app/Plugins/TextbeeSmsGateway/Http/Controllers/TextbeeCallbackController.php @@ -0,0 +1,55 @@ +credentialService->getCredentials(); + + if ($credentials?->webhook_secret) { + $signature = $request->header('x-signature'); + $expectedSignature = hash_hmac('sha256', $request->getContent(), $credentials->webhook_secret); + + if (!$signature || !hash_equals($expectedSignature, $signature)) { + return response()->json(['status' => 'unauthorized'], 401); + } + } + + $data = $request->all(); + $phoneNumber = $data['sender']; + $message = $data['message']; + $address = $this->addressesService->getAddressByPhoneNumber(str_replace(' ', '', $phoneNumber)); + $sender = $address instanceof Address ? $address->owner : null; + // @phpstan-ignore property.notFound + $senderId = $sender ? $sender->id : null; + + $smsData = [ + 'receiver' => $address->phone ?? $phoneNumber, + 'body' => $message, + 'sender_id' => $senderId, + 'direction' => Sms::DIRECTION_INCOMING, + 'status' => Sms::STATUS_DELIVERED, + ]; + + $sms = $this->smsService->createSms($smsData); + event(new SmsStoredEvent($phoneNumber, $message, $sms)); + + return response()->json(['status' => 'success']); + } +} diff --git a/src/backend/app/Plugins/TextbeeSmsGateway/Http/Controllers/TextbeeCredentialController.php b/src/backend/app/Plugins/TextbeeSmsGateway/Http/Controllers/TextbeeCredentialController.php index e7710d755..cba879771 100644 --- a/src/backend/app/Plugins/TextbeeSmsGateway/Http/Controllers/TextbeeCredentialController.php +++ b/src/backend/app/Plugins/TextbeeSmsGateway/Http/Controllers/TextbeeCredentialController.php @@ -19,12 +19,14 @@ public function show(): TextbeeResource { public function update(TextbeeCredentialRequest $request): TextbeeResource { $apiKey = $request->input('api_key'); $deviceId = $request->input('device_id'); + $webhookSecret = $request->input('webhook_secret'); $id = $request->input('id'); $credentials = $this->credentialService->updateCredentials([ 'id' => $id, 'api_key' => $apiKey, 'device_id' => $deviceId, + 'webhook_secret' => $webhookSecret, ]); return TextbeeResource::make($credentials); diff --git a/src/backend/app/Plugins/TextbeeSmsGateway/Listeners/SmsListener.php b/src/backend/app/Plugins/TextbeeSmsGateway/Listeners/SmsListener.php new file mode 100644 index 000000000..5a6fc7a7a --- /dev/null +++ b/src/backend/app/Plugins/TextbeeSmsGateway/Listeners/SmsListener.php @@ -0,0 +1,25 @@ +mainSettingsService->getAll()->first(); + + if ($mainSettings?->sms_gateway_id !== MpmPlugin::TEXTBEE_SMS_GATEWAY) { + return; + } + + $this->globalSmsListener->handle($event); + } +} diff --git a/src/backend/app/Plugins/TextbeeSmsGateway/Models/TextbeeCredential.php b/src/backend/app/Plugins/TextbeeSmsGateway/Models/TextbeeCredential.php index 29147e936..dc8394e1e 100644 --- a/src/backend/app/Plugins/TextbeeSmsGateway/Models/TextbeeCredential.php +++ b/src/backend/app/Plugins/TextbeeSmsGateway/Models/TextbeeCredential.php @@ -9,6 +9,7 @@ * @property int $id * @property string|null $api_key * @property string|null $device_id + * @property string|null $webhook_secret * @property Carbon|null $created_at * @property Carbon|null $updated_at */ diff --git a/src/backend/app/Plugins/TextbeeSmsGateway/Providers/EventServiceProvider.php b/src/backend/app/Plugins/TextbeeSmsGateway/Providers/EventServiceProvider.php index 5dc6f061e..ec490a243 100644 --- a/src/backend/app/Plugins/TextbeeSmsGateway/Providers/EventServiceProvider.php +++ b/src/backend/app/Plugins/TextbeeSmsGateway/Providers/EventServiceProvider.php @@ -2,9 +2,15 @@ namespace App\Plugins\TextbeeSmsGateway\Providers; +use App\Events\SmsStoredEvent; +use App\Plugins\TextbeeSmsGateway\Listeners\SmsListener; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; class EventServiceProvider extends ServiceProvider { + protected $listen = [ + SmsStoredEvent::class => [SmsListener::class], + ]; + /** * Register any events for your application. */ diff --git a/src/backend/app/Plugins/TextbeeSmsGateway/Services/TextbeeCredentialService.php b/src/backend/app/Plugins/TextbeeSmsGateway/Services/TextbeeCredentialService.php index 6d807e6c0..61de9dc9d 100644 --- a/src/backend/app/Plugins/TextbeeSmsGateway/Services/TextbeeCredentialService.php +++ b/src/backend/app/Plugins/TextbeeSmsGateway/Services/TextbeeCredentialService.php @@ -19,13 +19,14 @@ public function createCredentials(): TextbeeCredential { return $this->credential->newQuery()->firstOrCreate(['id' => 1], [ 'api_key' => null, 'device_id' => null, + 'webhook_secret' => null, ]); } public function getCredentials(): ?TextbeeCredential { $credential = $this->credential->newQuery()->first(); - return $this->decryptCredentialFields($credential, ['api_key', 'device_id']); + return $this->decryptCredentialFields($credential, ['api_key', 'device_id', 'webhook_secret']); } /** @@ -34,13 +35,13 @@ public function getCredentials(): ?TextbeeCredential { public function updateCredentials(array $data): TextbeeCredential { $credential = $this->credential->newQuery()->find($data['id']); - $encryptedData = $this->encryptCredentialFields($data, ['api_key', 'device_id']); + $encryptedData = $this->encryptCredentialFields($data, ['api_key', 'device_id', 'webhook_secret']); $credential->update($encryptedData); $credential->save(); $credential->fresh(); - return $this->decryptCredentialFields($credential, ['api_key', 'device_id']); + return $this->decryptCredentialFields($credential, ['api_key', 'device_id', 'webhook_secret']); } } diff --git a/src/backend/app/Plugins/TextbeeSmsGateway/routes/api.php b/src/backend/app/Plugins/TextbeeSmsGateway/routes/api.php index b2bf505a1..6bdfea88d 100644 --- a/src/backend/app/Plugins/TextbeeSmsGateway/routes/api.php +++ b/src/backend/app/Plugins/TextbeeSmsGateway/routes/api.php @@ -7,4 +7,7 @@ Route::get('/', 'TextbeeCredentialController@show'); Route::put('/', 'TextbeeCredentialController@update'); }); + Route::group(['prefix' => 'callback'], function () { + Route::post('/{slug}/incoming-messages', 'TextbeeCallbackController@incoming'); + }); }); diff --git a/src/backend/app/Services/ApiResolvers/Data/ApiResolverMap.php b/src/backend/app/Services/ApiResolvers/Data/ApiResolverMap.php index 390a500f3..6adb13132 100644 --- a/src/backend/app/Services/ApiResolvers/Data/ApiResolverMap.php +++ b/src/backend/app/Services/ApiResolvers/Data/ApiResolverMap.php @@ -13,6 +13,7 @@ use App\Services\ApiResolvers\PaystackApiResolver; use App\Services\ApiResolvers\SwiftaPaymentApiResolver; use App\Services\ApiResolvers\TestApiResolver; +use App\Services\ApiResolvers\TextbeeSmsGatewayApiResolver; use App\Services\ApiResolvers\ViberMessagingApiResolver; use App\Services\ApiResolvers\VodacomMobileMoneyApiResolver; use App\Services\ApiResolvers\WaveMoneyApiResolver; @@ -30,6 +31,7 @@ class ApiResolverMap { public const VODACOM_MOBILE_MONEY = 'api/vodacom/'; public const PAYSTACK_API = 'api/paystack/'; public const ECREEE_METER_DATA_API = 'api/ecreee-e-tender/ecreee-meter-data'; + public const TEXTBEE_SMS_GATEWAY_API = 'api/textbee-sms-gateway/callback'; public const RESOLVABLE_APIS = [ self::TEST_API, @@ -44,6 +46,7 @@ class ApiResolverMap { self::ODYSSEY_PAYMENTS_API, self::PAYSTACK_API, self::ECREEE_METER_DATA_API, + self::TEXTBEE_SMS_GATEWAY_API, ]; private const API_RESOLVER = [ @@ -59,6 +62,7 @@ class ApiResolverMap { self::ODYSSEY_PAYMENTS_API => OdysseyPaymentApiResolver::class, self::PAYSTACK_API => PaystackApiResolver::class, self::ECREEE_METER_DATA_API => EcreeeMeterDataApiResolver::class, + self::TEXTBEE_SMS_GATEWAY_API => TextbeeSmsGatewayApiResolver::class, ]; /** diff --git a/src/backend/app/Services/ApiResolvers/TextbeeSmsGatewayApiResolver.php b/src/backend/app/Services/ApiResolvers/TextbeeSmsGatewayApiResolver.php new file mode 100644 index 000000000..618aa3d52 --- /dev/null +++ b/src/backend/app/Services/ApiResolvers/TextbeeSmsGatewayApiResolver.php @@ -0,0 +1,22 @@ +segments(); + if (count($segments) !== 5) { + throw ValidationException::withMessages(['webhook' => 'failed to parse company identifier from the webhook']); + } + + $companyId = $segments[3]; + + return (int) $companyId; + } +} diff --git a/src/backend/bootstrap/providers.php b/src/backend/bootstrap/providers.php index 9e54f8934..6f6a16ade 100644 --- a/src/backend/bootstrap/providers.php +++ b/src/backend/bootstrap/providers.php @@ -39,6 +39,7 @@ HorizonServiceProvider::class, ServicesProvider::class, AfricasTalkingServiceProvider::class, + TextbeeSmsGatewayServiceProvider::class, AngazaSHSServiceProvider::class, BulkRegistrationServiceProvider::class, CalinMeterServiceProvider::class, @@ -59,7 +60,6 @@ StronMeterServiceProvider::class, SunKingSHSServiceProvider::class, SwiftaServiceProvider::class, - TextbeeSmsGatewayServiceProvider::class, ViberMessagingServiceProvider::class, VodacomMobileMoneyServiceProvider::class, WaveMoneyPaymentProviderServiceProvider::class, diff --git a/src/backend/database/migrations/tenant/2026_03_05_000000_add_webhook_secret_to_textbee_credentials.php b/src/backend/database/migrations/tenant/2026_03_05_000000_add_webhook_secret_to_textbee_credentials.php new file mode 100644 index 000000000..a9bf56c6b --- /dev/null +++ b/src/backend/database/migrations/tenant/2026_03_05_000000_add_webhook_secret_to_textbee_credentials.php @@ -0,0 +1,19 @@ +table('textbee_credentials', static function (Blueprint $table) { + $table->text('webhook_secret')->nullable()->after('device_id'); + }); + } + + public function down(): void { + Schema::connection('tenant')->table('textbee_credentials', static function (Blueprint $table) { + $table->dropColumn('webhook_secret'); + }); + } +}; diff --git a/src/backend/tests/Feature/TextbeeIncomingSmsTest.php b/src/backend/tests/Feature/TextbeeIncomingSmsTest.php new file mode 100644 index 000000000..6319449d9 --- /dev/null +++ b/src/backend/tests/Feature/TextbeeIncomingSmsTest.php @@ -0,0 +1,120 @@ +webhookUrl = '/api/textbee-sms-gateway/callback/'.$this->companyId.'/incoming-messages'; + } + + private function createCredentialWithSecret(): void { + TextbeeCredential::query()->create([ + 'api_key' => Crypt::encryptString('test-api-key'), + 'device_id' => Crypt::encryptString('test-device-id'), + 'webhook_secret' => Crypt::encryptString($this->webhookSecret), + ]); + } + + private function createCredentialWithoutSecret(): void { + TextbeeCredential::query()->create([ + 'api_key' => Crypt::encryptString('test-api-key'), + 'device_id' => Crypt::encryptString('test-device-id'), + 'webhook_secret' => null, + ]); + } + + private function buildPayload(string $sender = '+123456789', string $message = 'Hello from SMS'): array { + return [ + 'smsId' => 'sms-123', + 'sender' => $sender, + 'message' => $message, + 'receivedAt' => '2025-10-05T13:00:35.208Z', + 'deviceId' => 'device-123', + 'webhookSubscriptionId' => 'sub-123', + 'webhookEvent' => 'MESSAGE_RECEIVED', + ]; + } + + private function signPayload(array $payload): string { + return hash_hmac('sha256', json_encode($payload), $this->webhookSecret); + } + + public function testValidWebhookCreatesIncomingSmsRecord(): void { + $this->createCredentialWithSecret(); + Event::fake([SmsStoredEvent::class]); + + $payload = $this->buildPayload(); + $signature = $this->signPayload($payload); + + $response = $this->postJson($this->webhookUrl, $payload, [ + 'x-signature' => $signature, + ]); + + $response->assertStatus(200); + $response->assertJson(['status' => 'success']); + + $this->assertDatabaseHas('sms', [ + 'receiver' => '+123456789', + 'body' => 'Hello from SMS', + 'direction' => Sms::DIRECTION_INCOMING, + 'status' => Sms::STATUS_DELIVERED, + ], 'tenant'); + } + + public function testInvalidSignatureReturns401(): void { + $this->createCredentialWithSecret(); + + $payload = $this->buildPayload(); + + $response = $this->postJson($this->webhookUrl, $payload, [ + 'x-signature' => 'invalid-signature', + ]); + + $response->assertStatus(401); + $response->assertJson(['status' => 'unauthorized']); + } + + public function testSmsStoredEventIsDispatched(): void { + $this->createCredentialWithSecret(); + Event::fake([SmsStoredEvent::class]); + + $payload = $this->buildPayload('+987654321', 'Test message'); + $signature = $this->signPayload($payload); + + $response = $this->postJson($this->webhookUrl, $payload, [ + 'x-signature' => $signature, + ]); + + $response->assertStatus(200); + + Event::assertDispatched(SmsStoredEvent::class, fn (SmsStoredEvent $event): bool => $event->sender === '+987654321' + && $event->message === 'Test message' + && $event->sms instanceof Sms); + } + + public function testWebhookWorksWithoutSecret(): void { + $this->createCredentialWithoutSecret(); + Event::fake([SmsStoredEvent::class]); + + $payload = $this->buildPayload(); + + $response = $this->postJson($this->webhookUrl, $payload); + + $response->assertStatus(200); + $response->assertJson(['status' => 'success']); + } +} diff --git a/src/frontend/src/plugins/textbee-sms-gateway/modules/Overview/Credential.vue b/src/frontend/src/plugins/textbee-sms-gateway/modules/Overview/Credential.vue index e2bc273e5..87e88f7ac 100644 --- a/src/frontend/src/plugins/textbee-sms-gateway/modules/Overview/Credential.vue +++ b/src/frontend/src/plugins/textbee-sms-gateway/modules/Overview/Credential.vue @@ -72,6 +72,24 @@ + +
+ + + + + Required for receiving incoming SMS via webhooks + + +
@@ -98,6 +116,10 @@
  • Login to your TextBee account in the app
  • Generate an API key from the TextBee dashboard
  • Note your Device ID from the app or dashboard
  • +
  • + (Optional) Set up a webhook subscription in TextBee for + incoming SMS and copy the webhook secret +
  • @@ -109,6 +131,17 @@
    + +
    +
    + + Incoming Messages Webhook URL: +

    {{ incomingMessagesUrl }}

    +
    +
    +
    @@ -124,6 +157,8 @@ @@ -215,6 +258,16 @@ export default { } } +.token-value { + font-size: 16px; + color: #333; + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f9f9f9; + font-weight: normal; +} + .md-subhead { margin-top: 0.5rem; font-size: 0.9rem; diff --git a/src/frontend/src/plugins/textbee-sms-gateway/services/CredentialService.js b/src/frontend/src/plugins/textbee-sms-gateway/services/CredentialService.js index 5581e70a2..0018fba57 100644 --- a/src/frontend/src/plugins/textbee-sms-gateway/services/CredentialService.js +++ b/src/frontend/src/plugins/textbee-sms-gateway/services/CredentialService.js @@ -12,6 +12,7 @@ export class CredentialService { id: null, apiKey: null, deviceId: null, + webhookSecret: null, } } From fff7e32e3fa34c6ae3ce6dc6e5c65b1d0cf8a918 Mon Sep 17 00:00:00 2001 From: Obinna Ikeh Date: Tue, 3 Mar 2026 23:26:22 +0100 Subject: [PATCH 2/7] Feature: Sms Parsing Plugin + TextBee Gateway Integration --- src/backend/app/Models/MpmPlugin.php | 1 + .../Console/Commands/InstallPackage.php | 25 + .../Controllers/SmsParsingRuleController.php | 60 +++ .../Controllers/SmsTransactionController.php | 21 + .../Http/Requests/SmsParsingRuleRequest.php | 27 + .../Models/SmsParsingRule.php | 28 ++ .../Models/SmsTransaction.php | 89 ++++ .../Providers/EventServiceProvider.php | 19 + .../Providers/ObserverServiceProvider.php | 14 + .../Providers/RouteServiceProvider.php | 25 + .../SmsTransactionParserServiceProvider.php | 25 + .../Providers/SmsTransactionProvider.php | 83 ++++ .../Services/SmsParsingRuleService.php | 97 ++++ .../Services/SmsTransactionService.php | 100 ++++ .../Contracts/ISmsTransactionParser.php | 14 + .../SmsParsing/ParsedSmsData.php | 16 + .../Parsers/MovitelTransactionParser.php | 48 ++ .../Parsers/VodacomTransactionParser.php | 48 ++ .../SmsParsing/SmsParserFactory.php | 52 ++ .../SmsParsing/TemplateToRegexConverter.php | 72 +++ .../SmsTransactionParser/routes/api.php | 20 + .../Console/Commands/FetchIncomingSms.php | 64 +++ .../TextbeeSmsGatewayServiceProvider.php | 6 +- .../Services/TextbeeSmsPollingService.php | 91 ++++ .../Providers/Helpers/TransactionAdapter.php | 7 + ...actPaymentAggregatorTransactionService.php | 5 +- src/backend/bootstrap/providers.php | 2 + ...transaction-parser_to_mpm_plugin_table.php | 27 + ..._145333_create_sms_parsing_rules_table.php | 27 + ...2_145342_create_sms_transactions_table.php | 31 ++ .../MovitelTransactionParserTest.php | 37 ++ .../SmsParserFactoryTest.php | 124 +++++ .../SmsTransactionServiceTest.php | 52 ++ .../TemplateToRegexConverterTest.php | 78 +++ .../VodacomTransactionParserTest.php | 53 ++ src/frontend/src/ExportedRoutes.js | 25 + src/frontend/src/main.js | 4 + .../Transactions/SmsTransactionDetail.vue | 106 ++++ .../src/modules/Transactions/Transaction.vue | 31 ++ .../src/modules/Transactions/Transactions.vue | 21 + .../modules/Overview/Overview.vue | 21 + .../modules/Overview/ParsingRules.vue | 469 ++++++++++++++++++ .../modules/Overview/Setup.vue | 131 +++++ .../repositories/ParsingRuleRepository.js | 18 + .../repositories/SmsTransactionRepository.js | 9 + .../sms-transaction-parser/services/.gitkeep | 0 .../services/ParsingRuleService.js | 27 + .../services/SmsTransactionService.js | 13 + .../src/services/TransactionService.js | 1 + 49 files changed, 2361 insertions(+), 3 deletions(-) create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Console/Commands/InstallPackage.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Http/Controllers/SmsParsingRuleController.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Http/Controllers/SmsTransactionController.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Http/Requests/SmsParsingRuleRequest.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Models/SmsParsingRule.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Models/SmsTransaction.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Providers/EventServiceProvider.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Providers/ObserverServiceProvider.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Providers/RouteServiceProvider.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Providers/SmsTransactionParserServiceProvider.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Providers/SmsTransactionProvider.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Services/SmsParsingRuleService.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Services/SmsTransactionService.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Contracts/ISmsTransactionParser.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/SmsParsing/ParsedSmsData.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/MovitelTransactionParser.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/VodacomTransactionParser.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/SmsParsing/SmsParserFactory.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/SmsParsing/TemplateToRegexConverter.php create mode 100644 src/backend/app/Plugins/SmsTransactionParser/routes/api.php create mode 100644 src/backend/app/Plugins/TextbeeSmsGateway/Console/Commands/FetchIncomingSms.php create mode 100644 src/backend/app/Plugins/TextbeeSmsGateway/Services/TextbeeSmsPollingService.php create mode 100644 src/backend/database/migrations/2026_03_02_145240_add_sms-transaction-parser_to_mpm_plugin_table.php create mode 100644 src/backend/database/migrations/tenant/2026_03_02_145333_create_sms_parsing_rules_table.php create mode 100644 src/backend/database/migrations/tenant/2026_03_02_145342_create_sms_transactions_table.php create mode 100644 src/backend/tests/Unit/SmsTransactionParser/MovitelTransactionParserTest.php create mode 100644 src/backend/tests/Unit/SmsTransactionParser/SmsParserFactoryTest.php create mode 100644 src/backend/tests/Unit/SmsTransactionParser/SmsTransactionServiceTest.php create mode 100644 src/backend/tests/Unit/SmsTransactionParser/TemplateToRegexConverterTest.php create mode 100644 src/backend/tests/Unit/SmsTransactionParser/VodacomTransactionParserTest.php create mode 100644 src/frontend/src/modules/Transactions/SmsTransactionDetail.vue create mode 100644 src/frontend/src/plugins/sms-transaction-parser/modules/Overview/Overview.vue create mode 100644 src/frontend/src/plugins/sms-transaction-parser/modules/Overview/ParsingRules.vue create mode 100644 src/frontend/src/plugins/sms-transaction-parser/modules/Overview/Setup.vue create mode 100644 src/frontend/src/plugins/sms-transaction-parser/repositories/ParsingRuleRepository.js create mode 100644 src/frontend/src/plugins/sms-transaction-parser/repositories/SmsTransactionRepository.js create mode 100644 src/frontend/src/plugins/sms-transaction-parser/services/.gitkeep create mode 100644 src/frontend/src/plugins/sms-transaction-parser/services/ParsingRuleService.js create mode 100644 src/frontend/src/plugins/sms-transaction-parser/services/SmsTransactionService.js diff --git a/src/backend/app/Models/MpmPlugin.php b/src/backend/app/Models/MpmPlugin.php index 84bf47e7e..f86de6d0f 100644 --- a/src/backend/app/Models/MpmPlugin.php +++ b/src/backend/app/Models/MpmPlugin.php @@ -48,6 +48,7 @@ class MpmPlugin extends BaseModelCentral { public const TEXTBEE_SMS_GATEWAY = 26; public const ECREEE_E_TENDER = 27; public const SPARK_SHS = 28; + public const SMS_TRANSACTION_PARSER = 29; protected $table = 'mpm_plugins'; diff --git a/src/backend/app/Plugins/SmsTransactionParser/Console/Commands/InstallPackage.php b/src/backend/app/Plugins/SmsTransactionParser/Console/Commands/InstallPackage.php new file mode 100644 index 000000000..7c33e7391 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Console/Commands/InstallPackage.php @@ -0,0 +1,25 @@ +info('Installing SmsTransactionParser Integration Package\n'); + + $this->smsParsingRuleService->installDefaults(); + + $this->info('Package installed successfully..'); + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/Http/Controllers/SmsParsingRuleController.php b/src/backend/app/Plugins/SmsTransactionParser/Http/Controllers/SmsParsingRuleController.php new file mode 100644 index 000000000..03bd7b6ab --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Http/Controllers/SmsParsingRuleController.php @@ -0,0 +1,60 @@ +json([ + 'data' => $this->smsParsingRuleService->getAll(), + ]); + } + + public function store(SmsParsingRuleRequest $request): JsonResponse { + $data = $request->validated(); + $data['pattern'] = $this->templateToRegexConverter->convert($data['template']); + $rule = $this->smsParsingRuleService->create($data); + + return response()->json([ + 'data' => $rule, + ], 201); + } + + public function update(int $id, SmsParsingRuleRequest $request): JsonResponse { + $rule = $this->smsParsingRuleService->getById($id); + $data = $request->validated(); + $data['pattern'] = $this->templateToRegexConverter->convert($data['template']); + $updatedRule = $this->smsParsingRuleService->update($rule, $data); + + return response()->json([ + 'data' => $updatedRule, + ]); + } + + public function destroy(int $id): JsonResponse { + $rule = $this->smsParsingRuleService->getById($id); + $this->smsParsingRuleService->delete($rule); + + return response()->json(null, 204); + } + + public function install(): JsonResponse { + $rules = $this->smsParsingRuleService->installDefaults(); + + return response()->json([ + 'data' => $rules, + ]); + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/Http/Controllers/SmsTransactionController.php b/src/backend/app/Plugins/SmsTransactionParser/Http/Controllers/SmsTransactionController.php new file mode 100644 index 000000000..3315f2790 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Http/Controllers/SmsTransactionController.php @@ -0,0 +1,21 @@ +json( + $this->smsTransactionService->getAll(), + ); + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/Http/Requests/SmsParsingRuleRequest.php b/src/backend/app/Plugins/SmsTransactionParser/Http/Requests/SmsParsingRuleRequest.php new file mode 100644 index 000000000..c230b8fa2 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Http/Requests/SmsParsingRuleRequest.php @@ -0,0 +1,27 @@ + + */ + public function rules(): array { + $ruleId = $this->route('id'); + + return [ + 'provider_name' => 'required|string|max:255|unique:tenant.sms_parsing_rules,provider_name'.($ruleId ? ','.$ruleId : ''), + 'template' => ['required', 'string'], + 'sender_pattern' => ['nullable', 'string'], + 'enabled' => ['boolean'], + ]; + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/Models/SmsParsingRule.php b/src/backend/app/Plugins/SmsTransactionParser/Models/SmsParsingRule.php new file mode 100644 index 000000000..3005bd53b --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Models/SmsParsingRule.php @@ -0,0 +1,28 @@ + 'boolean', + ]; + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/Models/SmsTransaction.php b/src/backend/app/Plugins/SmsTransactionParser/Models/SmsTransaction.php new file mode 100644 index 000000000..419d879c3 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Models/SmsTransaction.php @@ -0,0 +1,89 @@ + $conflicts + * @property-read Model|null $manufacturerTransaction + * @property-read Transaction|null $transaction + */ +class SmsTransaction extends BasePaymentProviderTransaction { + protected $table = 'sms_transactions'; + public const RELATION_NAME = 'sms_transaction'; + public const STATUS_FAILED = -1; + public const STATUS_PENDING = 0; + public const STATUS_SUCCESS = 1; + + public function getTransactionReference(): string { + return $this->transaction_reference; + } + + public function getSenderPhone(): string { + return $this->sender_phone; + } + + public function getDeviceSerial(): ?string { + return $this->device_serial; + } + + public function getAmount(): float { + return $this->amount; + } + + public function getRawMessage(): string { + return $this->raw_message; + } + + public function getProviderName(): string { + return $this->provider_name; + } + + public function setStatus(int $status): void { + $this->status = $status; + } + + /** + * @return MorphMany + */ + public function conflicts(): MorphMany { + return $this->morphMany(TransactionConflicts::class, 'transaction'); + } + + public function getManufacturerTransferType(): ?string { + return 'SmsTransaction'; + } + + public function getDescription(): ?string { + return $this->raw_message; + } + + public static function getTransactionName(): string { + return self::RELATION_NAME; + } + + public function getId(): int { + return $this->id; + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/Providers/EventServiceProvider.php b/src/backend/app/Plugins/SmsTransactionParser/Providers/EventServiceProvider.php new file mode 100644 index 000000000..134fccb6e --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Providers/EventServiceProvider.php @@ -0,0 +1,19 @@ + + */ + protected $subscribe = []; + + /** + * Register any events for your application. + */ + public function boot(): void { + parent::boot(); + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/Providers/ObserverServiceProvider.php b/src/backend/app/Plugins/SmsTransactionParser/Providers/ObserverServiceProvider.php new file mode 100644 index 000000000..adafaef96 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Providers/ObserverServiceProvider.php @@ -0,0 +1,14 @@ +mapApiRoutes(); + } + + protected function mapApiRoutes(): void { + Route::prefix('api') + ->middleware('api') + ->namespace($this->namespace) + ->group(__DIR__.'/../routes/api.php'); + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/Providers/SmsTransactionParserServiceProvider.php b/src/backend/app/Plugins/SmsTransactionParser/Providers/SmsTransactionParserServiceProvider.php new file mode 100644 index 000000000..1f42a5628 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Providers/SmsTransactionParserServiceProvider.php @@ -0,0 +1,25 @@ +app->register(RouteServiceProvider::class); + $this->commands([InstallPackage::class]); + Relation::morphMap([ + SmsTransaction::RELATION_NAME => SmsTransaction::class, + ]); + } + + public function register(): void { + $this->app->register(EventServiceProvider::class); + $this->app->register(ObserverServiceProvider::class); + $this->app->bind(SmsTransactionProvider::class); + $this->app->alias(SmsTransactionProvider::class, 'SmsTransactionParser'); + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/Providers/SmsTransactionProvider.php b/src/backend/app/Plugins/SmsTransactionParser/Providers/SmsTransactionProvider.php new file mode 100644 index 000000000..c169d822e --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Providers/SmsTransactionProvider.php @@ -0,0 +1,83 @@ +originalTransaction()->first(); + + $smsTransaction->setStatus($requestType ? SmsTransaction::STATUS_SUCCESS : SmsTransaction::STATUS_FAILED); + $smsTransaction->save(); + + if ($requestType) { + $this->smsService->sendSms($transaction->toArray(), SmsTypes::TRANSACTION_CONFIRMATION, SmsConfigs::class); + } else { + Log::error('SMS parsed transaction has been cancelled'); + } + } + + public function validateRequest(Request $request): void {} + + public function confirm(): void {} + + public function getMessage(): string { + return $this->getTransaction()->getMessage(); + } + + public function getAmount(): float { + return $this->getTransaction()->getAmount(); + } + + public function getSender(): string { + return $this->getTransaction()->getSender(); + } + + public function saveCommonData(): Model { + throw new \BadMethodCallException('Method saveCommonData() not implemented for SMS transactions.'); + } + + public function init(BasePaymentProviderTransaction $transaction): void { + if (!$transaction instanceof SmsTransaction) { + throw new \InvalidArgumentException('Expected instance of '.SmsTransaction::class.', got '.$transaction::class); + } + $this->smsTransaction = $transaction; + $this->transaction = $transaction->transaction()->first(); + } + + public function addConflict(?string $message): void { + $conflict = $this->transactionConflicts->newQuery()->make([ + 'state' => $message, + ]); + $conflict->transaction()->associate($this->smsTransaction); + $conflict->save(); + } + + public function getTransaction(): Transaction { + return $this->transaction; + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/Services/SmsParsingRuleService.php b/src/backend/app/Plugins/SmsTransactionParser/Services/SmsParsingRuleService.php new file mode 100644 index 000000000..90191107e --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Services/SmsParsingRuleService.php @@ -0,0 +1,97 @@ + + */ + public function getAll(): Collection { + return $this->smsParsingRule->newQuery()->get(); + } + + public function getById(int $id): SmsParsingRule { + return $this->smsParsingRule->newQuery()->findOrFail($id); + } + + /** + * @param array $data + */ + public function create(array $data): SmsParsingRule { + return $this->smsParsingRule->newQuery()->create($data); + } + + /** + * @param array $data + */ + public function update(SmsParsingRule $rule, array $data): SmsParsingRule { + $rule->update($data); + + return $rule->fresh(); + } + + public function delete(SmsParsingRule $rule): void { + $rule->delete(); + } + + /** + * @param array $data + */ + public function createDefaultRule(array $data): SmsParsingRule { + return $this->smsParsingRule->newQuery()->firstOrCreate( + ['provider_name' => $data['provider_name']], + $data, + ); + } + + /** + * @return Collection + */ + public function installDefaults(): Collection { + $converter = app(TemplateToRegexConverter::class); + + $defaults = [ + [ + 'provider_name' => 'vodacom_en', + 'template' => 'Confirmed [transaction_ref].[*]amount of [amount]MT[*]reference [device_serial][*]', + 'sender_pattern' => '/M-?Pesa/i', + 'enabled' => true, + ], + [ + 'provider_name' => 'vodacom_pt', + 'template' => 'Confirmado [transaction_ref].[*]valor de [amount]MT[*]referencia [device_serial][*]', + 'sender_pattern' => '/M-?Pesa/i', + 'enabled' => true, + ], + [ + 'provider_name' => 'movitel_pt', + 'template' => 'ID da transacao[*][transaction_ref].[*][amount]MT[*]Conteudo:[*][device_serial].[*]', + 'sender_pattern' => '/e-?Mola/i', + 'enabled' => true, + ], + [ + 'provider_name' => 'movitel_en', + 'template' => 'Transaction ID [transaction_ref].[*][amount] MT[*]Content:[*][device_serial].[*]', + 'sender_pattern' => '/e-?Mola/i', + 'enabled' => true, + ], + ]; + + foreach ($defaults as $default) { + $default['pattern'] = $converter->convert($default['template']); + $this->createDefaultRule($default); + } + + return $this->getAll(); + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/Services/SmsTransactionService.php b/src/backend/app/Plugins/SmsTransactionParser/Services/SmsTransactionService.php new file mode 100644 index 000000000..c77a6f4e4 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Services/SmsTransactionService.php @@ -0,0 +1,100 @@ +smsParserFactory->parse($body, $sender); + + if (!$parsedData instanceof ParsedSmsData) { + return null; + } + + $existing = $this->smsTransaction->newQuery() + ->where('transaction_reference', $parsedData->transactionReference) + ->first(); + + if ($existing) { + return null; + } + + return $this->createTransaction($parsedData); + } + + private function createTransaction(ParsedSmsData $parsedData): ?SmsTransaction { + try { + /** @var SmsTransaction $smsTransaction */ + $smsTransaction = $this->smsTransaction->newQuery()->create([ + 'provider_name' => $parsedData->providerName, + 'transaction_reference' => $parsedData->transactionReference, + 'amount' => $parsedData->amount, + 'sender_phone' => $parsedData->senderPhone ?? '', + 'device_serial' => $parsedData->deviceSerial, + 'raw_message' => $parsedData->rawMessage, + 'status' => SmsTransaction::STATUS_PENDING, + ]); + + $transaction = $smsTransaction->transaction()->create([ + 'amount' => $parsedData->amount, + 'sender' => $parsedData->senderPhone ?? '', + 'message' => $parsedData->deviceSerial, + 'type' => 'energy', + ]); + + $companyId = CompanyDatabase::query() + ->where('database_name', config('database.connections.tenant.database')) + ->first() + ?->getCompanyId(); + + if ($companyId === null) { + throw new \RuntimeException('Could not determine company ID for current tenant'); + } + + dispatch(new ProcessPayment($companyId, $transaction->id)); + + $smsTransaction->setStatus(SmsTransaction::STATUS_SUCCESS); + $smsTransaction->save(); + + return $smsTransaction; + } catch (\Exception $e) { + Log::error('SMS transaction processing failed', [ + 'reference' => $parsedData->transactionReference, + 'error' => $e->getMessage(), + ]); + + if (isset($smsTransaction) && $smsTransaction->exists) { + $smsTransaction->setStatus(SmsTransaction::STATUS_FAILED); + $smsTransaction->save(); + + $smsTransaction->conflicts()->create([ + 'state' => $e->getMessage(), + ]); + } + + return null; + } + } + + /** + * @return LengthAwarePaginator + */ + public function getAll(int $limit = 50): LengthAwarePaginator { + return $this->smsTransaction->newQuery()->latest() + ->paginate($limit); + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Contracts/ISmsTransactionParser.php b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Contracts/ISmsTransactionParser.php new file mode 100644 index 000000000..387b6ade6 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Contracts/ISmsTransactionParser.php @@ -0,0 +1,14 @@ + $regexMatches Named capture groups from the parsing rule regex + */ + public function parse(string $body, array $regexMatches): ?ParsedSmsData; +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/ParsedSmsData.php b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/ParsedSmsData.php new file mode 100644 index 000000000..e6a88bc95 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/ParsedSmsData.php @@ -0,0 +1,16 @@ + $regexMatches + */ + public function parse(string $body, array $regexMatches): ?ParsedSmsData { + $amount = $regexMatches['amount'] ?? null; + $transactionRef = $regexMatches['transaction_ref'] ?? null; + $deviceSerial = $regexMatches['device_serial'] ?? null; + + if ($amount === null || $transactionRef === null || $deviceSerial === null) { + return null; + } + + $amount = (float) str_replace([',', ' '], '', $amount); + $transactionRef = trim($transactionRef); + $deviceSerial = trim($deviceSerial); + + if ($amount <= 0 || $transactionRef === '' || $deviceSerial === '') { + return null; + } + + $senderPhone = isset($regexMatches['sender_phone']) + ? preg_replace('/[^0-9+]/', '', $regexMatches['sender_phone']) + : null; + + if ($senderPhone === '') { + $senderPhone = null; + } + + return new ParsedSmsData( + amount: $amount, + deviceSerial: $deviceSerial, + transactionReference: $transactionRef, + providerName: 'movitel', + rawMessage: $body, + senderPhone: $senderPhone, + ); + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/VodacomTransactionParser.php b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/VodacomTransactionParser.php new file mode 100644 index 000000000..4c292b8b8 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/VodacomTransactionParser.php @@ -0,0 +1,48 @@ + $regexMatches + */ + public function parse(string $body, array $regexMatches): ?ParsedSmsData { + $amount = $regexMatches['amount'] ?? null; + $transactionRef = $regexMatches['transaction_ref'] ?? null; + $deviceSerial = $regexMatches['device_serial'] ?? null; + + if ($amount === null || $transactionRef === null || $deviceSerial === null) { + return null; + } + + $amount = (float) str_replace([',', ' '], '', $amount); + $transactionRef = trim($transactionRef); + $deviceSerial = trim($deviceSerial); + + if ($amount <= 0 || $transactionRef === '' || $deviceSerial === '') { + return null; + } + + $senderPhone = isset($regexMatches['sender_phone']) + ? preg_replace('/[^0-9+]/', '', $regexMatches['sender_phone']) + : null; + + if ($senderPhone === '') { + $senderPhone = null; + } + + return new ParsedSmsData( + amount: $amount, + deviceSerial: $deviceSerial, + transactionReference: $transactionRef, + providerName: 'vodacom', + rawMessage: $body, + senderPhone: $senderPhone, + ); + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/SmsParserFactory.php b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/SmsParserFactory.php new file mode 100644 index 000000000..3cf364e89 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/SmsParserFactory.php @@ -0,0 +1,52 @@ + */ + private const PARSER_MAP = [ + 'vodacom' => VodacomTransactionParser::class, + 'movitel' => MovitelTransactionParser::class, + ]; + + public function __construct( + private SmsParsingRule $smsParsingRule, + ) {} + + public function parse(string $body, string $sender): ?ParsedSmsData { + $rules = $this->smsParsingRule->newQuery() + ->where('enabled', true) + ->get(); + + foreach ($rules as $rule) { + if (isset($rule->sender_pattern) && $rule->sender_pattern != '' && !preg_match($rule->sender_pattern, $sender)) { + continue; + } + + if (!preg_match($rule->pattern, $body, $matches)) { + continue; + } + + $parserClass = self::PARSER_MAP[$rule->provider_name] + ?? self::PARSER_MAP[explode('_', $rule->provider_name, 2)[0]] + ?? null; + + if ($parserClass === null) { + continue; + } + + $parser = resolve($parserClass); + $namedMatches = array_filter($matches, is_string(...), ARRAY_FILTER_USE_KEY); + + return $parser->parse($body, $namedMatches); + } + + return null; + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/TemplateToRegexConverter.php b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/TemplateToRegexConverter.php new file mode 100644 index 000000000..2d9024998 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/TemplateToRegexConverter.php @@ -0,0 +1,72 @@ + + */ + private const VARIABLE_PATTERNS = [ + 'transaction_ref' => '[A-Za-z0-9.]+', + 'amount' => '[\d,]+(?:\.\d{1,2})?', + 'sender_phone' => '[\d+\s\-]+', + 'device_serial' => '[A-Za-z0-9]+', + ]; + + private const WILDCARD_PATTERN = '[\s\S]*?'; + + /** + * Convert a human-readable template with [variable] placeholders + * into a regex with named capture groups. + * + * Use [*] as a wildcard to match any text between fields. + * + * Example input: "Confirmed [transaction_ref].[*]amount of [amount]MT[*]reference [device_serial][*]" + * Example output: "/Confirmed\s+(?P[A-Za-z0-9.]+)\.[\s\S]*?amount\s+of\s+(?P[\d,]+(?:\.\d{1,2})?)MT[\s\S]*?reference\s+(?P[A-Za-z0-9]+)[\s\S]*?/si" + */ + public function convert(string $template): string { + // Escape regex special chars in the literal parts, but preserve the [variable] placeholders first + $placeholderMap = []; + $index = 0; + + // Replace placeholders (including [*] wildcard) with unique tokens before escaping + $tokenized = preg_replace_callback('/\[(\w+|\*)]/', function (array $matches) use (&$placeholderMap, &$index): string { + $token = '___PLACEHOLDER_'.$index.'___'; + $placeholderMap[$token] = $matches[1]; + ++$index; + + return $token; + }, $template); + + // Escape regex special characters in the literal text + $escaped = preg_quote($tokenized, '/'); + + // Collapse whitespace runs to flexible whitespace matchers + $escaped = preg_replace('/\s+/', '\\s+', $escaped); + + // Replace tokens back with named capture groups (or wildcards for [*]) + foreach ($placeholderMap as $token => $variable) { + $quotedToken = preg_quote($token, '/'); + + if ($variable === '*') { + $escaped = str_replace($quotedToken, self::WILDCARD_PATTERN, $escaped); + } else { + $variablePattern = self::VARIABLE_PATTERNS[$variable] ?? '.+?'; + $escaped = str_replace($quotedToken, '(?P<'.$variable.'>'.$variablePattern.')', $escaped); + } + } + + return '/'.$escaped.'/si'; + } + + /** + * @return list + */ + public function getRequiredVariables(): array { + return array_keys(self::VARIABLE_PATTERNS); + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/routes/api.php b/src/backend/app/Plugins/SmsTransactionParser/routes/api.php new file mode 100644 index 000000000..4934509f9 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/routes/api.php @@ -0,0 +1,20 @@ + 'sms-transaction-parser'], function () { + Route::post('/install', [SmsParsingRuleController::class, 'install']); + + Route::group(['prefix' => 'parsing-rules'], function () { + Route::get('/', [SmsParsingRuleController::class, 'index']); + Route::post('/', [SmsParsingRuleController::class, 'store']); + Route::put('/{id}', [SmsParsingRuleController::class, 'update']); + Route::delete('/{id}', [SmsParsingRuleController::class, 'destroy']); + }); + + Route::group(['prefix' => 'transactions'], function () { + Route::get('/', [SmsTransactionController::class, 'index']); + }); +}); diff --git a/src/backend/app/Plugins/TextbeeSmsGateway/Console/Commands/FetchIncomingSms.php b/src/backend/app/Plugins/TextbeeSmsGateway/Console/Commands/FetchIncomingSms.php new file mode 100644 index 000000000..19a87a34c --- /dev/null +++ b/src/backend/app/Plugins/TextbeeSmsGateway/Console/Commands/FetchIncomingSms.php @@ -0,0 +1,64 @@ +checkForPluginStatusIsActive(self::MPM_PLUGIN_ID)) { + return; + } + + $timeStart = microtime(true); + $this->info('#############################'); + $this->info('# TextBee SMS Polling #'); + $startedAt = Carbon::now()->toIso8601ZuluString(); + $this->info('fetch-incoming-sms command started at '.$startedAt); + + $messages = $this->pollingService->fetchNewMessages(); + $processed = 0; + $skipped = 0; + + foreach ($messages as $message) { + $result = $this->smsTransactionService->processIncomingSms( + $message['body'], + $message['sender'], + ); + + if ($result instanceof SmsTransaction) { + ++$processed; + } else { + ++$skipped; + } + } + + $timeEnd = microtime(true); + $totalTime = $timeEnd - $timeStart; + $this->info("Processed: {$processed}, Skipped: {$skipped}"); + $this->info('Took '.$totalTime.' seconds.'); + $this->info('#############################'); + } +} diff --git a/src/backend/app/Plugins/TextbeeSmsGateway/Providers/TextbeeSmsGatewayServiceProvider.php b/src/backend/app/Plugins/TextbeeSmsGateway/Providers/TextbeeSmsGatewayServiceProvider.php index 90f1f670a..46f4ad9b1 100644 --- a/src/backend/app/Plugins/TextbeeSmsGateway/Providers/TextbeeSmsGatewayServiceProvider.php +++ b/src/backend/app/Plugins/TextbeeSmsGateway/Providers/TextbeeSmsGatewayServiceProvider.php @@ -2,14 +2,18 @@ namespace App\Plugins\TextbeeSmsGateway\Providers; +use App\Plugins\TextbeeSmsGateway\Console\Commands\FetchIncomingSms; use App\Plugins\TextbeeSmsGateway\Console\Commands\InstallPackage; use App\Plugins\TextbeeSmsGateway\TextbeeSmsGateway; +use Illuminate\Console\Scheduling\Schedule; use Illuminate\Support\ServiceProvider; class TextbeeSmsGatewayServiceProvider extends ServiceProvider { public function boot(): void { $this->app->register(RouteServiceProvider::class); - $this->commands([InstallPackage::class]); + $this->commands([InstallPackage::class, FetchIncomingSms::class]); + $this->app->make(Schedule::class)->command('textbee-sms-gateway:fetch-incoming-sms')->everyTwoMinutes() + ->appendOutputTo(storage_path('logs/cron.log')); } public function register(): void { diff --git a/src/backend/app/Plugins/TextbeeSmsGateway/Services/TextbeeSmsPollingService.php b/src/backend/app/Plugins/TextbeeSmsGateway/Services/TextbeeSmsPollingService.php new file mode 100644 index 000000000..6384a5024 --- /dev/null +++ b/src/backend/app/Plugins/TextbeeSmsGateway/Services/TextbeeSmsPollingService.php @@ -0,0 +1,91 @@ + + */ + public function fetchNewMessages(): array { + $credentials = $this->credentialService->getCredentials(); + + if (!$credentials instanceof TextbeeCredential || empty($credentials->api_key) || empty($credentials->device_id)) { + Log::warning('TextBee credentials not configured for SMS polling'); + + return []; + } + + $url = self::BASE_URL.'/gateway/devices/'.$credentials->device_id.'/get-received-sms'; + + $lastPolledAt = Cache::get(self::CACHE_KEY); + + $queryParams = ['page' => 1, 'limit' => 20]; + if ($lastPolledAt) { + $queryParams['receivedAfter'] = $lastPolledAt; + } + + try { + $response = Http::withHeaders([ + 'x-api-key' => $credentials->api_key, + 'Accept' => 'application/json', + ])->get($url, $queryParams); + + if (!$response->successful()) { + Log::error('TextBee SMS polling failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return []; + } + + $data = $response->json('data', []); + + if (empty($data)) { + return []; + } + + $messages = []; + $latestReceivedAt = $lastPolledAt; + + foreach ($data as $sms) { + $receivedAt = $sms['receivedAt'] ?? null; + $messages[] = [ + 'sender' => $sms['sender'] ?? '', + 'body' => $sms['message'] ?? '', + 'receivedAt' => $receivedAt ?? '', + ]; + + if ($receivedAt && ($latestReceivedAt === null || $receivedAt > $latestReceivedAt)) { + $latestReceivedAt = $receivedAt; + } + } + + if ($latestReceivedAt) { + Cache::put(self::CACHE_KEY, $latestReceivedAt); + } + + return $messages; + } catch (\Exception $e) { + Log::error('TextBee SMS polling exception', [ + 'message' => $e->getMessage(), + ]); + + return []; + } + } +} diff --git a/src/backend/app/Providers/Helpers/TransactionAdapter.php b/src/backend/app/Providers/Helpers/TransactionAdapter.php index 93614ca3c..c8d961892 100644 --- a/src/backend/app/Providers/Helpers/TransactionAdapter.php +++ b/src/backend/app/Providers/Helpers/TransactionAdapter.php @@ -4,6 +4,8 @@ use App\Models\Transaction\AgentTransaction; use App\Plugins\PaystackPaymentProvider\Models\PaystackTransaction; +use App\Plugins\SmsTransactionParser\Models\SmsTransaction; +use App\Plugins\SmsTransactionParser\Providers\SmsTransactionProvider; use App\Plugins\SwiftaPaymentProvider\Models\SwiftaTransaction; use App\Plugins\SwiftaPaymentProvider\Providers\SwiftaTransactionProvider; use App\Plugins\WavecomPaymentProvider\Models\WaveComTransaction; @@ -44,6 +46,11 @@ public static function getTransaction($transactionProvider): ?ITransactionProvid $baseTransaction = resolve(WaveComTransactionProvider::class); $baseTransaction->init($transactionProvider); + return $baseTransaction; + } elseif ($transactionProvider instanceof SmsTransaction) { + $baseTransaction = resolve(SmsTransactionProvider::class); + $baseTransaction->init($transactionProvider); + return $baseTransaction; } diff --git a/src/backend/app/Services/AbstractPaymentAggregatorTransactionService.php b/src/backend/app/Services/AbstractPaymentAggregatorTransactionService.php index fc06baa12..8db5fef5d 100644 --- a/src/backend/app/Services/AbstractPaymentAggregatorTransactionService.php +++ b/src/backend/app/Services/AbstractPaymentAggregatorTransactionService.php @@ -10,6 +10,7 @@ use App\Models\Person\Person; use App\Models\Transaction\Transaction; use App\Plugins\PaystackPaymentProvider\Models\PaystackTransaction; +use App\Plugins\SmsTransactionParser\Models\SmsTransaction; use App\Plugins\SteamaMeter\Exceptions\ModelNotFoundException; use App\Plugins\SwiftaPaymentProvider\Models\SwiftaTransaction; use App\Plugins\WavecomPaymentProvider\Models\WaveComTransaction; @@ -28,7 +29,7 @@ public function __construct( private Meter $meter, private Address $address, private Transaction $transaction, - private SwiftaTransaction|WaveMoneyTransaction|WaveComTransaction|PaystackTransaction $paymentAggregatorTransaction, + private SwiftaTransaction|WaveMoneyTransaction|WaveComTransaction|PaystackTransaction|SmsTransaction $paymentAggregatorTransaction, ) {} public function validatePaymentOwner(string $meterSerialNumber, float $amount): void { @@ -145,7 +146,7 @@ public function getMinimumPurchaseAmount(): float { return $this->minimumPurchaseAmount; } - public function getPaymentAggregatorTransaction(): SwiftaTransaction|WaveMoneyTransaction|WaveComTransaction|PaystackTransaction { + public function getPaymentAggregatorTransaction(): SwiftaTransaction|WaveMoneyTransaction|WaveComTransaction|PaystackTransaction|SmsTransaction { return $this->paymentAggregatorTransaction; } } diff --git a/src/backend/bootstrap/providers.php b/src/backend/bootstrap/providers.php index 6f6a16ade..12bf531e4 100644 --- a/src/backend/bootstrap/providers.php +++ b/src/backend/bootstrap/providers.php @@ -17,6 +17,7 @@ use App\Plugins\OdysseyDataExport\Providers\OdysseyDataExportServiceProvider; use App\Plugins\PaystackPaymentProvider\Providers\PaystackPaymentProviderServiceProvider; use App\Plugins\Prospect\Providers\ProspectServiceProvider; +use App\Plugins\SmsTransactionParser\Providers\SmsTransactionParserServiceProvider; use App\Plugins\SparkMeter\Providers\SparkMeterServiceProvider; use App\Plugins\SparkShs\Providers\SparkShsServiceProvider; use App\Plugins\SteamaMeter\Providers\SteamaMeterServiceProvider; @@ -66,4 +67,5 @@ WavecomPaymentProviderServiceProvider::class, EcreeeETenderServiceProvider::class, SparkShsServiceProvider::class, + SmsTransactionParserServiceProvider::class, ]; diff --git a/src/backend/database/migrations/2026_03_02_145240_add_sms-transaction-parser_to_mpm_plugin_table.php b/src/backend/database/migrations/2026_03_02_145240_add_sms-transaction-parser_to_mpm_plugin_table.php new file mode 100644 index 000000000..63d4056b3 --- /dev/null +++ b/src/backend/database/migrations/2026_03_02_145240_add_sms-transaction-parser_to_mpm_plugin_table.php @@ -0,0 +1,27 @@ +insert([ + [ + 'id' => MpmPlugin::SMS_TRANSACTION_PARSER, + 'name' => 'SmsTransactionParser', + 'description' => 'Parse incoming SMS messages from mobile money providers to create payment transactions', + 'tail_tag' => 'SmsTransactionParser', + 'installation_command' => 'sms-transaction-parser:install', + 'root_class' => 'SmsTransactionParser', + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ], + ]); + } + + public function down(): void { + DB::table('mpm_plugins')->where('id', MpmPlugin::SMS_TRANSACTION_PARSER)->delete(); + } +}; diff --git a/src/backend/database/migrations/tenant/2026_03_02_145333_create_sms_parsing_rules_table.php b/src/backend/database/migrations/tenant/2026_03_02_145333_create_sms_parsing_rules_table.php new file mode 100644 index 000000000..25c47bbe3 --- /dev/null +++ b/src/backend/database/migrations/tenant/2026_03_02_145333_create_sms_parsing_rules_table.php @@ -0,0 +1,27 @@ +hasTable(self::TABLE_NAME)) { + Schema::connection('tenant')->create(self::TABLE_NAME, function (Blueprint $table) { + $table->increments('id'); + $table->string('provider_name')->unique(); + $table->text('template'); + $table->text('pattern'); + $table->string('sender_pattern')->nullable(); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + } + } + + public function down(): void { + Schema::connection('tenant')->dropIfExists(self::TABLE_NAME); + } +}; diff --git a/src/backend/database/migrations/tenant/2026_03_02_145342_create_sms_transactions_table.php b/src/backend/database/migrations/tenant/2026_03_02_145342_create_sms_transactions_table.php new file mode 100644 index 000000000..114b30ec6 --- /dev/null +++ b/src/backend/database/migrations/tenant/2026_03_02_145342_create_sms_transactions_table.php @@ -0,0 +1,31 @@ +hasTable(self::TABLE_NAME)) { + Schema::connection('tenant')->create(self::TABLE_NAME, function (Blueprint $table) { + $table->increments('id'); + $table->string('provider_name'); + $table->string('transaction_reference')->unique(); + $table->double('amount'); + $table->string('sender_phone'); + $table->string('device_serial')->nullable(); + $table->text('raw_message'); + $table->integer('status')->default(0); + $table->string('manufacturer_transaction_type')->nullable(); + $table->integer('manufacturer_transaction_id')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void { + Schema::connection('tenant')->dropIfExists(self::TABLE_NAME); + } +}; diff --git a/src/backend/tests/Unit/SmsTransactionParser/MovitelTransactionParserTest.php b/src/backend/tests/Unit/SmsTransactionParser/MovitelTransactionParserTest.php new file mode 100644 index 000000000..41924e9a4 --- /dev/null +++ b/src/backend/tests/Unit/SmsTransactionParser/MovitelTransactionParserTest.php @@ -0,0 +1,37 @@ + '1.00', + 'transaction_ref' => 'PP260101.0930.A12345', + 'device_serial' => '5566778899', + ]; + + $result = $parser->parse($body, $matches); + + $this->assertNotNull($result); + $this->assertEquals(1.00, $result->amount); + $this->assertNull($result->senderPhone); + $this->assertEquals('PP260101.0930.A12345', $result->transactionReference); + $this->assertEquals('5566778899', $result->deviceSerial); + $this->assertEquals('movitel', $result->providerName); + } + + public function testReturnsNullWhenRequiredFieldMissing(): void { + $parser = new MovitelTransactionParser(); + + $result = $parser->parse('some body', [ + 'amount' => '2500', + ]); + + $this->assertNull($result); + } +} diff --git a/src/backend/tests/Unit/SmsTransactionParser/SmsParserFactoryTest.php b/src/backend/tests/Unit/SmsTransactionParser/SmsParserFactoryTest.php new file mode 100644 index 000000000..2ed29d491 --- /dev/null +++ b/src/backend/tests/Unit/SmsTransactionParser/SmsParserFactoryTest.php @@ -0,0 +1,124 @@ +create([ + 'provider_name' => 'vodacom_en', + 'template' => $template, + 'pattern' => $converter->convert($template), + 'sender_pattern' => '/M-?Pesa/i', + 'enabled' => true, + ]); + + $result = app(SmsParserFactory::class)->parse( + 'Confirmed XYZ1A23BCDE. We have recorded a purchase transaction in the amount of 100.00MT, and the fee was 0.00MT, at the entity ACME SOLAR ENERGY LDA, with reference 991234567, on 26/02/26 at 8:29 AM. Your new M-Pesa balance is 140.00MT. If you have any questions, call 100. M-Pesa is easy!', + 'M-Pesa', + ); + + $this->assertNotNull($result); + $this->assertEquals('vodacom', $result->providerName); + $this->assertEquals(100.00, $result->amount); + $this->assertEquals('XYZ1A23BCDE', $result->transactionReference); + $this->assertEquals('991234567', $result->deviceSerial); + $this->assertNull($result->senderPhone); + } + + public function testMatchesVodacomPortugueseSms(): void { + $converter = app(TemplateToRegexConverter::class); + $template = 'Confirmado [transaction_ref].[*]valor de [amount]MT[*]referencia [device_serial][*]'; + + SmsParsingRule::query()->create([ + 'provider_name' => 'vodacom_pt', + 'template' => $template, + 'pattern' => $converter->convert($template), + 'sender_pattern' => '/M-?Pesa/i', + 'enabled' => true, + ]); + + $result = app(SmsParserFactory::class)->parse( + 'Confirmado XYZ1A23BCDE. Registamos uma operacao de compra no valor de 100.00MT e a taxa foi de 0.00MT na entidade ACME SOLAR ENERGY LDA com referencia 991234567 aos 26/2/26 as 8:29 AM. O teu novo saldo M-Pesa e de 140.00MT. Em caso de duvida, liga 100. M-Pesa e facil!', + 'M-Pesa', + ); + + $this->assertNotNull($result); + $this->assertEquals('vodacom', $result->providerName); + $this->assertEquals(100.00, $result->amount); + $this->assertEquals('XYZ1A23BCDE', $result->transactionReference); + $this->assertEquals('991234567', $result->deviceSerial); + } + + public function testMatchesMovitelEmolaPortugueseSms(): void { + $converter = app(TemplateToRegexConverter::class); + $template = 'ID da transacao[*][transaction_ref].[*][amount]MT[*]Conteudo:[*][device_serial].[*]'; + + SmsParsingRule::query()->create([ + 'provider_name' => 'movitel_pt', + 'template' => $template, + 'pattern' => $converter->convert($template), + 'sender_pattern' => '/e-?Mola/i', + 'enabled' => true, + ]); + + $result = app(SmsParserFactory::class)->parse( + 'ID da transacao PP260101.0930.A12345. Transferiste 1.00MT para conta 840000001, nome: Joao Manuel Silva as 11:40:51 de 03/03/2026. Taxa: 0.00MT. O saldo da tua conta e 404.00MT. Conteudo: 5566778899. Em caso de duvida, liga 100. Obrigado!', + 'eMola', + ); + + $this->assertNotNull($result); + $this->assertEquals('movitel', $result->providerName); + $this->assertEquals(1.00, $result->amount); + $this->assertEquals('PP260101.0930.A12345', $result->transactionReference); + $this->assertEquals('5566778899', $result->deviceSerial); + } + + public function testMatchesMovitelEmolaEnglishSms(): void { + $converter = app(TemplateToRegexConverter::class); + $template = 'Transaction ID [transaction_ref].[*][amount] MT[*]Content:[*][device_serial].[*]'; + + SmsParsingRule::query()->create([ + 'provider_name' => 'movitel_en', + 'template' => $template, + 'pattern' => $converter->convert($template), + 'sender_pattern' => '/e-?Mola/i', + 'enabled' => true, + ]); + + $result = app(SmsParserFactory::class)->parse( + 'Transaction ID PP260101.0930.A12345. You transferred 1.00 MT to account 840000001, name: Joao Manuel Silva, at 11:40:51 on 03/03/2026. Fee: 0.00 MT. Your account balance is 404.00 MT. Content: 5566778899. If you have any questions, call 100. Thank you!', + 'eMola', + ); + + $this->assertNotNull($result); + $this->assertEquals('movitel', $result->providerName); + $this->assertEquals(1.00, $result->amount); + $this->assertEquals('PP260101.0930.A12345', $result->transactionReference); + $this->assertEquals('5566778899', $result->deviceSerial); + } + + public function testReturnsNullForUnrecognizedMessage(): void { + $converter = app(TemplateToRegexConverter::class); + $template = 'Confirmed [transaction_ref].[*]amount of [amount]MT[*]reference [device_serial][*]'; + + SmsParsingRule::query()->create([ + 'provider_name' => 'vodacom_en', + 'template' => $template, + 'pattern' => $converter->convert($template), + 'sender_pattern' => null, + 'enabled' => true, + ]); + + $result = app(SmsParserFactory::class)->parse('Hello, your balance is 100 MT', 'M-Pesa'); + + $this->assertNull($result); + } +} diff --git a/src/backend/tests/Unit/SmsTransactionParser/SmsTransactionServiceTest.php b/src/backend/tests/Unit/SmsTransactionParser/SmsTransactionServiceTest.php new file mode 100644 index 000000000..1c9d53940 --- /dev/null +++ b/src/backend/tests/Unit/SmsTransactionParser/SmsTransactionServiceTest.php @@ -0,0 +1,52 @@ +create([ + 'provider_name' => 'vodacom_en', + 'template' => $template, + 'pattern' => $converter->convert($template), + 'sender_pattern' => '/M-?Pesa/i', + 'enabled' => true, + ]); + } + + public function testProcessesIncomingSmsAndCreatesSmsTransaction(): void { + $result = app(SmsTransactionService::class)->processIncomingSms($this->smsBody, 'M-Pesa'); + + $this->assertNotNull($result); + $this->assertInstanceOf(SmsTransaction::class, $result); + $this->assertEquals('XYZ1A23BCDE', $result->transaction_reference); + $this->assertEquals(100.00, $result->amount); + $this->assertEquals('991234567', $result->device_serial); + } + + public function testDeduplicatesByTransactionReference(): void { + $service = app(SmsTransactionService::class); + + $first = $service->processIncomingSms($this->smsBody, 'M-Pesa'); + $this->assertNotNull($first); + + $second = $service->processIncomingSms($this->smsBody, 'M-Pesa'); + $this->assertNull($second); + + $this->assertEquals(1, SmsTransaction::query()->where('transaction_reference', 'XYZ1A23BCDE')->count()); + } +} diff --git a/src/backend/tests/Unit/SmsTransactionParser/TemplateToRegexConverterTest.php b/src/backend/tests/Unit/SmsTransactionParser/TemplateToRegexConverterTest.php new file mode 100644 index 000000000..afb3f7f14 --- /dev/null +++ b/src/backend/tests/Unit/SmsTransactionParser/TemplateToRegexConverterTest.php @@ -0,0 +1,78 @@ +converter = new TemplateToRegexConverter(); + } + + public function testWildcardMatchesRealVodacomEnglishSms(): void { + $template = 'Confirmed [transaction_ref].[*]amount of [amount]MT[*]reference [device_serial][*]'; + $regex = $this->converter->convert($template); + $sms = 'Confirmed XYZ1A23BCDE. We have recorded a purchase transaction in the amount of 100.00MT, and the fee was 0.00MT, at the entity ACME SOLAR ENERGY LDA, with reference 991234567, on 26/02/26 at 8:29 AM. Your new M-Pesa balance is 140.00MT. If you have any questions, call 100. M-Pesa is easy!'; + + $this->assertSame(1, preg_match($regex, $sms, $matches)); + $this->assertEquals('XYZ1A23BCDE', $matches['transaction_ref']); + $this->assertEquals('100.00', $matches['amount']); + $this->assertEquals('991234567', $matches['device_serial']); + } + + public function testWildcardMatchesRealVodacomPortugueseSms(): void { + $template = 'Confirmado [transaction_ref].[*]valor de [amount]MT[*]referencia [device_serial][*]'; + $regex = $this->converter->convert($template); + $sms = 'Confirmado XYZ1A23BCDE. Registamos uma operacao de compra no valor de 100.00MT e a taxa foi de 0.00MT na entidade ACME SOLAR ENERGY LDA com referencia 991234567 aos 26/2/26 as 8:29 AM. O teu novo saldo M-Pesa e de 140.00MT. Em caso de duvida, liga 100. M-Pesa e facil!'; + + $this->assertSame(1, preg_match($regex, $sms, $matches)); + $this->assertEquals('XYZ1A23BCDE', $matches['transaction_ref']); + $this->assertEquals('100.00', $matches['amount']); + $this->assertEquals('991234567', $matches['device_serial']); + } + + public function testWildcardMatchesRealEmolaPortugueseSms(): void { + $template = 'ID da transacao[*][transaction_ref].[*][amount]MT[*]Conteudo:[*][device_serial].[*]'; + $regex = $this->converter->convert($template); + $sms = 'ID da transacao PP260101.0930.A12345. Transferiste 1.00MT para conta 840000001, nome: Joao Manuel Silva as 11:40:51 de 03/03/2026. Taxa: 0.00MT. O saldo da tua conta e 404.00MT. Conteudo: 5566778899. Em caso de duvida, liga 100. Obrigado!'; + + $this->assertSame(1, preg_match($regex, $sms, $matches)); + $this->assertEquals('PP260101.0930.A12345', $matches['transaction_ref']); + $this->assertEquals('1.00', $matches['amount']); + $this->assertEquals('5566778899', $matches['device_serial']); + } + + public function testWildcardMatchesRealEmolaEnglishSms(): void { + $template = 'Transaction ID [transaction_ref].[*][amount] MT[*]Content:[*][device_serial].[*]'; + $regex = $this->converter->convert($template); + $sms = 'Transaction ID PP260101.0930.A12345. You transferred 1.00 MT to account 840000001, name: Joao Manuel Silva, at 11:40:51 on 03/03/2026. Fee: 0.00 MT. Your account balance is 404.00 MT. Content: 5566778899. If you have any questions, call 100. Thank you!'; + + $this->assertSame(1, preg_match($regex, $sms, $matches)); + $this->assertEquals('PP260101.0930.A12345', $matches['transaction_ref']); + $this->assertEquals('1.00', $matches['amount']); + $this->assertEquals('5566778899', $matches['device_serial']); + } + + public function testCaseInsensitiveMatching(): void { + $template = 'Confirmado [transaction_ref].[*]valor de [amount]MT[*]referencia [device_serial][*]'; + $regex = $this->converter->convert($template); + $sms = 'CONFIRMADO ABC123. operacao no VALOR DE 500.00MT com REFERENCIA 12345 fim'; + + $this->assertSame(1, preg_match($regex, $sms, $matches)); + $this->assertEquals('ABC123', $matches['transaction_ref']); + $this->assertEquals('500.00', $matches['amount']); + $this->assertEquals('12345', $matches['device_serial']); + } + + public function testDoesNotMatchEmolaSmsWithoutConteudo(): void { + $template = 'ID da transacao[*][transaction_ref].[*][amount]MT[*]Conteudo:[*][device_serial].[*]'; + $regex = $this->converter->convert($template); + $sms = 'ID da Transacao: CO260101.0829.b99999. Efectuou um pagamento de 50.00 MT para Movitel,SA. A 08:29 03/03/2026. O seu saldo actual e de 405.00 MT. Obrigado!'; + + $this->assertSame(0, preg_match($regex, $sms)); + } +} diff --git a/src/backend/tests/Unit/SmsTransactionParser/VodacomTransactionParserTest.php b/src/backend/tests/Unit/SmsTransactionParser/VodacomTransactionParserTest.php new file mode 100644 index 000000000..24e33b6d1 --- /dev/null +++ b/src/backend/tests/Unit/SmsTransactionParser/VodacomTransactionParserTest.php @@ -0,0 +1,53 @@ + '100.00', + 'transaction_ref' => 'DBQ2J80CNQY', + 'device_serial' => '996997813', + ]; + + $result = $parser->parse($body, $matches); + + $this->assertNotNull($result); + $this->assertEquals(100.00, $result->amount); + $this->assertNull($result->senderPhone); + $this->assertEquals('DBQ2J80CNQY', $result->transactionReference); + $this->assertEquals('996997813', $result->deviceSerial); + $this->assertEquals('vodacom', $result->providerName); + } + + public function testParsesWithOptionalSenderPhone(): void { + $parser = new VodacomTransactionParser(); + $body = 'Confirmed ABC123XYZ. Purchase of 5,000.00 MT, reference SN12345.'; + $matches = [ + 'amount' => '5,000.00', + 'sender_phone' => '258841234567', + 'transaction_ref' => 'ABC123XYZ', + 'device_serial' => 'SN12345', + ]; + + $result = $parser->parse($body, $matches); + + $this->assertNotNull($result); + $this->assertEquals('258841234567', $result->senderPhone); + } + + public function testReturnsNullWhenRequiredFieldMissing(): void { + $parser = new VodacomTransactionParser(); + + $result = $parser->parse('some body', [ + 'amount' => '5000', + ]); + + $this->assertNull($result); + } +} diff --git a/src/frontend/src/ExportedRoutes.js b/src/frontend/src/ExportedRoutes.js index 7809c3a68..d6aaac787 100644 --- a/src/frontend/src/ExportedRoutes.js +++ b/src/frontend/src/ExportedRoutes.js @@ -106,6 +106,7 @@ import ChintMeterOverview from "@/plugins/chint-meter/modules/Overview/Overview" import OdysseyExportOverview from "@/plugins/odyssey-data-export/modules/Overview/Overview" import EcreeeETenderOverview from "@/plugins/ecreee-e-tender/modules/Overview/Overview" import SparkShsOverview from "@/plugins/spark-shs/modules/Overview/Overview" +import SmsTransactionParserOverview from "@/plugins/sms-transaction-parser/modules/Overview/Overview.vue" export const exportedRoutes = [ // Welcome and login routes @@ -1707,5 +1708,29 @@ export const exportedRoutes = [ }, ], }, + { + path: "/sms-transaction-parser", + component: ChildRouteWrapper, + meta: { + sidebar: { + enabled_by_mpm_plugin_id: 29, + name: "Sms Transaction Parser", + icon: "sms", + }, + }, + children: [ + { + path: "overview", + component: SmsTransactionParserOverview, + meta: { + layout: "default", + sidebar: { + enabled: true, + name: "Overview", + }, + }, + }, + ], + }, // NEW PLUGIN PLACEHOLDER (DO NOT REMOVE THIS LINE) ] diff --git a/src/frontend/src/main.js b/src/frontend/src/main.js index 672187015..67952c472 100644 --- a/src/frontend/src/main.js +++ b/src/frontend/src/main.js @@ -34,6 +34,7 @@ import VodacomTransactionDetail from "@/modules/Transactions/VodacomTransactionD import WaveMoneyTransactionDetail from "@/modules/Transactions/WaveMoneyTransactionDetail" import PaystackTransactionDetail from "@/modules/Transactions/PaystackTransactionDetail" import AgentTransactionDetail from "@/modules/Agent/AgentTransactionDetail" +import SmsTransactionDetail from "@/modules/Transactions/SmsTransactionDetail" import Angaza from "@/plugins/angaza-shs/modules/Overview/Credential" import DalyBms from "@/plugins/daly-bms/modules/Overview/Credential" import AfricasTalking from "@/plugins/africas-talking/modules/Overview/Credential" @@ -43,6 +44,7 @@ import Prospect from "@/plugins/prospect/modules/Overview/Credential" import Paystack from "@/plugins/paystack-payment-provider/modules/Overview/Credential.vue" import TextbeeSmsGateway from "@/plugins/textbee-sms-gateway/modules/Overview/Credential" import SparkShs from "@/plugins/spark-shs/modules/Overview/Credential.vue" +import SmsTransactionParserSetup from "@/plugins/sms-transaction-parser/modules/Overview/Setup.vue" import { getPermissionsForRoute, @@ -73,6 +75,7 @@ Vue.component("VodacomTransactionDetail", VodacomTransactionDetail) Vue.component("WaveMoneyTransactionDetail", WaveMoneyTransactionDetail) Vue.component("PaystackTransactionDetail", PaystackTransactionDetail) Vue.component("AgentTransactionDetail", AgentTransactionDetail) +Vue.component("SmsTransactionDetail", SmsTransactionDetail) Vue.component("Angaza-SHS", Angaza) Vue.component("Daly-Bms", DalyBms) Vue.component("Paystack-Payment-Provider", PaystackPaymentProvider) @@ -82,6 +85,7 @@ Vue.component("Prospect", Prospect) Vue.component("Paystack", Paystack) Vue.component("TextbeeSmsGateway", TextbeeSmsGateway) Vue.component("SparkShs", SparkShs) +Vue.component("SmsTransactionParser", SmsTransactionParserSetup) // NEW PLUGIN PLACEHOLDER (DO NOT REMOVE THIS LINE) const toArray = (value) => { diff --git a/src/frontend/src/modules/Transactions/SmsTransactionDetail.vue b/src/frontend/src/modules/Transactions/SmsTransactionDetail.vue new file mode 100644 index 000000000..b07e9c844 --- /dev/null +++ b/src/frontend/src/modules/Transactions/SmsTransactionDetail.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/src/frontend/src/modules/Transactions/Transaction.vue b/src/frontend/src/modules/Transactions/Transaction.vue index 621796621..4bd1b1c25 100644 --- a/src/frontend/src/modules/Transactions/Transaction.vue +++ b/src/frontend/src/modules/Transactions/Transaction.vue @@ -337,6 +337,33 @@ +
    +
    + + + +
    +
    + {{ $tc("words.body") }} +
    +
    + {{ ot.raw_message }} +
    +
    +
    +
    +
    +
    +
    @@ -348,6 +375,7 @@ import { currency } from "@/mixins/currency" import PaymentHistoryChart from "@/modules/Transactions/PaymentHistoryChart" import AgentTransactionDetail from "@/modules/Agent/AgentTransactionDetail" import CashTransactionDetail from "@/modules/Transactions/CashTransactionDetail" +import SmsTransactionDetail from "@/modules/Transactions/SmsTransactionDetail" import Widget from "@/shared/Widget.vue" import { TransactionService } from "@/services/TransactionService" import { PersonService } from "@/services/PersonService" @@ -361,6 +389,7 @@ export default { Widget, AgentTransactionDetail, CashTransactionDetail, + SmsTransactionDetail, PaymentHistoryChart, }, created() { @@ -403,6 +432,8 @@ export default { return "PaystackTransactionDetail" case "cash_transaction": return "CashTransactionDetail" + case "sms_transaction": + return "SmsTransactionDetail" default: return null } diff --git a/src/frontend/src/modules/Transactions/Transactions.vue b/src/frontend/src/modules/Transactions/Transactions.vue index d8b9f88db..171f3baff 100644 --- a/src/frontend/src/modules/Transactions/Transactions.vue +++ b/src/frontend/src/modules/Transactions/Transactions.vue @@ -292,6 +292,16 @@ :src="paystackLogo" style="max-height: 32px; max-width: 100px" /> + + {{ + smsProviderLabel( + item.original_transaction?.provider_name, + ) + }} + diff --git a/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/Overview.vue b/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/Overview.vue new file mode 100644 index 000000000..ed70c23b4 --- /dev/null +++ b/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/Overview.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/ParsingRules.vue b/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/ParsingRules.vue new file mode 100644 index 000000000..0be1b6451 --- /dev/null +++ b/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/ParsingRules.vue @@ -0,0 +1,469 @@ + + + + + diff --git a/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/Setup.vue b/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/Setup.vue new file mode 100644 index 000000000..2864885c2 --- /dev/null +++ b/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/Setup.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/src/frontend/src/plugins/sms-transaction-parser/repositories/ParsingRuleRepository.js b/src/frontend/src/plugins/sms-transaction-parser/repositories/ParsingRuleRepository.js new file mode 100644 index 000000000..87fb99a15 --- /dev/null +++ b/src/frontend/src/plugins/sms-transaction-parser/repositories/ParsingRuleRepository.js @@ -0,0 +1,18 @@ +import Client from "@/repositories/Client/AxiosClient" + +const resource = "/api/sms-transaction-parser/parsing-rules" + +export default { + list() { + return Client.get(`${resource}`) + }, + create(data) { + return Client.post(`${resource}`, data) + }, + update(id, data) { + return Client.put(`${resource}/${id}`, data) + }, + delete(id) { + return Client.delete(`${resource}/${id}`) + }, +} diff --git a/src/frontend/src/plugins/sms-transaction-parser/repositories/SmsTransactionRepository.js b/src/frontend/src/plugins/sms-transaction-parser/repositories/SmsTransactionRepository.js new file mode 100644 index 000000000..199537f36 --- /dev/null +++ b/src/frontend/src/plugins/sms-transaction-parser/repositories/SmsTransactionRepository.js @@ -0,0 +1,9 @@ +import Client from "@/repositories/Client/AxiosClient" + +const resource = "/api/sms-transaction-parser/transactions" + +export default { + list() { + return Client.get(`${resource}`) + }, +} diff --git a/src/frontend/src/plugins/sms-transaction-parser/services/.gitkeep b/src/frontend/src/plugins/sms-transaction-parser/services/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/frontend/src/plugins/sms-transaction-parser/services/ParsingRuleService.js b/src/frontend/src/plugins/sms-transaction-parser/services/ParsingRuleService.js new file mode 100644 index 000000000..b1a91e7a3 --- /dev/null +++ b/src/frontend/src/plugins/sms-transaction-parser/services/ParsingRuleService.js @@ -0,0 +1,27 @@ +import ParsingRuleRepository from "../repositories/ParsingRuleRepository" + +export class ParsingRuleService { + constructor() { + this.rules = [] + } + + async getRules() { + const { data } = await ParsingRuleRepository.list() + this.rules = data.data + return this.rules + } + + async createRule(ruleData) { + const { data } = await ParsingRuleRepository.create(ruleData) + return data.data + } + + async updateRule(id, ruleData) { + const { data } = await ParsingRuleRepository.update(id, ruleData) + return data.data + } + + async deleteRule(id) { + await ParsingRuleRepository.delete(id) + } +} diff --git a/src/frontend/src/plugins/sms-transaction-parser/services/SmsTransactionService.js b/src/frontend/src/plugins/sms-transaction-parser/services/SmsTransactionService.js new file mode 100644 index 000000000..e1be7fcba --- /dev/null +++ b/src/frontend/src/plugins/sms-transaction-parser/services/SmsTransactionService.js @@ -0,0 +1,13 @@ +import SmsTransactionRepository from "../repositories/SmsTransactionRepository" + +export class SmsTransactionService { + constructor() { + this.transactions = [] + } + + async getTransactions() { + const { data } = await SmsTransactionRepository.list() + this.transactions = data.data + return this.transactions + } +} diff --git a/src/frontend/src/services/TransactionService.js b/src/frontend/src/services/TransactionService.js index 3460720e2..893fd7df4 100644 --- a/src/frontend/src/services/TransactionService.js +++ b/src/frontend/src/services/TransactionService.js @@ -133,6 +133,7 @@ export class TransactionService { sentDate: transactionData.created_at, lastUpdate: transactionData.updated_at, status: this.getOriginalData(transactionData).status, + original_transaction: this.getOriginalData(transactionData), } } From 1887023e3136bcc9cf8c598a0b231c4ff851bfa6 Mon Sep 17 00:00:00 2001 From: Obinna Ikeh Date: Fri, 6 Mar 2026 13:43:43 +0100 Subject: [PATCH 3/7] Refactor approach a little --- .../Services/SmsParsingRuleService.php | 16 ++-------------- .../Parsers/MovitelTransactionParser.php | 2 +- .../Parsers/VodacomTransactionParser.php | 2 +- .../SmsParsing/SmsParserFactory.php | 6 +++--- .../MovitelTransactionParserTest.php | 2 +- .../SmsParserFactoryTest.php | 18 +++++++++--------- .../SmsTransactionServiceTest.php | 2 +- .../VodacomTransactionParserTest.php | 2 +- .../Transactions/SmsTransactionDetail.vue | 6 ++---- .../src/modules/Transactions/Transactions.vue | 3 +-- 10 files changed, 22 insertions(+), 37 deletions(-) diff --git a/src/backend/app/Plugins/SmsTransactionParser/Services/SmsParsingRuleService.php b/src/backend/app/Plugins/SmsTransactionParser/Services/SmsParsingRuleService.php index 90191107e..235ece285 100644 --- a/src/backend/app/Plugins/SmsTransactionParser/Services/SmsParsingRuleService.php +++ b/src/backend/app/Plugins/SmsTransactionParser/Services/SmsParsingRuleService.php @@ -62,25 +62,13 @@ public function installDefaults(): Collection { $defaults = [ [ - 'provider_name' => 'vodacom_en', + 'provider_name' => 'Vodacom', 'template' => 'Confirmed [transaction_ref].[*]amount of [amount]MT[*]reference [device_serial][*]', 'sender_pattern' => '/M-?Pesa/i', 'enabled' => true, ], [ - 'provider_name' => 'vodacom_pt', - 'template' => 'Confirmado [transaction_ref].[*]valor de [amount]MT[*]referencia [device_serial][*]', - 'sender_pattern' => '/M-?Pesa/i', - 'enabled' => true, - ], - [ - 'provider_name' => 'movitel_pt', - 'template' => 'ID da transacao[*][transaction_ref].[*][amount]MT[*]Conteudo:[*][device_serial].[*]', - 'sender_pattern' => '/e-?Mola/i', - 'enabled' => true, - ], - [ - 'provider_name' => 'movitel_en', + 'provider_name' => 'Movitel', 'template' => 'Transaction ID [transaction_ref].[*][amount] MT[*]Content:[*][device_serial].[*]', 'sender_pattern' => '/e-?Mola/i', 'enabled' => true, diff --git a/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/MovitelTransactionParser.php b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/MovitelTransactionParser.php index b8b42ba61..d24204254 100644 --- a/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/MovitelTransactionParser.php +++ b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/MovitelTransactionParser.php @@ -40,7 +40,7 @@ public function parse(string $body, array $regexMatches): ?ParsedSmsData { amount: $amount, deviceSerial: $deviceSerial, transactionReference: $transactionRef, - providerName: 'movitel', + providerName: 'Movitel', rawMessage: $body, senderPhone: $senderPhone, ); diff --git a/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/VodacomTransactionParser.php b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/VodacomTransactionParser.php index 4c292b8b8..72bb056d6 100644 --- a/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/VodacomTransactionParser.php +++ b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/Parsers/VodacomTransactionParser.php @@ -40,7 +40,7 @@ public function parse(string $body, array $regexMatches): ?ParsedSmsData { amount: $amount, deviceSerial: $deviceSerial, transactionReference: $transactionRef, - providerName: 'vodacom', + providerName: 'Vodacom', rawMessage: $body, senderPhone: $senderPhone, ); diff --git a/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/SmsParserFactory.php b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/SmsParserFactory.php index 3cf364e89..87ee6d7bc 100644 --- a/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/SmsParserFactory.php +++ b/src/backend/app/Plugins/SmsTransactionParser/SmsParsing/SmsParserFactory.php @@ -11,8 +11,8 @@ class SmsParserFactory { /** @var array */ private const PARSER_MAP = [ - 'vodacom' => VodacomTransactionParser::class, - 'movitel' => MovitelTransactionParser::class, + 'Vodacom' => VodacomTransactionParser::class, + 'Movitel' => MovitelTransactionParser::class, ]; public function __construct( @@ -34,7 +34,7 @@ public function parse(string $body, string $sender): ?ParsedSmsData { } $parserClass = self::PARSER_MAP[$rule->provider_name] - ?? self::PARSER_MAP[explode('_', $rule->provider_name, 2)[0]] + ?? self::PARSER_MAP[ucfirst(explode('_', $rule->provider_name, 2)[0])] ?? null; if ($parserClass === null) { diff --git a/src/backend/tests/Unit/SmsTransactionParser/MovitelTransactionParserTest.php b/src/backend/tests/Unit/SmsTransactionParser/MovitelTransactionParserTest.php index 41924e9a4..ccdbf53d3 100644 --- a/src/backend/tests/Unit/SmsTransactionParser/MovitelTransactionParserTest.php +++ b/src/backend/tests/Unit/SmsTransactionParser/MovitelTransactionParserTest.php @@ -22,7 +22,7 @@ public function testParsesValidRegexMatches(): void { $this->assertNull($result->senderPhone); $this->assertEquals('PP260101.0930.A12345', $result->transactionReference); $this->assertEquals('5566778899', $result->deviceSerial); - $this->assertEquals('movitel', $result->providerName); + $this->assertEquals('Movitel', $result->providerName); } public function testReturnsNullWhenRequiredFieldMissing(): void { diff --git a/src/backend/tests/Unit/SmsTransactionParser/SmsParserFactoryTest.php b/src/backend/tests/Unit/SmsTransactionParser/SmsParserFactoryTest.php index 2ed29d491..0e799955a 100644 --- a/src/backend/tests/Unit/SmsTransactionParser/SmsParserFactoryTest.php +++ b/src/backend/tests/Unit/SmsTransactionParser/SmsParserFactoryTest.php @@ -13,7 +13,7 @@ public function testMatchesVodacomEnglishSms(): void { $template = 'Confirmed [transaction_ref].[*]amount of [amount]MT[*]reference [device_serial][*]'; SmsParsingRule::query()->create([ - 'provider_name' => 'vodacom_en', + 'provider_name' => 'Vodacom', 'template' => $template, 'pattern' => $converter->convert($template), 'sender_pattern' => '/M-?Pesa/i', @@ -26,7 +26,7 @@ public function testMatchesVodacomEnglishSms(): void { ); $this->assertNotNull($result); - $this->assertEquals('vodacom', $result->providerName); + $this->assertEquals('Vodacom', $result->providerName); $this->assertEquals(100.00, $result->amount); $this->assertEquals('XYZ1A23BCDE', $result->transactionReference); $this->assertEquals('991234567', $result->deviceSerial); @@ -38,7 +38,7 @@ public function testMatchesVodacomPortugueseSms(): void { $template = 'Confirmado [transaction_ref].[*]valor de [amount]MT[*]referencia [device_serial][*]'; SmsParsingRule::query()->create([ - 'provider_name' => 'vodacom_pt', + 'provider_name' => 'Vodacom', 'template' => $template, 'pattern' => $converter->convert($template), 'sender_pattern' => '/M-?Pesa/i', @@ -51,7 +51,7 @@ public function testMatchesVodacomPortugueseSms(): void { ); $this->assertNotNull($result); - $this->assertEquals('vodacom', $result->providerName); + $this->assertEquals('Vodacom', $result->providerName); $this->assertEquals(100.00, $result->amount); $this->assertEquals('XYZ1A23BCDE', $result->transactionReference); $this->assertEquals('991234567', $result->deviceSerial); @@ -62,7 +62,7 @@ public function testMatchesMovitelEmolaPortugueseSms(): void { $template = 'ID da transacao[*][transaction_ref].[*][amount]MT[*]Conteudo:[*][device_serial].[*]'; SmsParsingRule::query()->create([ - 'provider_name' => 'movitel_pt', + 'provider_name' => 'Movitel', 'template' => $template, 'pattern' => $converter->convert($template), 'sender_pattern' => '/e-?Mola/i', @@ -75,7 +75,7 @@ public function testMatchesMovitelEmolaPortugueseSms(): void { ); $this->assertNotNull($result); - $this->assertEquals('movitel', $result->providerName); + $this->assertEquals('Movitel', $result->providerName); $this->assertEquals(1.00, $result->amount); $this->assertEquals('PP260101.0930.A12345', $result->transactionReference); $this->assertEquals('5566778899', $result->deviceSerial); @@ -86,7 +86,7 @@ public function testMatchesMovitelEmolaEnglishSms(): void { $template = 'Transaction ID [transaction_ref].[*][amount] MT[*]Content:[*][device_serial].[*]'; SmsParsingRule::query()->create([ - 'provider_name' => 'movitel_en', + 'provider_name' => 'Movitel', 'template' => $template, 'pattern' => $converter->convert($template), 'sender_pattern' => '/e-?Mola/i', @@ -99,7 +99,7 @@ public function testMatchesMovitelEmolaEnglishSms(): void { ); $this->assertNotNull($result); - $this->assertEquals('movitel', $result->providerName); + $this->assertEquals('Movitel', $result->providerName); $this->assertEquals(1.00, $result->amount); $this->assertEquals('PP260101.0930.A12345', $result->transactionReference); $this->assertEquals('5566778899', $result->deviceSerial); @@ -110,7 +110,7 @@ public function testReturnsNullForUnrecognizedMessage(): void { $template = 'Confirmed [transaction_ref].[*]amount of [amount]MT[*]reference [device_serial][*]'; SmsParsingRule::query()->create([ - 'provider_name' => 'vodacom_en', + 'provider_name' => 'Vodacom', 'template' => $template, 'pattern' => $converter->convert($template), 'sender_pattern' => null, diff --git a/src/backend/tests/Unit/SmsTransactionParser/SmsTransactionServiceTest.php b/src/backend/tests/Unit/SmsTransactionParser/SmsTransactionServiceTest.php index 1c9d53940..550e910b4 100644 --- a/src/backend/tests/Unit/SmsTransactionParser/SmsTransactionServiceTest.php +++ b/src/backend/tests/Unit/SmsTransactionParser/SmsTransactionServiceTest.php @@ -20,7 +20,7 @@ protected function setUp(): void { $converter = app(TemplateToRegexConverter::class); SmsParsingRule::query()->create([ - 'provider_name' => 'vodacom_en', + 'provider_name' => 'Vodacom', 'template' => $template, 'pattern' => $converter->convert($template), 'sender_pattern' => '/M-?Pesa/i', diff --git a/src/backend/tests/Unit/SmsTransactionParser/VodacomTransactionParserTest.php b/src/backend/tests/Unit/SmsTransactionParser/VodacomTransactionParserTest.php index 24e33b6d1..d80aabd2c 100644 --- a/src/backend/tests/Unit/SmsTransactionParser/VodacomTransactionParserTest.php +++ b/src/backend/tests/Unit/SmsTransactionParser/VodacomTransactionParserTest.php @@ -22,7 +22,7 @@ public function testParsesValidRegexMatches(): void { $this->assertNull($result->senderPhone); $this->assertEquals('DBQ2J80CNQY', $result->transactionReference); $this->assertEquals('996997813', $result->deviceSerial); - $this->assertEquals('vodacom', $result->providerName); + $this->assertEquals('Vodacom', $result->providerName); } public function testParsesWithOptionalSenderPhone(): void { diff --git a/src/frontend/src/modules/Transactions/SmsTransactionDetail.vue b/src/frontend/src/modules/Transactions/SmsTransactionDetail.vue index b07e9c844..e79353f98 100644 --- a/src/frontend/src/modules/Transactions/SmsTransactionDetail.vue +++ b/src/frontend/src/modules/Transactions/SmsTransactionDetail.vue @@ -66,10 +66,8 @@ export default { }, computed: { providerLabel() { - const name = this.ot.provider_name - if (!name) return "SMS" - const base = name.split("_")[0] - return base.charAt(0).toUpperCase() + base.slice(1) + " (SMS)" + if (!this.ot.provider_name) return "SMS" + return this.ot.provider_name + " (SMS)" }, }, } diff --git a/src/frontend/src/modules/Transactions/Transactions.vue b/src/frontend/src/modules/Transactions/Transactions.vue index 171f3baff..1764bdbf0 100644 --- a/src/frontend/src/modules/Transactions/Transactions.vue +++ b/src/frontend/src/modules/Transactions/Transactions.vue @@ -725,8 +725,7 @@ export default { }, smsProviderLabel(providerName) { if (!providerName) return "SMS" - const base = providerName.split("_")[0] - return base.charAt(0).toUpperCase() + base.slice(1) + " (SMS)" + return providerName + " (SMS)" }, async getTransactionProviders() { this.transactionProviders = From bcae404409e0ab73d63372af95d8cd4d9d3bda0a Mon Sep 17 00:00:00 2001 From: Obinna Ikeh Date: Fri, 6 Mar 2026 14:14:25 +0100 Subject: [PATCH 4/7] Register SmsStoredEvent listerner on SmsTransactionParser --- .../Listeners/SmsStoredListener.php | 30 +++++++++++++++++++ .../Providers/EventServiceProvider.php | 9 ++++++ .../TextbeeSmsGatewayServiceProvider.php | 3 -- src/backend/bootstrap/providers.php | 2 +- 4 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 src/backend/app/Plugins/SmsTransactionParser/Listeners/SmsStoredListener.php diff --git a/src/backend/app/Plugins/SmsTransactionParser/Listeners/SmsStoredListener.php b/src/backend/app/Plugins/SmsTransactionParser/Listeners/SmsStoredListener.php new file mode 100644 index 000000000..ac31519c4 --- /dev/null +++ b/src/backend/app/Plugins/SmsTransactionParser/Listeners/SmsStoredListener.php @@ -0,0 +1,30 @@ +smsTransactionService->processIncomingSms( + $event->message, + $event->sender, + ); + + if ($result instanceof SmsTransaction) { + Log::info('SMS transaction created from incoming SMS', [ + 'reference' => $result->transaction_reference, + 'provider' => $result->provider_name, + ]); + } + } +} diff --git a/src/backend/app/Plugins/SmsTransactionParser/Providers/EventServiceProvider.php b/src/backend/app/Plugins/SmsTransactionParser/Providers/EventServiceProvider.php index 134fccb6e..0b2671661 100644 --- a/src/backend/app/Plugins/SmsTransactionParser/Providers/EventServiceProvider.php +++ b/src/backend/app/Plugins/SmsTransactionParser/Providers/EventServiceProvider.php @@ -2,9 +2,18 @@ namespace App\Plugins\SmsTransactionParser\Providers; +use App\Events\SmsStoredEvent; +use App\Plugins\SmsTransactionParser\Listeners\SmsStoredListener; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; class EventServiceProvider extends ServiceProvider { + /** @var array> */ + protected $listen = [ + SmsStoredEvent::class => [ + SmsStoredListener::class, + ], + ]; + /** * @var array */ diff --git a/src/backend/app/Plugins/TextbeeSmsGateway/Providers/TextbeeSmsGatewayServiceProvider.php b/src/backend/app/Plugins/TextbeeSmsGateway/Providers/TextbeeSmsGatewayServiceProvider.php index 46f4ad9b1..2b00424ac 100644 --- a/src/backend/app/Plugins/TextbeeSmsGateway/Providers/TextbeeSmsGatewayServiceProvider.php +++ b/src/backend/app/Plugins/TextbeeSmsGateway/Providers/TextbeeSmsGatewayServiceProvider.php @@ -5,15 +5,12 @@ use App\Plugins\TextbeeSmsGateway\Console\Commands\FetchIncomingSms; use App\Plugins\TextbeeSmsGateway\Console\Commands\InstallPackage; use App\Plugins\TextbeeSmsGateway\TextbeeSmsGateway; -use Illuminate\Console\Scheduling\Schedule; use Illuminate\Support\ServiceProvider; class TextbeeSmsGatewayServiceProvider extends ServiceProvider { public function boot(): void { $this->app->register(RouteServiceProvider::class); $this->commands([InstallPackage::class, FetchIncomingSms::class]); - $this->app->make(Schedule::class)->command('textbee-sms-gateway:fetch-incoming-sms')->everyTwoMinutes() - ->appendOutputTo(storage_path('logs/cron.log')); } public function register(): void { diff --git a/src/backend/bootstrap/providers.php b/src/backend/bootstrap/providers.php index 12bf531e4..6fea92d2b 100644 --- a/src/backend/bootstrap/providers.php +++ b/src/backend/bootstrap/providers.php @@ -41,6 +41,7 @@ ServicesProvider::class, AfricasTalkingServiceProvider::class, TextbeeSmsGatewayServiceProvider::class, + SmsTransactionParserServiceProvider::class, AngazaSHSServiceProvider::class, BulkRegistrationServiceProvider::class, CalinMeterServiceProvider::class, @@ -67,5 +68,4 @@ WavecomPaymentProviderServiceProvider::class, EcreeeETenderServiceProvider::class, SparkShsServiceProvider::class, - SmsTransactionParserServiceProvider::class, ]; From 03b098b96dbe2a90d512adf78dd671772bd55d71 Mon Sep 17 00:00:00 2001 From: Obinna Ikeh Date: Fri, 6 Mar 2026 15:32:30 +0100 Subject: [PATCH 5/7] update Setup UI content --- .../sms-transaction-parser/modules/Overview/Setup.vue | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/Setup.vue b/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/Setup.vue index 2864885c2..636dd34d1 100644 --- a/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/Setup.vue +++ b/src/frontend/src/plugins/sms-transaction-parser/modules/Overview/Setup.vue @@ -12,14 +12,8 @@

    Default parsing rules will be installed for:

      -
    • - Vodacom M-Pesa - — English and Portuguese templates -
    • -
    • - Movitel e-Mola - — English and Portuguese templates -
    • +
    • Vodacom M-Pesa
    • +
    • Movitel e-Mola

    You can customize these rules later from the plugin settings page. From 44b43648798b672779161e8f53261162ac5e4614 Mon Sep 17 00:00:00 2001 From: Obinna Ikeh Date: Wed, 11 Mar 2026 11:58:21 +0100 Subject: [PATCH 6/7] Fix eslint errors --- .../src/modules/Transactions/SmsTransactionDetail.vue | 2 +- .../sms-transaction-parser/modules/Overview/Overview.vue | 3 ++- .../modules/Overview/ParsingRules.vue | 7 ++++--- .../sms-transaction-parser/modules/Overview/Setup.vue | 8 ++++---- .../repositories/ParsingRuleRepository.js | 2 +- .../repositories/SmsTransactionRepository.js | 2 +- .../sms-transaction-parser/services/ParsingRuleService.js | 2 +- .../services/SmsTransactionService.js | 2 +- 8 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/frontend/src/modules/Transactions/SmsTransactionDetail.vue b/src/frontend/src/modules/Transactions/SmsTransactionDetail.vue index e79353f98..3955ad210 100644 --- a/src/frontend/src/modules/Transactions/SmsTransactionDetail.vue +++ b/src/frontend/src/modules/Transactions/SmsTransactionDetail.vue @@ -73,7 +73,7 @@ export default { } -