From b9e1ff67f317fc42e7f34963f6230edc5dbfde02 Mon Sep 17 00:00:00 2001 From: Obinna Ikeh Date: Wed, 18 Mar 2026 14:07:57 +0100 Subject: [PATCH 1/5] Refactor: Generialize payment initiation This changes attempts to consolidate Payment initiation in on central location PaymentInitialization. It also introduces another payment method with sold appliance. --- docs/usage-guide/images/payment-flow.svg | 4 + .../AppliancePaymentController.php | 53 +++-- .../Controllers/AppliancePersonController.php | 57 ++++-- src/backend/app/Models/Plugins.php | 6 + .../Http/Controllers/PaystackController.php | 27 ++- .../Controllers/PaystackPublicController.php | 41 ++-- .../Services/PaystackTransactionService.php | 67 +++++++ .../Services/PaystackWebhookService.php | 19 +- .../Tests/Unit/PaystackTransactionTest.php | 3 - .../PaystackPaymentProvider/routes/api.php | 2 +- .../app/Services/CashTransactionService.php | 21 ++ .../Services/PaymentInitializationService.php | 97 +++++++++ src/backend/app/Services/PluginsService.php | 19 ++ .../Services/TransactionPaymentProcessor.php | 6 + src/backend/routes/api.php | 1 + .../AppliancePaymentControllerTest.php | 187 ++++++++++++++++++ ...pliancePersonControllerDownPaymentTest.php | 127 ++++++++++++ .../Unit/PaymentInitializationServiceTest.php | 169 ++++++++++++++++ ...ransactionServiceInitializePaymentTest.php | 98 +++++++++ .../Client/Appliances/SellApplianceModal.vue | 38 +++- .../Client/Appliances/SoldApplianceDetail.vue | 33 +++- .../modules/Overview/Credential.vue | 78 -------- .../modules/Payment/PublicPaymentForm.vue | 2 +- .../modules/Payment/PublicPaymentResult.vue | 34 +++- .../AppliancePaymentRepository.js | 3 + .../src/services/AppliancePaymentService.js | 12 ++ 26 files changed, 1036 insertions(+), 168 deletions(-) create mode 100644 docs/usage-guide/images/payment-flow.svg create mode 100644 src/backend/app/Services/PaymentInitializationService.php create mode 100644 src/backend/tests/Feature/AppliancePaymentControllerTest.php create mode 100644 src/backend/tests/Feature/AppliancePersonControllerDownPaymentTest.php create mode 100644 src/backend/tests/Unit/PaymentInitializationServiceTest.php create mode 100644 src/backend/tests/Unit/PaystackTransactionServiceInitializePaymentTest.php diff --git a/docs/usage-guide/images/payment-flow.svg b/docs/usage-guide/images/payment-flow.svg new file mode 100644 index 000000000..42035812b --- /dev/null +++ b/docs/usage-guide/images/payment-flow.svg @@ -0,0 +1,4 @@ + + +exitexitSuccessfulCustomer initiated paymentsSystem initiated PaymentsAgent AppWebPaymentCashUSSDMobile MoneyProvider eventsProvider Payment initializationPayment Provider Transaction initiation events (i.e MPesa,Paystack etc) URL webhooks: api/<provider>/callbackProvider Transaction initializationProcess Payment EventPayment Failed EventProvider initializationValidation \ No newline at end of file diff --git a/src/backend/app/Http/Controllers/AppliancePaymentController.php b/src/backend/app/Http/Controllers/AppliancePaymentController.php index ba183ca28..984cf47a7 100644 --- a/src/backend/app/Http/Controllers/AppliancePaymentController.php +++ b/src/backend/app/Http/Controllers/AppliancePaymentController.php @@ -7,15 +7,17 @@ use App\Models\AppliancePerson; use App\Services\AppliancePaymentService; use App\Services\AppliancePersonService; -use App\Services\CashTransactionService; +use App\Services\PaymentInitializationService; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; class AppliancePaymentController extends Controller { + public const CASH_TRANSACTION_PROVIVER = 0; + public function __construct( private AppliancePaymentService $appliancePaymentService, private AppliancePersonService $appliancePersonService, - private CashTransactionService $cashTransactionService, + private PaymentInitializationService $paymentInitializationService, ) {} public function store(AppliancePerson $appliancePerson, Request $request): ApiResource { @@ -24,10 +26,13 @@ public function store(AppliancePerson $appliancePerson, Request $request): ApiRe $result = $this->getPaymentForAppliance($request, $appliancePerson); DB::connection('tenant')->commit(); - return ApiResource::make([ - 'appliance_person' => $result['appliance_person'], - 'transaction_id' => $result['transaction_id'], - ]); + return ApiResource::make(array_merge( + [ + 'appliance_person' => $result['appliance_person'], + 'transaction_id' => $result['transaction_id'], + ], + $result['provider_data'], + )); } catch (\Exception $e) { DB::connection('tenant')->rollBack(); throw new \Exception($e->getMessage(), $e->getCode(), $e); @@ -40,32 +45,54 @@ public function checkStatus(int $transactionId): ApiResource { return ApiResource::make($status); } + public function paymentProviders(): ApiResource { + $providers = $this->paymentInitializationService->paymentProviders(); + + return ApiResource::make($providers); + } + /** * @return array */ - public function getPaymentForAppliance(Request $request, AppliancePerson $appliancePerson): array { + private function getPaymentForAppliance(Request $request, AppliancePerson $appliancePerson): array { $creatorId = auth('api')->user()->id; $amount = (float) $request->input('amount'); + $providerId = (int) $request->input('payment_provider', 0); + $companyId = $request->attributes->get('companyId'); + $applianceDetail = $this->appliancePersonService->getApplianceDetails($appliancePerson->id); $this->appliancePaymentService->validateAmount($applianceDetail, $amount); $deviceSerial = $applianceDetail->device_serial; $applianceOwner = $appliancePerson->person; - $companyId = $request->attributes->get('companyId'); if (!$applianceOwner) { throw new \InvalidArgumentException('Appliance owner not found'); } $ownerAddress = $applianceOwner->addresses()->where('is_primary', 1)->first(); - $sender = $ownerAddress == null ? '-' : $ownerAddress->phone; - $transaction = - $this->cashTransactionService->createCashTransaction($creatorId, $amount, $sender, $deviceSerial, $appliancePerson->id); + $sender = $ownerAddress === null ? '-' : $ownerAddress->phone; + + $message = $deviceSerial ?? (string) $appliancePerson->id; - dispatch(new ProcessPayment($companyId, $transaction->id)); + $result = $this->paymentInitializationService->initialize( + providerId: $providerId, + amount: $amount, + sender: $sender, + message: $message, + type: 'deferred_payment', + customerId: $applianceOwner->id, + creatorId: $creatorId, + serialId: $deviceSerial, + ); + + if ($providerId === $this::CASH_TRANSACTION_PROVIVER) { + dispatch(new ProcessPayment($companyId, $result['transaction']->id)); + } return [ 'appliance_person' => $appliancePerson, - 'transaction_id' => $transaction->id, + 'transaction_id' => $result['transaction']->id, + 'provider_data' => $result['provider_data'], ]; } } diff --git a/src/backend/app/Http/Controllers/AppliancePersonController.php b/src/backend/app/Http/Controllers/AppliancePersonController.php index 97a537d8c..23f56359d 100644 --- a/src/backend/app/Http/Controllers/AppliancePersonController.php +++ b/src/backend/app/Http/Controllers/AppliancePersonController.php @@ -5,6 +5,7 @@ use App\Events\PaymentSuccessEvent; use App\Events\TransactionSuccessfulEvent; use App\Http\Resources\ApiResource; +use App\Jobs\ProcessPayment; use App\Models\Appliance; use App\Models\AppliancePerson; use App\Models\Person\Person; @@ -13,15 +14,17 @@ use App\Services\AppliancePersonService; use App\Services\ApplianceRateService; use App\Services\ApplianceService; -use App\Services\CashTransactionService; use App\Services\DeviceService; use App\Services\GeographicalInformationService; +use App\Services\PaymentInitializationService; use App\Services\UserAppliancePersonService; use App\Services\UserService; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; class AppliancePersonController extends Controller { + public const CASH_TRANSACTION_PROVIVER = 0; + public function __construct( private AppliancePerson $appliancePerson, private AppliancePersonService $appliancePersonService, @@ -31,7 +34,7 @@ public function __construct( private AddressesService $addressesService, private GeographicalInformationService $geographicalInformationService, private AddressGeographicalInformationService $addressGeographicalInformationService, - private CashTransactionService $cashTransactionService, + private PaymentInitializationService $paymentInitializationService, private ApplianceService $applianceService, private ApplianceRateService $applianceRateService, ) {} @@ -98,29 +101,45 @@ public function store( $this->addressGeographicalInformationService->assign(); $this->geographicalInformationService->save($geographicalInformation); } + $downPaymentInitData = []; if ($downPayment > 0) { + $paymentProviderId = (int) $request->input('payment_provider', 0); + $companyId = $request->attributes->get('companyId'); $sender = isset($addressData) ? $addressData['phone'] : '-'; - $transaction = $this->cashTransactionService->createCashTransaction( - $user->id, - $downPayment, - $sender, - $deviceSerial + $message = $deviceSerial ?? (string) $appliancePerson->id; + $person = $appliancePerson->person; + + $result = $this->paymentInitializationService->initialize( + providerId: $paymentProviderId, + amount: $downPayment, + sender: $sender, + message: $message, + type: 'deferred_payment', + customerId: $person->id, + creatorId: $user->id, + serialId: $deviceSerial ?? null, ); - $applianceRate = $this->applianceRateService->getDownPaymentAsApplianceRate($appliancePerson); - event(new PaymentSuccessEvent( - amount: (int) $transaction->amount, - paymentService: 'web', - paymentType: 'down payment', - sender: $transaction->sender, - paidFor: $applianceRate, - payer: $appliancePerson->person, - transaction: $transaction, - )); - event(new TransactionSuccessfulEvent($transaction)); + + if ($paymentProviderId === $this::CASH_TRANSACTION_PROVIVER) { + $applianceRate = $this->applianceRateService->getDownPaymentAsApplianceRate($appliancePerson); + event(new PaymentSuccessEvent( + amount: (int) $result['transaction']->amount, + paymentService: 'web', + paymentType: 'down payment', + sender: $result['transaction']->sender, + paidFor: $applianceRate, + payer: $appliancePerson->person, + transaction: $result['transaction'], + )); + event(new TransactionSuccessfulEvent($result['transaction'])); + } else { + dispatch(new ProcessPayment($companyId, $result['transaction']->id)); + $downPaymentInitData = $result['provider_data']; + } } DB::connection('tenant')->commit(); - return ApiResource::make($appliancePerson); + return ApiResource::make(array_merge(['appliance_person' => $appliancePerson], $downPaymentInitData)); } catch (\Exception $e) { DB::connection('tenant')->rollBack(); throw new \Exception($e->getMessage(), (int) $e->getCode(), $e); diff --git a/src/backend/app/Models/Plugins.php b/src/backend/app/Models/Plugins.php index 4e96895b7..fb7598741 100644 --- a/src/backend/app/Models/Plugins.php +++ b/src/backend/app/Models/Plugins.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\Base\BaseModel; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Carbon; /** @@ -15,4 +16,9 @@ class Plugins extends BaseModel { public const ACTIVE = 1; public const INACTIVE = 0; + + /** @return BelongsTo */ + public function mpmPlugin(): BelongsTo { + return $this->belongsTo(MpmPlugin::class); + } } diff --git a/src/backend/app/Plugins/PaystackPaymentProvider/Http/Controllers/PaystackController.php b/src/backend/app/Plugins/PaystackPaymentProvider/Http/Controllers/PaystackController.php index e8c1b369a..41941b3d0 100644 --- a/src/backend/app/Plugins/PaystackPaymentProvider/Http/Controllers/PaystackController.php +++ b/src/backend/app/Plugins/PaystackPaymentProvider/Http/Controllers/PaystackController.php @@ -5,7 +5,6 @@ namespace App\Plugins\PaystackPaymentProvider\Http\Controllers; use App\Plugins\PaystackPaymentProvider\Http\Requests\TransactionInitializeRequest; -use App\Plugins\PaystackPaymentProvider\Http\Resources\PaystackResource; use App\Plugins\PaystackPaymentProvider\Http\Resources\PaystackTransactionResource; use App\Plugins\PaystackPaymentProvider\Models\PaystackTransaction; use App\Plugins\PaystackPaymentProvider\Modules\Api\PaystackApiService; @@ -23,10 +22,28 @@ public function __construct( private PaystackWebhookService $webhookService, ) {} - public function startTransaction(TransactionInitializeRequest $request): PaystackResource { - $transaction = $request->getPaystackTransaction(); - - return PaystackResource::make($this->apiService->initializeTransaction($transaction)); + public function initializeTransaction(TransactionInitializeRequest $request): JsonResponse { + $customerId = (int) $request->input('customer_id'); + $serialId = $request->input('device_serial'); + $amount = (float) $request->input('amount'); + $sender = $this->transactionService->getCustomerPhoneByCustomerId($customerId) ?? ''; + + $result = $this->transactionService->initializePayment( + amount: $amount, + sender: $sender, + message: $serialId, + type: 'energy', + customerId: $customerId, + serialId: $serialId, + ); + + return response()->json([ + 'data' => [ + 'redirectionUrl' => $result['provider_data']['redirect_url'], + 'reference' => $result['provider_data']['reference'], + 'error' => null, + ], + ]); } /** diff --git a/src/backend/app/Plugins/PaystackPaymentProvider/Http/Controllers/PaystackPublicController.php b/src/backend/app/Plugins/PaystackPaymentProvider/Http/Controllers/PaystackPublicController.php index 69041f8bb..18dc948a9 100644 --- a/src/backend/app/Plugins/PaystackPaymentProvider/Http/Controllers/PaystackPublicController.php +++ b/src/backend/app/Plugins/PaystackPaymentProvider/Http/Controllers/PaystackPublicController.php @@ -15,7 +15,6 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Log; -use Ramsey\Uuid\Uuid; class PaystackPublicController extends Controller { public function __construct( @@ -66,12 +65,7 @@ public function initiatePayment(PublicPaymentRequest $request, string $companyHa return response()->json(['error' => 'Invalid company identifier'], 400); } - // Validate device serial and amount $validatedData = $request->validated(); - - // Get agent_id from query parameters if present - $agentId = $request->query('agent'); - $deviceType = $validatedData['device_type'] ?? 'meter'; $deviceSerial = $validatedData['device_serial']; @@ -82,30 +76,26 @@ public function initiatePayment(PublicPaymentRequest $request, string $companyHa $customerId = $this->transactionService->getCustomerIdByMeterSerial($deviceSerial); } - // Create Paystack transaction - $transaction = $this->transactionService->createPublicTransaction([ - 'amount' => $validatedData['amount'], - 'currency' => $validatedData['currency'], - 'serial_id' => $deviceSerial, - 'device_type' => $deviceType, - 'customer_id' => $customerId, - 'order_id' => Uuid::uuid4()->toString(), - 'reference_id' => Uuid::uuid4()->toString(), - 'agent_id' => $agentId ? (int) $agentId : null, - ]); + if (!$customerId) { + return response()->json(['error' => 'Customer not found for device'], 400); + } - // Initialize Paystack transaction with company ID for callback URL - $result = $this->apiService->initializeTransaction($transaction, $companyId); + $sender = $this->transactionService->getCustomerPhoneByCustomerId($customerId) ?? ''; - if ($result['error']) { - return response()->json(['error' => $result['error']], 400); - } + $result = $this->transactionService->initializePayment( + amount: (float) $validatedData['amount'], + sender: $sender, + message: $deviceSerial, + type: 'energy', + customerId: $customerId, + serialId: $deviceSerial, + ); return response()->json([ 'success' => true, - 'redirection_url' => $result['redirectionUrl'], - 'reference' => $result['reference'], - 'transaction_id' => $transaction->getId(), + 'redirect_url' => $result['provider_data']['redirect_url'], + 'reference' => $result['provider_data']['reference'], + 'transaction_id' => $result['transaction']->id, ]); } catch (\Exception $e) { Log::error('PaystackPublicController: Failed to initiate payment', [ @@ -167,6 +157,7 @@ public function showResult(Request $request, string $companyHash, ?int $companyI 'currency' => $transaction->getCurrency(), 'serial_id' => $transaction->getDeviceSerial(), 'device_type' => $transaction->getDeviceType(), + 'payment_type' => $mainTransaction?->type, 'status' => $transaction->getStatus(), 'created_at' => $transaction->getAttribute('created_at'), ], diff --git a/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackTransactionService.php b/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackTransactionService.php index 1b8c94bf1..f48441e13 100644 --- a/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackTransactionService.php +++ b/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackTransactionService.php @@ -12,7 +12,9 @@ use App\Models\SolarHomeSystem; use App\Models\Transaction\Transaction; use App\Plugins\PaystackPaymentProvider\Models\PaystackTransaction; +use App\Plugins\PaystackPaymentProvider\Modules\Api\PaystackApiService; use App\Services\AbstractPaymentAggregatorTransactionService; +use App\Services\DeviceService; use App\Services\Interfaces\IBaseService; use App\Services\PersonService; use Illuminate\Database\Eloquent\Collection; @@ -28,6 +30,7 @@ public function __construct( private Address $address, private Transaction $transaction, private PaystackTransaction $paystackTransaction, + private PaystackApiService $paystackApiService, ) { parent::__construct( $this->meter, @@ -136,6 +139,70 @@ public function processSuccessfulPayment(int $companyId, PaystackTransaction $tr $transaction->save(); } + public function processFailedPayment(PaystackTransaction $transaction): void { + $transaction->setStatus(PaystackTransaction::STATUS_FAILED); + $transaction->save(); + + $relatedTransaction = $transaction->transaction; + if ($relatedTransaction) { + $relatedTransaction->update(['status' => PaystackTransaction::STATUS_FAILED]); + } + } + + /** + * Create a PaystackTransaction + Transaction and initialize via the Paystack API. + * The caller supplies message and type, keeping routing knowledge outside this service. + * + * @return array{transaction: Transaction, provider_data: array} + */ + public function initializePayment( + float $amount, + string $sender, + string $message, + string $type, + int $customerId, + ?string $serialId = null, + ): array { + $deviceType = null; + if ($serialId !== null) { + $device = app(DeviceService::class)->getBySerialNumber($serialId); + $deviceType = $device?->device_type; + } + + $paystackTxn = $this->paystackTransaction->newQuery()->create([ + 'amount' => $amount, + 'currency' => config('paystack-payment-provider.currency.default', 'NGN'), + 'order_id' => Uuid::uuid4()->toString(), + 'reference_id' => Uuid::uuid4()->toString(), + 'status' => PaystackTransaction::STATUS_REQUESTED, + 'customer_id' => $customerId, + 'serial_id' => $serialId, + 'device_type' => $deviceType, + 'metadata' => ['customer_id' => $customerId, 'serial_id' => $serialId, 'transaction_type' => $type], + ]); + + /** @var Transaction $transaction */ + $transaction = $paystackTxn->transaction()->create([ + 'amount' => $amount, + 'sender' => $sender, + 'message' => $message, + 'type' => $type, + ]); + + $result = $this->paystackApiService->initializeTransaction($paystackTxn); + if ($result['error']) { + throw new \RuntimeException('Paystack initialization failed: '.$result['error']); + } + + return [ + 'transaction' => $transaction, + 'provider_data' => [ + 'redirect_url' => $result['redirectionUrl'], + 'reference' => $result['reference'], + ], + ]; + } + /** * @param array $transactionData */ diff --git a/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackWebhookService.php b/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackWebhookService.php index af97d976e..b67ca3112 100644 --- a/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackWebhookService.php +++ b/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackWebhookService.php @@ -58,20 +58,9 @@ private function handleSuccessfulPayment(array $data, int $companyId): void { return; } - $paystackTransaction->setExternalTransactionId($data['id'] ?? ''); - - // Get customer's phone number for sender field - $customerPhone = $this->transactionService->getCustomerPhoneByCustomerId($paystackTransaction->getCustomerId()); - $sender = $customerPhone ?: ''; - - $paystackTransaction->transaction()->create([ - 'amount' => $paystackTransaction->getAmount(), - 'sender' => $sender, - 'message' => $paystackTransaction->getDeviceSerial(), - 'type' => 'energy', - ]); + $paystackTransaction->setExternalTransactionId((string) ($data['id'] ?? '')); $paystackTransaction->save(); - // Process the successful payment in MPM + $this->transactionService->processSuccessfulPayment($companyId, $paystackTransaction); } catch (\Exception $e) { Log::error('PaystackWebhookService: Failed to process payment', [ @@ -94,7 +83,9 @@ private function handleFailedPayment(array $data): void { return; } - $paystackTransaction->setStatus(PaystackTransaction::STATUS_FAILED); + $paystackTransaction->setExternalTransactionId((string) ($data['id'] ?? '')); $paystackTransaction->save(); + + $this->transactionService->processFailedPayment($paystackTransaction); } } diff --git a/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionTest.php b/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionTest.php index 1129404b4..426aac226 100644 --- a/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionTest.php +++ b/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionTest.php @@ -6,12 +6,10 @@ use PHPUnit\Framework\TestCase as BaseTestCase; class PaystackTransactionTest extends BaseTestCase { - /** @test */ public function itReturnsCorrectTransactionName(): void { $this->assertEquals('paystack_transaction', PaystackTransaction::getTransactionName()); } - /** @test */ public function itHasCorrectStatusConstants(): void { $this->assertEquals(0, PaystackTransaction::STATUS_REQUESTED); $this->assertEquals(1, PaystackTransaction::STATUS_SUCCESS); @@ -21,7 +19,6 @@ public function itHasCorrectStatusConstants(): void { $this->assertEquals(5, PaystackTransaction::MAX_ATTEMPTS); } - /** @test */ public function itHasCorrectRelationName(): void { $this->assertEquals('paystack_transaction', PaystackTransaction::RELATION_NAME); } diff --git a/src/backend/app/Plugins/PaystackPaymentProvider/routes/api.php b/src/backend/app/Plugins/PaystackPaymentProvider/routes/api.php index 021c95a8d..1944b5cc6 100644 --- a/src/backend/app/Plugins/PaystackPaymentProvider/routes/api.php +++ b/src/backend/app/Plugins/PaystackPaymentProvider/routes/api.php @@ -13,7 +13,7 @@ Route::post('/credential/agent-payment-url', [PaystackCredentialController::class, 'generateAgentPaymentUrl']); // Transaction management - Route::post('/transaction/initialize', [PaystackController::class, 'startTransaction']); + Route::post('/transaction/initialize', [PaystackController::class, 'initializeTransaction']); Route::get('/transaction/verify/{reference}', [PaystackController::class, 'verifyTransaction']); Route::get('/transactions', [PaystackController::class, 'getTransactions']); Route::get('/transactions/{id}', [PaystackController::class, 'getTransaction']); diff --git a/src/backend/app/Services/CashTransactionService.php b/src/backend/app/Services/CashTransactionService.php index 57f815e25..b935257b0 100644 --- a/src/backend/app/Services/CashTransactionService.php +++ b/src/backend/app/Services/CashTransactionService.php @@ -9,6 +9,27 @@ class CashTransactionService { public function __construct(private CashTransaction $cashTransaction, private Transaction $transaction) {} + public function createTransaction(int $creatorId, float $amount, string $sender, string $message, string $type): Transaction { + return DB::transaction(function () use ($creatorId, $amount, $sender, $message, $type) { + $cashTransaction = $this->cashTransaction->newQuery()->create([ + 'user_id' => $creatorId, + 'status' => 1, + ]); + + $transaction = $this->transaction->newQuery()->make([ + 'amount' => $amount, + 'sender' => $sender, + 'message' => $message, + 'type' => $type, + ]); + + $transaction->originalTransaction()->associate($cashTransaction); + $transaction->save(); + + return $transaction; + }); + } + public function createCashTransaction(int $creatorId, float $amount, string $sender, ?string $deviceSerial = null, ?int $applianceId = null): Transaction { return DB::transaction(function () use ($creatorId, $amount, $sender, $deviceSerial, $applianceId) { $cashTransaction = $this->cashTransaction->newQuery()->create([ diff --git a/src/backend/app/Services/PaymentInitializationService.php b/src/backend/app/Services/PaymentInitializationService.php new file mode 100644 index 000000000..5d8430165 --- /dev/null +++ b/src/backend/app/Services/PaymentInitializationService.php @@ -0,0 +1,97 @@ +} + */ + public function initialize( + int $providerId, + float $amount, + string $sender, + string $message, + string $type, + int $customerId, + int $creatorId, + ?string $serialId = null, + ): array { + $validProviderIds = [ + 0, // cash + MpmPlugin::PAYSTACK_PAYMENT_PROVIDER, + ]; + + if (!in_array($providerId, $validProviderIds, true)) { + throw new \InvalidArgumentException("Unsupported payment provider ID: {$providerId}"); + } + + return match ($providerId) { + MpmPlugin::PAYSTACK_PAYMENT_PROVIDER => $this->paystackTransactionService->initializePayment( + $amount, + $sender, + $message, + $type, + $customerId, + $serialId, + ), + default => $this->initializeCash($creatorId, $amount, $sender, $message, $type), + }; + } + + /** + * @return array{transaction: Transaction, provider_data: array} + */ + private function initializeCash( + int $creatorId, + float $amount, + string $sender, + string $message, + string $type, + ): array { + $transaction = $this->cashTransactionService->createTransaction( + $creatorId, + $amount, + $sender, + $message, + $type, + ); + + return ['transaction' => $transaction, 'provider_data' => []]; + } + + /** @return Collection */ + public function paymentProviders(): Collection { + $activePlugins = $this->pluginsService->getActivePaymentProviders(); + + return $activePlugins->map(function (Plugins $plugin): array { + $mpmPlugin = $this->mpmPluginService->getById($plugin->mpm_plugin_id); + + return ['id' => $plugin->mpm_plugin_id, 'name' => $mpmPlugin instanceof MpmPlugin ? $mpmPlugin->name : 'Unknown']; + }); + } +} diff --git a/src/backend/app/Services/PluginsService.php b/src/backend/app/Services/PluginsService.php index 9cf8fc49d..b5d50ba61 100644 --- a/src/backend/app/Services/PluginsService.php +++ b/src/backend/app/Services/PluginsService.php @@ -50,6 +50,25 @@ public function getByMpmPluginId(int $mpmPluginId): ?Plugins { ->first(); } + /** + * @return Collection + */ + public function getActivePaymentProviders(): Collection { + $paymentProviderIds = [ + MpmPlugin::SWIFTA_PAYMENT_PROVIDER, + MpmPlugin::MESOMB_PAYMENT_PROVIDER, + MpmPlugin::WAVE_MONEY_PAYMENT_PROVIDER, + MpmPlugin::WAVECOM_PAYMENT_PROVIDER, + MpmPlugin::VODACOM_MOBILE_MONEY, + MpmPlugin::PAYSTACK_PAYMENT_PROVIDER, + ]; + + return $this->plugin->newQuery() + ->where('status', Plugins::ACTIVE) + ->whereIn('mpm_plugin_id', $paymentProviderIds) + ->get(); + } + public function isPluginActive(int $pluginId): bool { return $this->plugin->newQuery() ->where('mpm_plugin_id', '=', $pluginId) diff --git a/src/backend/app/Services/TransactionPaymentProcessor.php b/src/backend/app/Services/TransactionPaymentProcessor.php index f48864acd..54934985a 100644 --- a/src/backend/app/Services/TransactionPaymentProcessor.php +++ b/src/backend/app/Services/TransactionPaymentProcessor.php @@ -34,6 +34,12 @@ public static function process(int $companyId, int $transactionId): void { return; } + if ($transaction->paygoAppliance()->exists()) { + dispatch(new ApplianceTransactionProcessor($companyId, $transactionId)); + + return; + } + if ($transaction->nonPaygoAppliance()->exists()) { dispatch(new ApplianceTransactionProcessor($companyId, $transactionId)); diff --git a/src/backend/routes/api.php b/src/backend/routes/api.php index be163ba11..4b6f1a9ee 100644 --- a/src/backend/routes/api.php +++ b/src/backend/routes/api.php @@ -138,6 +138,7 @@ }); Route::group(['prefix' => 'payment'], static function () { + Route::get('/providers', [AppliancePaymentController::class, 'paymentProviders'])->middleware('permission:payments'); Route::post('/{appliance_person}', [AppliancePaymentController::class, 'store'])->middleware('permission:payments'); Route::get('/status/{transaction_id}', [AppliancePaymentController::class, 'checkStatus'])->where('transaction_id', '[0-9]+')->middleware('permission:payments'); }); diff --git a/src/backend/tests/Feature/AppliancePaymentControllerTest.php b/src/backend/tests/Feature/AppliancePaymentControllerTest.php new file mode 100644 index 000000000..b69ee8650 --- /dev/null +++ b/src/backend/tests/Feature/AppliancePaymentControllerTest.php @@ -0,0 +1,187 @@ +createTestData(); + + Plugins::query()->create([ + 'mpm_plugin_id' => MpmPlugin::PAYSTACK_PAYMENT_PROVIDER, + 'status' => Plugins::ACTIVE, + ]); + + Plugins::query()->create([ + 'mpm_plugin_id' => MpmPlugin::WAVE_MONEY_PAYMENT_PROVIDER, + 'status' => Plugins::INACTIVE, + ]); + + $response = $this->actingAs($this->user)->get('/api/appliances/payment/providers'); + + $response->assertStatus(200); + $this->assertCount(1, $response['data']); + $this->assertEquals(MpmPlugin::PAYSTACK_PAYMENT_PROVIDER, $response['data'][0]['id']); + } + + public function testReturnsEmptyListWhenNoActivePaymentPlugins(): void { + $this->createTestData(); + + $response = $this->actingAs($this->user)->get('/api/appliances/payment/providers'); + + $response->assertStatus(200); + $this->assertCount(0, $response['data']); + } + + public function testCreatesCashTransactionAndDispatchesProcessPaymentJob(): void { + $this->createTestData(); + Queue::fake(); + + $person = PersonFactory::new()->create(); + $applianceType = ApplianceTypeFactory::new()->create(); + $appliance = Appliance::query()->create([ + 'name' => 'Test Appliance', + 'price' => 1000, + 'appliance_type_id' => $applianceType->id, + ]); + + /** @var AppliancePerson $appliancePerson */ + $appliancePerson = AppliancePersonFactory::new()->create([ + 'appliance_id' => $appliance->id, + 'person_id' => $person->id, + 'total_cost' => 500, + 'rate_count' => 5, + 'down_payment' => 0, + ]); + + ApplianceRate::query()->create([ + 'appliance_person_id' => $appliancePerson->id, + 'rate_cost' => 100, + 'remaining' => 100, + 'remind' => 0, + 'due_date' => now()->addMonth(), + ]); + + $response = $this->actingAs($this->user)->post( + "/api/appliances/payment/{$appliancePerson->id}", + [ + 'amount' => 100, + 'payment_provider' => 0, + ] + ); + + $response->assertStatus(200); + $this->assertEquals($appliancePerson->id, $response['data']['appliance_person']['id']); + $this->assertNotNull($response['data']['transaction_id']); + + Queue::assertPushed(ProcessPayment::class); + } + + public function testReturnsRedirectUrlForPaystackPayment(): void { + $this->createTestData(); + Queue::fake(); + + $apiService = $this->createMock(PaystackApiService::class); + $apiService->method('initializeTransaction')->willReturn([ + 'error' => null, + 'redirectionUrl' => 'https://paystack.com/pay/test123', + 'reference' => 'ref_test123', + ]); + $this->app->instance(PaystackApiService::class, $apiService); + + $person = PersonFactory::new()->create(); + $applianceType = ApplianceTypeFactory::new()->create(); + $appliance = Appliance::query()->create([ + 'name' => 'Test Appliance', + 'price' => 1000, + 'appliance_type_id' => $applianceType->id, + ]); + + /** @var AppliancePerson $appliancePerson */ + $appliancePerson = AppliancePersonFactory::new()->create([ + 'appliance_id' => $appliance->id, + 'person_id' => $person->id, + 'total_cost' => 500, + 'rate_count' => 5, + 'down_payment' => 0, + ]); + + ApplianceRate::query()->create([ + 'appliance_person_id' => $appliancePerson->id, + 'rate_cost' => 100, + 'remaining' => 100, + 'remind' => 0, + 'due_date' => now()->addMonth(), + ]); + + $response = $this->actingAs($this->user)->post( + "/api/appliances/payment/{$appliancePerson->id}", + [ + 'amount' => 100, + 'payment_provider' => MpmPlugin::PAYSTACK_PAYMENT_PROVIDER, + ] + ); + + $response->assertStatus(200); + $this->assertEquals('https://paystack.com/pay/test123', $response['data']['redirect_url']); + $this->assertEquals('ref_test123', $response['data']['reference']); + + Queue::assertNotPushed(ProcessPayment::class); + } + + public function testRejectsUnknownPaymentProviderId(): void { + $this->createTestData(); + + $person = PersonFactory::new()->create(); + $applianceType = ApplianceTypeFactory::new()->create(); + $appliance = Appliance::query()->create([ + 'name' => 'Test Appliance', + 'price' => 1000, + 'appliance_type_id' => $applianceType->id, + ]); + + /** @var AppliancePerson $appliancePerson */ + $appliancePerson = AppliancePersonFactory::new()->create([ + 'appliance_id' => $appliance->id, + 'person_id' => $person->id, + 'total_cost' => 500, + 'rate_count' => 5, + 'down_payment' => 0, + ]); + + ApplianceRate::query()->create([ + 'appliance_person_id' => $appliancePerson->id, + 'rate_cost' => 100, + 'remaining' => 100, + 'remind' => 0, + 'due_date' => now()->addMonth(), + ]); + + $response = $this->actingAs($this->user)->post( + "/api/appliances/payment/{$appliancePerson->id}", + [ + 'amount' => 100, + 'payment_provider' => 999, + ] + ); + + $response->assertStatus(500); + } +} diff --git a/src/backend/tests/Feature/AppliancePersonControllerDownPaymentTest.php b/src/backend/tests/Feature/AppliancePersonControllerDownPaymentTest.php new file mode 100644 index 000000000..106610dfd --- /dev/null +++ b/src/backend/tests/Feature/AppliancePersonControllerDownPaymentTest.php @@ -0,0 +1,127 @@ + + */ + private function buildSaleRequest(int $applianceId, int $personId, int $userId, float $downPayment): array { + return [ + 'id' => $applianceId, + 'person_id' => $personId, + 'user_id' => $userId, + 'cost' => 1000, + 'rate' => 5, + 'rate_type' => 'monthly', + 'down_payment' => $downPayment, + 'points' => '0,0', + ]; + } + + public function testItFiresPaymentSuccessEventForCashDownPayment(): void { + $this->createTestData(); + Event::fake([PaymentSuccessEvent::class, TransactionSuccessfulEvent::class]); + + $person = PersonFactory::new()->create(); + $applianceType = ApplianceTypeFactory::new()->create(); + $appliance = Appliance::query()->create([ + 'name' => 'Test Solar Panel', + 'price' => 1000, + 'appliance_type_id' => $applianceType->id, + ]); + $seller = UserFactory::new()->create(['company_id' => $this->companyId]); + + $response = $this->actingAs($this->user)->post( + "/api/appliances/person/{$appliance->id}/people/{$person->id}", + array_merge($this->buildSaleRequest($appliance->id, $person->id, $seller->id, 200), [ + 'payment_provider' => 0, + ]) + ); + + $response->assertStatus(200); + + Event::assertDispatched(PaymentSuccessEvent::class); + Event::assertDispatched(TransactionSuccessfulEvent::class); + + $this->assertNotNull($response['data']['appliance_person']); + } + + public function testItDispatchesProcessPaymentJobForPaystackDownPayment(): void { + $this->createTestData(); + Queue::fake(); + + $apiService = $this->createMock(PaystackApiService::class); + $apiService->method('initializeTransaction')->willReturn([ + 'error' => null, + 'redirectionUrl' => 'https://paystack.com/pay/dp_test', + 'reference' => 'ref_dp_test', + ]); + $this->app->instance(PaystackApiService::class, $apiService); + + $person = PersonFactory::new()->create(); + $applianceType = ApplianceTypeFactory::new()->create(); + $appliance = Appliance::query()->create([ + 'name' => 'Test Solar Panel', + 'price' => 1000, + 'appliance_type_id' => $applianceType->id, + ]); + $seller = UserFactory::new()->create(['company_id' => $this->companyId]); + + $response = $this->actingAs($this->user)->post( + "/api/appliances/person/{$appliance->id}/people/{$person->id}", + array_merge($this->buildSaleRequest($appliance->id, $person->id, $seller->id, 200), [ + 'payment_provider' => MpmPlugin::PAYSTACK_PAYMENT_PROVIDER, + ]) + ); + + $response->assertStatus(200); + + Queue::assertPushed(ProcessPayment::class); + + $this->assertNotNull($response['data']['appliance_person']); + $this->assertEquals('https://paystack.com/pay/dp_test', $response['data']['redirect_url']); + } + + public function testItCreatesAppliancePersonWithoutDownPayment(): void { + $this->createTestData(); + Event::fake(); + + $person = PersonFactory::new()->create(); + $applianceType = ApplianceTypeFactory::new()->create(); + $appliance = Appliance::query()->create([ + 'name' => 'Test Solar Panel', + 'price' => 1000, + 'appliance_type_id' => $applianceType->id, + ]); + $seller = UserFactory::new()->create(['company_id' => $this->companyId]); + + $response = $this->actingAs($this->user)->post( + "/api/appliances/person/{$appliance->id}/people/{$person->id}", + $this->buildSaleRequest($appliance->id, $person->id, $seller->id, 0) + ); + + $response->assertStatus(200); + + Event::assertNotDispatched(PaymentSuccessEvent::class); + $this->assertNotNull($response['data']['appliance_person']); + } +} diff --git a/src/backend/tests/Unit/PaymentInitializationServiceTest.php b/src/backend/tests/Unit/PaymentInitializationServiceTest.php new file mode 100644 index 000000000..f600e4c20 --- /dev/null +++ b/src/backend/tests/Unit/PaymentInitializationServiceTest.php @@ -0,0 +1,169 @@ +cashService = $this->createMock(CashTransactionService::class); + $this->paystackService = $this->createMock(PaystackTransactionService::class); + $pluginsService = $this->createMock(PluginsService::class); + $mpmPluginService = $this->createMock(MpmPluginService::class); + + $this->service = new PaymentInitializationService( + $this->cashService, + $this->paystackService, + $pluginsService, + $mpmPluginService, + ); + } + + public function testThrowsForUnknownProviderId(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported payment provider ID: 999'); + + $this->service->initialize( + providerId: 999, + amount: 100.0, + sender: '+2340000', + message: 'DEVICE-001', + type: 'deferred_payment', + customerId: 1, + creatorId: 1, + ); + } + + public function testDelegatesToCashTransactionServiceForProviderZero(): void { + $transaction = new Transaction(); + + $this->cashService + ->expects($this->once()) + ->method('createTransaction') + ->with(1, 100.0, '+2340000', '42', 'deferred_payment') + ->willReturn($transaction); + + $result = $this->service->initialize( + providerId: 0, + amount: 100.0, + sender: '+2340000', + message: '42', + type: 'deferred_payment', + customerId: 5, + creatorId: 1, + ); + + $this->assertSame($transaction, $result['transaction']); + $this->assertSame([], $result['provider_data']); + } + + public function testDelegatesToPaystackServiceForPaystackProvider(): void { + $transaction = new Transaction(); + + $this->paystackService + ->expects($this->once()) + ->method('initializePayment') + ->with(100.0, '+2340000', '42', 'deferred_payment', 5, null) + ->willReturn([ + 'transaction' => $transaction, + 'provider_data' => [ + 'redirect_url' => 'https://paystack.com/pay/abc', + 'reference' => 'ref_abc', + ], + ]); + + $result = $this->service->initialize( + providerId: MpmPlugin::PAYSTACK_PAYMENT_PROVIDER, + amount: 100.0, + sender: '+2340000', + message: '42', + type: 'deferred_payment', + customerId: 5, + creatorId: 1, + ); + + $this->assertSame($transaction, $result['transaction']); + $this->assertSame('https://paystack.com/pay/abc', $result['provider_data']['redirect_url']); + } + + public function testDoesNotCallPaystackServiceForCashProvider(): void { + $transaction = new Transaction(); + + $this->cashService->method('createTransaction')->willReturn($transaction); + $this->paystackService->expects($this->never())->method('initializePayment'); + + $this->service->initialize( + providerId: 0, + amount: 50.0, + sender: '-', + message: '1', + type: 'deferred_payment', + customerId: 1, + creatorId: 1, + ); + } + + public function testDoesNotCallCashServiceForPaystackProvider(): void { + $transaction = new Transaction(); + + $this->paystackService->method('initializePayment')->willReturn([ + 'transaction' => $transaction, + 'provider_data' => ['redirect_url' => 'https://paystack.com/pay/x', 'reference' => 'ref_x'], + ]); + $this->cashService->expects($this->never())->method('createTransaction'); + + $this->service->initialize( + providerId: MpmPlugin::PAYSTACK_PAYMENT_PROVIDER, + amount: 50.0, + sender: '-', + message: '1', + type: 'deferred_payment', + customerId: 1, + creatorId: 1, + ); + } + + public function testPassesSerialIdToPaystackServiceWhenProvided(): void { + $transaction = new Transaction(); + + $this->paystackService + ->expects($this->once()) + ->method('initializePayment') + ->with(200.0, '+2340000', 'SERIAL-001', 'deferred_payment', 5, 'SERIAL-001') + ->willReturn([ + 'transaction' => $transaction, + 'provider_data' => ['redirect_url' => 'https://paystack.com/pay/y', 'reference' => 'ref_y'], + ]); + + $this->service->initialize( + providerId: MpmPlugin::PAYSTACK_PAYMENT_PROVIDER, + amount: 200.0, + sender: '+2340000', + message: 'SERIAL-001', + type: 'deferred_payment', + customerId: 5, + creatorId: 1, + serialId: 'SERIAL-001', + ); + } +} diff --git a/src/backend/tests/Unit/PaystackTransactionServiceInitializePaymentTest.php b/src/backend/tests/Unit/PaystackTransactionServiceInitializePaymentTest.php new file mode 100644 index 000000000..763df8127 --- /dev/null +++ b/src/backend/tests/Unit/PaystackTransactionServiceInitializePaymentTest.php @@ -0,0 +1,98 @@ +createMock(PaystackApiService::class); + $apiService->method('initializeTransaction')->willReturn($apiResponse); + + $this->app->instance(PaystackApiService::class, $apiService); + + /** @var PaystackTransactionService $service */ + $service = $this->app->make(PaystackTransactionService::class); + + return $service; + } + + public function testCreatesPaystackTransactionAndTransactionRecords(): void { + $service = $this->makeServiceWithMockedApi([ + 'error' => null, + 'redirectionUrl' => 'https://paystack.com/pay/test', + 'reference' => 'ref_test_123', + ]); + + $result = $service->initializePayment( + amount: 200.0, + sender: '+2340000', + message: '42', + type: 'deferred_payment', + customerId: 1, + ); + + $this->assertInstanceOf(Transaction::class, $result['transaction']); + $this->assertSame('42', $result['transaction']->message); + $this->assertSame('deferred_payment', $result['transaction']->type); + $this->assertSame('https://paystack.com/pay/test', $result['provider_data']['redirect_url']); + $this->assertSame('ref_test_123', $result['provider_data']['reference']); + + $paystackTxn = PaystackTransaction::query()->where('customer_id', 1)->where('amount', 200.0)->first(); + $this->assertNotNull($paystackTxn); + + $transaction = Transaction::query()->where('message', '42')->where('type', 'deferred_payment')->first(); + $this->assertNotNull($transaction); + } + + public function testSetsSerialIdOnPaystackTransactionWhenProvided(): void { + $service = $this->makeServiceWithMockedApi([ + 'error' => null, + 'redirectionUrl' => 'https://paystack.com/pay/serial', + 'reference' => 'ref_serial', + ]); + + $service->initializePayment( + amount: 150.0, + sender: '+2340001', + message: 'SERIAL-XYZ', + type: 'deferred_payment', + customerId: 2, + serialId: 'SERIAL-XYZ', + ); + + $paystackTxn = PaystackTransaction::query()->where('serial_id', 'SERIAL-XYZ')->first(); + $this->assertNotNull($paystackTxn); + + $transaction = Transaction::query()->where('message', 'SERIAL-XYZ')->first(); + $this->assertNotNull($transaction); + } + + public function testThrowsWhenPaystackApiReturnsError(): void { + $service = $this->makeServiceWithMockedApi([ + 'error' => 'API error: invalid key', + 'redirectionUrl' => null, + 'reference' => null, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Paystack initialization failed: API error: invalid key'); + + $service->initializePayment( + amount: 100.0, + sender: '-', + message: '1', + type: 'deferred_payment', + customerId: 1, + ); + } +} diff --git a/src/frontend/src/modules/Client/Appliances/SellApplianceModal.vue b/src/frontend/src/modules/Client/Appliances/SellApplianceModal.vue index 8dfe62b13..ecf1bddad 100644 --- a/src/frontend/src/modules/Client/Appliances/SellApplianceModal.vue +++ b/src/frontend/src/modules/Client/Appliances/SellApplianceModal.vue @@ -389,6 +389,24 @@

+
+ + + + Cash + + {{ provider.name }} + + + +
How Much Do You Want to Pay?
+ + + + Cash + + {{ provider.name }} + + + payments @@ -368,6 +381,9 @@ export default { detailedDeviceInfo: null, editRow: null, tempCost: null, + paymentProviders: [], + selectedProviderId: 0, + redirectUrl: null, } }, computed: { @@ -454,10 +470,19 @@ export default { this.tempCost = cost this.editRow = "rate_" + id }, + async openPaymentDialog() { + this.getPayment = true + const providers = await this.appliancePayment.getPaymentProviders() + if (!(providers instanceof ErrorHandler)) { + this.paymentProviders = providers + } + }, closeGetPayment() { this.getPayment = false this.payment = null this.errorLabel = false + this.selectedProviderId = 0 + this.redirectUrl = null }, async editRate(data) { this.progress = true @@ -557,6 +582,7 @@ export default { adminId: this.adminId, rates: this.soldAppliance.rates, amount: this.payment, + paymentProvider: this.selectedProviderId, } const result = await this.appliancePayment.getPaymentForAppliance( @@ -568,6 +594,11 @@ export default { throw result } + if (result.redirect_url) { + this.redirectUrl = result.redirect_url + window.open(result.redirect_url, "_blank") + } + // Check if transaction_id is returned (async processing) if (result.transaction_id) { // Poll for payment processing status diff --git a/src/frontend/src/plugins/paystack-payment-provider/modules/Overview/Credential.vue b/src/frontend/src/plugins/paystack-payment-provider/modules/Overview/Credential.vue index d309240b9..7d36af0f5 100644 --- a/src/frontend/src/plugins/paystack-payment-provider/modules/Overview/Credential.vue +++ b/src/frontend/src/plugins/paystack-payment-provider/modules/Overview/Credential.vue @@ -240,55 +240,6 @@
- -
- - -
- -
- - - content_copy - -
-
- -
- -
- - - content_copy - -
-
- -

- warning - These URLs expire in 24 hours. Use for temporary or - agent-generated links. -

-
@@ -336,8 +287,6 @@ export default { loadingUrls: false, publicUrls: { permanent_payment_url: "", - time_based_payment_url: "", - time_based_result_url: "", }, callbackUrl: "", } @@ -382,13 +331,6 @@ export default { permanent_payment_url: this.addFrontendPrefix( response.permanent_payment_url, ), - time_based_payment_url: this.addFrontendPrefix( - response.time_based_payment_url, - ), - time_based_result_url: this.addFrontendPrefix( - response.time_based_result_url, - ), - company_id: response.company_id, } this.callbackUrl = this.addFrontendPrefix( @@ -486,31 +428,11 @@ export default { margin-bottom: 1.5rem; } -.time-based-url { - border-left: 4px solid #ff9800; - padding-left: 1rem; - background-color: #fff3e0; - border-radius: 4px; - margin-bottom: 1.5rem; -} - .url-icon { margin-right: 8px; vertical-align: middle; } -.url-sublabel { - display: block; - font-weight: 500; - margin-bottom: 0.5rem; - color: #666; - font-size: 14px; -} - -.url-sub-item { - margin-bottom: 1rem; -} - .url-description { display: flex; align-items: center; diff --git a/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentForm.vue b/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentForm.vue index ffb9f45a6..45bef9082 100644 --- a/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentForm.vue +++ b/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentForm.vue @@ -290,7 +290,7 @@ export default { timer: 2000, timerProgressBar: true, }).then(() => { - window.location = data.redirection_url + window.location = data.redirect_url }) } catch (error) { this.$swal({ diff --git a/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentResult.vue b/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentResult.vue index e203c7be9..fc9badf32 100644 --- a/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentResult.vue +++ b/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentResult.vue @@ -28,16 +28,20 @@ Transaction ID: {{ paymentResult.transaction.id }}
-
+
{{ deviceLabel }}: {{ paymentResult.transaction.serial_id }}
-
+
Device Type: {{ deviceTypeName }}
+
+ Payment Type: + Installment +
Amount Paid: @@ -58,7 +62,7 @@
-
+

{{ tokenTitle }}

@@ -179,16 +183,20 @@ Transaction ID: {{ paymentResult.transaction.id }}
-
+
{{ deviceLabel }}: {{ paymentResult.transaction.serial_id }}
-
+
Device Type: {{ deviceTypeName }}
+
+ Payment Type: + Installment +
Amount: @@ -267,11 +275,21 @@ export default { const urlParams = new URLSearchParams(window.location.search) return urlParams.get("reference") }, + paymentType() { + return this.paymentResult?.transaction?.payment_type + }, + isInstallment() { + return this.paymentType === "deferred_payment" + }, + // Non-paygo installments have no device_serial — no device or token to show + isNonPaygoInstallment() { + return this.isInstallment && !this.paymentResult?.transaction?.serial_id + }, deviceType() { return this.paymentResult?.transaction?.device_type || "meter" }, isSHS() { - return this.deviceType === "shs" + return this.deviceType === "solar_home_system" || this.deviceType === "shs" }, deviceTypeName() { return this.isSHS ? "Solar Home System" : "Meter" @@ -289,6 +307,10 @@ export default { return this.isSHS ? "Token Amount" : "Energy Amount" }, successMessage() { + if (this.isNonPaygoInstallment) { + return "Your payment has been processed successfully. Your account balance will be updated shortly." + } + if (this.isSHS) { return "Your payment has been processed successfully. Your appliance token will be generated shortly." } diff --git a/src/frontend/src/repositories/AppliancePaymentRepository.js b/src/frontend/src/repositories/AppliancePaymentRepository.js index 826c80fbd..aaea589e0 100644 --- a/src/frontend/src/repositories/AppliancePaymentRepository.js +++ b/src/frontend/src/repositories/AppliancePaymentRepository.js @@ -3,6 +3,9 @@ import Client from "@/repositories/Client/AxiosClient.js" const resource = `/api/appliances/payment` export default { + getProviders() { + return Client.get(`${resource}/providers`) + }, update(id, data) { return Client.post(`${resource}/${id}`, data) }, diff --git a/src/frontend/src/services/AppliancePaymentService.js b/src/frontend/src/services/AppliancePaymentService.js index 2dc2651e5..75c4d8ca8 100644 --- a/src/frontend/src/services/AppliancePaymentService.js +++ b/src/frontend/src/services/AppliancePaymentService.js @@ -37,6 +37,18 @@ export class AppliancePaymentService { } } + async getPaymentProviders() { + try { + const { data, status, error } = await this.repository.getProviders() + if (status !== 200) return new ErrorHandler(error, "http", status) + + return data.data + } catch (e) { + const errorMessage = e.response?.data?.message || e.message + return new ErrorHandler(errorMessage, "http", e.response?.status || 500) + } + } + async pollPaymentStatus(transactionId, options = {}) { const { maxAttempts = 30, interval = 1000, onProgress = null } = options From db411dec823bb19e4278870cc3861ecc6991a55a Mon Sep 17 00:00:00 2001 From: Obinna Ikeh Date: Wed, 18 Mar 2026 14:44:34 +0100 Subject: [PATCH 2/5] Update test config + move tests around --- .../app/Http/Controllers/AppliancePaymentController.php | 9 +++++++-- .../Services/PaystackTransactionService.php | 2 -- .../PaystackTransactionServiceInitializePaymentTest.php | 2 +- .../Tests/Unit/PaystackTransactionTest.php | 6 +++--- src/backend/phpunit.xml | 1 + .../tests/Feature/AppliancePaymentControllerTest.php | 3 ++- 6 files changed, 14 insertions(+), 9 deletions(-) rename src/backend/{tests => app/Plugins/PaystackPaymentProvider/Tests}/Unit/PaystackTransactionServiceInitializePaymentTest.php (98%) diff --git a/src/backend/app/Http/Controllers/AppliancePaymentController.php b/src/backend/app/Http/Controllers/AppliancePaymentController.php index 984cf47a7..527396e2c 100644 --- a/src/backend/app/Http/Controllers/AppliancePaymentController.php +++ b/src/backend/app/Http/Controllers/AppliancePaymentController.php @@ -8,6 +8,7 @@ use App\Services\AppliancePaymentService; use App\Services\AppliancePersonService; use App\Services\PaymentInitializationService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -20,7 +21,7 @@ public function __construct( private PaymentInitializationService $paymentInitializationService, ) {} - public function store(AppliancePerson $appliancePerson, Request $request): ApiResource { + public function store(AppliancePerson $appliancePerson, Request $request): ApiResource|JsonResponse { try { DB::connection('tenant')->beginTransaction(); $result = $this->getPaymentForAppliance($request, $appliancePerson); @@ -33,9 +34,13 @@ public function store(AppliancePerson $appliancePerson, Request $request): ApiRe ], $result['provider_data'], )); + } catch (\InvalidArgumentException $e) { + DB::connection('tenant')->rollBack(); + + return response()->json(['message' => $e->getMessage()], 422); } catch (\Exception $e) { DB::connection('tenant')->rollBack(); - throw new \Exception($e->getMessage(), $e->getCode(), $e); + throw new \Exception($e->getMessage(), (int) $e->getCode(), $e); } } diff --git a/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackTransactionService.php b/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackTransactionService.php index f48441e13..ab520e088 100644 --- a/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackTransactionService.php +++ b/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackTransactionService.php @@ -2,8 +2,6 @@ declare(strict_types=1); -namespace App\Plugins\PaystackPaymentProvider\Modules\Transaction; - namespace App\Plugins\PaystackPaymentProvider\Services; use App\Jobs\ProcessPayment; diff --git a/src/backend/tests/Unit/PaystackTransactionServiceInitializePaymentTest.php b/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionServiceInitializePaymentTest.php similarity index 98% rename from src/backend/tests/Unit/PaystackTransactionServiceInitializePaymentTest.php rename to src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionServiceInitializePaymentTest.php index 763df8127..c038a7f51 100644 --- a/src/backend/tests/Unit/PaystackTransactionServiceInitializePaymentTest.php +++ b/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionServiceInitializePaymentTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Unit; +namespace App\Plugins\PaystackPaymentProvider\Tests\Unit; use App\Models\Transaction\Transaction; use App\Plugins\PaystackPaymentProvider\Models\PaystackTransaction; diff --git a/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionTest.php b/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionTest.php index 426aac226..c42b7a5fc 100644 --- a/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionTest.php +++ b/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionTest.php @@ -6,11 +6,11 @@ use PHPUnit\Framework\TestCase as BaseTestCase; class PaystackTransactionTest extends BaseTestCase { - public function itReturnsCorrectTransactionName(): void { + public function testReturnsCorrectTransactionName(): void { $this->assertEquals('paystack_transaction', PaystackTransaction::getTransactionName()); } - public function itHasCorrectStatusConstants(): void { + public function testHasCorrectStatusConstants(): void { $this->assertEquals(0, PaystackTransaction::STATUS_REQUESTED); $this->assertEquals(1, PaystackTransaction::STATUS_SUCCESS); $this->assertEquals(2, PaystackTransaction::STATUS_COMPLETED); @@ -19,7 +19,7 @@ public function itHasCorrectStatusConstants(): void { $this->assertEquals(5, PaystackTransaction::MAX_ATTEMPTS); } - public function itHasCorrectRelationName(): void { + public function testHasCorrectRelationName(): void { $this->assertEquals('paystack_transaction', PaystackTransaction::RELATION_NAME); } } diff --git a/src/backend/phpunit.xml b/src/backend/phpunit.xml index 422a31445..3f34ca755 100644 --- a/src/backend/phpunit.xml +++ b/src/backend/phpunit.xml @@ -12,6 +12,7 @@ tests/Unit + app/Plugins/PaystackPaymentProvider/Tests/Unit/ tests/Feature diff --git a/src/backend/tests/Feature/AppliancePaymentControllerTest.php b/src/backend/tests/Feature/AppliancePaymentControllerTest.php index b69ee8650..de08048b9 100644 --- a/src/backend/tests/Feature/AppliancePaymentControllerTest.php +++ b/src/backend/tests/Feature/AppliancePaymentControllerTest.php @@ -182,6 +182,7 @@ public function testRejectsUnknownPaymentProviderId(): void { ] ); - $response->assertStatus(500); + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Unsupported payment provider ID: 999']); } } From ecd27c55434ba3575bdfa7ca37dfa8c631beec60 Mon Sep 17 00:00:00 2001 From: Obinna Ikeh Date: Wed, 18 Mar 2026 15:01:49 +0100 Subject: [PATCH 3/5] Apply quality check fixes --- .../PaystackTransactionServiceInitializePaymentTest.php | 3 +++ .../modules/Overview/Credential.vue | 1 - .../modules/Payment/PublicPaymentForm.vue | 2 +- .../modules/Payment/PublicPaymentResult.vue | 6 ++++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionServiceInitializePaymentTest.php b/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionServiceInitializePaymentTest.php index c038a7f51..4adec4712 100644 --- a/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionServiceInitializePaymentTest.php +++ b/src/backend/app/Plugins/PaystackPaymentProvider/Tests/Unit/PaystackTransactionServiceInitializePaymentTest.php @@ -14,6 +14,9 @@ class PaystackTransactionServiceInitializePaymentTest extends TestCase { use RefreshMultipleDatabases; + /** + * @param array $apiResponse + */ private function makeServiceWithMockedApi(array $apiResponse): PaystackTransactionService { $apiService = $this->createMock(PaystackApiService::class); $apiService->method('initializeTransaction')->willReturn($apiResponse); diff --git a/src/frontend/src/plugins/paystack-payment-provider/modules/Overview/Credential.vue b/src/frontend/src/plugins/paystack-payment-provider/modules/Overview/Credential.vue index 7d36af0f5..ebe7b27fe 100644 --- a/src/frontend/src/plugins/paystack-payment-provider/modules/Overview/Credential.vue +++ b/src/frontend/src/plugins/paystack-payment-provider/modules/Overview/Credential.vue @@ -239,7 +239,6 @@
-
diff --git a/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentForm.vue b/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentForm.vue index 45bef9082..a6fb89cb0 100644 --- a/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentForm.vue +++ b/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentForm.vue @@ -290,7 +290,7 @@ export default { timer: 2000, timerProgressBar: true, }).then(() => { - window.location = data.redirect_url + window.location = data.redirect_url }) } catch (error) { this.$swal({ diff --git a/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentResult.vue b/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentResult.vue index fc9badf32..ce1ba6911 100644 --- a/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentResult.vue +++ b/src/frontend/src/plugins/paystack-payment-provider/modules/Payment/PublicPaymentResult.vue @@ -283,13 +283,15 @@ export default { }, // Non-paygo installments have no device_serial — no device or token to show isNonPaygoInstallment() { - return this.isInstallment && !this.paymentResult?.transaction?.serial_id + return this.isInstallment && !this.paymentResult?.transaction?.serial_id }, deviceType() { return this.paymentResult?.transaction?.device_type || "meter" }, isSHS() { - return this.deviceType === "solar_home_system" || this.deviceType === "shs" + return ( + this.deviceType === "solar_home_system" || this.deviceType === "shs" + ) }, deviceTypeName() { return this.isSHS ? "Solar Home System" : "Meter" From cf885d9a338e5022ff7977d69ceac5050c2f6796 Mon Sep 17 00:00:00 2001 From: Obinna Ikeh Date: Wed, 1 Apr 2026 00:38:12 +0100 Subject: [PATCH 4/5] Refactor: apply review improvements --- docs/development/plugins.md | 48 ++++++++++++++++ ...t-flow.svg => payment-flow.excalidraw.svg} | 0 .../AppliancePaymentController.php | 2 - .../Controllers/AppliancePersonController.php | 1 - .../Services/PaystackTransactionService.php | 3 +- .../app/Services/CashTransactionService.php | 39 +++++++------ .../Interfaces/PaymentInitializer.php | 36 ++++++++++++ .../Services/PaymentInitializationService.php | 55 +++++++------------ .../Services/TransactionPaymentProcessor.php | 6 -- .../Unit/PaymentInitializationServiceTest.php | 32 ++++++----- 10 files changed, 142 insertions(+), 80 deletions(-) rename docs/usage-guide/images/{payment-flow.svg => payment-flow.excalidraw.svg} (100%) create mode 100644 src/backend/app/Services/Interfaces/PaymentInitializer.php diff --git a/docs/development/plugins.md b/docs/development/plugins.md index 7644bb8ad..c7bcb6dc4 100644 --- a/docs/development/plugins.md +++ b/docs/development/plugins.md @@ -150,6 +150,54 @@ class InstallPackage extends Command } ``` +#### Payment Provider Plugins + +If your plugin is a **payment provider** (processes transactions from an external payment gateway), there are additional integration steps required: + +1. **Implement the `PaymentInitializer` interface** + + Your plugin's transaction service must implement `App\Services\Interfaces\PaymentInitializer`. This enforces a consistent `initializePayment()` method that creates the provider-specific record and an associated `Transaction` entry. + + ```php + use App\Services\Interfaces\PaymentInitializer; + + class YourProviderTransactionService implements PaymentInitializer { + public function initializePayment( + float $amount, + string $sender, + string $message, + string $type, + int $customerId, + ?string $serialId = null, + ): array { + // 1. Create your provider-specific transaction record + // 2. Create the associated Transaction record + // 3. Call your provider's API if needed (e.g. to get a redirect URL) + // 4. Return ['transaction' => $transaction, 'provider_data' => [...]] + } + } + ``` + +2. **Register in the provider map** + + Add your plugin's MpmPlugin ID and service class to the `PROVIDER_MAP` constant in `App\Services\PaymentInitializationService`: + + ```php + private const PROVIDER_MAP = [ + 0 => CashTransactionService::class, + MpmPlugin::PAYSTACK_PAYMENT_PROVIDER => PaystackTransactionService::class, + MpmPlugin::YOUR_PAYMENT_PROVIDER => YourProviderTransactionService::class, + ]; + ``` + +3. **Register in the active providers list** + + Add your plugin's MpmPlugin ID to the `$paymentProviderIds` array in `App\Services\PluginsService::getActivePaymentProviders()`. This is required for your provider to appear as an available payment option in the UI. + +4. **Provider validation** + + Payment validation (device ownership, minimum purchase amounts) is handled via the `ITransactionProvider::validateRequest()` interface and `AbstractPaymentAggregatorTransactionService::validatePaymentOwner()`. If your provider receives incoming payment callbacks, implement `ITransactionProvider` in your provider class to plug into the existing validation pipeline. + ### Frontend Integration This section describe how to integrate your plugin with the MicroPowerManager frontend. diff --git a/docs/usage-guide/images/payment-flow.svg b/docs/usage-guide/images/payment-flow.excalidraw.svg similarity index 100% rename from docs/usage-guide/images/payment-flow.svg rename to docs/usage-guide/images/payment-flow.excalidraw.svg diff --git a/src/backend/app/Http/Controllers/AppliancePaymentController.php b/src/backend/app/Http/Controllers/AppliancePaymentController.php index 527396e2c..be789546b 100644 --- a/src/backend/app/Http/Controllers/AppliancePaymentController.php +++ b/src/backend/app/Http/Controllers/AppliancePaymentController.php @@ -60,7 +60,6 @@ public function paymentProviders(): ApiResource { * @return array */ private function getPaymentForAppliance(Request $request, AppliancePerson $appliancePerson): array { - $creatorId = auth('api')->user()->id; $amount = (float) $request->input('amount'); $providerId = (int) $request->input('payment_provider', 0); $companyId = $request->attributes->get('companyId'); @@ -86,7 +85,6 @@ private function getPaymentForAppliance(Request $request, AppliancePerson $appli message: $message, type: 'deferred_payment', customerId: $applianceOwner->id, - creatorId: $creatorId, serialId: $deviceSerial, ); diff --git a/src/backend/app/Http/Controllers/AppliancePersonController.php b/src/backend/app/Http/Controllers/AppliancePersonController.php index 23f56359d..710324aa8 100644 --- a/src/backend/app/Http/Controllers/AppliancePersonController.php +++ b/src/backend/app/Http/Controllers/AppliancePersonController.php @@ -116,7 +116,6 @@ public function store( message: $message, type: 'deferred_payment', customerId: $person->id, - creatorId: $user->id, serialId: $deviceSerial ?? null, ); diff --git a/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackTransactionService.php b/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackTransactionService.php index ab520e088..56e4e7cb7 100644 --- a/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackTransactionService.php +++ b/src/backend/app/Plugins/PaystackPaymentProvider/Services/PaystackTransactionService.php @@ -14,6 +14,7 @@ use App\Services\AbstractPaymentAggregatorTransactionService; use App\Services\DeviceService; use App\Services\Interfaces\IBaseService; +use App\Services\Interfaces\PaymentInitializer; use App\Services\PersonService; use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; @@ -22,7 +23,7 @@ /** * @implements IBaseService */ -class PaystackTransactionService extends AbstractPaymentAggregatorTransactionService implements IBaseService { +class PaystackTransactionService extends AbstractPaymentAggregatorTransactionService implements IBaseService, PaymentInitializer { public function __construct( private Meter $meter, private Address $address, diff --git a/src/backend/app/Services/CashTransactionService.php b/src/backend/app/Services/CashTransactionService.php index b935257b0..7787f3b54 100644 --- a/src/backend/app/Services/CashTransactionService.php +++ b/src/backend/app/Services/CashTransactionService.php @@ -4,9 +4,11 @@ use App\Models\Transaction\CashTransaction; use App\Models\Transaction\Transaction; +use App\Services\Interfaces\PaymentInitializer; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -class CashTransactionService { +class CashTransactionService implements PaymentInitializer { public function __construct(private CashTransaction $cashTransaction, private Transaction $transaction) {} public function createTransaction(int $creatorId, float $amount, string $sender, string $message, string $type): Transaction { @@ -30,24 +32,21 @@ public function createTransaction(int $creatorId, float $amount, string $sender, }); } - public function createCashTransaction(int $creatorId, float $amount, string $sender, ?string $deviceSerial = null, ?int $applianceId = null): Transaction { - return DB::transaction(function () use ($creatorId, $amount, $sender, $deviceSerial, $applianceId) { - $cashTransaction = $this->cashTransaction->newQuery()->create([ - 'user_id' => $creatorId, - 'status' => 1, - ]); - - $transaction = $this->transaction->newQuery()->make([ - 'amount' => $amount, - 'sender' => $sender, - 'message' => $deviceSerial ?? strval($applianceId ?? '-'), - 'type' => 'deferred_payment', - ]); - - $transaction->originalTransaction()->associate($cashTransaction); - $transaction->save(); - - return $transaction; - }); + /** + * @return array{transaction: Transaction, provider_data: array} + */ + public function initializePayment( + float $amount, + string $sender, + string $message, + string $type, + int $customerId, + ?string $serialId = null, + ): array { + $creatorId = Auth::id(); + + $transaction = $this->createTransaction($creatorId, $amount, $sender, $message, $type); + + return ['transaction' => $transaction, 'provider_data' => []]; } } diff --git a/src/backend/app/Services/Interfaces/PaymentInitializer.php b/src/backend/app/Services/Interfaces/PaymentInitializer.php new file mode 100644 index 000000000..cf427eb59 --- /dev/null +++ b/src/backend/app/Services/Interfaces/PaymentInitializer.php @@ -0,0 +1,36 @@ +} + */ + public function initializePayment( + float $amount, + string $sender, + string $message, + string $type, + int $customerId, + ?string $serialId = null, + ): array; +} diff --git a/src/backend/app/Services/PaymentInitializationService.php b/src/backend/app/Services/PaymentInitializationService.php index 5d8430165..3c2b7f36c 100644 --- a/src/backend/app/Services/PaymentInitializationService.php +++ b/src/backend/app/Services/PaymentInitializationService.php @@ -8,14 +8,26 @@ use App\Models\Plugins; use App\Models\Transaction\Transaction; use App\Plugins\PaystackPaymentProvider\Services\PaystackTransactionService; +use App\Services\Interfaces\PaymentInitializer; +use Illuminate\Contracts\Container\Container; use Illuminate\Support\Collection; class PaymentInitializationService { + /** + * Maps provider IDs to their PaymentInitializer implementation class. + * When adding a new payment provider plugin, register it here. + * + * @var array> + */ + private const PROVIDER_MAP = [ + 0 => CashTransactionService::class, + MpmPlugin::PAYSTACK_PAYMENT_PROVIDER => PaystackTransactionService::class, + ]; + public function __construct( - private CashTransactionService $cashTransactionService, - private PaystackTransactionService $paystackTransactionService, private PluginsService $pluginsService, private MpmPluginService $mpmPluginService, + private Container $container, ) {} /** @@ -26,7 +38,6 @@ public function __construct( * @param string $message Transaction routing key (device serial or entity ID) * @param string $type Transaction type (e.g. 'deferred_payment', 'energy') * @param int $customerId Person ID of the customer - * @param int $creatorId Admin user ID (used for cash transactions) * @param string|null $serialId Device serial, if applicable * * @return array{transaction: Transaction, provider_data: array} @@ -38,50 +49,22 @@ public function initialize( string $message, string $type, int $customerId, - int $creatorId, ?string $serialId = null, ): array { - $validProviderIds = [ - 0, // cash - MpmPlugin::PAYSTACK_PAYMENT_PROVIDER, - ]; - - if (!in_array($providerId, $validProviderIds, true)) { + if (!isset(self::PROVIDER_MAP[$providerId])) { throw new \InvalidArgumentException("Unsupported payment provider ID: {$providerId}"); } - return match ($providerId) { - MpmPlugin::PAYSTACK_PAYMENT_PROVIDER => $this->paystackTransactionService->initializePayment( - $amount, - $sender, - $message, - $type, - $customerId, - $serialId, - ), - default => $this->initializeCash($creatorId, $amount, $sender, $message, $type), - }; - } + $initializer = $this->container->make(self::PROVIDER_MAP[$providerId]); - /** - * @return array{transaction: Transaction, provider_data: array} - */ - private function initializeCash( - int $creatorId, - float $amount, - string $sender, - string $message, - string $type, - ): array { - $transaction = $this->cashTransactionService->createTransaction( - $creatorId, + return $initializer->initializePayment( $amount, $sender, $message, $type, + $customerId, + $serialId, ); - - return ['transaction' => $transaction, 'provider_data' => []]; } /** @return Collection */ diff --git a/src/backend/app/Services/TransactionPaymentProcessor.php b/src/backend/app/Services/TransactionPaymentProcessor.php index 54934985a..f48864acd 100644 --- a/src/backend/app/Services/TransactionPaymentProcessor.php +++ b/src/backend/app/Services/TransactionPaymentProcessor.php @@ -34,12 +34,6 @@ public static function process(int $companyId, int $transactionId): void { return; } - if ($transaction->paygoAppliance()->exists()) { - dispatch(new ApplianceTransactionProcessor($companyId, $transactionId)); - - return; - } - if ($transaction->nonPaygoAppliance()->exists()) { dispatch(new ApplianceTransactionProcessor($companyId, $transactionId)); diff --git a/src/backend/tests/Unit/PaymentInitializationServiceTest.php b/src/backend/tests/Unit/PaymentInitializationServiceTest.php index f600e4c20..7cd43c84e 100644 --- a/src/backend/tests/Unit/PaymentInitializationServiceTest.php +++ b/src/backend/tests/Unit/PaymentInitializationServiceTest.php @@ -11,12 +11,16 @@ use App\Services\MpmPluginService; use App\Services\PaymentInitializationService; use App\Services\PluginsService; +use Illuminate\Contracts\Container\Container; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class PaymentInitializationServiceTest extends TestCase { private PaymentInitializationService $service; + /** @var Container&MockObject */ + private MockObject $container; + /** @var CashTransactionService&MockObject */ private MockObject $cashService; @@ -31,11 +35,16 @@ protected function setUp(): void { $pluginsService = $this->createMock(PluginsService::class); $mpmPluginService = $this->createMock(MpmPluginService::class); + $this->container = $this->createMock(Container::class); + $this->container->method('make')->willReturnMap([ + [CashTransactionService::class, [], $this->cashService], + [PaystackTransactionService::class, [], $this->paystackService], + ]); + $this->service = new PaymentInitializationService( - $this->cashService, - $this->paystackService, $pluginsService, $mpmPluginService, + $this->container, ); } @@ -50,18 +59,17 @@ public function testThrowsForUnknownProviderId(): void { message: 'DEVICE-001', type: 'deferred_payment', customerId: 1, - creatorId: 1, ); } - public function testDelegatesToCashTransactionServiceForProviderZero(): void { + public function testDelegatesToCashServiceForProviderZero(): void { $transaction = new Transaction(); $this->cashService ->expects($this->once()) - ->method('createTransaction') - ->with(1, 100.0, '+2340000', '42', 'deferred_payment') - ->willReturn($transaction); + ->method('initializePayment') + ->with(100.0, '+2340000', '42', 'deferred_payment', 5, null) + ->willReturn(['transaction' => $transaction, 'provider_data' => []]); $result = $this->service->initialize( providerId: 0, @@ -70,7 +78,6 @@ public function testDelegatesToCashTransactionServiceForProviderZero(): void { message: '42', type: 'deferred_payment', customerId: 5, - creatorId: 1, ); $this->assertSame($transaction, $result['transaction']); @@ -99,7 +106,6 @@ public function testDelegatesToPaystackServiceForPaystackProvider(): void { message: '42', type: 'deferred_payment', customerId: 5, - creatorId: 1, ); $this->assertSame($transaction, $result['transaction']); @@ -109,7 +115,8 @@ public function testDelegatesToPaystackServiceForPaystackProvider(): void { public function testDoesNotCallPaystackServiceForCashProvider(): void { $transaction = new Transaction(); - $this->cashService->method('createTransaction')->willReturn($transaction); + $this->cashService->method('initializePayment') + ->willReturn(['transaction' => $transaction, 'provider_data' => []]); $this->paystackService->expects($this->never())->method('initializePayment'); $this->service->initialize( @@ -119,7 +126,6 @@ public function testDoesNotCallPaystackServiceForCashProvider(): void { message: '1', type: 'deferred_payment', customerId: 1, - creatorId: 1, ); } @@ -130,7 +136,7 @@ public function testDoesNotCallCashServiceForPaystackProvider(): void { 'transaction' => $transaction, 'provider_data' => ['redirect_url' => 'https://paystack.com/pay/x', 'reference' => 'ref_x'], ]); - $this->cashService->expects($this->never())->method('createTransaction'); + $this->cashService->expects($this->never())->method('initializePayment'); $this->service->initialize( providerId: MpmPlugin::PAYSTACK_PAYMENT_PROVIDER, @@ -139,7 +145,6 @@ public function testDoesNotCallCashServiceForPaystackProvider(): void { message: '1', type: 'deferred_payment', customerId: 1, - creatorId: 1, ); } @@ -162,7 +167,6 @@ public function testPassesSerialIdToPaystackServiceWhenProvided(): void { message: 'SERIAL-001', type: 'deferred_payment', customerId: 5, - creatorId: 1, serialId: 'SERIAL-001', ); } From e170a391a5ee40fb55babc071199b8131575f7a7 Mon Sep 17 00:00:00 2001 From: Obinna Ikeh Date: Wed, 1 Apr 2026 00:52:26 +0100 Subject: [PATCH 5/5] Fix broken tests --- src/backend/app/Services/PluginsService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/app/Services/PluginsService.php b/src/backend/app/Services/PluginsService.php index b5d50ba61..6c0d43b98 100644 --- a/src/backend/app/Services/PluginsService.php +++ b/src/backend/app/Services/PluginsService.php @@ -59,7 +59,7 @@ public function getActivePaymentProviders(): Collection { MpmPlugin::MESOMB_PAYMENT_PROVIDER, MpmPlugin::WAVE_MONEY_PAYMENT_PROVIDER, MpmPlugin::WAVECOM_PAYMENT_PROVIDER, - MpmPlugin::VODACOM_MOBILE_MONEY, + MpmPlugin::VODACOM_MZ_PAYMENT_PROVIDER, MpmPlugin::PAYSTACK_PAYMENT_PROVIDER, ];