From 3ae504fa3ca94271aee6f61dd490dd95111efc87 Mon Sep 17 00:00:00 2001 From: Gabriel Peixoto Date: Sat, 20 Jun 2026 00:25:25 -0300 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20Rota=20para=20cancelar=20o=20pix=20?= =?UTF-8?q?autom=C3=A1tico.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Contracts/AutomaticPixContract.php | 24 ++++++++++++++++++ src/Facades/MultiPayment.php | 1 + src/Gateways/IuguGateway.php | 34 +++++++++++++++++++++++++- src/MultiPayment.php | 19 ++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 src/Contracts/AutomaticPixContract.php diff --git a/src/Contracts/AutomaticPixContract.php b/src/Contracts/AutomaticPixContract.php new file mode 100644 index 0000000..9ea36b3 --- /dev/null +++ b/src/Contracts/AutomaticPixContract.php @@ -0,0 +1,24 @@ +parseInvoice($iuguInvoice); } + /** + * @inheritDoc + * + * Endpoint: PUT /automatic_pix/receiver_recurrences/{id}/cancel + */ + public function cancelAutomaticPixRecurrence(string $recurrenceId): object + { + $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrences/' . $recurrenceId . '/cancel'; + + try { + $response = (new Iugu_APIRequest())->request('PUT', $url); + } catch (\IuguRequestException | IuguObjectNotFound $e) { + if (str_contains($e->getMessage(), '502 Bad Gateway')) { + throw new GatewayNotAvailableException($e->getMessage()); + } else { + throw new GatewayException($e->getMessage()); + } + } catch (\IuguAuthenticationException $e) { + throw new GatewayNotAvailableException($e->getMessage()); + } catch (\Exception $e) { + throw new GatewayException("Error cancelling automatic pix recurrence: {$e->getMessage()}"); + } + + if (!empty($response->errors)) { + throw new GatewayException('Error cancelling automatic pix recurrence', (array) $response->errors); + } + + return $response; + } + /** * @inheritDoc */ diff --git a/src/MultiPayment.php b/src/MultiPayment.php index 6ee3686..b6b7caa 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -12,6 +12,7 @@ use Potelo\MultiPayment\Builders\CustomerBuilder; use Potelo\MultiPayment\Builders\CreditCardBuilder; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Contracts\AutomaticPixContract; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -267,4 +268,22 @@ public function setDefaultCard(string $customerId, string $creditCardId): Custom return $customer->setDefaultCard($creditCardId); } + /** + * Cancela uma recorrência de Pix Automático no gateway. + * + * @param string $recurrenceId UUID da recorrência (receiver_recurrence_id). + * @return object + * @throws MultiPaymentException + * @throws GatewayException + * @throws GatewayNotAvailableException + */ + public function cancelAutomaticPixRecurrence(string $recurrenceId): object + { + if (!$this->gateway instanceof AutomaticPixContract) { + throw new MultiPaymentException('The selected gateway does not support automatic pix.'); + } + + return $this->gateway->cancelAutomaticPixRecurrence($recurrenceId); + } + } From 227662bada817a2068261a57de67bbb2685f71ce Mon Sep 17 00:00:00 2001 From: Gabriel Peixoto Date: Sat, 20 Jun 2026 13:17:25 -0300 Subject: [PATCH 2/8] feat: Normaliza objetos --- src/Exceptions/GatewayException.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Exceptions/GatewayException.php b/src/Exceptions/GatewayException.php index f8455c7..7934a98 100644 --- a/src/Exceptions/GatewayException.php +++ b/src/Exceptions/GatewayException.php @@ -86,6 +86,13 @@ private function flattenErrors(array $array, array &$messages, string $prefix = // Constrói a chave completa para o item atual $newKey = $prefix ? "{$prefix}.{$key}" : $key; + // Normaliza objetos (ex.: stdClass aninhado vindo da Iugu) para array + // antes de prosseguir, evitando "Object of class stdClass could not be + // converted to string" ao tentar interpolar o valor. + if (is_object($value)) { + $value = (array) $value; + } + if (is_array($value) && !empty($value)) { // Se o valor for um array não vazio, continua a recursão $this->flattenErrors($value, $messages, $newKey); From 1d683c3d940ca3122146377f42ad82b469428f83 Mon Sep 17 00:00:00 2001 From: Gabriel Peixoto Date: Thu, 16 Jul 2026 11:43:11 -0300 Subject: [PATCH 3/8] feat: cancela agendamento pix e fatura --- src/Contracts/AutomaticPixContract.php | 13 ++ src/Contracts/InvoiceCancellationContract.php | 19 +++ src/Gateways/IuguGateway.php | 77 +++++++++- src/MultiPayment.php | 48 +++++++ tests/Unit/AutomaticPixTest.php | 66 +++++++++ .../Gateways/IuguGatewayAutomaticPixTest.php | 131 ++++++++++++++++++ 6 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 src/Contracts/InvoiceCancellationContract.php create mode 100644 tests/Unit/AutomaticPixTest.php create mode 100644 tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php diff --git a/src/Contracts/AutomaticPixContract.php b/src/Contracts/AutomaticPixContract.php index 9ea36b3..680cebe 100644 --- a/src/Contracts/AutomaticPixContract.php +++ b/src/Contracts/AutomaticPixContract.php @@ -13,6 +13,19 @@ */ interface AutomaticPixContract { + /** + * Solicita o cancelamento de um pagamento agendado de Pix Automático. + * + * @param string $receiverRecurrencePaymentId UUID do pagamento agendado. + * @param string $endToEndId Identificador E2E do pagamento. + * @return object Resposta do gateway. + * @throws GatewayException|GatewayNotAvailableException + */ + public function cancelAutomaticPixScheduledPayment( + string $receiverRecurrencePaymentId, + string $endToEndId + ): object; + /** * Solicita o cancelamento de uma recorrência de Pix Automático. * diff --git a/src/Contracts/InvoiceCancellationContract.php b/src/Contracts/InvoiceCancellationContract.php new file mode 100644 index 0000000..8d63417 --- /dev/null +++ b/src/Contracts/InvoiceCancellationContract.php @@ -0,0 +1,19 @@ +apiRequest = $apiRequest ?? new Iugu_APIRequest(); } /** @@ -351,6 +355,34 @@ public function refundInvoice(Invoice $invoice): Invoice return $this->parseInvoice($iuguInvoice, $invoice); } + /** + * @inheritDoc + */ + public function cancelInvoice(Invoice $invoice): Invoice + { + $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/cancel'; + + try { + $response = $this->apiRequest->request('PUT', $url); + } catch (\IuguRequestException | IuguObjectNotFound $e) { + if (str_contains($e->getMessage(), '502 Bad Gateway')) { + throw new GatewayNotAvailableException($e->getMessage()); + } + + throw new GatewayException($e->getMessage()); + } catch (\IuguAuthenticationException $e) { + throw new GatewayNotAvailableException($e->getMessage()); + } catch (\Exception $e) { + throw new GatewayException("Error cancelling invoice: {$e->getMessage()}"); + } + + if (!empty($response->errors)) { + throw new GatewayException('Error cancelling invoice', (array) $response->errors); + } + + return $this->parseInvoice($response, $invoice); + } + /** * @inheritDoc */ @@ -389,7 +421,7 @@ public function cancelAutomaticPixRecurrence(string $recurrenceId): object $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrences/' . $recurrenceId . '/cancel'; try { - $response = (new Iugu_APIRequest())->request('PUT', $url); + $response = $this->apiRequest->request('PUT', $url); } catch (\IuguRequestException | IuguObjectNotFound $e) { if (str_contains($e->getMessage(), '502 Bad Gateway')) { throw new GatewayNotAvailableException($e->getMessage()); @@ -409,6 +441,45 @@ public function cancelAutomaticPixRecurrence(string $recurrenceId): object return $response; } + /** + * @inheritDoc + * + * Endpoint: POST /automatic_pix/receiver_recurrence_payments/cancel + */ + public function cancelAutomaticPixScheduledPayment( + string $receiverRecurrencePaymentId, + string $endToEndId + ): object { + $query = http_build_query([ + 'receiver_recurrence_payment_id' => $receiverRecurrencePaymentId, + 'end_to_end_id' => $endToEndId, + ], '', '&', PHP_QUERY_RFC3986); + $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrence_payments/cancel?' . $query; + + try { + $response = $this->apiRequest->request('POST', $url); + } catch (\IuguRequestException | IuguObjectNotFound $e) { + if (str_contains($e->getMessage(), '502 Bad Gateway')) { + throw new GatewayNotAvailableException($e->getMessage()); + } + + throw new GatewayException($e->getMessage()); + } catch (\IuguAuthenticationException $e) { + throw new GatewayNotAvailableException($e->getMessage()); + } catch (\Exception $e) { + throw new GatewayException("Error cancelling automatic pix scheduled payment: {$e->getMessage()}"); + } + + if (($response->success ?? false) !== true) { + throw new GatewayException( + 'Error cancelling automatic pix scheduled payment', + (array) ($response->errors ?? []) + ); + } + + return $response; + } + /** * @inheritDoc */ diff --git a/src/MultiPayment.php b/src/MultiPayment.php index b6b7caa..0175386 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -13,6 +13,7 @@ use Potelo\MultiPayment\Builders\CreditCardBuilder; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Contracts\AutomaticPixContract; +use Potelo\MultiPayment\Contracts\InvoiceCancellationContract; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -171,6 +172,29 @@ public function refundInvoice(string $id, ?int $partialValueCents = null): Invoi } + /** + * Cancel an invoice. + * + * @param Invoice|string $invoice + * @return Invoice + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + */ + public function cancelInvoice(Invoice|string $invoice): Invoice + { + if (!$this->gateway instanceof InvoiceCancellationContract) { + throw new MultiPaymentException('The selected gateway does not support invoice cancellation.'); + } + + if (is_string($invoice)) { + $invoiceInstance = new Invoice(); + $invoiceInstance->id = $invoice; + $invoice = $invoiceInstance; + } + + return $this->gateway->cancelInvoice($invoice); + } + /** * Charge invoice with credit card * @@ -286,4 +310,28 @@ public function cancelAutomaticPixRecurrence(string $recurrenceId): object return $this->gateway->cancelAutomaticPixRecurrence($recurrenceId); } + /** + * Cancela um pagamento agendado de Pix Automático no gateway. + * + * @param string $receiverRecurrencePaymentId UUID do pagamento agendado. + * @param string $endToEndId Identificador E2E do pagamento. + * @return object + * @throws MultiPaymentException + * @throws GatewayException + * @throws GatewayNotAvailableException + */ + public function cancelAutomaticPixScheduledPayment( + string $receiverRecurrencePaymentId, + string $endToEndId + ): object { + if (!$this->gateway instanceof AutomaticPixContract) { + throw new MultiPaymentException('The selected gateway does not support automatic pix.'); + } + + return $this->gateway->cancelAutomaticPixScheduledPayment( + $receiverRecurrencePaymentId, + $endToEndId + ); + } + } diff --git a/tests/Unit/AutomaticPixTest.php b/tests/Unit/AutomaticPixTest.php new file mode 100644 index 0000000..81bed19 --- /dev/null +++ b/tests/Unit/AutomaticPixTest.php @@ -0,0 +1,66 @@ + true, + 'cancellation_id' => 'd87f02d3-c7bd-4096-b397-867fdae99d10', + ]; + + $gateway = Mockery::mock(GatewayContract::class, AutomaticPixContract::class); + $gateway->shouldReceive('cancelAutomaticPixScheduledPayment') + ->once() + ->with('payment-id', 'end-to-end-id') + ->andReturn($response); + + $result = (new MultiPayment($gateway)) + ->cancelAutomaticPixScheduledPayment('payment-id', 'end-to-end-id'); + + $this->assertSame($response, $result); + } + + public function testCancelsInvoiceThroughGateway(): void + { + $cancelledInvoice = new Invoice(); + $cancelledInvoice->id = 'invoice-id'; + $cancelledInvoice->status = Invoice::STATUS_CANCELED; + + $gateway = Mockery::mock(GatewayContract::class, InvoiceCancellationContract::class); + $gateway->shouldReceive('cancelInvoice') + ->once() + ->with(Mockery::on(fn(Invoice $invoice) => $invoice->id === 'invoice-id')) + ->andReturn($cancelledInvoice); + + $result = (new MultiPayment($gateway))->cancelInvoice('invoice-id'); + + $this->assertSame($cancelledInvoice, $result); + } + + public function testRejectsInvoiceCancellationForUnsupportedGateway(): void + { + $this->expectException(MultiPaymentException::class); + + (new MultiPayment(Mockery::mock(GatewayContract::class))) + ->cancelInvoice('invoice-id'); + } +} diff --git a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php new file mode 100644 index 0000000..d291c29 --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php @@ -0,0 +1,131 @@ +instance('config', new Repository([ + 'multi-payment.gateways.iugu.api_key' => 'test-api-key', + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCancelsScheduledPaymentWithRequiredQueryParameters(): void + { + $apiRequest = new RecordingIuguApiRequest((object) [ + 'success' => true, + 'cancellation_id' => 'd87f02d3-c7bd-4096-b397-867fdae99d10', + ]); + + $result = (new IuguGateway($apiRequest)) + ->cancelAutomaticPixScheduledPayment('payment-id', 'end-to-end-id'); + + $this->assertTrue($result->success); + $this->assertSame('POST', $apiRequest->method); + $this->assertSame('/v1/automatic_pix/receiver_recurrence_payments/cancel', parse_url($apiRequest->url, PHP_URL_PATH)); + parse_str((string) parse_url($apiRequest->url, PHP_URL_QUERY), $query); + $this->assertSame([ + 'receiver_recurrence_payment_id' => 'payment-id', + 'end_to_end_id' => 'end-to-end-id', + ], $query); + $this->assertSame([], $apiRequest->data); + } + + public function testRejectsUnsuccessfulScheduledPaymentCancellation(): void + { + $apiRequest = new RecordingIuguApiRequest((object) [ + 'success' => false, + 'errors' => [(object) ['message' => 'Pagamento não pode ser cancelado']], + ]); + + $this->expectException(GatewayException::class); + + (new IuguGateway($apiRequest)) + ->cancelAutomaticPixScheduledPayment('payment-id', 'end-to-end-id'); + } + + public function testCancelsInvoiceAndReturnsParsedInvoice(): void + { + $apiRequest = new RecordingIuguApiRequest($this->cancelledInvoiceResponse()); + + $invoice = new Invoice(); + $invoice->id = 'invoice-id'; + + $result = (new IuguGateway($apiRequest))->cancelInvoice($invoice); + + $this->assertSame('PUT', $apiRequest->method); + $this->assertSame('/v1/invoices/invoice-id/cancel', parse_url($apiRequest->url, PHP_URL_PATH)); + $this->assertSame(Invoice::STATUS_CANCELED, $result->status); + $this->assertSame('invoice-id', $result->id); + } + + private function cancelledInvoiceResponse(): object + { + return (object) [ + 'id' => 'invoice-id', + 'status' => 'canceled', + 'total_cents' => 100, + 'paid_at' => null, + 'secure_url' => null, + 'taxes_paid_cents' => null, + 'created_at_iso' => '2026-07-16T10:20:03-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 0, + 'due_date' => '2026-07-17', + 'payment_method' => null, + 'payable_with' => 'pix', + 'customer_id' => 'customer-id', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'credit_card_transaction' => null, + ]; + } +} + +class RecordingIuguApiRequest extends Iugu_APIRequest +{ + public ?string $method = null; + public ?string $url = null; + public array $data = []; + + public function __construct(private object $response) + { + } + + public function request($method, $url, $data = []) + { + $this->method = $method; + $this->url = $url; + $this->data = $data; + + return $this->response; + } +} From f409fe194182b5723a1e5ce2d07255c7a28683d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Walker?= Date: Sat, 18 Jul 2026 11:55:47 -0300 Subject: [PATCH 4/8] =?UTF-8?q?feat(pix):=20integrar=20Pix=20Autom=C3=A1ti?= =?UTF-8?q?co=20ao=20fluxo=20de=20faturas=20da=20Iugu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Builders/InvoiceBuilder.php | 39 +++ src/Contracts/AutomaticPixContract.php | 48 +-- src/Contracts/GatewayContract.php | 2 +- src/Contracts/InvoiceCancellationContract.php | 19 -- src/Contracts/InvoiceContract.php | 9 + src/Facades/MultiPayment.php | 7 +- src/Gateways/IuguGateway.php | 295 +++++++++++++++--- src/Models/AutomaticPix.php | 96 ++++++ src/Models/AutomaticPixCancellation.php | 24 ++ src/Models/Invoice.php | 40 +++ src/MultiPayment.php | 93 ++++-- 11 files changed, 566 insertions(+), 106 deletions(-) delete mode 100644 src/Contracts/InvoiceCancellationContract.php create mode 100644 src/Models/AutomaticPix.php create mode 100644 src/Models/AutomaticPixCancellation.php diff --git a/src/Builders/InvoiceBuilder.php b/src/Builders/InvoiceBuilder.php index 3c23122..e2e1307 100644 --- a/src/Builders/InvoiceBuilder.php +++ b/src/Builders/InvoiceBuilder.php @@ -8,6 +8,7 @@ use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\InvoiceItem; +use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Contracts\GatewayContract; /** @@ -87,6 +88,44 @@ public function setExpiresAt($expiresAt): InvoiceBuilder return $this; } + /** + * Set an Automatic Pix recurrence on the invoice. + */ + public function setAutomaticPix(AutomaticPix $automaticPix): InvoiceBuilder + { + $this->model->automaticPix = $automaticPix; + + return $this; + } + + /** + * Add Automatic Pix recurrence data to the invoice. + * + * @param Carbon|string $startsAt + * @param Carbon|string|null $endsAt + */ + public function addAutomaticPix( + string $authorizationType, + string $frequency, + Carbon|string $startsAt, + string $contractReference, + Carbon|string|null $endsAt = null, + string $retryPolicy = AutomaticPix::RETRY_POLICY_NOT_ALLOWED, + ?string $id = null + ): InvoiceBuilder { + $automaticPix = new AutomaticPix(); + $automaticPix->authorizationType = $authorizationType; + $automaticPix->frequency = $frequency; + $automaticPix->startsAt = $startsAt instanceof Carbon ? $startsAt : Carbon::parse($startsAt); + $automaticPix->contractReference = $contractReference; + $automaticPix->endsAt = is_string($endsAt) ? Carbon::parse($endsAt) : $endsAt; + $automaticPix->retryPolicy = $retryPolicy; + $automaticPix->id = $id; + $this->model->automaticPix = $automaticPix; + + return $this; + } + /** * Set the invoice items * diff --git a/src/Contracts/AutomaticPixContract.php b/src/Contracts/AutomaticPixContract.php index 680cebe..2e8232a 100644 --- a/src/Contracts/AutomaticPixContract.php +++ b/src/Contracts/AutomaticPixContract.php @@ -2,36 +2,48 @@ namespace Potelo\MultiPayment\Contracts; +use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\AutomaticPix; +use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; -/** - * Operações de Pix Automático (recorrência do Bacen). - * - * Por ser específico de gateways que suportam o Pix Automático, fica fora do - * GatewayContract para não obrigar implementações que não suportam recorrência. - */ interface AutomaticPixContract { /** - * Solicita o cancelamento de um pagamento agendado de Pix Automático. - * - * @param string $receiverRecurrencePaymentId UUID do pagamento agendado. - * @param string $endToEndId Identificador E2E do pagamento. - * @return object Resposta do gateway. + * @throws GatewayException|GatewayNotAvailableException + */ + public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice; + + /** * @throws GatewayException|GatewayNotAvailableException */ public function cancelAutomaticPixScheduledPayment( - string $receiverRecurrencePaymentId, + string $paymentId, string $endToEndId - ): object; + ): AutomaticPixCancellation; + + /** + * @throws GatewayException|GatewayNotAvailableException + */ + public function cancelAutomaticPixRecurrence( + AutomaticPix $automaticPix + ): AutomaticPixCancellation; + + /** + * @throws GatewayException|GatewayNotAvailableException + */ + public function getAutomaticPixCancellation( + AutomaticPixCancellation $cancellation + ): AutomaticPixCancellation; /** - * Solicita o cancelamento de uma recorrência de Pix Automático. - * - * @param string $recurrenceId UUID da recorrência (receiver_recurrence_id). - * @return object Resposta do gateway. + * @return AutomaticPixCancellation[] * @throws GatewayException|GatewayNotAvailableException */ - public function cancelAutomaticPixRecurrence(string $recurrenceId): object; + public function listAutomaticPixCancellations( + AutomaticPix $automaticPix, + int $page = 1, + int $limit = 100 + ): array; } diff --git a/src/Contracts/GatewayContract.php b/src/Contracts/GatewayContract.php index 1541b34..ca7e44d 100644 --- a/src/Contracts/GatewayContract.php +++ b/src/Contracts/GatewayContract.php @@ -2,7 +2,7 @@ namespace Potelo\MultiPayment\Contracts; -interface GatewayContract extends CreditCardContract, CustomerContract, InvoiceContract +interface GatewayContract extends CreditCardContract, CustomerContract, InvoiceContract, AutomaticPixContract { public function __toString(); } diff --git a/src/Contracts/InvoiceCancellationContract.php b/src/Contracts/InvoiceCancellationContract.php deleted file mode 100644 index 8d63417..0000000 --- a/src/Contracts/InvoiceCancellationContract.php +++ /dev/null @@ -1,19 +0,0 @@ -availablePaymentMethods; } + if (!empty($invoice->automaticPix)) { + $iuguInvoiceData['automatic_pix'] = $this->automaticPixToIuguData($invoice->automaticPix); + } + if (!empty($invoice->gatewayAdicionalOptions)) { foreach ($invoice->gatewayAdicionalOptions as $option => $value) { $iuguInvoiceData[$option] = $value; @@ -411,53 +415,213 @@ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gat return $this->parseInvoice($iuguInvoice); } - /** - * @inheritDoc - * - * Endpoint: PUT /automatic_pix/receiver_recurrences/{id}/cancel - */ - public function cancelAutomaticPixRecurrence(string $recurrenceId): object + /** @inheritDoc */ + public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice { - $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrences/' . $recurrenceId . '/cancel'; + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } - try { - $response = $this->apiRequest->request('PUT', $url); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); - } catch (\Exception $e) { - throw new GatewayException("Error cancelling automatic pix recurrence: {$e->getMessage()}"); + $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) + . '/reschedule_automatic_pix_payment'; + $response = $this->automaticPixRequest('POST', $url, [], 'rescheduling automatic pix payment'); + + if (!empty($response->id) && !empty($response->status) && isset($response->total_cents)) { + return $this->parseInvoice($response, $invoice); } - if (!empty($response->errors)) { - throw new GatewayException('Error cancelling automatic pix recurrence', (array) $response->errors); + $invoice->gateway = 'iugu'; + $invoice->original = $response; + + return $invoice; + } + + /** @inheritDoc */ + public function cancelAutomaticPixRecurrence( + AutomaticPix $automaticPix + ): AutomaticPixCancellation { + if (empty($automaticPix->id)) { + throw ModelAttributeValidationException::required('AutomaticPix', 'id'); } - return $response; + $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrences/' + . rawurlencode($automaticPix->id) . '/cancel'; + $response = $this->automaticPixRequest('PUT', $url, [], 'cancelling automatic pix recurrence'); + + $cancellation = $this->parseAutomaticPixCancellation($response); + $cancellation->recurrenceId = $automaticPix->id; + $cancellation->status ??= AutomaticPixCancellation::STATUS_REQUESTED; + + return $cancellation; } - /** - * @inheritDoc - * - * Endpoint: POST /automatic_pix/receiver_recurrence_payments/cancel - */ + /** @inheritDoc */ public function cancelAutomaticPixScheduledPayment( - string $receiverRecurrencePaymentId, + string $paymentId, string $endToEndId - ): object { + ): AutomaticPixCancellation { + if (empty($paymentId)) { + throw ModelAttributeValidationException::required('AutomaticPixScheduledPayment', 'paymentId'); + } + if (empty($endToEndId)) { + throw ModelAttributeValidationException::required('AutomaticPixScheduledPayment', 'endToEndId'); + } + $query = http_build_query([ - 'receiver_recurrence_payment_id' => $receiverRecurrencePaymentId, + 'receiver_recurrence_payment_id' => $paymentId, 'end_to_end_id' => $endToEndId, ], '', '&', PHP_QUERY_RFC3986); $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrence_payments/cancel?' . $query; + $response = $this->automaticPixRequest( + 'POST', + $url, + [], + 'cancelling automatic pix scheduled payment' + ); + + $cancellation = $this->parseAutomaticPixCancellation($response); + $cancellation->paymentId ??= $paymentId; + $cancellation->endToEndId ??= $endToEndId; + $cancellation->status ??= AutomaticPixCancellation::STATUS_REQUESTED; + + return $cancellation; + } + + /** @inheritDoc */ + public function getAutomaticPixCancellation( + AutomaticPixCancellation $cancellation + ): AutomaticPixCancellation { + if (empty($cancellation->recurrenceId)) { + throw ModelAttributeValidationException::required('AutomaticPixCancellation', 'recurrenceId'); + } + if (empty($cancellation->id)) { + throw ModelAttributeValidationException::required('AutomaticPixCancellation', 'id'); + } + + $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrences/' + . rawurlencode($cancellation->recurrenceId) . '/cancellations/' + . rawurlencode($cancellation->id); + $response = $this->automaticPixRequest('GET', $url, [], 'getting automatic pix cancellation'); + + return $this->parseAutomaticPixCancellation($response, $cancellation); + } + + /** @inheritDoc */ + public function listAutomaticPixCancellations( + AutomaticPix $automaticPix, + int $page = 1, + int $limit = 100 + ): array { + if (empty($automaticPix->id)) { + throw ModelAttributeValidationException::required('AutomaticPix', 'id'); + } + if ($page < 1) { + throw new GatewayException('Automatic Pix cancellation page must be at least 1'); + } + if ($limit < 1 || $limit > 100) { + throw new GatewayException('Automatic Pix cancellation limit must be between 1 and 100'); + } + $query = http_build_query(['limit' => $limit, 'page' => $page], '', '&', PHP_QUERY_RFC3986); + $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrences/' + . rawurlencode($automaticPix->id) . '/cancellations?' . $query; + $response = $this->automaticPixRequest('GET', $url, [], 'listing automatic pix cancellations'); + + $items = $this->automaticPixCancellationItems($response); + + return array_map(function ($item) use ($automaticPix) { + $cancellation = $this->parseAutomaticPixCancellation($item); + $cancellation->recurrenceId ??= $automaticPix->id; + + return $cancellation; + }, $items); + } + + /** + * Convert the gateway-neutral recurrence model into Iugu invoice fields. + */ + private function automaticPixToIuguData(AutomaticPix $automaticPix): array + { + $automaticPix->validateForInvoice(); + + $journeys = [ + AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT => 3, + AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_RECURRENCE_OFFER => 4, + ]; + if (!isset($journeys[$automaticPix->authorizationType])) { + throw ModelAttributeValidationException::invalid( + 'AutomaticPix', + 'authorizationType', + 'authorizationType is not supported by the Iugu gateway' + ); + } + + $retryPolicies = [ + AutomaticPix::RETRY_POLICY_ALLOWED => 'retry_allowed', + AutomaticPix::RETRY_POLICY_NOT_ALLOWED => 'retry_not_allowed', + ]; + + $data = [ + 'journey' => $journeys[$automaticPix->authorizationType], + 'frequency' => $automaticPix->frequency, + 'recurrence_beginning' => $automaticPix->startsAt?->format('Y-m-d'), + 'contract_number' => $automaticPix->contractReference, + 'end_date' => $automaticPix->endsAt?->format('Y-m-d'), + 'receiver_recurrence_id' => $automaticPix->id, + 'retry_policy' => $retryPolicies[$automaticPix->retryPolicy] ?? $automaticPix->retryPolicy, + ]; + + return array_filter($data, static fn ($value) => !is_null($value)); + } + + /** + * Convert Iugu recurrence fields back into the gateway-neutral model. + */ + private function parseAutomaticPix($data, ?AutomaticPix $automaticPix = null): AutomaticPix + { + $data = (object) $data; + $automaticPix ??= new AutomaticPix(); + $authorizationTypes = [ + 3 => AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT, + 4 => AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_RECURRENCE_OFFER, + ]; + $retryPolicies = [ + 'retry_allowed' => AutomaticPix::RETRY_POLICY_ALLOWED, + 'retry_not_allowed' => AutomaticPix::RETRY_POLICY_NOT_ALLOWED, + ]; + + $automaticPix->id = $data->receiver_recurrence_id ?? $data->id ?? $automaticPix->id; + if (isset($data->journey, $authorizationTypes[(int) $data->journey])) { + $automaticPix->authorizationType = $authorizationTypes[(int) $data->journey]; + } + $automaticPix->frequency = $data->frequency ?? $automaticPix->frequency; + $automaticPix->startsAt = !empty($data->recurrence_beginning) + ? new Carbon($data->recurrence_beginning) + : $automaticPix->startsAt; + $automaticPix->contractReference = $data->contract_number ?? $automaticPix->contractReference; + $automaticPix->endsAt = !empty($data->end_date) + ? new Carbon($data->end_date) + : $automaticPix->endsAt; + $automaticPix->retryPolicy = $retryPolicies[$data->retry_policy ?? ''] + ?? $automaticPix->retryPolicy; + $automaticPix->status = $data->status ?? $automaticPix->status; + $automaticPix->gateway = 'iugu'; + $automaticPix->original = $data; + + return $automaticPix; + } + + /** + * Perform a raw Iugu request while preserving the package exception contract. + */ + private function automaticPixRequest( + string $method, + string $url, + array $data, + string $operation + ): object|array { try { - $response = $this->apiRequest->request('POST', $url); + $response = $this->apiRequest->request($method, $url, $data); } catch (\IuguRequestException | IuguObjectNotFound $e) { if (str_contains($e->getMessage(), '502 Bad Gateway')) { throw new GatewayNotAvailableException($e->getMessage()); @@ -467,19 +631,63 @@ public function cancelAutomaticPixScheduledPayment( } catch (\IuguAuthenticationException $e) { throw new GatewayNotAvailableException($e->getMessage()); } catch (\Exception $e) { - throw new GatewayException("Error cancelling automatic pix scheduled payment: {$e->getMessage()}"); + throw new GatewayException("Error {$operation}: {$e->getMessage()}"); } - if (($response->success ?? false) !== true) { - throw new GatewayException( - 'Error cancelling automatic pix scheduled payment', - (array) ($response->errors ?? []) - ); + $responseObject = is_array($response) ? (object) $response : $response; + if ( + !empty($responseObject->errors) + || (isset($responseObject->success) && $responseObject->success !== true) + ) { + throw new GatewayException("Error {$operation}", (array) ($responseObject->errors ?? [])); } return $response; } + /** + * @return array + */ + private function automaticPixCancellationItems(object|array $response): array + { + if (is_array($response)) { + return array_values($response); + } + + foreach (['cancellations', 'items', 'data', 'results'] as $property) { + if (isset($response->{$property}) && is_array($response->{$property})) { + return array_values($response->{$property}); + } + } + + return []; + } + + private function parseAutomaticPixCancellation( + $data, + ?AutomaticPixCancellation $cancellation = null + ): AutomaticPixCancellation { + $data = (object) $data; + $cancellation ??= new AutomaticPixCancellation(); + + $cancellation->id = $data->cancellation_id ?? $data->id ?? $cancellation->id; + $cancellation->recurrenceId = $data->receiver_recurrence_id + ?? $cancellation->recurrenceId; + $cancellation->paymentId = $data->receiver_recurrence_payment_id + ?? $cancellation->paymentId; + $cancellation->endToEndId = $data->end_to_end_id ?? $cancellation->endToEndId; + $cancellation->status = $data->status ?? $cancellation->status; + $cancellation->amount = $data->amount ?? $cancellation->amount; + $cancellation->payerAccount = $data->payer_account ?? $cancellation->payerAccount; + $cancellation->createdAt = !empty($data->created_at) + ? new Carbon($data->created_at) + : $cancellation->createdAt; + $cancellation->gateway = 'iugu'; + $cancellation->original = $data; + + return $cancellation; + } + /** * @inheritDoc */ @@ -590,6 +798,13 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice $invoice->pix->qrCodeText = $iuguInvoice->pix->qrcode_text; } + if (!empty($iuguInvoice->automatic_pix)) { + $invoice->automaticPix = $this->parseAutomaticPix( + $iuguInvoice->automatic_pix, + $invoice->automaticPix + ); + } + if (!empty($iuguInvoice->credit_card_transaction)) { if (empty($invoice->creditCard)) { $invoice->creditCard = new CreditCard(); diff --git a/src/Models/AutomaticPix.php b/src/Models/AutomaticPix.php new file mode 100644 index 0000000..8ba0502 --- /dev/null +++ b/src/Models/AutomaticPix.php @@ -0,0 +1,96 @@ + 'startsAt', 'ends_at' => 'endsAt'] as $key => $attribute) { + if (!empty($data[$key])) { + $this->{$attribute} = $data[$key] instanceof Carbon + ? $data[$key] + : Carbon::parse($data[$key]); + unset($data[$key]); + } + } + + parent::fill($data); + } + + /** + * Validate the fields required when an invoice creates or schedules a recurrence. + */ + public function validateForInvoice(): void + { + foreach (['authorizationType', 'frequency', 'startsAt', 'contractReference'] as $attribute) { + if (empty($this->{$attribute})) { + throw ModelAttributeValidationException::required($this->getClassName(), $attribute); + } + } + + $this->validate(); + } + + protected function validateFrequencyAttribute(): void + { + $frequencies = [ + self::FREQUENCY_WEEKLY, + self::FREQUENCY_MONTHLY, + self::FREQUENCY_QUARTERLY, + self::FREQUENCY_SEMIANNUAL, + self::FREQUENCY_ANNUAL, + ]; + + if (!in_array($this->frequency, $frequencies, true)) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'frequency', + 'frequency must be one of: ' . implode(', ', $frequencies) + ); + } + } + + protected function validateRetryPolicyAttribute(): void + { + $policies = [self::RETRY_POLICY_ALLOWED, self::RETRY_POLICY_NOT_ALLOWED]; + + if (!in_array($this->retryPolicy, $policies, true)) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'retryPolicy', + 'retryPolicy must be one of: ' . implode(', ', $policies) + ); + } + } +} diff --git a/src/Models/AutomaticPixCancellation.php b/src/Models/AutomaticPixCancellation.php new file mode 100644 index 0000000..621b8bc --- /dev/null +++ b/src/Models/AutomaticPixCancellation.php @@ -0,0 +1,24 @@ +creditCard->fill($data['credit_card']); unset($data['credit_card']); } + + if (!empty($data['automatic_pix']) && is_array($data['automatic_pix'])) { + $this->automaticPix = new AutomaticPix(); + $this->automaticPix->fill($data['automatic_pix']); + unset($data['automatic_pix']); + } + parent::fill($data); } @@ -237,6 +249,14 @@ public function validateCreditCardAttribute() $this->creditCard->validate(); } + /** + * @throws ModelAttributeValidationException + */ + public function validateAutomaticPixAttribute(): void + { + $this->automaticPix->validateForInvoice(); + } + /** * @inheritDoc */ @@ -299,4 +319,24 @@ public function duplicate(Carbon $expiresAt, array $gatewayOptions = []): Invoic $gateway = ConfigurationHelper::resolveGateway($this->gateway); return $gateway->duplicateInvoice($this, $expiresAt, $gatewayOptions); } + + /** + * Cancel the invoice. + */ + public function cancel(GatewayContract|string|null $gateway = null): Invoice + { + $gateway = ConfigurationHelper::resolveGateway($gateway ?? $this->gateway); + + return $gateway->cancelInvoice($this); + } + + /** + * Request a new debit schedule after a failed Automatic Pix payment. + */ + public function rescheduleAutomaticPixPayment(GatewayContract|string|null $gateway = null): Invoice + { + $gateway = ConfigurationHelper::resolveGateway($gateway ?? $this->gateway); + + return $gateway->rescheduleAutomaticPixPayment($this); + } } diff --git a/src/MultiPayment.php b/src/MultiPayment.php index 0175386..32a92ee 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -7,13 +7,13 @@ use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Customer; +use Potelo\MultiPayment\Models\AutomaticPix; +use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Builders\InvoiceBuilder; use Potelo\MultiPayment\Builders\CustomerBuilder; use Potelo\MultiPayment\Builders\CreditCardBuilder; use Potelo\MultiPayment\Exceptions\GatewayException; -use Potelo\MultiPayment\Contracts\AutomaticPixContract; -use Potelo\MultiPayment\Contracts\InvoiceCancellationContract; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -177,22 +177,18 @@ public function refundInvoice(string $id, ?int $partialValueCents = null): Invoi * * @param Invoice|string $invoice * @return Invoice - * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException */ public function cancelInvoice(Invoice|string $invoice): Invoice { - if (!$this->gateway instanceof InvoiceCancellationContract) { - throw new MultiPaymentException('The selected gateway does not support invoice cancellation.'); - } - if (is_string($invoice)) { $invoiceInstance = new Invoice(); $invoiceInstance->id = $invoice; $invoice = $invoiceInstance; } - return $this->gateway->cancelInvoice($invoice); + return $invoice->cancel($this->gateway); } /** @@ -295,43 +291,86 @@ public function setDefaultCard(string $customerId, string $creditCardId): Custom /** * Cancela uma recorrência de Pix Automático no gateway. * - * @param string $recurrenceId UUID da recorrência (receiver_recurrence_id). - * @return object - * @throws MultiPaymentException + * @param AutomaticPix|string $automaticPix * @throws GatewayException * @throws GatewayNotAvailableException */ - public function cancelAutomaticPixRecurrence(string $recurrenceId): object + public function cancelAutomaticPixRecurrence( + AutomaticPix|string $automaticPix + ): AutomaticPixCancellation { - if (!$this->gateway instanceof AutomaticPixContract) { - throw new MultiPaymentException('The selected gateway does not support automatic pix.'); + if (is_string($automaticPix)) { + $automaticPixModel = new AutomaticPix(); + $automaticPixModel->id = $automaticPix; + $automaticPix = $automaticPixModel; } - return $this->gateway->cancelAutomaticPixRecurrence($recurrenceId); + return $this->gateway->cancelAutomaticPixRecurrence($automaticPix); } /** * Cancela um pagamento agendado de Pix Automático no gateway. * - * @param string $receiverRecurrencePaymentId UUID do pagamento agendado. - * @param string $endToEndId Identificador E2E do pagamento. - * @return object - * @throws MultiPaymentException + * @param string $paymentId + * @param string $endToEndId * @throws GatewayException * @throws GatewayNotAvailableException */ public function cancelAutomaticPixScheduledPayment( - string $receiverRecurrencePaymentId, + string $paymentId, string $endToEndId - ): object { - if (!$this->gateway instanceof AutomaticPixContract) { - throw new MultiPaymentException('The selected gateway does not support automatic pix.'); + ): AutomaticPixCancellation { + return $this->gateway->cancelAutomaticPixScheduledPayment($paymentId, $endToEndId); + } + + /** + * Request a new Automatic Pix debit schedule for an expired invoice. + */ + public function rescheduleAutomaticPixPayment(Invoice|string $invoice): Invoice + { + if (is_string($invoice)) { + $invoiceModel = new Invoice(); + $invoiceModel->id = $invoice; + $invoice = $invoiceModel; + } + + return $invoice->rescheduleAutomaticPixPayment($this->gateway); + } + + /** + * Get one cancellation from an Automatic Pix recurrence. + */ + public function getAutomaticPixCancellation( + AutomaticPixCancellation|string $cancellation, + ?string $cancellationId = null + ): AutomaticPixCancellation { + if (is_string($cancellation)) { + $recurrenceId = $cancellation; + $cancellation = new AutomaticPixCancellation(); + $cancellation->recurrenceId = $recurrenceId; + $cancellation->id = $cancellationId; + } + + return $this->gateway->getAutomaticPixCancellation($cancellation); + } + + /** + * List cancellations from an Automatic Pix recurrence. + * + * @return AutomaticPixCancellation[] + */ + public function listAutomaticPixCancellations( + AutomaticPix|string $automaticPix, + int $page = 1, + int $limit = 100 + ): array { + if (is_string($automaticPix)) { + $automaticPixModel = new AutomaticPix(); + $automaticPixModel->id = $automaticPix; + $automaticPix = $automaticPixModel; } - return $this->gateway->cancelAutomaticPixScheduledPayment( - $receiverRecurrencePaymentId, - $endToEndId - ); + return $this->gateway->listAutomaticPixCancellations($automaticPix, $page, $limit); } } From 1d7844f15d185f2585b8b7d0006b3420693b557b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Walker?= Date: Sat, 18 Jul 2026 11:56:09 -0300 Subject: [PATCH 5/8] =?UTF-8?q?test(pix):=20separar=20testes=20unit=C3=A1r?= =?UTF-8?q?ios=20e=20integra=C3=A7=C3=B5es=20com=20a=20sandbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phpunit.xml.dist | 5 +- .../Builders/CreditCardBuilderTest.php | 10 +- .../Builders/CustomerBuilderTest.php | 2 +- .../Builders/InvoiceBuilderTest.php | 49 +++++- .../MultiPaymentTest.php | 115 +++++++++++++- tests/TestCase.php | 7 +- tests/Unit/AutomaticPixTest.php | 141 ++++++++++++++--- .../Gateways/IuguGatewayAutomaticPixTest.php | 144 +++++++++++++++++- 8 files changed, 433 insertions(+), 40 deletions(-) rename tests/{Unit => Integration}/Builders/CreditCardBuilderTest.php (95%) rename tests/{Unit => Integration}/Builders/CustomerBuilderTest.php (99%) rename tests/{Unit => Integration}/Builders/InvoiceBuilderTest.php (89%) rename tests/{Unit => Integration}/MultiPaymentTest.php (76%) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d8235e1..7d43f7b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -22,6 +22,9 @@ ./tests/Unit + + ./tests/Integration + @@ -30,4 +33,4 @@ - \ No newline at end of file + diff --git a/tests/Unit/Builders/CreditCardBuilderTest.php b/tests/Integration/Builders/CreditCardBuilderTest.php similarity index 95% rename from tests/Unit/Builders/CreditCardBuilderTest.php rename to tests/Integration/Builders/CreditCardBuilderTest.php index f3ef06f..e618b2b 100644 --- a/tests/Unit/Builders/CreditCardBuilderTest.php +++ b/tests/Integration/Builders/CreditCardBuilderTest.php @@ -1,6 +1,6 @@ [ 'iugu', [ - 'token' => self::iuguCreditCardToken(), + 'createToken' => true, 'description' => 'Test credit card', 'customer' => self::customerWithoutAddress(), ], @@ -122,8 +122,8 @@ public function testShouldCreateACreditCardWithHash($gateway, $data) $creditCardBuilder = MultiPayment::setGateway($gateway)->newCreditCard(); $customer = $this->createCustomer($gateway, $data['customer']); $creditCardBuilder->setCustomerId($customer->id); - if (!empty($data['token'])) { - $creditCardBuilder->setToken($data['token']); + if (!empty($data['createToken'])) { + $creditCardBuilder->setToken(self::iuguCreditCardToken()); } if (!empty($data['description'])) { $creditCardBuilder->setDescription($data['description']); @@ -133,4 +133,4 @@ public function testShouldCreateACreditCardWithHash($gateway, $data) $this->assertEquals($gateway, $creditCard->gateway); } -} \ No newline at end of file +} diff --git a/tests/Unit/Builders/CustomerBuilderTest.php b/tests/Integration/Builders/CustomerBuilderTest.php similarity index 99% rename from tests/Unit/Builders/CustomerBuilderTest.php rename to tests/Integration/Builders/CustomerBuilderTest.php index 32995ff..9721345 100644 --- a/tests/Unit/Builders/CustomerBuilderTest.php +++ b/tests/Integration/Builders/CustomerBuilderTest.php @@ -1,6 +1,6 @@ markTestSkipped( + 'A sandbox da Iugu retorna que Pix Automático não está disponível no modo de teste.' + ); + + $reference = 'multipayment-' . Carbon::now()->format('YmdHis'); + $invoice = (new \Potelo\MultiPayment\MultiPayment('iugu'))->newInvoice() + ->addAvailablePaymentMethod(Invoice::PAYMENT_METHOD_PIX) + ->addCustomer( + 'Automatic Pix Sandbox', + "{$reference}@example.com", + '20176996915', + null, + '71', + '982345678' + ) + ->addItem('Automatic Pix sandbox test', 100, 1) + ->setExpiresAt(Carbon::now()->addDays(2)) + ->addAutomaticPix( + AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT, + AutomaticPix::FREQUENCY_MONTHLY, + Carbon::now()->addDays(3), + $reference, + Carbon::now()->addYear(), + AutomaticPix::RETRY_POLICY_ALLOWED + ) + ->create(); + + $this->assertNotEmpty($invoice->id); + $this->assertInstanceOf(AutomaticPix::class, $invoice->automaticPix); + $this->assertNotEmpty($invoice->automaticPix->id); + $this->assertSame($reference, $invoice->automaticPix->contractReference); + $this->assertSame('iugu', $invoice->automaticPix->gateway); + $this->assertNotNull($invoice->automaticPix->original); + } + /** * Create a invoice with mocked data * @@ -332,4 +377,4 @@ public function shouldNotCreateInvoiceDataProvider(): array ], ]; } -} \ No newline at end of file +} diff --git a/tests/Unit/MultiPaymentTest.php b/tests/Integration/MultiPaymentTest.php similarity index 76% rename from tests/Unit/MultiPaymentTest.php rename to tests/Integration/MultiPaymentTest.php index 72d19e8..f802115 100644 --- a/tests/Unit/MultiPaymentTest.php +++ b/tests/Integration/MultiPaymentTest.php @@ -1,15 +1,126 @@ markTestSkipped( + 'A sandbox da Iugu não permite criar a fatura de Pix Automático necessária para a consulta.' + ); + + $reference = 'multipayment-' . now()->format('YmdHis'); + $invoice = MultiPayment::setGateway('iugu')->newInvoice() + ->addAvailablePaymentMethod(Invoice::PAYMENT_METHOD_PIX) + ->addCustomer( + 'Automatic Pix Sandbox', + "{$reference}@example.com", + '20176996915', + null, + '71', + '982345678' + ) + ->addItem('Automatic Pix sandbox test', 100, 1) + ->setExpiresAt(now()->addDays(2)) + ->addAutomaticPix( + AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT, + AutomaticPix::FREQUENCY_MONTHLY, + now()->addDays(3), + $reference, + now()->addYear(), + AutomaticPix::RETRY_POLICY_ALLOWED + ) + ->create(); + + $invoiceFetched = MultiPayment::setGateway('iugu')->getInvoice($invoice->id); + + $this->assertSame($invoice->id, $invoiceFetched->id); + $this->assertInstanceOf(AutomaticPix::class, $invoiceFetched->automaticPix); + $this->assertSame($invoice->automaticPix->id, $invoiceFetched->automaticPix->id); + $this->assertSame($reference, $invoiceFetched->automaticPix->contractReference); + $this->assertSame('iugu', $invoiceFetched->automaticPix->gateway); + $this->assertNotNull($invoiceFetched->automaticPix->original); + } + + /** + * @group iugu-sandbox-limitation + * + * A retentativa exige uma fatura expirada após falha de débito de uma + * recorrência autorizada, estado que não pode ser criado na sandbox. + */ + public function testShouldRescheduleAutomaticPixPayment(): void + { + $this->markTestSkipped( + 'A sandbox da Iugu não permite criar a recorrência e a fatura expirada necessárias para a retentativa.' + ); + } + + /** + * @group iugu-sandbox-limitation + * + * O cancelamento exige uma recorrência autorizada criada durante o teste, + * mas a sandbox não oferece suporte à criação de Pix Automático. + */ + public function testShouldCancelAutomaticPixRecurrence(): void + { + $this->markTestSkipped( + 'A sandbox da Iugu não permite criar a recorrência ativa necessária para testar o cancelamento.' + ); + } + + /** + * @group iugu-sandbox-limitation + * + * O cancelamento de agendamento exige um débito agendado e seu end-to-end + * ID, que não podem ser produzidos pela sandbox no fluxo do teste. + */ + public function testShouldCancelAutomaticPixScheduledPayment(): void + { + $this->markTestSkipped( + 'A sandbox da Iugu não permite criar o pagamento agendado necessário para testar o cancelamento.' + ); + } + + /** + * @group iugu-sandbox-limitation + * + * A consulta exige que uma recorrência seja criada e cancelada no próprio + * teste; a sandbox bloqueia a etapa inicial desse fluxo. + */ + public function testShouldGetAutomaticPixCancellation(): void + { + $this->markTestSkipped( + 'A sandbox da Iugu não permite criar o cancelamento de Pix Automático necessário para a consulta.' + ); + } + + /** + * @group iugu-sandbox-limitation + * + * A listagem exige uma recorrência com cancelamentos criados durante o + * teste; a sandbox bloqueia a criação dessa recorrência. + */ + public function testShouldListAutomaticPixCancellations(): void + { + $this->markTestSkipped( + 'A sandbox da Iugu não permite criar o histórico de cancelamentos necessário para a listagem.' + ); + } + /** * Test if can get the invoice by id * @@ -369,4 +480,4 @@ public function shouldChargeInvoiceWithCreditCard(): array ], ]; } -} \ No newline at end of file +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 3a25863..9d9c1b1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -17,6 +17,11 @@ public function __construct(?string $name = null, array $data = [], $dataName = protected function setUp(): void { parent::setUp(); + + if (in_array('iugu-sandbox-limitation', $this->getGroups(), true)) { + return; + } + // pausa para evitar problemas com o Iugu sleep(12); } @@ -137,4 +142,4 @@ public function createCustomer($gateway, $data): \Potelo\MultiPayment\Models\Cus } return $customerBuilder->create(); } -} \ No newline at end of file +} diff --git a/tests/Unit/AutomaticPixTest.php b/tests/Unit/AutomaticPixTest.php index 81bed19..3f96ac8 100644 --- a/tests/Unit/AutomaticPixTest.php +++ b/tests/Unit/AutomaticPixTest.php @@ -3,13 +3,13 @@ namespace Potelo\MultiPayment\Tests\Unit; use Mockery; +use Carbon\Carbon; use PHPUnit\Framework\TestCase; use Potelo\MultiPayment\MultiPayment; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\AutomaticPix; +use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Contracts\GatewayContract; -use Potelo\MultiPayment\Contracts\AutomaticPixContract; -use Potelo\MultiPayment\Contracts\InvoiceCancellationContract; -use Potelo\MultiPayment\Exceptions\MultiPaymentException; class AutomaticPixTest extends TestCase { @@ -20,23 +20,130 @@ protected function tearDown(): void parent::tearDown(); } - public function testCancelsScheduledAutomaticPixPaymentThroughSupportedGateway(): void + public function testBuildsAutomaticPixAsPartOfInvoice(): void { - $response = (object) [ - 'success' => true, - 'cancellation_id' => 'd87f02d3-c7bd-4096-b397-867fdae99d10', - ]; + $gateway = Mockery::mock(GatewayContract::class); - $gateway = Mockery::mock(GatewayContract::class, AutomaticPixContract::class); + $invoice = (new MultiPayment($gateway))->newInvoice() + ->addAutomaticPix( + AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT, + AutomaticPix::FREQUENCY_MONTHLY, + '2026-08-01', + 'contract-123', + '2027-08-01', + AutomaticPix::RETRY_POLICY_ALLOWED + ) + ->get(); + + $this->assertInstanceOf(AutomaticPix::class, $invoice->automaticPix); + $this->assertSame( + AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT, + $invoice->automaticPix->authorizationType + ); + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $invoice->automaticPix->frequency); + $this->assertSame('2026-08-01', $invoice->automaticPix->startsAt->format('Y-m-d')); + $this->assertSame('contract-123', $invoice->automaticPix->contractReference); + $this->assertSame('2027-08-01', $invoice->automaticPix->endsAt->format('Y-m-d')); + $this->assertSame(AutomaticPix::RETRY_POLICY_ALLOWED, $invoice->automaticPix->retryPolicy); + } + + public function testFillsAutomaticPixModelFromInvoiceAttributes(): void + { + $invoice = new Invoice(); + $invoice->fill([ + 'automatic_pix' => [ + 'authorization_type' => AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_RECURRENCE_OFFER, + 'frequency' => AutomaticPix::FREQUENCY_WEEKLY, + 'starts_at' => '2026-08-01', + 'contract_reference' => 'contract-456', + 'retry_policy' => AutomaticPix::RETRY_POLICY_NOT_ALLOWED, + ], + ]); + + $this->assertInstanceOf(AutomaticPix::class, $invoice->automaticPix); + $this->assertSame( + AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_RECURRENCE_OFFER, + $invoice->automaticPix->authorizationType + ); + $this->assertSame('contract-456', $invoice->automaticPix->contractReference); + $this->assertInstanceOf(Carbon::class, $invoice->automaticPix->startsAt); + } + + public function testCancelsScheduledAutomaticPixPaymentThroughScalarContract(): void + { + $cancellation = new AutomaticPixCancellation(); + $cancellation->id = 'cancellation-id'; + + $gateway = Mockery::mock(GatewayContract::class); $gateway->shouldReceive('cancelAutomaticPixScheduledPayment') ->once() ->with('payment-id', 'end-to-end-id') - ->andReturn($response); + ->andReturn($cancellation); $result = (new MultiPayment($gateway)) ->cancelAutomaticPixScheduledPayment('payment-id', 'end-to-end-id'); - $this->assertSame($response, $result); + $this->assertSame($cancellation, $result); + } + + public function testCancelsAutomaticPixRecurrenceThroughModelContract(): void + { + $cancellation = new AutomaticPixCancellation(); + $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('cancelAutomaticPixRecurrence') + ->once() + ->with(Mockery::on(fn (AutomaticPix $automaticPix) => $automaticPix->id === 'recurrence-id')) + ->andReturn($cancellation); + + $result = (new MultiPayment($gateway))->cancelAutomaticPixRecurrence('recurrence-id'); + + $this->assertSame($cancellation, $result); + } + + public function testReschedulesAutomaticPixPaymentThroughInvoiceContract(): void + { + $invoice = new Invoice(); + $invoice->id = 'invoice-id'; + + $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('rescheduleAutomaticPixPayment') + ->once() + ->with(Mockery::on(fn (Invoice $model) => $model->id === 'invoice-id')) + ->andReturn($invoice); + + $result = (new MultiPayment($gateway))->rescheduleAutomaticPixPayment('invoice-id'); + + $this->assertSame($invoice, $result); + } + + public function testGetsAndListsMappedCancellations(): void + { + $cancellation = new AutomaticPixCancellation(); + $cancellation->id = 'cancellation-id'; + $cancellation->recurrenceId = 'recurrence-id'; + + $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('getAutomaticPixCancellation') + ->once() + ->with(Mockery::on(fn (AutomaticPixCancellation $model) => + $model->id === 'cancellation-id' && $model->recurrenceId === 'recurrence-id' + )) + ->andReturn($cancellation); + $gateway->shouldReceive('listAutomaticPixCancellations') + ->once() + ->with(Mockery::on(fn (AutomaticPix $model) => $model->id === 'recurrence-id'), 2, 25) + ->andReturn([$cancellation]); + + $multiPayment = new MultiPayment($gateway); + + $this->assertSame( + $cancellation, + $multiPayment->getAutomaticPixCancellation('recurrence-id', 'cancellation-id') + ); + $this->assertSame( + [$cancellation], + $multiPayment->listAutomaticPixCancellations('recurrence-id', 2, 25) + ); } public function testCancelsInvoiceThroughGateway(): void @@ -45,22 +152,14 @@ public function testCancelsInvoiceThroughGateway(): void $cancelledInvoice->id = 'invoice-id'; $cancelledInvoice->status = Invoice::STATUS_CANCELED; - $gateway = Mockery::mock(GatewayContract::class, InvoiceCancellationContract::class); + $gateway = Mockery::mock(GatewayContract::class); $gateway->shouldReceive('cancelInvoice') ->once() - ->with(Mockery::on(fn(Invoice $invoice) => $invoice->id === 'invoice-id')) + ->with(Mockery::on(fn (Invoice $invoice) => $invoice->id === 'invoice-id')) ->andReturn($cancelledInvoice); $result = (new MultiPayment($gateway))->cancelInvoice('invoice-id'); $this->assertSame($cancelledInvoice, $result); } - - public function testRejectsInvoiceCancellationForUnsupportedGateway(): void - { - $this->expectException(MultiPaymentException::class); - - (new MultiPayment(Mockery::mock(GatewayContract::class))) - ->cancelInvoice('invoice-id'); - } } diff --git a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php index d291c29..cdfdc13 100644 --- a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php +++ b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php @@ -8,8 +8,11 @@ use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\AutomaticPix; +use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; class IuguGatewayAutomaticPixTest extends TestCase { @@ -32,17 +35,61 @@ protected function tearDown(): void parent::tearDown(); } - public function testCancelsScheduledPaymentWithRequiredQueryParameters(): void + public function testMapsGenericAutomaticPixFieldsToIuguInvoiceFields(): void + { + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'recurrence-id'; + $automaticPix->authorizationType = AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT; + $automaticPix->frequency = AutomaticPix::FREQUENCY_MONTHLY; + $automaticPix->startsAt = now()->startOfDay(); + $automaticPix->contractReference = 'contract-123'; + $automaticPix->endsAt = now()->addYear()->startOfDay(); + $automaticPix->retryPolicy = AutomaticPix::RETRY_POLICY_ALLOWED; + + $method = new \ReflectionMethod(IuguGateway::class, 'automaticPixToIuguData'); + $method->setAccessible(true); + $data = $method->invoke(new IuguGateway(new RecordingIuguApiRequest((object) [])), $automaticPix); + + $this->assertSame([ + 'journey' => 3, + 'frequency' => 'monthly', + 'recurrence_beginning' => $automaticPix->startsAt->format('Y-m-d'), + 'contract_number' => 'contract-123', + 'end_date' => $automaticPix->endsAt->format('Y-m-d'), + 'receiver_recurrence_id' => 'recurrence-id', + 'retry_policy' => 'retry_allowed', + ], $data); + } + + public function testRejectsAuthorizationTypeUnsupportedByIugu(): void + { + $automaticPix = new AutomaticPix(); + $automaticPix->authorizationType = 'push'; + $automaticPix->frequency = AutomaticPix::FREQUENCY_MONTHLY; + $automaticPix->startsAt = now()->startOfDay(); + $automaticPix->contractReference = 'contract-123'; + + $method = new \ReflectionMethod(IuguGateway::class, 'automaticPixToIuguData'); + $method->setAccessible(true); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('authorizationType is not supported by the Iugu gateway'); + + $method->invoke(new IuguGateway(new RecordingIuguApiRequest((object) [])), $automaticPix); + } + + public function testCancelsScheduledPaymentAndReturnsCancellationModel(): void { $apiRequest = new RecordingIuguApiRequest((object) [ 'success' => true, - 'cancellation_id' => 'd87f02d3-c7bd-4096-b397-867fdae99d10', + 'cancellation_id' => 'cancellation-id', ]); - $result = (new IuguGateway($apiRequest)) ->cancelAutomaticPixScheduledPayment('payment-id', 'end-to-end-id'); - $this->assertTrue($result->success); + $this->assertInstanceOf(AutomaticPixCancellation::class, $result); + $this->assertSame('cancellation-id', $result->id); + $this->assertSame('payment-id', $result->paymentId); $this->assertSame('POST', $apiRequest->method); $this->assertSame('/v1/automatic_pix/receiver_recurrence_payments/cancel', parse_url($apiRequest->url, PHP_URL_PATH)); parse_str((string) parse_url($apiRequest->url, PHP_URL_QUERY), $query); @@ -59,17 +106,99 @@ public function testRejectsUnsuccessfulScheduledPaymentCancellation(): void 'success' => false, 'errors' => [(object) ['message' => 'Pagamento não pode ser cancelado']], ]); - $this->expectException(GatewayException::class); (new IuguGateway($apiRequest)) ->cancelAutomaticPixScheduledPayment('payment-id', 'end-to-end-id'); } + public function testCancelsRecurrenceAndReturnsRequestedCancellation(): void + { + $apiRequest = new RecordingIuguApiRequest((object) [ + 'success' => true, + 'message' => 'Recurrence cancellation requested', + ]); + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'recurrence-id'; + + $result = (new IuguGateway($apiRequest))->cancelAutomaticPixRecurrence($automaticPix); + + $this->assertSame('PUT', $apiRequest->method); + $this->assertSame( + '/v1/automatic_pix/receiver_recurrences/recurrence-id/cancel', + parse_url($apiRequest->url, PHP_URL_PATH) + ); + $this->assertSame('recurrence-id', $result->recurrenceId); + $this->assertSame(AutomaticPixCancellation::STATUS_REQUESTED, $result->status); + } + + public function testReschedulesPaymentFromInvoice(): void + { + $apiRequest = new RecordingIuguApiRequest((object) ['success' => true]); + $invoice = new Invoice(); + $invoice->id = 'invoice-id'; + + $result = (new IuguGateway($apiRequest))->rescheduleAutomaticPixPayment($invoice); + + $this->assertSame($invoice, $result); + $this->assertSame('POST', $apiRequest->method); + $this->assertSame( + '/v1/invoices/invoice-id/reschedule_automatic_pix_payment', + parse_url($apiRequest->url, PHP_URL_PATH) + ); + } + + public function testGetsCancellationMappedToGenericFields(): void + { + $apiRequest = new RecordingIuguApiRequest((object) [ + 'id' => 'cancellation-id', + 'receiver_recurrence_id' => 'recurrence-id', + 'receiver_recurrence_payment_id' => 'payment-id', + 'end_to_end_id' => 'end-to-end-id', + 'status' => 'cancelled', + 'amount' => 1250, + 'payer_account' => '12345-6', + ]); + $cancellation = new AutomaticPixCancellation(); + $cancellation->id = 'cancellation-id'; + $cancellation->recurrenceId = 'recurrence-id'; + + $result = (new IuguGateway($apiRequest))->getAutomaticPixCancellation($cancellation); + + $this->assertSame( + '/v1/automatic_pix/receiver_recurrences/recurrence-id/cancellations/cancellation-id', + parse_url($apiRequest->url, PHP_URL_PATH) + ); + $this->assertSame('payment-id', $result->paymentId); + $this->assertSame('end-to-end-id', $result->endToEndId); + $this->assertSame(1250, $result->amount); + $this->assertSame('12345-6', $result->payerAccount); + } + + public function testListsMappedCancellationsWithPagination(): void + { + $apiRequest = new RecordingIuguApiRequest((object) [ + 'cancellations' => [ + (object) ['id' => 'one', 'amount' => 100], + (object) ['id' => 'two', 'amount' => 200], + ], + ]); + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'recurrence-id'; + + $result = (new IuguGateway($apiRequest))->listAutomaticPixCancellations($automaticPix, 2, 25); + + parse_str((string) parse_url($apiRequest->url, PHP_URL_QUERY), $query); + $this->assertSame(['limit' => '25', 'page' => '2'], $query); + $this->assertCount(2, $result); + $this->assertContainsOnlyInstancesOf(AutomaticPixCancellation::class, $result); + $this->assertSame('one', $result[0]->id); + $this->assertSame('recurrence-id', $result[0]->recurrenceId); + } + public function testCancelsInvoiceAndReturnsParsedInvoice(): void { $apiRequest = new RecordingIuguApiRequest($this->cancelledInvoiceResponse()); - $invoice = new Invoice(); $invoice->id = 'invoice-id'; @@ -105,6 +234,7 @@ private function cancelledInvoiceResponse(): object 'payer_address_zip_code' => null, 'bank_slip' => null, 'pix' => null, + 'automatic_pix' => null, 'credit_card_transaction' => null, ]; } @@ -116,7 +246,7 @@ class RecordingIuguApiRequest extends Iugu_APIRequest public ?string $url = null; public array $data = []; - public function __construct(private object $response) + public function __construct(private object|array $response) { } From fad0e6edd3a6434d300aec547e863c370f0659cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Walker?= Date: Sat, 18 Jul 2026 11:56:27 -0300 Subject: [PATCH 6/8] =?UTF-8?q?docs(pix):=20documentar=20uso=20e=20limita?= =?UTF-8?q?=C3=A7=C3=B5es=20da=20sandbox=20da=20Iugu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/README.md b/README.md index 228f389..7289490 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [Utilizando](#utilizando) - [MultiPayment](#multipayment) - [InvoiceBuilder](#invoicebuilder) + - [Pix Automático](#pix-automático) - [CustomerBuilder](#customerbuilder) - [getInvoice](#getinvoice) - [charge](#charge) @@ -90,6 +91,60 @@ $invoice = $invoiceBuilder->setPaymentMethod('payment_method') ->create(); ``` Confira `src/MultiPayment/Builders/InvoiceBuilder.php` para saber quais métodos estão disponíveis. + +#### Pix Automático + +O Pix Automático está disponível no gateway Iugu e é configurado como parte da fatura: + +```php +use Potelo\MultiPayment\Models\AutomaticPix; + +$invoice = (new \Potelo\MultiPayment\MultiPayment('iugu')) + ->newInvoice() + ->addAvailablePaymentMethod('pix') + ->addCustomer('Nome', 'email@example.com', '01234567891') + ->addItem('Mensalidade', 10000, 1) + ->addAutomaticPix( + AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT, + AutomaticPix::FREQUENCY_MONTHLY, + '2026-08-01', + 'contrato-123', + '2027-08-01', + AutomaticPix::RETRY_POLICY_ALLOWED, + ) + ->create(); +``` + +As demais operações também utilizam os modelos do MultiPayment, enquanto os nomes específicos da Iugu são tratados internamente pelo gateway: + +```php +$multiPayment = new \Potelo\MultiPayment\MultiPayment('iugu'); + +$multiPayment->rescheduleAutomaticPixPayment($invoiceId); +$multiPayment->cancelAutomaticPixRecurrence($recurrenceId); +$multiPayment->cancelAutomaticPixScheduledPayment($paymentId, $endToEndId); +$multiPayment->getAutomaticPixCancellation($recurrenceId, $cancellationId); +$multiPayment->listAutomaticPixCancellations($recurrenceId, page: 1, limit: 100); +``` + +##### Testes com a sandbox da Iugu + +A suíte `Integration` reúne todos os testes que acessam a sandbox da Iugu. Cada +teste cria durante a execução os clientes, faturas e cartões de que precisa; não +há dependência de IDs ou outros dados previamente existentes no gateway. + +```bash +IUGU_ID=seu_account_id \ +IUGU_APIKEY=seu_api_token \ +./vendor/bin/phpunit -c phpunit.xml.dist --testsuite Integration +``` + +Atualmente, a sandbox responde que Pix Automático não está disponível no modo de +teste. Os cenários que dependem desse recurso estão identificados com o grupo +`iugu-sandbox-limitation` e usam um `skip` explícito com a razão da limitação. Os +testes permanecem junto das classes responsáveis pelo builder e pela facade para +que possam ser reativados quando o ambiente passar a suportar o fluxo. + #### CustomerBuilder ```php $multiPayment = new \Potelo\MultiPayment\MultiPayment('iugu'); From 96ef2f8093a9fc10cae93cb93cbe93993831fd3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Walker?= Date: Sat, 18 Jul 2026 12:35:06 -0300 Subject: [PATCH 7/8] =?UTF-8?q?fix(pix):=20permitir=20cobran=C3=A7a=20em?= =?UTF-8?q?=20recorr=C3=AAncia=20autom=C3=A1tica=20existente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Gateways/IuguGateway.php | 4 ++++ src/Models/AutomaticPix.php | 4 ++++ tests/Unit/AutomaticPixTest.php | 16 ++++++++++++++++ .../Gateways/IuguGatewayAutomaticPixTest.php | 14 ++++++++++++-- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index e33f1b0..6fc3e0c 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -544,6 +544,10 @@ private function automaticPixToIuguData(AutomaticPix $automaticPix): array { $automaticPix->validateForInvoice(); + if (!empty($automaticPix->id)) { + return ['receiver_recurrence_id' => $automaticPix->id]; + } + $journeys = [ AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT => 3, AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_RECURRENCE_OFFER => 4, diff --git a/src/Models/AutomaticPix.php b/src/Models/AutomaticPix.php index 8ba0502..2b90866 100644 --- a/src/Models/AutomaticPix.php +++ b/src/Models/AutomaticPix.php @@ -53,6 +53,10 @@ public function fill(array $data): void */ public function validateForInvoice(): void { + if (!empty($this->id)) { + return; + } + foreach (['authorizationType', 'frequency', 'startsAt', 'contractReference'] as $attribute) { if (empty($this->{$attribute})) { throw ModelAttributeValidationException::required($this->getClassName(), $attribute); diff --git a/tests/Unit/AutomaticPixTest.php b/tests/Unit/AutomaticPixTest.php index 3f96ac8..093fd1d 100644 --- a/tests/Unit/AutomaticPixTest.php +++ b/tests/Unit/AutomaticPixTest.php @@ -47,6 +47,22 @@ public function testBuildsAutomaticPixAsPartOfInvoice(): void $this->assertSame(AutomaticPix::RETRY_POLICY_ALLOWED, $invoice->automaticPix->retryPolicy); } + public function testBuildsInvoiceUsingAnExistingAutomaticPixRecurrence(): void + { + $gateway = Mockery::mock(GatewayContract::class); + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'recurrence-id'; + + $invoice = (new MultiPayment($gateway))->newInvoice() + ->setAutomaticPix($automaticPix) + ->get(); + + $invoice->automaticPix->validateForInvoice(); + + $this->assertSame($automaticPix, $invoice->automaticPix); + $this->assertSame('recurrence-id', $invoice->automaticPix->id); + } + public function testFillsAutomaticPixModelFromInvoiceAttributes(): void { $invoice = new Invoice(); diff --git a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php index cdfdc13..37a854a 100644 --- a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php +++ b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php @@ -38,7 +38,6 @@ protected function tearDown(): void public function testMapsGenericAutomaticPixFieldsToIuguInvoiceFields(): void { $automaticPix = new AutomaticPix(); - $automaticPix->id = 'recurrence-id'; $automaticPix->authorizationType = AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT; $automaticPix->frequency = AutomaticPix::FREQUENCY_MONTHLY; $automaticPix->startsAt = now()->startOfDay(); @@ -56,11 +55,22 @@ public function testMapsGenericAutomaticPixFieldsToIuguInvoiceFields(): void 'recurrence_beginning' => $automaticPix->startsAt->format('Y-m-d'), 'contract_number' => 'contract-123', 'end_date' => $automaticPix->endsAt->format('Y-m-d'), - 'receiver_recurrence_id' => 'recurrence-id', 'retry_policy' => 'retry_allowed', ], $data); } + public function testMapsExistingAutomaticPixRecurrenceWithoutCreationFields(): void + { + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'recurrence-id'; + + $method = new \ReflectionMethod(IuguGateway::class, 'automaticPixToIuguData'); + $method->setAccessible(true); + $data = $method->invoke(new IuguGateway(new RecordingIuguApiRequest((object) [])), $automaticPix); + + $this->assertSame(['receiver_recurrence_id' => 'recurrence-id'], $data); + } + public function testRejectsAuthorizationTypeUnsupportedByIugu(): void { $automaticPix = new AutomaticPix(); From 729a3bd4f820617e0215aae8054f36e4426550e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Walker?= Date: Sat, 18 Jul 2026 13:29:45 -0300 Subject: [PATCH 8/8] =?UTF-8?q?feat(pix):=20modelar=20cobran=C3=A7a=20auto?= =?UTF-8?q?m=C3=A1tica=20de=20forma=20gen=C3=A9rica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- src/Builders/InvoiceBuilder.php | 28 +++++++ src/Contracts/AutomaticPixContract.php | 4 +- src/Facades/MultiPayment.php | 2 +- src/Gateways/IuguGateway.php | 75 ++++++++++++++++--- src/Models/AutomaticPixCharge.php | 34 +++++++++ src/Models/Invoice.php | 11 +++ src/MultiPayment.php | 18 +++-- .../Builders/InvoiceBuilderTest.php | 1 + tests/Integration/MultiPaymentTest.php | 1 + tests/Unit/AutomaticPixTest.php | 29 ++++++- .../Gateways/IuguGatewayAutomaticPixTest.php | 52 ++++++++++++- 12 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 src/Models/AutomaticPixCharge.php diff --git a/README.md b/README.md index 7289490..7f36970 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ $invoice = (new \Potelo\MultiPayment\MultiPayment('iugu')) '2027-08-01', AutomaticPix::RETRY_POLICY_ALLOWED, ) + ->addAutomaticPixCharge('Mensalidade do plano') ->create(); ``` @@ -122,7 +123,7 @@ $multiPayment = new \Potelo\MultiPayment\MultiPayment('iugu'); $multiPayment->rescheduleAutomaticPixPayment($invoiceId); $multiPayment->cancelAutomaticPixRecurrence($recurrenceId); -$multiPayment->cancelAutomaticPixScheduledPayment($paymentId, $endToEndId); +$multiPayment->cancelAutomaticPixScheduledPayment($invoice->automaticPixCharge); $multiPayment->getAutomaticPixCancellation($recurrenceId, $cancellationId); $multiPayment->listAutomaticPixCancellations($recurrenceId, page: 1, limit: 100); ``` diff --git a/src/Builders/InvoiceBuilder.php b/src/Builders/InvoiceBuilder.php index e2e1307..14d710a 100644 --- a/src/Builders/InvoiceBuilder.php +++ b/src/Builders/InvoiceBuilder.php @@ -9,6 +9,7 @@ use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\InvoiceItem; use Potelo\MultiPayment\Models\AutomaticPix; +use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Contracts\GatewayContract; /** @@ -98,6 +99,33 @@ public function setAutomaticPix(AutomaticPix $automaticPix): InvoiceBuilder return $this; } + /** + * Set the charge associated with this Automatic Pix invoice. + */ + public function setAutomaticPixCharge(AutomaticPixCharge $charge): InvoiceBuilder + { + $this->model->automaticPixCharge = $charge; + + return $this; + } + + /** + * Add data for the charge associated with this Automatic Pix invoice. + */ + public function addAutomaticPixCharge( + ?string $description = null, + ?string $id = null, + ?string $endToEndId = null + ): InvoiceBuilder { + $charge = new AutomaticPixCharge(); + $charge->description = $description; + $charge->id = $id; + $charge->endToEndId = $endToEndId; + $this->model->automaticPixCharge = $charge; + + return $this; + } + /** * Add Automatic Pix recurrence data to the invoice. * diff --git a/src/Contracts/AutomaticPixContract.php b/src/Contracts/AutomaticPixContract.php index 2e8232a..4ed791a 100644 --- a/src/Contracts/AutomaticPixContract.php +++ b/src/Contracts/AutomaticPixContract.php @@ -4,6 +4,7 @@ use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\AutomaticPix; +use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; @@ -19,8 +20,7 @@ public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice; * @throws GatewayException|GatewayNotAvailableException */ public function cancelAutomaticPixScheduledPayment( - string $paymentId, - string $endToEndId + AutomaticPixCharge $charge ): AutomaticPixCancellation; /** diff --git a/src/Facades/MultiPayment.php b/src/Facades/MultiPayment.php index a4e63ba..4a0b1c8 100644 --- a/src/Facades/MultiPayment.php +++ b/src/Facades/MultiPayment.php @@ -23,7 +23,7 @@ * @method static Invoice cancelInvoice(Invoice|string $invoice) * @method static Invoice rescheduleAutomaticPixPayment(Invoice|string $invoice) * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation cancelAutomaticPixRecurrence(\Potelo\MultiPayment\Models\AutomaticPix|string $automaticPix) - * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation cancelAutomaticPixScheduledPayment(string $paymentId, string $endToEndId) + * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation cancelAutomaticPixScheduledPayment(\Potelo\MultiPayment\Models\AutomaticPixCharge|string $charge, ?string $endToEndId = null) * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation getAutomaticPixCancellation(\Potelo\MultiPayment\Models\AutomaticPixCancellation|string $cancellation, ?string $cancellationId = null) * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation[] listAutomaticPixCancellations(\Potelo\MultiPayment\Models\AutomaticPix|string $automaticPix, int $page = 1, int $limit = 100) */ diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index 6fc3e0c..7264c97 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -18,6 +18,7 @@ use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\InvoiceItem; use Potelo\MultiPayment\Models\AutomaticPix; +use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Exceptions\GatewayException; @@ -93,6 +94,13 @@ public function createInvoice(Invoice $invoice): Invoice $iuguInvoiceData['automatic_pix'] = $this->automaticPixToIuguData($invoice->automaticPix); } + if (!empty($invoice->automaticPixCharge?->description)) { + $iuguInvoiceData = array_merge( + $iuguInvoiceData, + $this->automaticPixChargeToIuguData($invoice->automaticPixCharge) + ); + } + if (!empty($invoice->gatewayAdicionalOptions)) { foreach ($invoice->gatewayAdicionalOptions as $option => $value) { $iuguInvoiceData[$option] = $value; @@ -457,19 +465,18 @@ public function cancelAutomaticPixRecurrence( /** @inheritDoc */ public function cancelAutomaticPixScheduledPayment( - string $paymentId, - string $endToEndId + AutomaticPixCharge $charge ): AutomaticPixCancellation { - if (empty($paymentId)) { - throw ModelAttributeValidationException::required('AutomaticPixScheduledPayment', 'paymentId'); + if (empty($charge->id)) { + throw ModelAttributeValidationException::required('AutomaticPixCharge', 'id'); } - if (empty($endToEndId)) { - throw ModelAttributeValidationException::required('AutomaticPixScheduledPayment', 'endToEndId'); + if (empty($charge->endToEndId)) { + throw ModelAttributeValidationException::required('AutomaticPixCharge', 'endToEndId'); } $query = http_build_query([ - 'receiver_recurrence_payment_id' => $paymentId, - 'end_to_end_id' => $endToEndId, + 'receiver_recurrence_payment_id' => $charge->id, + 'end_to_end_id' => $charge->endToEndId, ], '', '&', PHP_QUERY_RFC3986); $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrence_payments/cancel?' . $query; $response = $this->automaticPixRequest( @@ -480,8 +487,8 @@ public function cancelAutomaticPixScheduledPayment( ); $cancellation = $this->parseAutomaticPixCancellation($response); - $cancellation->paymentId ??= $paymentId; - $cancellation->endToEndId ??= $endToEndId; + $cancellation->paymentId ??= $charge->id; + $cancellation->endToEndId ??= $charge->endToEndId; $cancellation->status ??= AutomaticPixCancellation::STATUS_REQUESTED; return $cancellation; @@ -578,6 +585,16 @@ private function automaticPixToIuguData(AutomaticPix $automaticPix): array return array_filter($data, static fn ($value) => !is_null($value)); } + /** + * Convert the gateway-neutral charge model into Iugu invoice fields. + */ + private function automaticPixChargeToIuguData(AutomaticPixCharge $charge): array + { + return array_filter([ + 'pix_remittance_info' => $charge->description, + ], static fn ($value) => !is_null($value)); + } + /** * Convert Iugu recurrence fields back into the gateway-neutral model. */ @@ -615,6 +632,34 @@ private function parseAutomaticPix($data, ?AutomaticPix $automaticPix = null): A return $automaticPix; } + /** + * Convert Iugu scheduled charge fields into the gateway-neutral model. + */ + private function parseAutomaticPixCharge( + $data, + ?AutomaticPixCharge $charge = null, + ?string $recurrenceId = null, + ?string $description = null + ): AutomaticPixCharge { + $data = (object) $data; + $charge ??= new AutomaticPixCharge(); + $charge->id = $data->receiver_recurrence_payment_id ?? $data->id ?? $charge->id; + $charge->recurrenceId = $recurrenceId ?? $charge->recurrenceId; + $charge->endToEndId = $data->receiver_recurrence_payment_end_to_end_id + ?? $data->end_to_end_id + ?? $charge->endToEndId; + $charge->description = $description ?? $data->description ?? $charge->description; + $charge->amount = $data->amount ?? $charge->amount; + $charge->scheduledAt = !empty($data->scheduled_payment_at) + ? new Carbon($data->scheduled_payment_at) + : $charge->scheduledAt; + $charge->status = $data->status ?? $charge->status; + $charge->gateway = 'iugu'; + $charge->original = $data; + + return $charge; + } + /** * Perform a raw Iugu request while preserving the package exception contract. */ @@ -807,6 +852,16 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice $iuguInvoice->automatic_pix, $invoice->automaticPix ); + + $automaticPix = (object) $iuguInvoice->automatic_pix; + if (!empty($automaticPix->recurrence_receiver_payment)) { + $invoice->automaticPixCharge = $this->parseAutomaticPixCharge( + $automaticPix->recurrence_receiver_payment, + $invoice->automaticPixCharge, + $invoice->automaticPix->id, + $iuguInvoice->pix_remittance_info ?? null + ); + } } if (!empty($iuguInvoice->credit_card_transaction)) { diff --git a/src/Models/AutomaticPixCharge.php b/src/Models/AutomaticPixCharge.php new file mode 100644 index 0000000..81a258e --- /dev/null +++ b/src/Models/AutomaticPixCharge.php @@ -0,0 +1,34 @@ +scheduledAt = $data['scheduled_at'] instanceof Carbon + ? $data['scheduled_at'] + : Carbon::parse($data['scheduled_at']); + unset($data['scheduled_at']); + } + + parent::fill($data); + } +} diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 43a2176..b9c3848 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -92,6 +92,11 @@ class Invoice extends Model */ public ?AutomaticPix $automaticPix = null; + /** + * @var AutomaticPixCharge|null + */ + public ?AutomaticPixCharge $automaticPixCharge = null; + /** * @var Carbon|null */ @@ -175,6 +180,12 @@ public function fill(array $data): void unset($data['automatic_pix']); } + if (!empty($data['automatic_pix_charge']) && is_array($data['automatic_pix_charge'])) { + $this->automaticPixCharge = new AutomaticPixCharge(); + $this->automaticPixCharge->fill($data['automatic_pix_charge']); + unset($data['automatic_pix_charge']); + } + parent::fill($data); } diff --git a/src/MultiPayment.php b/src/MultiPayment.php index 32a92ee..048157d 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -8,6 +8,7 @@ use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\AutomaticPix; +use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Builders\InvoiceBuilder; @@ -311,16 +312,23 @@ public function cancelAutomaticPixRecurrence( /** * Cancela um pagamento agendado de Pix Automático no gateway. * - * @param string $paymentId - * @param string $endToEndId + * @param AutomaticPixCharge|string $charge + * @param string|null $endToEndId * @throws GatewayException * @throws GatewayNotAvailableException */ public function cancelAutomaticPixScheduledPayment( - string $paymentId, - string $endToEndId + AutomaticPixCharge|string $charge, + ?string $endToEndId = null ): AutomaticPixCancellation { - return $this->gateway->cancelAutomaticPixScheduledPayment($paymentId, $endToEndId); + if (is_string($charge)) { + $chargeModel = new AutomaticPixCharge(); + $chargeModel->id = $charge; + $chargeModel->endToEndId = $endToEndId; + $charge = $chargeModel; + } + + return $this->gateway->cancelAutomaticPixScheduledPayment($charge); } /** diff --git a/tests/Integration/Builders/InvoiceBuilderTest.php b/tests/Integration/Builders/InvoiceBuilderTest.php index b856162..50aa027 100644 --- a/tests/Integration/Builders/InvoiceBuilderTest.php +++ b/tests/Integration/Builders/InvoiceBuilderTest.php @@ -45,6 +45,7 @@ public function testShouldCreateAutomaticPixInvoice(): void Carbon::now()->addYear(), AutomaticPix::RETRY_POLICY_ALLOWED ) + ->addAutomaticPixCharge('Automatic Pix sandbox test') ->create(); $this->assertNotEmpty($invoice->id); diff --git a/tests/Integration/MultiPaymentTest.php b/tests/Integration/MultiPaymentTest.php index f802115..c6badcc 100644 --- a/tests/Integration/MultiPaymentTest.php +++ b/tests/Integration/MultiPaymentTest.php @@ -44,6 +44,7 @@ public function testShouldGetAutomaticPixInvoice(): void now()->addYear(), AutomaticPix::RETRY_POLICY_ALLOWED ) + ->addAutomaticPixCharge('Automatic Pix sandbox test') ->create(); $invoiceFetched = MultiPayment::setGateway('iugu')->getInvoice($invoice->id); diff --git a/tests/Unit/AutomaticPixTest.php b/tests/Unit/AutomaticPixTest.php index 093fd1d..a199adc 100644 --- a/tests/Unit/AutomaticPixTest.php +++ b/tests/Unit/AutomaticPixTest.php @@ -8,6 +8,7 @@ use Potelo\MultiPayment\MultiPayment; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\AutomaticPix; +use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Contracts\GatewayContract; @@ -33,6 +34,7 @@ public function testBuildsAutomaticPixAsPartOfInvoice(): void '2027-08-01', AutomaticPix::RETRY_POLICY_ALLOWED ) + ->addAutomaticPixCharge('Monthly subscription') ->get(); $this->assertInstanceOf(AutomaticPix::class, $invoice->automaticPix); @@ -45,6 +47,8 @@ public function testBuildsAutomaticPixAsPartOfInvoice(): void $this->assertSame('contract-123', $invoice->automaticPix->contractReference); $this->assertSame('2027-08-01', $invoice->automaticPix->endsAt->format('Y-m-d')); $this->assertSame(AutomaticPix::RETRY_POLICY_ALLOWED, $invoice->automaticPix->retryPolicy); + $this->assertInstanceOf(AutomaticPixCharge::class, $invoice->automaticPixCharge); + $this->assertSame('Monthly subscription', $invoice->automaticPixCharge->description); } public function testBuildsInvoiceUsingAnExistingAutomaticPixRecurrence(): void @@ -85,6 +89,27 @@ public function testFillsAutomaticPixModelFromInvoiceAttributes(): void $this->assertInstanceOf(Carbon::class, $invoice->automaticPix->startsAt); } + public function testFillsAutomaticPixChargeFromInvoiceAttributes(): void + { + $invoice = new Invoice(); + $invoice->fill([ + 'automatic_pix_charge' => [ + 'id' => 'charge-id', + 'recurrence_id' => 'recurrence-id', + 'end_to_end_id' => 'end-to-end-id', + 'description' => 'Monthly subscription', + 'scheduled_at' => '2026-08-01T10:00:00-03:00', + ], + ]); + + $this->assertInstanceOf(AutomaticPixCharge::class, $invoice->automaticPixCharge); + $this->assertSame('charge-id', $invoice->automaticPixCharge->id); + $this->assertSame('recurrence-id', $invoice->automaticPixCharge->recurrenceId); + $this->assertSame('end-to-end-id', $invoice->automaticPixCharge->endToEndId); + $this->assertSame('Monthly subscription', $invoice->automaticPixCharge->description); + $this->assertInstanceOf(Carbon::class, $invoice->automaticPixCharge->scheduledAt); + } + public function testCancelsScheduledAutomaticPixPaymentThroughScalarContract(): void { $cancellation = new AutomaticPixCancellation(); @@ -93,7 +118,9 @@ public function testCancelsScheduledAutomaticPixPaymentThroughScalarContract(): $gateway = Mockery::mock(GatewayContract::class); $gateway->shouldReceive('cancelAutomaticPixScheduledPayment') ->once() - ->with('payment-id', 'end-to-end-id') + ->with(Mockery::on(fn (AutomaticPixCharge $charge) => + $charge->id === 'payment-id' && $charge->endToEndId === 'end-to-end-id' + )) ->andReturn($cancellation); $result = (new MultiPayment($gateway)) diff --git a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php index 37a854a..6ac3a2c 100644 --- a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php +++ b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php @@ -2,6 +2,7 @@ namespace Potelo\MultiPayment\Tests\Unit\Gateways; +use Carbon\Carbon; use Iugu_APIRequest; use PHPUnit\Framework\TestCase; use Illuminate\Config\Repository; @@ -9,6 +10,7 @@ use Illuminate\Support\Facades\Facade; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\AutomaticPix; +use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Exceptions\GatewayException; @@ -71,6 +73,18 @@ public function testMapsExistingAutomaticPixRecurrenceWithoutCreationFields(): v $this->assertSame(['receiver_recurrence_id' => 'recurrence-id'], $data); } + public function testMapsGenericChargeDescriptionToIuguRemittanceInformation(): void + { + $charge = new AutomaticPixCharge(); + $charge->description = 'Monthly subscription'; + + $method = new \ReflectionMethod(IuguGateway::class, 'automaticPixChargeToIuguData'); + $method->setAccessible(true); + $data = $method->invoke(new IuguGateway(new RecordingIuguApiRequest((object) [])), $charge); + + $this->assertSame(['pix_remittance_info' => 'Monthly subscription'], $data); + } + public function testRejectsAuthorizationTypeUnsupportedByIugu(): void { $automaticPix = new AutomaticPix(); @@ -94,8 +108,11 @@ public function testCancelsScheduledPaymentAndReturnsCancellationModel(): void 'success' => true, 'cancellation_id' => 'cancellation-id', ]); + $charge = new AutomaticPixCharge(); + $charge->id = 'payment-id'; + $charge->endToEndId = 'end-to-end-id'; $result = (new IuguGateway($apiRequest)) - ->cancelAutomaticPixScheduledPayment('payment-id', 'end-to-end-id'); + ->cancelAutomaticPixScheduledPayment($charge); $this->assertInstanceOf(AutomaticPixCancellation::class, $result); $this->assertSame('cancellation-id', $result->id); @@ -116,10 +133,13 @@ public function testRejectsUnsuccessfulScheduledPaymentCancellation(): void 'success' => false, 'errors' => [(object) ['message' => 'Pagamento não pode ser cancelado']], ]); + $charge = new AutomaticPixCharge(); + $charge->id = 'payment-id'; + $charge->endToEndId = 'end-to-end-id'; $this->expectException(GatewayException::class); (new IuguGateway($apiRequest)) - ->cancelAutomaticPixScheduledPayment('payment-id', 'end-to-end-id'); + ->cancelAutomaticPixScheduledPayment($charge); } public function testCancelsRecurrenceAndReturnsRequestedCancellation(): void @@ -220,6 +240,34 @@ public function testCancelsInvoiceAndReturnsParsedInvoice(): void $this->assertSame('invoice-id', $result->id); } + public function testMapsIuguScheduledPaymentToGenericCharge(): void + { + $response = $this->cancelledInvoiceResponse(); + $response->automatic_pix = (object) [ + 'receiver_recurrence_id' => 'recurrence-id', + 'recurrence_receiver_payment' => (object) [ + 'receiver_recurrence_payment_id' => 'charge-id', + 'receiver_recurrence_payment_end_to_end_id' => 'end-to-end-id', + 'scheduled_payment_at' => '2026-08-01T10:00:00-03:00', + 'status' => 'scheduled', + ], + ]; + $response->pix_remittance_info = 'Monthly subscription'; + + $invoice = new Invoice(); + $invoice->id = 'invoice-id'; + $result = (new IuguGateway(new RecordingIuguApiRequest($response)))->cancelInvoice($invoice); + + $this->assertInstanceOf(AutomaticPixCharge::class, $result->automaticPixCharge); + $this->assertSame('charge-id', $result->automaticPixCharge->id); + $this->assertSame('recurrence-id', $result->automaticPixCharge->recurrenceId); + $this->assertSame('end-to-end-id', $result->automaticPixCharge->endToEndId); + $this->assertSame('Monthly subscription', $result->automaticPixCharge->description); + $this->assertSame('scheduled', $result->automaticPixCharge->status); + $this->assertInstanceOf(Carbon::class, $result->automaticPixCharge->scheduledAt); + $this->assertSame('iugu', $result->automaticPixCharge->gateway); + } + private function cancelledInvoiceResponse(): object { return (object) [