diff --git a/README.md b/README.md index 1bcd611..f0e5095 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ To install the `MoneyMan` package, follow these steps: MONEYMAN_CHAPA_SECRET_KEY= MONEYMAN_CHAPA_BASE_URL= MONEYMAN_CHAPA_CALLBACK_URL= + MONEYMAN_CHAPA_WEBHOOK_SECRET= MONEYMAN_SANTIMPAY_BASE_URL= MONEYMAN_SANTIMPAY_PUBLIC_KEY= diff --git a/database/migrations/2026_01_01_000000_create_webhook_events_table.php b/database/migrations/2026_01_01_000000_create_webhook_events_table.php index 60c39d0..627c8b0 100644 --- a/database/migrations/2026_01_01_000000_create_webhook_events_table.php +++ b/database/migrations/2026_01_01_000000_create_webhook_events_table.php @@ -13,7 +13,7 @@ public function up() Schema::create('webhook_events', function (Blueprint $table) { $table->uuid('id')->primary(); $table->string('provider'); - $table->string('is_success')->default(false); + $table->boolean('is_success')->default(false); $table->string('tx_ref')->nullable(); $table->string('provider_ref')->nullable(); $table->string('status'); @@ -21,7 +21,7 @@ public function up() $table->decimal('charge')->nullable(); $table->string('currency', 10)->default('ETB'); $table->json('data')->nullable(); - $table->unique(['event_type', 'tx_ref']); + $table->unique(['provider', 'tx_ref']); $table->timestamps(); }); } diff --git a/src/Actions/WebhookHandler.php b/src/Actions/WebhookHandler.php index 51334ef..b849e15 100644 --- a/src/Actions/WebhookHandler.php +++ b/src/Actions/WebhookHandler.php @@ -21,7 +21,7 @@ public function handle(Request $request, string $provider): WebhookEvent $event = $driver->parse($request); - return WebhookEvent::firstOrCreate([ + return WebhookEvent::updateOrCreate([ 'provider' => $event->provider(), 'is_success' => $event->isSuccess(), 'tx_ref' => $event->getReference(), diff --git a/src/Models/WebhookEvent.php b/src/Models/WebhookEvent.php index b33d90d..78a6f6f 100644 --- a/src/Models/WebhookEvent.php +++ b/src/Models/WebhookEvent.php @@ -14,6 +14,8 @@ class WebhookEvent extends Model protected $table = 'webhook_events'; + protected $guarded = []; + protected $casts = [ 'provider' => Provider::class, 'data' => 'array', diff --git a/src/MoneyManServiceProvider.php b/src/MoneyManServiceProvider.php index 2879522..2f72e4b 100644 --- a/src/MoneyManServiceProvider.php +++ b/src/MoneyManServiceProvider.php @@ -4,7 +4,6 @@ namespace Vptrading\MoneyMan; -use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Console\AboutCommand; use Illuminate\Support\ServiceProvider; use Vptrading\MoneyMan\Contracts\WebhookRegistry; @@ -17,7 +16,6 @@ class MoneyManServiceProvider extends ServiceProvider { public function boot(): void { - Model::unguard(); AboutCommand::add('MoneyMan', fn () => [ 'Version' => '1.1.0', ]); diff --git a/src/Providers/Chapa/WebhookDriver.php b/src/Providers/Chapa/WebhookDriver.php index 696d584..1f1f008 100644 --- a/src/Providers/Chapa/WebhookDriver.php +++ b/src/Providers/Chapa/WebhookDriver.php @@ -12,7 +12,7 @@ class WebhookDriver implements WebhookDriverInterface { public function verify(Request $request): bool { - $secret = config('chapa.webhook_secret'); + $secret = config('moneyman.providers.chapa.webhook_secret'); $hash = hash_hmac('sha256', $request->getContent(), $secret); diff --git a/src/Providers/Telebirr/Telebirr.php b/src/Providers/Telebirr/Telebirr.php index 777d607..d8200ef 100644 --- a/src/Providers/Telebirr/Telebirr.php +++ b/src/Providers/Telebirr/Telebirr.php @@ -63,7 +63,7 @@ public function verify(string $transactionId): PaymentVerifyResponse 'biz_content' => [ 'appid' => config('moneyman.providers.telebirr.merchant_app_id'), 'merch_code' => config('moneyman.providers.telebirr.short_code'), - 'merch_order_id' => 'glRDRSe9U6', + 'merch_order_id' => $transactionId, ], ]; diff --git a/src/Providers/Telebirr/WebhookEvent.php b/src/Providers/Telebirr/WebhookEvent.php index c54d1c8..3f5a347 100644 --- a/src/Providers/Telebirr/WebhookEvent.php +++ b/src/Providers/Telebirr/WebhookEvent.php @@ -21,7 +21,7 @@ public function provider(): string public function isSuccess(): bool { - if ($this->payload['trans_currency'] !== 'Completed') { + if ($this->payload['trade_status'] !== 'Completed') { return false; } diff --git a/tests/Feature/Http/Controllers/WebhookControllerTest.php b/tests/Feature/Http/Controllers/WebhookControllerTest.php index f91b571..bed56d2 100644 --- a/tests/Feature/Http/Controllers/WebhookControllerTest.php +++ b/tests/Feature/Http/Controllers/WebhookControllerTest.php @@ -3,10 +3,11 @@ declare(strict_types=1); use Firebase\JWT\JWT; +use Vptrading\MoneyMan\Exceptions\InvalidSignatureException; it('stores chapa webhook events', function (): void { $this->withoutExceptionHandling(); - config()->set('chapa.webhook_secret', 'test_secret'); + config()->set('moneyman.providers.chapa.webhook_secret', 'test_secret'); $payload = [ 'event' => 'charge.success', 'first_name' => 'John', @@ -129,3 +130,44 @@ 'currency' => 'ETB', ]); }); + +it('rejects chapa webhook events with invalid signature', function (): void { + $this->withoutExceptionHandling(); + config()->set('chapa.webhook_secret', 'test_secret'); + + $payload = [ + 'event' => 'charge.success', + 'tx_ref' => 'TX-INVALID', + 'reference' => 'REF-INVALID', + 'amount' => '100.00', + 'charge' => '3.00', + 'currency' => 'ETB', + 'status' => 'success', + ]; + + expect(fn () => $this->postJson(route('moneyman.webhook', [ + 'provider' => 'chapa', + ]), $payload, ['x-chapa-signature' => 'bad-signature'])) + ->toThrow(InvalidSignatureException::class); + + $this->assertDatabaseCount('webhook_events', 0); +}); + +it('rejects santimpay webhook events with invalid signature', function (): void { + $this->withoutExceptionHandling(); + + $payload = [ + 'txnId' => 'txn-invalid', + 'refId' => 'ref-invalid', + 'amount' => '1', + 'currency' => 'ETB', + 'status' => 'COMPLETED', + ]; + + expect(fn () => $this->postJson(route('moneyman.webhook', [ + 'provider' => 'santimpay', + ]), $payload, ['signed-token' => 'bad-signature'])) + ->toThrow(InvalidSignatureException::class); + + $this->assertDatabaseCount('webhook_events', 0); +}); diff --git a/tests/Unit/MoneyManManagerTest.php b/tests/Unit/MoneyManManagerTest.php new file mode 100644 index 0000000..0f15a8a --- /dev/null +++ b/tests/Unit/MoneyManManagerTest.php @@ -0,0 +1,20 @@ +toBeInstanceOf(Chapa::class); + expect(MoneyMan::provider(ProviderEnum::SantimPay))->toBeInstanceOf(SantimPay::class); + expect(MoneyMan::provider(ProviderEnum::Telebirr))->toBeInstanceOf(Telebirr::class); +}); + +it('throws for unsupported provider', function (): void { + expect(fn () => MoneyMan::provider('unsupported')) + ->toThrow(InvalidArgumentException::class); +});