From 26f29a6f13cf487eae307f20e509d9372f03a0a7 Mon Sep 17 00:00:00 2001 From: jfrosorio Date: Sat, 26 Sep 2026 16:57:13 +0100 Subject: [PATCH 1/3] fix(payments): accept strict models, reused instances and string ids --- src/CreditCard/CreditCard.php | 2 + src/EuPago.php | 17 +++++++- src/MB/MB.php | 4 +- src/MBWay/MBWay.php | 4 +- src/Traits/CreatesEuPagoReferences.php | 7 +++- src/Traits/HasMbWayReferences.php | 2 +- src/Traits/HasMultibancoReferences.php | 4 +- tests/Feature/RefundTest.php | 24 +++++++++++ tests/Feature/TraitHelpersTest.php | 56 ++++++++++++++++++++++++++ 9 files changed, 111 insertions(+), 9 deletions(-) diff --git a/src/CreditCard/CreditCard.php b/src/CreditCard/CreditCard.php index 2e2f0ed..07393db 100644 --- a/src/CreditCard/CreditCard.php +++ b/src/CreditCard/CreditCard.php @@ -104,6 +104,8 @@ public function __construct( */ public function create(): array { + $this->clearErrors(); + $response = $this->withApiKey()->post($this->getBaseUri().static::URI, $this->getParams()); return $this->mappedReferenceKeys($this->parseTransactionResponse($response)); diff --git a/src/EuPago.php b/src/EuPago.php index ad85a09..ff893cb 100644 --- a/src/EuPago.php +++ b/src/EuPago.php @@ -68,6 +68,8 @@ public function getBaseUri() */ public function create(): array { + $this->clearErrors(); + $response = Http::asForm()->post($this->getBaseUri().static::URI, $this->getParams())->throw(); $referenceData = $response->json(); @@ -91,6 +93,8 @@ public function create(): array */ public function status(string $reference, ?string $entity = null): array { + $this->clearErrors(); + $params = [ 'chave' => config('eupago.api_key'), 'referencia' => $reference, @@ -129,6 +133,8 @@ public function status(string $reference, ?string $entity = null): array */ public function refund(int|string $transactionId, float $amount, ?string $reason = null, ?string $iban = null, ?string $bic = null): array { + $this->clearErrors(); + $params = array_filter([ 'amount' => $amount, 'reason' => $reason, @@ -137,7 +143,7 @@ public function refund(int|string $transactionId, float $amount, ?string $reason ], fn ($value) => $value !== null); $response = Http::withToken((new TokenProvider)->token()) - ->post($this->getBaseUri().static::REFUND_URI.$transactionId, $params); + ->post($this->getBaseUri().static::REFUND_URI.rawurlencode((string) $transactionId), $params); return $this->mappedRefundKeys($this->parseTransactionResponse($response)); } @@ -199,6 +205,15 @@ public function hasErrors(): bool return count($this->errors) > 0; } + /** + * Empties the error bag, so a reused instance only reports the errors of + * its latest operation. + */ + protected function clearErrors(): void + { + $this->errors = []; + } + /** * Adds an error to the bag. */ diff --git a/src/MB/MB.php b/src/MB/MB.php index e54304c..3a4e663 100644 --- a/src/MB/MB.php +++ b/src/MB/MB.php @@ -2,8 +2,8 @@ namespace CodeTech\EuPago\MB; -use Carbon\Carbon; use CodeTech\EuPago\EuPago; +use DateTimeInterface; class MB extends EuPago { @@ -64,7 +64,7 @@ class MB extends EuPago /** * MB constructor. */ - public function __construct(float $value, string $id, Carbon $startDate, Carbon $endDate, float $minValue, float $maxValue, bool $allowDuplication = false) + public function __construct(float $value, string $id, DateTimeInterface $startDate, DateTimeInterface $endDate, float $minValue, float $maxValue, bool $allowDuplication = false) { $this->value = $value; $this->id = $id; diff --git a/src/MBWay/MBWay.php b/src/MBWay/MBWay.php index 2ce3102..f0f43dd 100644 --- a/src/MBWay/MBWay.php +++ b/src/MBWay/MBWay.php @@ -21,7 +21,7 @@ class MBWay extends EuPago /** * External identifier. Ex: the order id. * - * @var int + * @var int|string */ protected $id; @@ -42,7 +42,7 @@ class MBWay extends EuPago /** * MBWay constructor. */ - public function __construct(float $value, int $id, string $alias, ?string $description = null) + public function __construct(float $value, int|string $id, string $alias, ?string $description = null) { $this->value = $value; $this->id = $id; diff --git a/src/Traits/CreatesEuPagoReferences.php b/src/Traits/CreatesEuPagoReferences.php index 5c85dd2..d715fbb 100644 --- a/src/Traits/CreatesEuPagoReferences.php +++ b/src/Traits/CreatesEuPagoReferences.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; +use Illuminate\Support\Arr; trait CreatesEuPagoReferences { @@ -27,6 +28,10 @@ protected function persistReference(EuPago $payment, string $relation) return $payment->getErrors(); } - return $this->{$relation}()->create($referenceData); + // The result also carries keys that are not columns (success, response, + // ...), which a strict model refuses after Eupago created the payment. + $reference = $this->{$relation}(); + + return $reference->create(Arr::only($referenceData, $reference->getRelated()->getFillable())); } } diff --git a/src/Traits/HasMbWayReferences.php b/src/Traits/HasMbWayReferences.php index 89476bc..bc6e83a 100644 --- a/src/Traits/HasMbWayReferences.php +++ b/src/Traits/HasMbWayReferences.php @@ -28,7 +28,7 @@ public function mbwayReferences() * @throws ConnectionException * @throws RequestException */ - public function createMbwayReference(float $value, int $id, string $alias, ?string $description = null) + public function createMbwayReference(float $value, int|string $id, string $alias, ?string $description = null) { return $this->persistReference( new MBWay($value, $id, $alias, $description), diff --git a/src/Traits/HasMultibancoReferences.php b/src/Traits/HasMultibancoReferences.php index de6f2bf..48ced95 100644 --- a/src/Traits/HasMultibancoReferences.php +++ b/src/Traits/HasMultibancoReferences.php @@ -2,9 +2,9 @@ namespace CodeTech\EuPago\Traits; -use Carbon\Carbon; use CodeTech\EuPago\MB\MB; use CodeTech\EuPago\Models\MbReference; +use DateTimeInterface; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; @@ -29,7 +29,7 @@ public function mbReferences() * @throws ConnectionException * @throws RequestException */ - public function createMbReference(float $value, string $id, Carbon $startDate, Carbon $endDate, float $minValue, float $maxValue, bool $allowDuplication = false) + public function createMbReference(float $value, string $id, DateTimeInterface $startDate, DateTimeInterface $endDate, float $minValue, float $maxValue, bool $allowDuplication = false) { return $this->persistReference( new MB($value, $id, $startDate, $endDate, $minValue, $maxValue, $allowDuplication), diff --git a/tests/Feature/RefundTest.php b/tests/Feature/RefundTest.php index feaf149..c2b2480 100644 --- a/tests/Feature/RefundTest.php +++ b/tests/Feature/RefundTest.php @@ -97,3 +97,27 @@ function fakeRefund(array $refundResponse, int $refundStatus = 201): void Http::assertSentCount(3); }); + +it('encodes the transaction id in the refund URL', function () { + fakeRefund(['transactionStatus' => 'Success', 'refundId' => '12345']); + + (new EuPago)->refund('12/34?x', 10.50); + + Http::assertSent(fn ($request) => str_ends_with($request->url(), '/refund/12%2F34%3Fx')); +}); + +it('reports only the errors of the latest refund on a reused instance', function () { + Http::fake([ + '*/api/auth/token' => Http::response(['access_token' => 'the-token', 'token_type' => 'Bearer', 'expires_in' => 3600]), + '*/api/management/*' => Http::sequence() + ->push(['transactionStatus' => 'Rejected', 'code' => 'AMOUNT', 'text' => 'Invalid amount'], 400) + ->push(['transactionStatus' => 'Success', 'refundId' => '12345'], 201), + ]); + $eupago = new EuPago; + + $eupago->refund(987654, 999.00); + expect($eupago->hasErrors())->toBeTrue(); + + $eupago->refund(987654, 10.50); + expect($eupago->hasErrors())->toBeFalse(); +}); diff --git a/tests/Feature/TraitHelpersTest.php b/tests/Feature/TraitHelpersTest.php index d4b4989..289d306 100644 --- a/tests/Feature/TraitHelpersTest.php +++ b/tests/Feature/TraitHelpersTest.php @@ -1,5 +1,6 @@ payable = DummyPayable::create(); }); +afterEach(function () { + Model::preventSilentlyDiscardingAttributes(false); +}); + +it('persists only the reference attributes, so strict models accept them', function () { + Model::preventSilentlyDiscardingAttributes(); + Http::fake([ + '*/multibanco/*' => Http::response([ + 'sucesso' => true, 'estado' => 0, 'resposta' => 'OK', + 'entidade' => '12345', 'referencia' => '123456789', 'valor' => 10.50, + 'valor_minimo' => 10.50, 'valor_maximo' => 10.50, + 'data_inicio' => '2026-06-01', 'data_fim' => '2026-06-30', + ]), + '*/creditcard/*' => Http::response([ + 'transactionStatus' => 'Success', + 'transactionID' => '6526ds26653sad5489sa32', + 'reference' => '00235', + 'redirectUrl' => 'https://sandbox.eupago.pt/api/extern/creditcard/form/6526ds26653sad5489sa32', + ]), + ]); + + $mb = $this->payable->createMbReference(10.50, '1', now(), now()->addMonth(), 10.50, 10.50); + $creditCard = $this->payable->createCreditCardReference( + 30.00, 'order-50', 'https://shop.test/success', 'https://shop.test/fail', 'https://shop.test/back', 'customer@shop.test' + ); + + expect($mb)->toBeInstanceOf(MbReference::class) + ->and($creditCard)->toBeInstanceOf(CreditCardReference::class); +}); + +it('creates an MB reference from immutable dates', function () { + Http::fake(['*' => Http::response([ + 'sucesso' => true, 'estado' => 0, 'resposta' => 'OK', + 'entidade' => '12345', 'referencia' => '123456789', 'valor' => 10.50, + 'valor_minimo' => 10.50, 'valor_maximo' => 10.50, + 'data_inicio' => '2026-06-01', 'data_fim' => '2026-06-30', + ])]); + + $reference = $this->payable->createMbReference(10.50, '1', CarbonImmutable::parse('2026-06-01'), CarbonImmutable::parse('2026-06-30'), 10.50, 10.50); + + expect($reference)->toBeInstanceOf(MbReference::class); + Http::assertSent(fn ($request) => $request['data_inicio'] === '2026-06-01' && $request['data_fim'] === '2026-06-30'); +}); + +it('sends a string MB Way identifier as given', function () { + Http::fake(['*' => Http::response([ + 'sucesso' => true, 'estado' => 0, 'resposta' => 'OK', + 'referencia' => '987654321', 'valor' => 15.00, 'alias' => '912345678', + ])]); + + $this->payable->createMbwayReference(15.00, '007', '912345678'); + + Http::assertSent(fn ($request) => $request['id'] === '007'); +}); + it('creates and persists an MB reference via the trait helper', function () { Http::fake(['*' => Http::response([ 'sucesso' => true, 'estado' => 0, 'resposta' => 'OK', From 6ae880fb8787c74ad96d40f1e616c568f582f1f6 Mon Sep 17 00:00:00 2001 From: jfrosorio Date: Sat, 26 Sep 2026 16:57:13 +0100 Subject: [PATCH 2/3] docs(mbway): drop the integer note from the identifier --- docs/mbway.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mbway.md b/docs/mbway.md index 9321c3b..e2ee0f9 100644 --- a/docs/mbway.md +++ b/docs/mbway.md @@ -28,7 +28,7 @@ $order = Order::find(1); $mbway = new MBWay( $order->value, // payment value - $order->id, // your identifier (int), echoed back as `identificador` in the callback + $order->id, // your identifier, echoed back as `identificador` in the callback '912345678', // the customer's MB WAY alias (phone number) 'Order #1' // optional description ); From 9c905d7d160f6f801e3707aff898f5e049a4ad4b Mon Sep 17 00:00:00 2001 From: jfrosorio Date: Sat, 26 Sep 2026 17:16:12 +0100 Subject: [PATCH 3/3] fix(payments): filter reference attributes without Arr --- src/Traits/CreatesEuPagoReferences.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Traits/CreatesEuPagoReferences.php b/src/Traits/CreatesEuPagoReferences.php index d715fbb..b2b5ced 100644 --- a/src/Traits/CreatesEuPagoReferences.php +++ b/src/Traits/CreatesEuPagoReferences.php @@ -6,7 +6,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; -use Illuminate\Support\Arr; trait CreatesEuPagoReferences { @@ -32,6 +31,6 @@ protected function persistReference(EuPago $payment, string $relation) // ...), which a strict model refuses after Eupago created the payment. $reference = $this->{$relation}(); - return $reference->create(Arr::only($referenceData, $reference->getRelated()->getFillable())); + return $reference->create(array_intersect_key($referenceData, array_flip($reference->getRelated()->getFillable()))); } }