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; + } +}