diff --git a/README.md b/README.md
index 228f389..7f36970 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,61 @@ $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,
+ )
+ ->addAutomaticPixCharge('Mensalidade do plano')
+ ->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($invoice->automaticPixCharge);
+$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');
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/src/Builders/InvoiceBuilder.php b/src/Builders/InvoiceBuilder.php
index 3c23122..14d710a 100644
--- a/src/Builders/InvoiceBuilder.php
+++ b/src/Builders/InvoiceBuilder.php
@@ -8,6 +8,8 @@
use Potelo\MultiPayment\Models\Customer;
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;
/**
@@ -87,6 +89,71 @@ 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;
+ }
+
+ /**
+ * 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.
+ *
+ * @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
new file mode 100644
index 0000000..4ed791a
--- /dev/null
+++ b/src/Contracts/AutomaticPixContract.php
@@ -0,0 +1,49 @@
+flattenErrors($value, $messages, $newKey);
diff --git a/src/Facades/MultiPayment.php b/src/Facades/MultiPayment.php
index 7a0f9ec..4a0b1c8 100644
--- a/src/Facades/MultiPayment.php
+++ b/src/Facades/MultiPayment.php
@@ -20,6 +20,12 @@
* @method static \Potelo\MultiPayment\MultiPayment setGateway($gateway)
* @method static Invoice chargeInvoiceWithCreditCard($invoice, ?string $creditCardToken = null, ?string $creditCardId = null)
* @method static \Potelo\MultiPayment\Models\Customer setDefaultCard(string $customerId, string $creditCardId)
+ * @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(\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)
*/
class MultiPayment extends Facade
{
diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php
index e4f63c0..7264c97 100644
--- a/src/Gateways/IuguGateway.php
+++ b/src/Gateways/IuguGateway.php
@@ -5,6 +5,7 @@
use Iugu;
use Iugu_Customer;
use Carbon\Carbon;
+use Iugu_APIRequest;
use Iugu_PaymentToken;
use Iugu_PaymentMethod;
use IuguObjectNotFound;
@@ -16,6 +17,9 @@
use Potelo\MultiPayment\Models\BankSlip;
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;
use Potelo\MultiPayment\Exceptions\ChargingException;
@@ -38,12 +42,15 @@ class IuguGateway implements GatewayContract
private const STATUS_CHARGEBACK = 'chargeback';
private const STATUS_AUTHORIZED = 'authorized';
+ private Iugu_APIRequest $apiRequest;
+
/**
* Set iugu api key.
*/
- public function __construct()
+ public function __construct(?Iugu_APIRequest $apiRequest = null)
{
Iugu::setApiKey(Config::get('multi-payment.gateways.iugu.api_key'));
+ $this->apiRequest = $apiRequest ?? new Iugu_APIRequest();
}
/**
@@ -83,6 +90,17 @@ public function createInvoice(Invoice $invoice): Invoice
$iuguInvoiceData['payable_with'] = $invoice->availablePaymentMethods;
}
+ if (!empty($invoice->automaticPix)) {
+ $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;
@@ -349,6 +367,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
*/
@@ -377,6 +423,320 @@ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gat
return $this->parseInvoice($iuguInvoice);
}
+ /** @inheritDoc */
+ public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice
+ {
+ if (empty($invoice->id)) {
+ throw ModelAttributeValidationException::required('Invoice', 'id');
+ }
+
+ $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);
+ }
+
+ $invoice->gateway = 'iugu';
+ $invoice->original = $response;
+
+ return $invoice;
+ }
+
+ /** @inheritDoc */
+ public function cancelAutomaticPixRecurrence(
+ AutomaticPix $automaticPix
+ ): AutomaticPixCancellation {
+ if (empty($automaticPix->id)) {
+ throw ModelAttributeValidationException::required('AutomaticPix', 'id');
+ }
+
+ $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 */
+ public function cancelAutomaticPixScheduledPayment(
+ AutomaticPixCharge $charge
+ ): AutomaticPixCancellation {
+ if (empty($charge->id)) {
+ throw ModelAttributeValidationException::required('AutomaticPixCharge', 'id');
+ }
+ if (empty($charge->endToEndId)) {
+ throw ModelAttributeValidationException::required('AutomaticPixCharge', 'endToEndId');
+ }
+
+ $query = http_build_query([
+ '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(
+ 'POST',
+ $url,
+ [],
+ 'cancelling automatic pix scheduled payment'
+ );
+
+ $cancellation = $this->parseAutomaticPixCancellation($response);
+ $cancellation->paymentId ??= $charge->id;
+ $cancellation->endToEndId ??= $charge->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();
+
+ 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,
+ ];
+ 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 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.
+ */
+ 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;
+ }
+
+ /**
+ * 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.
+ */
+ private function automaticPixRequest(
+ string $method,
+ string $url,
+ array $data,
+ string $operation
+ ): object|array {
+ try {
+ $response = $this->apiRequest->request($method, $url, $data);
+ } 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 {$operation}: {$e->getMessage()}");
+ }
+
+ $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
*/
@@ -487,6 +847,23 @@ 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
+ );
+
+ $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)) {
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..2b90866
--- /dev/null
+++ b/src/Models/AutomaticPix.php
@@ -0,0 +1,100 @@
+ '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
+ {
+ if (!empty($this->id)) {
+ return;
+ }
+
+ 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 @@
+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 93868b3..b9c3848 100644
--- a/src/Models/Invoice.php
+++ b/src/Models/Invoice.php
@@ -87,6 +87,16 @@ class Invoice extends Model
*/
public ?Pix $pix = null;
+ /**
+ * @var AutomaticPix|null
+ */
+ public ?AutomaticPix $automaticPix = null;
+
+ /**
+ * @var AutomaticPixCharge|null
+ */
+ public ?AutomaticPixCharge $automaticPixCharge = null;
+
/**
* @var Carbon|null
*/
@@ -163,6 +173,19 @@ public function fill(array $data): void
$this->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']);
+ }
+
+ 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);
}
@@ -237,6 +260,14 @@ public function validateCreditCardAttribute()
$this->creditCard->validate();
}
+ /**
+ * @throws ModelAttributeValidationException
+ */
+ public function validateAutomaticPixAttribute(): void
+ {
+ $this->automaticPix->validateForInvoice();
+ }
+
/**
* @inheritDoc
*/
@@ -299,4 +330,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 6ee3686..048157d 100644
--- a/src/MultiPayment.php
+++ b/src/MultiPayment.php
@@ -7,6 +7,9 @@
use Potelo\MultiPayment\Models\CreditCard;
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;
use Potelo\MultiPayment\Builders\CustomerBuilder;
@@ -170,6 +173,25 @@ public function refundInvoice(string $id, ?int $partialValueCents = null): Invoi
}
+ /**
+ * Cancel an invoice.
+ *
+ * @param Invoice|string $invoice
+ * @return Invoice
+ * @throws \Potelo\MultiPayment\Exceptions\GatewayException
+ * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException
+ */
+ public function cancelInvoice(Invoice|string $invoice): Invoice
+ {
+ if (is_string($invoice)) {
+ $invoiceInstance = new Invoice();
+ $invoiceInstance->id = $invoice;
+ $invoice = $invoiceInstance;
+ }
+
+ return $invoice->cancel($this->gateway);
+ }
+
/**
* Charge invoice with credit card
*
@@ -267,4 +289,96 @@ public function setDefaultCard(string $customerId, string $creditCardId): Custom
return $customer->setDefaultCard($creditCardId);
}
+ /**
+ * Cancela uma recorrência de Pix Automático no gateway.
+ *
+ * @param AutomaticPix|string $automaticPix
+ * @throws GatewayException
+ * @throws GatewayNotAvailableException
+ */
+ public function cancelAutomaticPixRecurrence(
+ AutomaticPix|string $automaticPix
+ ): AutomaticPixCancellation
+ {
+ if (is_string($automaticPix)) {
+ $automaticPixModel = new AutomaticPix();
+ $automaticPixModel->id = $automaticPix;
+ $automaticPix = $automaticPixModel;
+ }
+
+ return $this->gateway->cancelAutomaticPixRecurrence($automaticPix);
+ }
+
+ /**
+ * Cancela um pagamento agendado de Pix Automático no gateway.
+ *
+ * @param AutomaticPixCharge|string $charge
+ * @param string|null $endToEndId
+ * @throws GatewayException
+ * @throws GatewayNotAvailableException
+ */
+ public function cancelAutomaticPixScheduledPayment(
+ AutomaticPixCharge|string $charge,
+ ?string $endToEndId = null
+ ): AutomaticPixCancellation {
+ if (is_string($charge)) {
+ $chargeModel = new AutomaticPixCharge();
+ $chargeModel->id = $charge;
+ $chargeModel->endToEndId = $endToEndId;
+ $charge = $chargeModel;
+ }
+
+ return $this->gateway->cancelAutomaticPixScheduledPayment($charge);
+ }
+
+ /**
+ * 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->listAutomaticPixCancellations($automaticPix, $page, $limit);
+ }
+
}
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
+ )
+ ->addAutomaticPixCharge('Automatic Pix sandbox test')
+ ->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 +378,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..c6badcc 100644
--- a/tests/Unit/MultiPaymentTest.php
+++ b/tests/Integration/MultiPaymentTest.php
@@ -1,15 +1,127 @@
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
+ )
+ ->addAutomaticPixCharge('Automatic Pix sandbox test')
+ ->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 +481,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
new file mode 100644
index 0000000..a199adc
--- /dev/null
+++ b/tests/Unit/AutomaticPixTest.php
@@ -0,0 +1,208 @@
+newInvoice()
+ ->addAutomaticPix(
+ AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT,
+ AutomaticPix::FREQUENCY_MONTHLY,
+ '2026-08-01',
+ 'contract-123',
+ '2027-08-01',
+ AutomaticPix::RETRY_POLICY_ALLOWED
+ )
+ ->addAutomaticPixCharge('Monthly subscription')
+ ->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);
+ $this->assertInstanceOf(AutomaticPixCharge::class, $invoice->automaticPixCharge);
+ $this->assertSame('Monthly subscription', $invoice->automaticPixCharge->description);
+ }
+
+ 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();
+ $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 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();
+ $cancellation->id = 'cancellation-id';
+
+ $gateway = Mockery::mock(GatewayContract::class);
+ $gateway->shouldReceive('cancelAutomaticPixScheduledPayment')
+ ->once()
+ ->with(Mockery::on(fn (AutomaticPixCharge $charge) =>
+ $charge->id === 'payment-id' && $charge->endToEndId === 'end-to-end-id'
+ ))
+ ->andReturn($cancellation);
+
+ $result = (new MultiPayment($gateway))
+ ->cancelAutomaticPixScheduledPayment('payment-id', 'end-to-end-id');
+
+ $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
+ {
+ $cancelledInvoice = new Invoice();
+ $cancelledInvoice->id = 'invoice-id';
+ $cancelledInvoice->status = Invoice::STATUS_CANCELED;
+
+ $gateway = Mockery::mock(GatewayContract::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);
+ }
+}
diff --git a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php
new file mode 100644
index 0000000..6ac3a2c
--- /dev/null
+++ b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php
@@ -0,0 +1,319 @@
+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 testMapsGenericAutomaticPixFieldsToIuguInvoiceFields(): void
+ {
+ $automaticPix = new AutomaticPix();
+ $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'),
+ '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 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();
+ $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' => 'cancellation-id',
+ ]);
+ $charge = new AutomaticPixCharge();
+ $charge->id = 'payment-id';
+ $charge->endToEndId = 'end-to-end-id';
+ $result = (new IuguGateway($apiRequest))
+ ->cancelAutomaticPixScheduledPayment($charge);
+
+ $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);
+ $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']],
+ ]);
+ $charge = new AutomaticPixCharge();
+ $charge->id = 'payment-id';
+ $charge->endToEndId = 'end-to-end-id';
+ $this->expectException(GatewayException::class);
+
+ (new IuguGateway($apiRequest))
+ ->cancelAutomaticPixScheduledPayment($charge);
+ }
+
+ 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';
+
+ $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);
+ }
+
+ 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) [
+ '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,
+ 'automatic_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|array $response)
+ {
+ }
+
+ public function request($method, $url, $data = [])
+ {
+ $this->method = $method;
+ $this->url = $url;
+ $this->data = $data;
+
+ return $this->response;
+ }
+}