diff --git a/UPGRADE.md b/UPGRADE.md index 6ebc920..6095452 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -48,6 +48,42 @@ If `resources/lang` is now empty, remove it so Laravel goes back to `lang/`: rmdir resources/lang/vendor resources/lang ``` +Eupago sends every payment method's notification to the single URL a channel takes, so the per-method callback endpoints used to confirm only the payments of the method whose URL was set. There is now a single endpoint, and the per-method ones are aliases of it, so every method is confirmed whichever URL is set. The per-method endpoints are deprecated and will be removed in v4, so in the Eupago backoffice, set the channel's notification URL to the new endpoint: + +``` +https://your-app.test/eupago/callback +``` + +The per-method routes now point at `CallbackController`, so build their URLs by route name (`route('eupago.mb.callback')`) rather than by controller action. + +If you disabled the package routes and mounted a per-method controller on a route of your own, it still confirms only that method. Point your route at `CallbackController` instead. Keep its path, so the URL set in the backoffice keeps working, and set its method as the fallback for a notification whose `mp` the package does not know. Take it out of the `web` middleware group too, which stores the URL — API key included — in the session: + +```php +use CodeTech\EuPago\Enums\PaymentMethod; +use CodeTech\EuPago\Http\Controllers\CallbackController; + +Route::get('webhooks/eupago/mb', [CallbackController::class, 'callback']) + ->defaults('default_payment_method', PaymentMethod::Multibanco->value) + ->withoutMiddleware('web') + ->name('eupago.mb.callback'); +``` + +The paid events now fire once the payment is stored, so queued listeners always find it. A notification Eupago delivers again now gets a 200 instead of a 404, still without firing the event. + +Multibanco payments are now recorded in a table of their own, so references with an amount range or with repeat payments can be confirmed. Re-publish the migrations, which leaves the existing files untouched: + +```bash +php artisan vendor:publish --provider=CodeTech\\EuPago\\Providers\\EuPagoServiceProvider --tag=eupago-migrations +``` + +Run the new migration, which also records the payment of every reference paid since v3.9.0, when the transaction started being stored: + +```bash +php artisan migrate +``` + +A Multibanco reference with an amount range is now confirmed for any amount within it, so the payment can be less than the reference's value. If your references accept a range, compare `$event->payment?->value` in your `MBReferencePaid` listeners with the amount owed. + ## From v3.8.x to v3.9.0 Multibanco, MB WAY, PayShop and PaysafeCard references now store the Eupago transaction their callback delivers, so a paid reference can be refunded through `$reference->transaction_id`. A new migration adds the column. Re-publish the migrations, which leaves the existing files untouched: diff --git a/config/eupago.php b/config/eupago.php index c74dae5..15426f7 100644 --- a/config/eupago.php +++ b/config/eupago.php @@ -56,8 +56,8 @@ | Routes |-------------------------------------------------------------------------- | - | The package automatically registers the payment callback routes - | (e.g. /eupago/mb/callback). Disable this if you use the package as + | The package automatically registers the payment callback route + | (/eupago/callback). Disable this if you use the package as | a thin API client and handle EuPago's webhooks yourself. | */ diff --git a/database/migrations/2026_09_26_000000_create_mb_reference_payments_table.php b/database/migrations/2026_09_26_000000_create_mb_reference_payments_table.php new file mode 100644 index 0000000..ef7d39b --- /dev/null +++ b/database/migrations/2026_09_26_000000_create_mb_reference_payments_table.php @@ -0,0 +1,52 @@ +id(); + $table->foreignId('mb_reference_id')->index()->constrained()->cascadeOnDelete(); + $table->string('transaction_id')->unique(); + $table->decimal('value', 10, 2); + $table->timestamps(); + }); + + DB::table('mb_reference_payments')->insertUsing( + ['mb_reference_id', 'transaction_id', 'value', 'created_at', 'updated_at'], + DB::table('mb_references') + ->select(['id', 'transaction_id', 'value', 'updated_at', 'updated_at']) + ->where('state', 1) + ->whereNotNull('transaction_id') + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('mb_reference_payments'); + } +}; diff --git a/docs/callbacks.md b/docs/callbacks.md index 698ff32..2ae29a4 100644 --- a/docs/callbacks.md +++ b/docs/callbacks.md @@ -4,23 +4,53 @@ weight: 10 group: Handling payments --- -Eupago notifies your application of confirmed payments through a webhook — configure the -callback URL in the [Eupago backoffice](https://clientes.eupago.pt) for your channel. -The package registers one endpoint per payment method: +Eupago notifies your application of confirmed payments through a webhook. The +[Eupago backoffice](https://clientes.eupago.pt) takes a single notification URL per channel and +sends every payment method's notification to it, so set it to the package's callback endpoint: -| Payment method | Callback endpoint | Event fired | -|----------------|-------------------------------|--------------------------| -| Multibanco | `GET /eupago/mb/callback` | `MBReferencePaid` | -| MB WAY | `GET /eupago/mbway/callback` | `MBWayReferencePaid` | -| PayShop | `GET /eupago/payshop/callback`| `PayShopReferencePaid` | -| PaysafeCard | `GET /eupago/paysafecard/callback` | `PaysafeCardReferencePaid` | -| Credit Card | `GET /eupago/creditcard/callback` | `CreditCardReferencePaid` | +``` +https://your-app.test/eupago/callback +``` -Each callback validates the payload (including the channel and API key), matches the -pending reference against the values Eupago echoes back, marks it as paid, and fires the -corresponding event with the reference as payload. +The endpoint reads the payment method from the notification (`mp`) and handles it: -All callbacks receive the same query parameters: +| Payment method | `mp` | Event fired | +|----------------|---------|----------------------------| +| Multibanco | `PC:PT` | `MBReferencePaid` | +| MB WAY | `MW:PT` | `MBWayReferencePaid` | +| PayShop | `PS:PT` | `PayShopReferencePaid` | +| PaysafeCard | `PF:PT` | `PaysafeCardReferencePaid` | +| Credit Card | `CC:PT` | `CreditCardReferencePaid` | + +The callback first checks the channel and API key, then matches the reference against the +values Eupago echoes back, marks it as paid and fires the method's event with the reference as +payload. The event fires once the payment is stored, and only once per payment: a notification +Eupago delivers again is acknowledged without firing it. That also holds when a listener throws, +so put work that can fail, such as calling another service, in a +[queued listener](https://laravel.com/docs/events#queued-event-listeners), which the queue retries. + +Multibanco references can accept an amount range and allow repeat payments, so each payment is +recorded on its own, and the `MBReferencePaid` event carries it next to the reference. With an +amount range, the payment can be less than the reference's value, so compare the amount paid +with what is owed: + +```php +public function handle(MBReferencePaid $event): void +{ + $event->payment?->value; // the amount paid + $event->payment?->transaction_id; // the Eupago transaction +} +``` + +The payment is null when you dispatch the event yourself without one. + +The per-method endpoints of earlier versions — `/eupago/mb/callback`, `/eupago/mbway/callback`, +`/eupago/payshop/callback`, `/eupago/paysafecard/callback` and `/eupago/creditcard/callback` — are +deprecated and will be removed in v4. They are now aliases of `/eupago/callback`, so a URL already +set in the backoffice keeps working for every payment method, but switch it to +`/eupago/callback` before upgrading to v4. + +The callback receives these query parameters: | Name | Type | Required | |---------------|-------------------------------|:--------:| @@ -36,5 +66,5 @@ All callbacks receive the same query parameters: | comissao | float | yes | | local | string | no | -To mount the callback controllers on routes of your own instead of the automatically +To mount the callback controller on a route of your own instead of the automatically registered ones, see [Routes](configuration.md#routes). diff --git a/docs/configuration.md b/docs/configuration.md index deecd18..b1a6195 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,7 +41,7 @@ status, you can leave these empty. The package supports two levels of usage: - **Full integration** (default): use the traits and models to persist references, and let the - package handle Eupago's webhooks — it registers the callback routes (`/eupago/*/callback`) + package handle Eupago's webhooks — it registers the [callback](callbacks.md) endpoint (`/eupago/callback`) automatically. - **Thin API client**: use only the payment classes (e.g. `(new MB(...))->create()`) and handle persistence and webhooks yourself. @@ -52,16 +52,19 @@ If you only need the thin client, disable the automatic route registration: EUPAGO_ROUTES=false ``` -With the routes disabled you can still mount the package's callback controllers on routes of +With the routes disabled you can still mount the package's callback controller on a route of your own, giving you full control over the path and middleware: ```php -use CodeTech\EuPago\Http\Controllers\MBController; +use CodeTech\EuPago\Http\Controllers\CallbackController; -Route::get('webhooks/eupago/mb', [MBController::class, 'callback']) - ->middleware('web') - ->name('eupago.mb.callback'); +Route::get('webhooks/eupago', [CallbackController::class, 'callback']) + ->withoutMiddleware('web') + ->name('eupago.callback'); ``` +Keep the route out of the `web` middleware group: a webhook has no use for a session, and the +group would start one on every call, storing its URL — API key included. + > **Note:** if your application caches routes, run `php artisan route:clear` after changing > this setting. diff --git a/docs/multibanco.md b/docs/multibanco.md index e825575..9ce1ecb 100644 --- a/docs/multibanco.md +++ b/docs/multibanco.md @@ -81,3 +81,11 @@ $mbReferences = $order->mbReferences; When the reference is paid, the [callback](callbacks.md) fires an `MBReferencePaid` event and stores the Eupago transaction on the reference, so a paid reference can be [refunded](refunds.md) through `$reference->transaction_id`. + +A reference with a minimum and maximum value accepts any amount in that range, and one that +allows duplicated payments can be paid more than once. Each payment is recorded with the amount +paid and its transaction, and the reference keeps the latest one: + +```php +$payments = $reference->payments; +``` diff --git a/routes/web.php b/routes/web.php index b55641f..ee4536b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,10 +1,7 @@ name('mb.')->group(function () { - Route::get('callback', [MBController::class, 'callback'])->name('callback'); -}); +Route::get('callback', [CallbackController::class, 'callback'])->name('callback'); -// MB Way -Route::prefix('mbway')->name('mbway.')->group(function () { - Route::get('callback', [MBWayController::class, 'callback'])->name('callback'); -}); - -// PayShop -Route::prefix('payshop')->name('payshop.')->group(function () { - Route::get('callback', [PayShopController::class, 'callback'])->name('callback'); -}); +/* +| Deprecated, to be removed in v4. A channel takes a single notification URL, +| so these are aliases of the endpoint above, kept for the URLs already set +| in Eupago's backoffice. Each falls back to its own payment method when a +| notification's `mp` is not one the package knows. +*/ -// PaysafeCard -Route::prefix('paysafecard')->name('paysafecard.')->group(function () { - Route::get('callback', [PaysafeCardController::class, 'callback'])->name('callback'); -}); +$aliases = [ + 'mb' => PaymentMethod::Multibanco, + 'mbway' => PaymentMethod::MbWay, + 'payshop' => PaymentMethod::PayShop, + 'paysafecard' => PaymentMethod::PaysafeCard, + 'creditcard' => PaymentMethod::CreditCard, +]; -// Credit Card -Route::prefix('creditcard')->name('creditcard.')->group(function () { - Route::get('callback', [CreditCardController::class, 'callback'])->name('callback'); -}); +foreach ($aliases as $prefix => $method) { + Route::get("{$prefix}/callback", [CallbackController::class, 'callback']) + ->defaults('default_payment_method', $method->value) + ->name("{$prefix}.callback"); +} diff --git a/src/Enums/PaymentMethod.php b/src/Enums/PaymentMethod.php new file mode 100644 index 0000000..f4ca214 --- /dev/null +++ b/src/Enums/PaymentMethod.php @@ -0,0 +1,16 @@ +reference = $reference; + $this->payment = $payment; } /** diff --git a/src/Http/Controllers/CallbackController.php b/src/Http/Controllers/CallbackController.php new file mode 100644 index 0000000..216fe4f --- /dev/null +++ b/src/Http/Controllers/CallbackController.php @@ -0,0 +1,66 @@ +paymentMethod($request); + + if ($method === null) { + // A method the package does not handle: validating it rejects the + // notification, after checking the caller as any callback does. + $validatedData = $this->validateCallback($request, ['mp' => ['required', new Enum(PaymentMethod::class)]]); + $method = PaymentMethod::from($validatedData['mp']); + } + + return App::make($this->controllerFor($method))->callback($request); + } + + /** + * Get the payment method the notification names, or else the route's default. + */ + private function paymentMethod(Request $request): ?PaymentMethod + { + foreach ([$request->input('mp'), $request->route('default_payment_method')] as $code) { + if (is_string($code) && $method = PaymentMethod::tryFrom($code)) { + return $method; + } + } + + return null; + } + + /** + * Get the controller that handles the payment method. + * + * @return class-string + */ + private function controllerFor(PaymentMethod $method): string + { + return match ($method) { + PaymentMethod::Multibanco => MBController::class, + PaymentMethod::MbWay => MBWayController::class, + PaymentMethod::PayShop => PayShopController::class, + PaymentMethod::PaysafeCard => PaysafeCardController::class, + PaymentMethod::CreditCard => CreditCardController::class, + }; + } +} diff --git a/src/Http/Controllers/Controller.php b/src/Http/Controllers/Controller.php index bda84b9..d9ba2cf 100644 --- a/src/Http/Controllers/Controller.php +++ b/src/Http/Controllers/Controller.php @@ -2,7 +2,10 @@ namespace CodeTech\EuPago\Http\Controllers; +use CodeTech\EuPago\Http\Requests\CallbackRequest; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Exceptions\HttpResponseException; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; use Illuminate\Support\Facades\Log; @@ -12,6 +15,10 @@ class Controller extends BaseController { /** * Logs and validates an incoming EuPago callback, returning the validated data. + * + * The caller is checked on its own first, so a request without the + * channel and API key never reaches the remaining rules — some of which + * query the database and would otherwise reveal which references exist. */ protected function validateCallback(Request $request, array $rules): array { @@ -27,6 +34,76 @@ protected function validateCallback(Request $request, array $rules): array 'payload' => $payload, ]); + $this->validateOrFail($request, CallbackRequest::callerRules()); + + return $this->validateOrFail($request, $rules); + } + + /** + * Marks the pending reference the query finds as paid, then fires the + * given event with it. + * + * The reference row is locked, and a reference already paid by this + * transaction is found as well, so of simultaneous deliveries only one + * marks it as paid and the others are acknowledged without firing the + * event again. The event fires after the commit, so a queued listener + * always finds the payment stored. + * + * @param class-string $event + */ + protected function confirmPayment(Builder $query, string $transaction, string $event): JsonResponse + { + $reference = $query->getConnection()->transaction(function () use ($query, $transaction) { + // A reference already paid by this transaction comes first, so a + // redelivery never marks another pending match as paid. + $reference = $query + ->where(fn ($query) => $query->where('state', 0)->orWhere('transaction_id', $transaction)) + ->orderByDesc('state') + ->lockForUpdate() + ->first(); + + if ($reference && $reference->getAttribute('transaction_id') !== $transaction) { + $reference->update([ + 'state' => 1, + 'transaction_id' => $transaction, + ]); + } + + return $reference; + }); + + if (! $reference) { + return $this->pendingReferenceNotFound(); + } + + if ($reference->wasChanged('state')) { + event(new $event($reference)); + } + + return $this->paymentConfirmed(); + } + + /** + * The response for a payment that was confirmed. + */ + protected function paymentConfirmed(): JsonResponse + { + return response()->json(['response' => 'Success']); + } + + /** + * The response for a payment that matches no pending reference. + */ + protected function pendingReferenceNotFound(): JsonResponse + { + return response()->json(['response' => 'No pending reference found'], 404); + } + + /** + * Validates the request against the rules, or aborts with a 422. + */ + private function validateOrFail(Request $request, array $rules): array + { $validator = Validator::make($request->all(), $rules); if ($validator->fails()) { diff --git a/src/Http/Controllers/CreditCardController.php b/src/Http/Controllers/CreditCardController.php index 5d4b20f..39b9810 100644 --- a/src/Http/Controllers/CreditCardController.php +++ b/src/Http/Controllers/CreditCardController.php @@ -22,24 +22,10 @@ public function callback(Request $request) // Credit Card references are short per-channel counters, so unlike the // other methods `reference` is not unique on its own. The identifier // the callback echoes back pins the match to the right payment. - $reference = CreditCardReference::where('reference', $validatedData['referencia']) + $query = CreditCardReference::where('reference', $validatedData['referencia']) ->where('identifier', $validatedData['identificador']) - ->where('value', $validatedData['valor']) - ->where('state', 0) - ->first(); + ->where('value', $validatedData['valor']); - if (! $reference) { - return response()->json(['response' => 'No pending reference found'])->setStatusCode(404); - } - - $reference->update([ - 'state' => 1, - 'transaction_id' => $validatedData['transacao'], - ]); - - // trigger event - event(new CreditCardReferencePaid($reference)); - - return response()->json(['response' => 'Success'])->setStatusCode(200); + return $this->confirmPayment($query, $validatedData['transacao'], CreditCardReferencePaid::class); } } diff --git a/src/Http/Controllers/MBController.php b/src/Http/Controllers/MBController.php index ff8f2f8..2068108 100644 --- a/src/Http/Controllers/MBController.php +++ b/src/Http/Controllers/MBController.php @@ -5,6 +5,7 @@ use CodeTech\EuPago\Events\MBReferencePaid; use CodeTech\EuPago\Http\Requests\MbCallbackRequest; use CodeTech\EuPago\Models\MbReference; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -19,23 +20,66 @@ public function callback(Request $request) { $validatedData = $this->validateCallback($request, (new MbCallbackRequest)->rules()); - $reference = MbReference::where('reference', $validatedData['referencia']) - ->where('value', $validatedData['valor']) - ->where('state', 0) - ->first(); + $query = MbReference::where('reference', $validatedData['referencia']) + ->accepting($validatedData['valor']); - if (! $reference) { - return response()->json(['response' => 'No pending reference found'])->setStatusCode(404); - } + return $this->recordPayment($query, $validatedData['transacao'], $validatedData['valor']); + } + + /** + * Records the payment on the reference the query finds, marks it as paid, + * then fires its event. + * + * The reference row is locked, and the checks below run under that lock, + * so simultaneous deliveries are handled one at a time. The event fires after the commit, so a queued listener always + * finds the payment stored. A redelivered notification is acknowledged + * without recording or firing anything again. + * + * @param Builder $query + */ + private function recordPayment(Builder $query, string $transaction, string $value): JsonResponse + { + $payment = $query->getConnection()->transaction(function () use ($query, $transaction, $value) { + $reference = $query->lockForUpdate()->first(); - $reference->update([ - 'state' => 1, - 'transaction_id' => $validatedData['transacao'], - ]); + if (! $reference) { + return null; + } - // trigger event - event(new MBReferencePaid($reference)); + $recorded = $reference->payments()->where('transaction_id', $transaction)->first(); + + if ($recorded) { + return $recorded; + } + + // A reference can allow repeat payments, so one already paid still + // takes a new payment, as long as its payments are recorded to tell + // it from a redelivered one. + if ((int) $reference->getAttribute('state') !== 0 && ! $reference->payments()->exists()) { + return null; + } + + $reference->update([ + 'state' => 1, + 'transaction_id' => $transaction, + ]); + + return $reference->payments() + ->create([ + 'transaction_id' => $transaction, + 'value' => $value, + ]) + ->setRelation('reference', $reference); + }); + + if (! $payment) { + return $this->pendingReferenceNotFound(); + } + + if ($payment->wasRecentlyCreated) { + event(new MBReferencePaid($payment->reference, $payment)); + } - return response()->json(['response' => 'Success'])->setStatusCode(200); + return $this->paymentConfirmed(); } } diff --git a/src/Http/Controllers/MBWayController.php b/src/Http/Controllers/MBWayController.php index 094f436..eabfd3f 100644 --- a/src/Http/Controllers/MBWayController.php +++ b/src/Http/Controllers/MBWayController.php @@ -19,23 +19,9 @@ public function callback(Request $request) { $validatedData = $this->validateCallback($request, (new MbWayCallbackRequest)->rules()); - $reference = MbwayReference::where('reference', $validatedData['referencia']) - ->where('value', $validatedData['valor']) - ->where('state', 0) - ->first(); + $query = MbwayReference::where('reference', $validatedData['referencia']) + ->where('value', $validatedData['valor']); - if (! $reference) { - return response()->json(['response' => 'No pending reference found'])->setStatusCode(404); - } - - $reference->update([ - 'state' => 1, - 'transaction_id' => $validatedData['transacao'], - ]); - - // trigger event - event(new MBWayReferencePaid($reference)); - - return response()->json(['response' => 'Success'])->setStatusCode(200); + return $this->confirmPayment($query, $validatedData['transacao'], MBWayReferencePaid::class); } } diff --git a/src/Http/Controllers/PayShopController.php b/src/Http/Controllers/PayShopController.php index 5e81d44..807a0e1 100644 --- a/src/Http/Controllers/PayShopController.php +++ b/src/Http/Controllers/PayShopController.php @@ -19,23 +19,9 @@ public function callback(Request $request) { $validatedData = $this->validateCallback($request, (new PayShopCallbackRequest)->rules()); - $reference = PayShopReference::where('reference', $validatedData['referencia']) - ->where('value', $validatedData['valor']) - ->where('state', 0) - ->first(); + $query = PayShopReference::where('reference', $validatedData['referencia']) + ->where('value', $validatedData['valor']); - if (! $reference) { - return response()->json(['response' => 'No pending reference found'])->setStatusCode(404); - } - - $reference->update([ - 'state' => 1, - 'transaction_id' => $validatedData['transacao'], - ]); - - // trigger event - event(new PayShopReferencePaid($reference)); - - return response()->json(['response' => 'Success'])->setStatusCode(200); + return $this->confirmPayment($query, $validatedData['transacao'], PayShopReferencePaid::class); } } diff --git a/src/Http/Controllers/PaysafeCardController.php b/src/Http/Controllers/PaysafeCardController.php index 94c5e5e..0b7e5f2 100644 --- a/src/Http/Controllers/PaysafeCardController.php +++ b/src/Http/Controllers/PaysafeCardController.php @@ -19,23 +19,9 @@ public function callback(Request $request) { $validatedData = $this->validateCallback($request, (new PaysafeCardCallbackRequest)->rules()); - $reference = PaysafeCardReference::where('reference', $validatedData['referencia']) - ->where('value', $validatedData['valor']) - ->where('state', 0) - ->first(); + $query = PaysafeCardReference::where('reference', $validatedData['referencia']) + ->where('value', $validatedData['valor']); - if (! $reference) { - return response()->json(['response' => 'No pending reference found'])->setStatusCode(404); - } - - $reference->update([ - 'state' => 1, - 'transaction_id' => $validatedData['transacao'], - ]); - - // trigger event - event(new PaysafeCardReferencePaid($reference)); - - return response()->json(['response' => 'Success'])->setStatusCode(200); + return $this->confirmPayment($query, $validatedData['transacao'], PaysafeCardReferencePaid::class); } } diff --git a/src/Http/Requests/CallbackRequest.php b/src/Http/Requests/CallbackRequest.php index f36f8d9..ec57675 100644 --- a/src/Http/Requests/CallbackRequest.php +++ b/src/Http/Requests/CallbackRequest.php @@ -7,28 +7,37 @@ abstract class CallbackRequest { /** - * Get the validation rules that apply to the callback. + * Get the validation rules that identify the caller as Eupago. */ - public function rules(): array + public static function callerRules(): array { return [ - 'valor' => 'required', 'canal' => [ 'required', Rule::in([config('eupago.channel')]), ], - 'referencia' => ['required'], - 'transacao' => 'required', - 'identificador' => 'required', - 'mp' => 'required', 'chave_api' => [ 'required', Rule::in([config('eupago.api_key')]), ], + ]; + } + + /** + * Get the validation rules that apply to the callback. + */ + public function rules(): array + { + return array_merge(static::callerRules(), [ + 'valor' => 'required|numeric', + 'referencia' => ['required'], + 'transacao' => 'required', + 'identificador' => 'required', + 'mp' => 'required', 'data' => 'required|date_format:Y-m-d:H:i:s', 'entidade' => 'required', 'comissao' => 'required', 'local' => 'nullable', - ]; + ]); } } diff --git a/src/Models/MbReference.php b/src/Models/MbReference.php index a729ed1..308c3a2 100644 --- a/src/Models/MbReference.php +++ b/src/Models/MbReference.php @@ -3,6 +3,7 @@ namespace CodeTech\EuPago\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class MbReference extends Model { @@ -42,6 +43,20 @@ public function scopePaid($query) return $query->where('state', 1); } + /** + * Scopes a query to the references that accept a payment of the given + * value: their own value, or any amount within their range. + * + * @return mixed + */ + public function scopeAccepting($query, $value) + { + return $query->where(function ($query) use ($value) { + $query->where('value', $value) + ->orWhere(fn ($query) => $query->where('min_value', '<=', $value)->where('max_value', '>=', $value)); + }); + } + /* |-------------------------------------------------------------------------- | Relationships @@ -55,4 +70,14 @@ public function mbable() { return $this->morphTo(); } + + /** + * Get the payments made to the reference. + * + * @return HasMany + */ + public function payments(): HasMany + { + return $this->hasMany(MbReferencePayment::class); + } } diff --git a/src/Models/MbReferencePayment.php b/src/Models/MbReferencePayment.php new file mode 100644 index 0000000..5a1a00f --- /dev/null +++ b/src/Models/MbReferencePayment.php @@ -0,0 +1,37 @@ + 'float', + ]; + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + /** + * Get the paid MB reference. + * + * @return BelongsTo + */ + public function reference(): BelongsTo + { + return $this->belongsTo(MbReference::class, 'mb_reference_id'); + } +} diff --git a/src/Providers/EuPagoServiceProvider.php b/src/Providers/EuPagoServiceProvider.php index 6af29ac..cbd5627 100644 --- a/src/Providers/EuPagoServiceProvider.php +++ b/src/Providers/EuPagoServiceProvider.php @@ -55,8 +55,9 @@ private function loadRoutes() return; } - Route::middleware('web') - ->prefix('eupago') + // No middleware: a webhook has no use for a session, and the web group + // would start one for every call, storing its URL — API key included. + Route::prefix('eupago') ->name('eupago.') ->group(__DIR__.'/../../routes/web.php'); } diff --git a/tests/Feature/CallbackTest.php b/tests/Feature/CallbackTest.php new file mode 100644 index 0000000..d0a3905 --- /dev/null +++ b/tests/Feature/CallbackTest.php @@ -0,0 +1,156 @@ +getJson(route('eupago.callback', $payload(['mp' => $mp]))); + + $response->assertOk()->assertJson(['response' => 'Success']); + expect((int) $reference->fresh()->state)->toBe(1); + Event::assertDispatched($event); +})->with([ + 'Multibanco' => ['PC:PT', fn () => createPendingMbReference(), fn ($o) => validMbCallbackPayload($o), MBReferencePaid::class], + 'MB WAY' => ['MW:PT', fn () => createPendingMbwayReference(), fn ($o) => validMbwayCallbackPayload($o), MBWayReferencePaid::class], + 'PayShop' => ['PS:PT', fn () => createPendingPayShopReference(), fn ($o) => validPayShopCallbackPayload($o), PayShopReferencePaid::class], + 'PaysafeCard' => ['PF:PT', fn () => createPendingPaysafeCardReference(), fn ($o) => validPaysafeCardCallbackPayload($o), PaysafeCardReferencePaid::class], + 'Credit Card' => ['CC:PT', fn () => createPendingCreditCardReference(), fn ($o) => validCreditCardCallbackPayload($o), CreditCardReferencePaid::class], +]); + +it('rejects a payment method the package does not handle', function () { + $response = $this->getJson(route('eupago.callback', validMbCallbackPayload(['mp' => 'XX:PT']))); + + $response->assertStatus(422)->assertJsonStructure(['mp']); +}); + +it('rejects a payment method that is not a string', function () { + $response = $this->getJson(route('eupago.callback', validMbCallbackPayload(['mp' => ['PC:PT']]))); + + $response->assertStatus(422)->assertJsonStructure(['mp']); +}); + +it('rejects a value that is not numeric', function () { + createPendingMbReference(); + + $response = $this->getJson(route('eupago.callback', validMbCallbackPayload(['valor' => 'abc']))); + + $response->assertStatus(422)->assertJsonStructure(['valor']); +}); + +it('checks the api key before the payment method', function () { + $response = $this->getJson(route('eupago.callback', validMbCallbackPayload([ + 'mp' => 'XX:PT', + 'chave_api' => 'wrong-key', + ]))); + + $response->assertStatus(422)->assertJsonStructure(['chave_api'])->assertJsonMissingPath('mp'); +}); + +it('checks the api key and channel before looking up the reference', function () { + $response = $this->getJson(route('eupago.mb.callback', validMbCallbackPayload([ + 'referencia' => '000000000', + 'canal' => 'someone-else', + 'chave_api' => 'wrong-key', + ]))); + + $response->assertStatus(422) + ->assertJsonStructure(['canal', 'chave_api']) + ->assertJsonMissingPath('referencia'); +}); + +it('handles a notification of another method on a deprecated per-method endpoint', function () { + Event::fake([MBWayReferencePaid::class]); + $reference = createPendingMbwayReference(); + + $response = $this->getJson(route('eupago.mb.callback', validMbwayCallbackPayload())); + + $response->assertOk(); + expect((int) $reference->fresh()->state)->toBe(1); + Event::assertDispatched(MBWayReferencePaid::class); +}); + +it('falls back to the method of a deprecated per-method endpoint for an unknown code', function () { + Event::fake([MBWayReferencePaid::class]); + $reference = createPendingMbwayReference(); + + $response = $this->getJson(route('eupago.mbway.callback', validMbwayCallbackPayload(['mp' => 'MBWAY']))); + + $response->assertOk(); + expect((int) $reference->fresh()->state)->toBe(1); +}); + +it('does not start a session for a callback', function (string $route) { + $middleware = app('router')->getRoutes()->getByName($route)->gatherMiddleware(); + + expect($middleware)->toBe([]); +})->with(['eupago.callback', 'eupago.mb.callback', 'eupago.creditcard.callback']); + +it('acknowledges a redelivered notification without firing the event again', function () { + Event::fake([MBWayReferencePaid::class]); + createPendingMbwayReference(); + + $this->getJson(route('eupago.callback', validMbwayCallbackPayload()))->assertOk(); + $response = $this->getJson(route('eupago.callback', validMbwayCallbackPayload())); + + $response->assertOk(); + Event::assertDispatchedTimes(MBWayReferencePaid::class, 1); +}); + +it('returns 404 for a reference paid by another transaction', function () { + Event::fake([MBWayReferencePaid::class]); + createPendingMbwayReference(['state' => 1, 'transaction_id' => 'TXN000']); + + $response = $this->getJson(route('eupago.callback', validMbwayCallbackPayload())); + + $response->assertNotFound(); + Event::assertNotDispatched(MBWayReferencePaid::class); +}); + +it('fires the event after the payment is committed', function () { + $transactionLevel = null; + Event::listen(MBWayReferencePaid::class, function () use (&$transactionLevel) { + $transactionLevel = DB::transactionLevel(); + }); + createPendingMbwayReference(); + + $this->getJson(route('eupago.callback', validMbwayCallbackPayload()))->assertOk(); + + expect($transactionLevel)->toBe(0); +}); + +it('fires the model events when marking a reference as paid', function () { + $updated = false; + MbwayReference::updated(function () use (&$updated) { + $updated = true; + }); + createPendingMbwayReference(); + + $this->getJson(route('eupago.callback', validMbwayCallbackPayload()))->assertOk(); + + expect($updated)->toBeTrue(); +}); + +it('keeps the payment when a listener throws, and acknowledges the retry', function () { + $reference = createPendingMbwayReference(); + Event::listen(MBWayReferencePaid::class, fn () => throw new RuntimeException('listener failed')); + + $this->getJson(route('eupago.callback', validMbwayCallbackPayload()))->assertServerError(); + + expect((int) $reference->fresh()->state)->toBe(1) + ->and($reference->fresh()->transaction_id)->toBe('TXN999'); + + Event::forget(MBWayReferencePaid::class); + Event::fake([MBWayReferencePaid::class]); + + $this->getJson(route('eupago.callback', validMbwayCallbackPayload()))->assertOk(); + Event::assertNotDispatched(MBWayReferencePaid::class); +}); diff --git a/tests/Feature/CreditCardCallbackTest.php b/tests/Feature/CreditCardCallbackTest.php index e8e8417..0fff10b 100644 --- a/tests/Feature/CreditCardCallbackTest.php +++ b/tests/Feature/CreditCardCallbackTest.php @@ -86,3 +86,17 @@ $response->assertStatus(422); }); + +it('acknowledges a redelivery without paying another matching reference', function () { + Event::fake([CreditCardReferencePaid::class]); + // Same counter, identifier and amount: only the recorded transaction + // tells the paid reference from the pending one. + $paid = createPendingCreditCardReference(['state' => 1, 'transaction_id' => '29753077']); + $pending = createPendingCreditCardReference(); + + $response = $this->getJson(route('eupago.creditcard.callback', validCreditCardCallbackPayload())); + + $response->assertOk(); + expect((int) $pending->fresh()->state)->toBe(0); + Event::assertNotDispatched(CreditCardReferencePaid::class); +}); diff --git a/tests/Feature/MbCallbackTest.php b/tests/Feature/MbCallbackTest.php index b04fa3f..ee59769 100644 --- a/tests/Feature/MbCallbackTest.php +++ b/tests/Feature/MbCallbackTest.php @@ -1,6 +1,7 @@ 1]); +it('acknowledges a redelivered payment without recording or firing it again', function () { + Event::fake([MBReferencePaid::class]); + $reference = createPendingMbReference(); + + $this->getJson(route('eupago.mb.callback', validMbCallbackPayload()))->assertOk(); + $response = $this->getJson(route('eupago.mb.callback', validMbCallbackPayload())); + + $response->assertOk(); + expect($reference->payments()->count())->toBe(1); + Event::assertDispatchedTimes(MBReferencePaid::class, 1); +}); + +it('returns 404 for a reference paid without its payments recorded', function () { + Event::fake([MBReferencePaid::class]); + // Paid before payments were recorded, so a new transaction cannot be told + // apart from a redelivery. + $reference = createPendingMbReference(['state' => 1, 'transaction_id' => 'TXN000']); $response = $this->getJson(route('eupago.mb.callback', validMbCallbackPayload())); $response->assertNotFound(); + expect($reference->payments()->count())->toBe(0); + Event::assertNotDispatched(MBReferencePaid::class); +}); + +it('records the payment and passes it to the event', function () { + Event::fake([MBReferencePaid::class]); + $reference = createPendingMbReference(); + + $this->getJson(route('eupago.mb.callback', validMbCallbackPayload()))->assertOk(); + + $payment = $reference->payments()->sole(); + expect($payment->transaction_id)->toBe('TXN123') + ->and($payment->value)->toBe(10.50); + Event::assertDispatched( + MBReferencePaid::class, + fn (MBReferencePaid $event) => $event->payment->is($payment) + ); +}); + +it('confirms a payment within the reference amount range', function () { + Event::fake([MBReferencePaid::class]); + $reference = createPendingMbReference(['min_value' => 5, 'max_value' => 20]); + + $response = $this->getJson(route('eupago.mb.callback', validMbCallbackPayload([ + 'valor' => '7.25000', + ]))); + + $response->assertOk(); + expect((int) $reference->fresh()->state)->toBe(1) + ->and($reference->payments()->sole()->value)->toBe(7.25); +}); + +it('returns 404 when the value is outside the reference amount range', function () { + Event::fake([MBReferencePaid::class]); + createPendingMbReference(['min_value' => 5, 'max_value' => 20]); + + $response = $this->getJson(route('eupago.mb.callback', validMbCallbackPayload([ + 'valor' => '20.01', + ]))); + + $response->assertNotFound(); + Event::assertNotDispatched(MBReferencePaid::class); +}); + +it('confirms every payment of a reference that allows repeat payments', function () { + Event::fake([MBReferencePaid::class]); + $reference = createPendingMbReference(); + + $this->getJson(route('eupago.mb.callback', validMbCallbackPayload()))->assertOk(); + $response = $this->getJson(route('eupago.mb.callback', validMbCallbackPayload([ + 'transacao' => 'TXN124', + ]))); + + $response->assertOk(); + expect($reference->payments()->pluck('transaction_id')->all())->toBe(['TXN123', 'TXN124']) + ->and($reference->fresh()->transaction_id)->toBe('TXN124'); + Event::assertDispatchedTimes(MBReferencePaid::class, 2); +}); + +it('fires the event after the payment is committed', function () { + $transactionLevel = null; + Event::listen(MBReferencePaid::class, function () use (&$transactionLevel) { + $transactionLevel = DB::transactionLevel(); + }); + createPendingMbReference(); + + $this->getJson(route('eupago.mb.callback', validMbCallbackPayload()))->assertOk(); + + expect($transactionLevel)->toBe(0); }); it('rejects an MB callback for a reference that does not exist', function () { diff --git a/tests/Feature/MbReferencePaymentsMigrationTest.php b/tests/Feature/MbReferencePaymentsMigrationTest.php new file mode 100644 index 0000000..14b3c47 --- /dev/null +++ b/tests/Feature/MbReferencePaymentsMigrationTest.php @@ -0,0 +1,22 @@ +down(); + + $paid = createPendingMbReference(['reference' => '111', 'state' => 1, 'transaction_id' => 'TXN1']); + createPendingMbReference(['reference' => '222', 'state' => 0]); + // Paid before the transaction was stored: there is nothing to key it by. + createPendingMbReference(['reference' => '333', 'state' => 1]); + + $migration->up(); + + expect(Schema::hasTable('mb_reference_payments'))->toBeTrue(); + $payment = $paid->payments()->sole(); + expect($payment->transaction_id)->toBe('TXN1') + ->and($payment->value)->toBe(10.50); + expect(MbReferencePayment::count())->toBe(1); +}); diff --git a/tests/Feature/PackageBootTest.php b/tests/Feature/PackageBootTest.php index 2d163c5..7522a86 100644 --- a/tests/Feature/PackageBootTest.php +++ b/tests/Feature/PackageBootTest.php @@ -7,6 +7,7 @@ it('registers the eupago callback routes by default', function () { expect(config('eupago.routes'))->toBeTrue() + ->and(app('router')->has('eupago.callback'))->toBeTrue() ->and(app('router')->has('eupago.mb.callback'))->toBeTrue() ->and(app('router')->has('eupago.mbway.callback'))->toBeTrue() ->and(app('router')->has('eupago.payshop.callback'))->toBeTrue(); diff --git a/tests/Feature/RoutesDisabledTest.php b/tests/Feature/RoutesDisabledTest.php index 3e73428..b57ed80 100644 --- a/tests/Feature/RoutesDisabledTest.php +++ b/tests/Feature/RoutesDisabledTest.php @@ -19,6 +19,7 @@ protected function getEnvironmentSetUp($app): void public function test_no_callback_routes_are_registered_when_routes_are_disabled(): void { + $this->assertFalse(app('router')->has('eupago.callback')); $this->assertFalse(app('router')->has('eupago.mb.callback')); $this->assertFalse(app('router')->has('eupago.mbway.callback')); $this->assertFalse(app('router')->has('eupago.payshop.callback')); diff --git a/tests/Pest.php b/tests/Pest.php index 3e05826..378273a 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -27,8 +27,8 @@ function createPendingMbReference(array $overrides = []): MbReference 'value' => 10.50, 'start_date' => '2026-06-01', 'end_date' => '2026-06-30', - 'min_value' => 0, - 'max_value' => 100, + 'min_value' => 10.50, + 'max_value' => 10.50, 'state' => 0, ], $overrides)); @@ -66,7 +66,7 @@ function validMbCallbackPayload(array $overrides = []): array 'referencia' => '123456789', 'transacao' => 'TXN123', 'identificador' => 'ID123', - 'mp' => 'MB', + 'mp' => 'PC:PT', 'chave_api' => config('eupago.api_key'), 'data' => now()->format('Y-m-d:H:i:s'), 'entidade' => '12345', @@ -82,7 +82,7 @@ function validMbwayCallbackPayload(array $overrides = []): array 'referencia' => '987654321', 'transacao' => 'TXN999', 'identificador' => 'ID999', - 'mp' => 'MBWAY', + 'mp' => 'MW:PT', 'chave_api' => config('eupago.api_key'), 'data' => now()->format('Y-m-d:H:i:s'), 'entidade' => '54321', @@ -113,7 +113,7 @@ function validPayShopCallbackPayload(array $overrides = []): array 'referencia' => '555444333', 'transacao' => 'TXN555', 'identificador' => 'ID555', - 'mp' => 'PS', + 'mp' => 'PS:PT', 'chave_api' => config('eupago.api_key'), 'data' => now()->format('Y-m-d:H:i:s'), 'entidade' => '00000',