From d5930746add34d8fcc897c36106d2e3b7ebdb2c8 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Fri, 14 Aug 2026 20:41:19 -0300 Subject: [PATCH 01/32] =?UTF-8?q?chore:=20ignorar=20docs/implementacoes=20?= =?UTF-8?q?no=20controle=20de=20vers=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diretório de estudos e planos de implementação locais (ex.: estudo do gateway Stripe), que não fazem parte do pacote publicado. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 995ff5b..529f84c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ vendor/ .idea/ .vscode/ +/docs/implementacoes/ /.phpunit.result.cache /.phpunit.cache/ phpunit.xml \ No newline at end of file From 07d582863429cb93117030e230f3c1c20c05b643 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Fri, 14 Aug 2026 20:41:20 -0300 Subject: [PATCH 02/32] =?UTF-8?q?feat(stripe):=20adicionar=20gateway=20Str?= =?UTF-8?q?ipe=20com=20opera=C3=A7=C3=B5es=20de=20Customer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fundação do segundo gateway do pacote (fase 1 do plano): - stripe/stripe-php ^21.2 com prefer-stable (sem ele o lock resolvia para v21.3.0-alpha.1, um SDK alpha da API preview) - config do gateway stripe (STRIPE_APIKEY, customer_column stripe_id) - StripeGateway com os 17 métodos do contrato: Customer completo (create/update/get/setCustomerDefaultCard) e os demais lançando GatewayException clara de não implementado; versão da API fixada em 2026-07-29.dahlia no client - mapeamento customerToStripeData/parseCustomer no padrão da Iugu: tax id via tax_id_data/tax_ids (com sync create-antes-de-delete no update), bairro/país/nascimento em metadata, telefone concatenado +{país}{DDD}{número} com decomposição no parse - testes unitários com fake da camada HTTP do stripe-php (ApiRequestor::setHttpClient, resetado no tearDown) e caso stripe no dataProvider do CustomerBuilderTest (sandbox real) - sleep(12) do TestCase condicionado aos testes que usam a Iugu - STRIPE_APIKEY no phpunit.xml.dist e nos secrets da CI --- .github/workflows/test.yml | 2 + README.md | 10 +- composer.json | 4 +- composer.lock | 67 ++- phpunit.xml.dist | 1 + src/Gateways/StripeGateway.php | 511 ++++++++++++++++++ src/config/multi-payment.php | 5 + .../Builders/CustomerBuilderTest.php | 1 + tests/TestCase.php | 8 +- .../Gateways/StripeGatewayCustomerTest.php | 436 +++++++++++++++ 10 files changed, 1036 insertions(+), 9 deletions(-) create mode 100644 src/Gateways/StripeGateway.php create mode 100644 tests/Unit/Gateways/StripeGatewayCustomerTest.php diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae7cf57..255c642 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,10 +53,12 @@ jobs: MULTIPAYMENT_DEFAULT: iugu IUGU_ID: ${{ secrets.IUGU_ID }} IUGU_APIKEY: ${{ secrets.IUGU_APIKEY }} + STRIPE_APIKEY: ${{ secrets.STRIPE_APIKEY }} run: | docker run --rm \ --env APP_ENV \ --env MULTIPAYMENT_DEFAULT \ --env IUGU_ID \ --env IUGU_APIKEY \ + --env STRIPE_APIKEY \ multi-payment:latest composer test diff --git a/README.md b/README.md index 7f36970..c07bbe4 100644 --- a/README.md +++ b/README.md @@ -128,15 +128,17 @@ $multiPayment->getAutomaticPixCancellation($recurrenceId, $cancellationId); $multiPayment->listAutomaticPixCancellations($recurrenceId, page: 1, limit: 100); ``` -##### Testes com a sandbox da Iugu +##### Testes com as sandboxes dos gateways -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. +A suíte `Integration` reúne todos os testes que acessam as sandboxes reais (Iugu +e Stripe). 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 \ +STRIPE_APIKEY=sua_chave_sk_test \ ./vendor/bin/phpunit -c phpunit.xml.dist --testsuite Integration ``` diff --git a/composer.json b/composer.json index acd290a..039e425 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ } }, "minimum-stability": "dev", + "prefer-stable": true, "scripts": { "test": [ "Composer\\Config::disableProcessTimeout", @@ -45,7 +46,8 @@ "php": "^8.0|^8.1|^8.2|^8.3", "illuminate/config": "^8.0|^9.0|^10.0|^11.0|^12.0", "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", - "iugu/iugu": "dev-master" + "iugu/iugu": "dev-master", + "stripe/stripe-php": "^21.2" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/composer.lock b/composer.lock index ba8fdc6..0d17e7d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "05e0cd64d25873188e9b879f62942d57", + "content-hash": "47b0f53e9707493e12692a7257266e54", "packages": [ { "name": "brick/math", @@ -2427,6 +2427,69 @@ }, "time": "2025-06-27T02:21:05+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v21.2.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "edf8118f0b96d69f06f372da9168d613d1aed072" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/edf8118f0b96d69f06f372da9168d613d1aed072", + "reference": "edf8118f0b96d69f06f372da9168d613d1aed072", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=7.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.94.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "files": [ + "lib/version_check.php", + "lib/agent_plugin_hint.php" + ], + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v21.2.0" + }, + "time": "2026-08-10T22:11:35+00:00" + }, { "name": "symfony/console", "version": "6.4.x-dev", @@ -7660,7 +7723,7 @@ "stability-flags": { "iugu/iugu": 20 }, - "prefer-stable": false, + "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.0|^8.1|^8.2|^8.3" diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7d43f7b..2f7d124 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -32,5 +32,6 @@ + diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php new file mode 100644 index 0000000..af3489a --- /dev/null +++ b/src/Gateways/StripeGateway.php @@ -0,0 +1,511 @@ +client = $client ?? new StripeClient([ + 'api_key' => Config::get('multi-payment.gateways.stripe.api_key'), + 'stripe_version' => self::STRIPE_API_VERSION, + ]); + } + + /** + * @inheritDoc + */ + public function createCustomer(Customer $customer): Customer + { + $stripeCustomerData = $this->customerToStripeData($customer); + + if (!empty($customer->taxDocument)) { + $stripeCustomerData['tax_id_data'] = [[ + 'type' => $this->taxDocumentType($customer->taxDocument), + 'value' => $customer->taxDocument, + ]]; + } + + $stripeCustomer = $this->stripeRequest(function () use ($stripeCustomerData) { + return $this->client->customers->create($this->withTaxIdsExpanded($stripeCustomerData)); + }); + + return $this->parseCustomer($stripeCustomer, $customer); + } + + /** + * @inheritDoc + * @throws ModelAttributeValidationException + */ + public function updateCustomer(Customer $customer): Customer + { + if (empty($customer->id)) { + throw ModelAttributeValidationException::required('Customer', 'id'); + } + + $stripeCustomerData = $this->customerToStripeData($customer); + + $stripeCustomer = $this->stripeRequest(function () use ($customer, $stripeCustomerData) { + $stripeCustomer = $this->client->customers->update( + $customer->id, + $this->withTaxIdsExpanded($stripeCustomerData) + ); + + if ($this->syncCustomerTaxDocument($stripeCustomer, $customer->taxDocument)) { + $stripeCustomer = $this->client->customers->retrieve( + $customer->id, + ['expand' => ['tax_ids']] + ); + } + + return $stripeCustomer; + }); + + return $this->parseCustomer($stripeCustomer, $customer); + } + + /** + * @inheritDoc + */ + public function getCustomer(Customer $customer): Customer + { + $stripeCustomer = $this->stripeRequest(function () use ($customer) { + return $this->client->customers->retrieve($customer->id, ['expand' => ['tax_ids']]); + }); + + return $this->parseCustomer($stripeCustomer, $customer); + } + + /** + * @inheritDoc + * @throws ModelAttributeValidationException + */ + public function setCustomerDefaultCard(Customer $customer, string $cardId): Customer + { + $customer->defaultCard = new CreditCard(); + $customer->defaultCard->id = $cardId; + + return $this->updateCustomer($customer); + } + + /** + * Garante o expand de tax_ids no payload sem descartar um expand vindo de + * gatewayAdicionalOptions — sem esse expand a Stripe não devolve os tax ids + * e o parse/sync de taxDocument corromperia silenciosamente. + * + * @param array $stripeCustomerData + * @return array + */ + private function withTaxIdsExpanded(array $stripeCustomerData): array + { + $stripeCustomerData['expand'] = array_values(array_unique(array_merge( + $stripeCustomerData['expand'] ?? [], + ['tax_ids'] + ))); + + return $stripeCustomerData; + } + + /** + * Converte o model Customer do MultiPayment para o formato de dados da Stripe. + * + * O Stripe não tem campos nativos para bairro, país em texto livre e data de nascimento — + * esses valores vão para metadata (mesma solução das custom_variables da Iugu) e voltam + * de lá no parseCustomer. + * + * @param \Potelo\MultiPayment\Models\Customer $customer + * @return array + */ + private function customerToStripeData(Customer $customer): array + { + $stripeCustomerData = []; + $metadata = []; + + if (!is_null($customer->name)) { + $stripeCustomerData['name'] = $customer->name; + } + if (!is_null($customer->email)) { + $stripeCustomerData['email'] = $customer->email; + } + if (!empty($customer->phoneArea) && !empty($customer->phoneNumber)) { + $stripeCustomerData['phone'] = '+' + . ($customer->phoneCountryCode ?? self::DEFAULT_PHONE_COUNTRY_CODE) + . $customer->phoneArea + . $customer->phoneNumber; + } + if (!empty($customer->birthDate)) { + $metadata['birth_date'] = $customer->birthDate->format('Y-m-d'); + } + + if (!empty($customer->address)) { + $address = $customer->address; + $line1 = trim(($address->street ?? '') . ', ' . ($address->number ?: 'S/N'), ', '); + $stripeCustomerData['address'] = array_filter([ + 'line1' => $line1, + 'line2' => $address->complement, + 'city' => $address->city, + 'state' => $address->state, + 'postal_code' => $address->zipCode, + ], static fn ($value) => !is_null($value) && $value !== ''); + + // string vazia limpa a chave no metadata da Stripe (que faz merge por chave, + // diferente do hash address, que é substituído inteiro) — mantém a semântica + // de replace do endereço para os campos que vivem no metadata + $metadata['district'] = $address->district ?? ''; + $metadata['country'] = $address->country ?? ''; + } + + if (!empty($metadata)) { + $stripeCustomerData['metadata'] = $metadata; + } + + if (!empty($customer->defaultCard) && !empty($customer->defaultCard->id)) { + $stripeCustomerData['invoice_settings']['default_payment_method'] = $customer->defaultCard->id; + } + + if (!empty($customer->gatewayAdicionalOptions)) { + foreach ($customer->gatewayAdicionalOptions as $option => $value) { + $stripeCustomerData[$option] = $value; + } + } + + return $stripeCustomerData; + } + + /** + * Converte o customer da Stripe em um Customer do MultiPayment. + * + * @param \Stripe\Customer $stripeCustomer + * @param \Potelo\MultiPayment\Models\Customer|null $customer + * @return \Potelo\MultiPayment\Models\Customer + */ + private function parseCustomer(StripeCustomer $stripeCustomer, ?Customer $customer = null): Customer + { + $customer = $customer ?? new Customer(); + $metadata = !empty($stripeCustomer->metadata) ? $stripeCustomer->metadata->toArray() : []; + + $customer->id = $stripeCustomer->id; + $customer->name = $stripeCustomer->name; + $customer->email = $stripeCustomer->email; + + foreach ($stripeCustomer->tax_ids->data ?? [] as $taxId) { + if (in_array($taxId->type, ['br_cpf', 'br_cnpj'], true)) { + // a Stripe devolve o documento formatado (201.769.969-15) mesmo recebendo dígitos + $customer->taxDocument = preg_replace('/\D/', '', $taxId->value); + break; + } + } + + // desfaz a concatenação +{país}{DDD}{número} feita no customerToStripeData; + // o reset evita reter dados velhos do model quando o telefone da Stripe está + // ausente ou fora do formato gravado pelo pacote + $customer->phoneCountryCode = null; + $customer->phoneArea = null; + $customer->phoneNumber = null; + if (!empty($stripeCustomer->phone) + && preg_match('/^\+(\d{2})(\d{2})(\d{8,9})$/', $stripeCustomer->phone, $phoneParts)) { + $customer->phoneCountryCode = $phoneParts[1]; + $customer->phoneArea = $phoneParts[2]; + $customer->phoneNumber = $phoneParts[3]; + } + + $customer->birthDate = null; + if (!empty($metadata['birth_date'])) { + try { + $customer->birthDate = Carbon::createFromFormat('Y-m-d', $metadata['birth_date']); + } catch (\Exception $e) { + // metadata pode ter sido editado fora do pacote; data ilegível vira null + } + } + + if (!empty($stripeCustomer->address)) { + if (empty($customer->address)) { + $customer->address = new Address(); + } + $stripeAddress = $stripeCustomer->address; + if (!empty($stripeAddress->line1)) { + // desfaz a concatenação "rua, número" feita no customerToStripeData; + // line1 sozinho no formato de número (endereço sem rua) volta para number + $separatorPosition = strrpos($stripeAddress->line1, ', '); + if ($separatorPosition !== false) { + $customer->address->street = substr($stripeAddress->line1, 0, $separatorPosition); + $customer->address->number = substr($stripeAddress->line1, $separatorPosition + 2); + } elseif (preg_match('/^([0-9]+[a-zA-Z]*|S\/N)$/', $stripeAddress->line1)) { + $customer->address->number = $stripeAddress->line1; + } else { + $customer->address->street = $stripeAddress->line1; + } + } + $customer->address->complement = $stripeAddress->line2 ?? null; + $customer->address->city = $stripeAddress->city ?? null; + $customer->address->state = $stripeAddress->state ?? null; + $customer->address->zipCode = $stripeAddress->postal_code ?? null; + $customer->address->district = !empty($metadata['district']) ? $metadata['district'] : null; + $customer->address->country = !empty($metadata['country']) ? $metadata['country'] : null; + } + + if (!empty($stripeCustomer->invoice_settings?->default_payment_method)) { + $customer->defaultCard = new CreditCard(); + $customer->defaultCard->id = $stripeCustomer->invoice_settings->default_payment_method; + } + + $customer->gateway = 'stripe'; + $customer->createdAt = Carbon::createFromTimestamp($stripeCustomer->created); + $customer->original = $stripeCustomer; + + return $customer; + } + + /** + * Garante que o tax id brasileiro do customer na Stripe reflita o taxDocument do model — + * tax ids não são atualizáveis pelo update de customer, só criados/excluídos à parte. + * + * @param \Stripe\Customer $stripeCustomer customer com `tax_ids` expandido + * @param string|null $taxDocument + * @return bool true se algum tax id foi criado/excluído (o customer precisa de refetch) + * @throws ApiErrorException + */ + private function syncCustomerTaxDocument(StripeCustomer $stripeCustomer, ?string $taxDocument): bool + { + if (empty($taxDocument)) { + return false; + } + + $alreadyPresent = false; + $staleTaxIds = []; + foreach ($stripeCustomer->tax_ids->data ?? [] as $stripeTaxId) { + if (!in_array($stripeTaxId->type, ['br_cpf', 'br_cnpj'], true)) { + continue; + } + // comparação por dígitos: a Stripe devolve o documento formatado (201.769.969-15) + if (preg_replace('/\D/', '', $stripeTaxId->value) === preg_replace('/\D/', '', $taxDocument)) { + $alreadyPresent = true; + continue; + } + $staleTaxIds[] = $stripeTaxId->id; + } + + // cria o novo antes de excluir o antigo: se a criação falhar, o customer + // não fica sem documento na Stripe + if (!$alreadyPresent) { + $this->client->customers->createTaxId($stripeCustomer->id, [ + 'type' => $this->taxDocumentType($taxDocument), + 'value' => $taxDocument, + ]); + } + foreach ($staleTaxIds as $staleTaxIdId) { + $this->client->customers->deleteTaxId($stripeCustomer->id, $staleTaxIdId); + } + + return !$alreadyPresent || !empty($staleTaxIds); + } + + /** + * Decide o tipo de tax id da Stripe pelo tamanho do documento (14 dígitos = CNPJ). + * + * @param string $taxDocument + * @return string + */ + private function taxDocumentType(string $taxDocument): string + { + return strlen(preg_replace('/\D/', '', $taxDocument)) === 14 ? 'br_cnpj' : 'br_cpf'; + } + + /** + * Executa uma chamada à API da Stripe traduzindo as exceções para o contrato do pacote. + * + * @param callable $request + * @return mixed + * @throws GatewayException|GatewayNotAvailableException + */ + private function stripeRequest(callable $request) + { + try { + return $request(); + } catch (AuthenticationException | ApiConnectionException $e) { + throw new GatewayNotAvailableException($e->getMessage()); + } catch (ApiErrorException $e) { + $error = $e->getError(); + throw new GatewayException($e->getMessage(), array_filter([ + 'type' => $error?->type, + 'code' => $error?->code, + 'param' => $error?->param, + ])); + } catch (MultiPaymentException $e) { + throw $e; + } catch (\Exception $e) { + throw new GatewayException($e->getMessage()); + } + } + + /** + * Exceção padrão para operações do contrato ainda não implementadas neste gateway — + * mais clara que o methodNotFound do despacho por convenção, que sugeriria erro de digitação. + * + * @param string $operation + * @return GatewayException + */ + private function operationNotImplemented(string $operation): GatewayException + { + return new GatewayException("Operation [{$operation}] is not yet implemented by the stripe gateway"); + } + + /** + * @inheritDoc + */ + public function createInvoice(Invoice $invoice): Invoice + { + throw $this->operationNotImplemented('createInvoice'); + } + + /** + * @inheritDoc + */ + public function getInvoice(Invoice $invoice): Invoice + { + throw $this->operationNotImplemented('getInvoice'); + } + + /** + * @inheritDoc + */ + public function refundInvoice(Invoice $invoice): Invoice + { + throw $this->operationNotImplemented('refundInvoice'); + } + + /** + * @inheritDoc + */ + public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice + { + throw $this->operationNotImplemented('chargeInvoiceWithCreditCard'); + } + + /** + * @inheritDoc + */ + public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gatewayOptions = []): Invoice + { + throw $this->operationNotImplemented('duplicateInvoice'); + } + + /** + * @inheritDoc + */ + public function cancelInvoice(Invoice $invoice): Invoice + { + throw $this->operationNotImplemented('cancelInvoice'); + } + + /** + * @inheritDoc + */ + public function createCreditCard(CreditCard $creditCard): CreditCard + { + throw $this->operationNotImplemented('createCreditCard'); + } + + /** + * @inheritDoc + */ + public function getCreditCard(CreditCard $creditCard): CreditCard + { + throw $this->operationNotImplemented('getCreditCard'); + } + + /** + * @inheritDoc + */ + public function deleteCreditCard(CreditCard $creditCard): void + { + throw $this->operationNotImplemented('deleteCreditCard'); + } + + /** + * @inheritDoc + */ + public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice + { + throw $this->operationNotImplemented('rescheduleAutomaticPixPayment'); + } + + /** + * @inheritDoc + */ + public function cancelAutomaticPixScheduledPayment(AutomaticPixCharge $charge): AutomaticPixCancellation + { + throw $this->operationNotImplemented('cancelAutomaticPixScheduledPayment'); + } + + /** + * @inheritDoc + */ + public function cancelAutomaticPixRecurrence(AutomaticPix $automaticPix): AutomaticPixCancellation + { + throw $this->operationNotImplemented('cancelAutomaticPixRecurrence'); + } + + /** + * @inheritDoc + */ + public function getAutomaticPixCancellation(AutomaticPixCancellation $cancellation): AutomaticPixCancellation + { + throw $this->operationNotImplemented('getAutomaticPixCancellation'); + } + + /** + * @inheritDoc + */ + public function listAutomaticPixCancellations(AutomaticPix $automaticPix, int $page = 1, int $limit = 100): array + { + throw $this->operationNotImplemented('listAutomaticPixCancellations'); + } + + /** + * @inheritDoc + */ + public function __toString() + { + return 'stripe'; + } +} diff --git a/src/config/multi-payment.php b/src/config/multi-payment.php index 3add0d0..8fa229c 100644 --- a/src/config/multi-payment.php +++ b/src/config/multi-payment.php @@ -38,5 +38,10 @@ 'customer_column' => 'iugu_id', 'class' => \Potelo\MultiPayment\Gateways\IuguGateway::class, ], + 'stripe' => [ + 'api_key' => env('STRIPE_APIKEY'), + 'customer_column' => 'stripe_id', + 'class' => \Potelo\MultiPayment\Gateways\StripeGateway::class, + ], ], ]; \ No newline at end of file diff --git a/tests/Integration/Builders/CustomerBuilderTest.php b/tests/Integration/Builders/CustomerBuilderTest.php index 9721345..fbcfe01 100644 --- a/tests/Integration/Builders/CustomerBuilderTest.php +++ b/tests/Integration/Builders/CustomerBuilderTest.php @@ -18,6 +18,7 @@ public static function shouldCreateACustomerDataProvider(): array { return [ ['iugu'], + ['stripe'], ]; } diff --git a/tests/TestCase.php b/tests/TestCase.php index 9d9c1b1..c843767 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -22,8 +22,12 @@ protected function setUp(): void return; } - // pausa para evitar problemas com o Iugu - sleep(12); + // pausa para respeitar o rate limit da sandbox da Iugu — a da Stripe não tem esse limite; + // o gateway do teste vem do dataProvider (primeiro argumento, posicional ou chave 'gateway') + $providedData = $this->getProvidedData(); + if (($providedData[0] ?? $providedData['gateway'] ?? null) !== 'stripe') { + sleep(12); + } } protected function getPackageProviders($app): array diff --git a/tests/Unit/Gateways/StripeGatewayCustomerTest.php b/tests/Unit/Gateways/StripeGatewayCustomerTest.php new file mode 100644 index 0000000..9d6b782 --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayCustomerTest.php @@ -0,0 +1,436 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + + // fake vazio por padrão: teste que esquecer withResponses() estoura em vez de ir à rede + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + // o hook de HTTP do stripe-php é estático — sem o reset, o fake vazaria para outros testes + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreatesCustomerSendingGenericFieldsAsStripeData(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = $this->customerModel(); + $result = (new StripeGateway())->createCustomer($customer); + + $this->assertCount(1, $httpClient->calls); + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/customers', parse_url($url, PHP_URL_PATH)); + + // payload completo: uma chave extra vazando para o request também deve falhar + $this->assertSame([ + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'phone' => '+5571982345678', + 'address' => [ + 'line1' => 'Rua Deputado Mário Lima, 123', + 'line2' => 'Apto. 123', + 'city' => 'Salvador', + 'state' => 'BA', + 'postal_code' => '41820330', + ], + 'metadata' => [ + 'birth_date' => '1980-01-01', + 'district' => 'Caminho das Árvores', + 'country' => 'Brasil', + ], + 'tax_id_data' => [['type' => 'br_cpf', 'value' => '20176996915']], + 'expand' => ['tax_ids'], + ], $params); + + $this->assertSame('cus_fake123', $result->id); + $this->assertSame('stripe', $result->gateway); + $this->assertInstanceOf(Carbon::class, $result->createdAt); + } + + public function testCreatesCustomerWithCnpjUsingBrCnpjTaxIdType(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->name = 'Fake Company'; + $customer->email = 'email@exemplo.com'; + $customer->taxDocument = '28585583000189'; + (new StripeGateway())->createCustomer($customer); + + [, , $params] = $httpClient->calls[0]; + $this->assertSame([['type' => 'br_cnpj', 'value' => '28585583000189']], $params['tax_id_data']); + } + + public function testParsesStripeCustomerIntoGenericModel(): void + { + RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $result = (new StripeGateway())->getCustomer($customer); + + $this->assertSame('cus_fake123', $result->id); + $this->assertSame('Fake Customer', $result->name); + $this->assertSame('email@exemplo.com', $result->email); + $this->assertSame('20176996915', $result->taxDocument); + $this->assertSame('55', $result->phoneCountryCode); + $this->assertSame('71', $result->phoneArea); + $this->assertSame('982345678', $result->phoneNumber); + $this->assertTrue($result->birthDate->isSameDay(Carbon::createFromFormat('Y-m-d', '1980-01-01'))); + $this->assertSame('Rua Deputado Mário Lima', $result->address->street); + $this->assertSame('123', $result->address->number); + $this->assertSame('Apto. 123', $result->address->complement); + $this->assertSame('Caminho das Árvores', $result->address->district); + $this->assertSame('Salvador', $result->address->city); + $this->assertSame('BA', $result->address->state); + $this->assertSame('41820330', $result->address->zipCode); + $this->assertSame('Brasil', $result->address->country); + $this->assertSame('stripe', $result->gateway); + $this->assertNotNull($result->original); + } + + public function testParsesCustomerWithoutAddressKeepingAddressNull(): void + { + $response = $this->stripeCustomerResponse(); + $response['address'] = null; + $response['metadata'] = ['birth_date' => '1980-01-01']; + RecordingStripeHttpClient::withResponses([$response]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $result = (new StripeGateway())->getCustomer($customer); + + $this->assertNull($result->address); + $this->assertTrue($result->birthDate->isSameDay(Carbon::createFromFormat('Y-m-d', '1980-01-01'))); + } + + public function testUpdateCustomerRequiresId(): void + { + RecordingStripeHttpClient::withResponses([]); + + $this->expectException(ModelAttributeValidationException::class); + + (new StripeGateway())->updateCustomer(new Customer()); + } + + public function testUpdateCustomerReplacesChangedTaxDocument(): void + { + $staleCustomer = $this->stripeCustomerResponse(); + $freshCustomer = $this->stripeCustomerResponse(); + $freshCustomer['tax_ids']['data'][0]['value'] = '68419761001'; + $httpClient = RecordingStripeHttpClient::withResponses([ + $staleCustomer, + ['id' => 'txi_fake2', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '68419761001'], + ['id' => 'txi_fake1', 'object' => 'tax_id', 'deleted' => true], + $freshCustomer, + ]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $customer->taxDocument = '68419761001'; + $result = (new StripeGateway())->updateCustomer($customer); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'post /v1/customers/cus_fake123', + 'post /v1/customers/cus_fake123/tax_ids', + 'delete /v1/customers/cus_fake123/tax_ids/txi_fake1', + 'get /v1/customers/cus_fake123', + ], $paths); + $this->assertSame(['type' => 'br_cpf', 'value' => '68419761001'], $httpClient->calls[1][2]); + $this->assertSame('68419761001', $result->taxDocument); + } + + public function testUpdateCustomerKeepsUnchangedTaxDocumentWithoutExtraRequests(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $customer->taxDocument = '20176996915'; + (new StripeGateway())->updateCustomer($customer); + + $this->assertCount(1, $httpClient->calls); + } + + public function testSetCustomerDefaultCardSendsInvoiceSettingsAndParsesDefaultCard(): void + { + $response = $this->stripeCustomerResponse(); + $response['invoice_settings'] = ['default_payment_method' => 'pm_fake123']; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $result = (new StripeGateway())->setCustomerDefaultCard($customer, 'pm_fake123'); + + [, , $params] = $httpClient->calls[0]; + $this->assertSame('pm_fake123', $params['invoice_settings']['default_payment_method']); + $this->assertSame('pm_fake123', $result->defaultCard->id); + } + + public function testGatewayAdicionalOptionsReachThePayloadAndExpandIsMerged(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->name = 'Fake Customer'; + $customer->taxDocument = '20176996915'; + $customer->gatewayAdicionalOptions = [ + 'preferred_locales' => ['pt-BR'], + 'expand' => ['subscriptions'], + ]; + (new StripeGateway())->createCustomer($customer); + + [, , $params] = $httpClient->calls[0]; + $this->assertSame(['pt-BR'], $params['preferred_locales']); + // o expand do usuário não pode descartar o tax_ids exigido pelo parse/sync + $this->assertSame(['subscriptions', 'tax_ids'], $params['expand']); + } + + public function testCustomerWithExplicitPhoneCountryCodeIsConcatenated(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->phoneCountryCode = '01'; + $customer->phoneArea = '71'; + $customer->phoneNumber = '982345678'; + (new StripeGateway())->createCustomer($customer); + + [, , $params] = $httpClient->calls[0]; + $this->assertSame('+0171982345678', $params['phone']); + } + + public function testCustomerWithoutTaxDocumentOmitsTaxIdData(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->name = 'Fake Customer'; + (new StripeGateway())->createCustomer($customer); + + [, , $params] = $httpClient->calls[0]; + $this->assertArrayNotHasKey('tax_id_data', $params); + } + + public function testAddressWithoutNumberUsesSNPlaceholderAndClearsAbsentMetadata(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->fill(['address' => ['street' => 'Rua Deputado Mário Lima', 'zip_code' => '41820330']]); + (new StripeGateway())->createCustomer($customer); + + [, , $params] = $httpClient->calls[0]; + $this->assertSame('Rua Deputado Mário Lima, S/N', $params['address']['line1']); + // bairro/país ausentes limpam as chaves no metadata (que faz merge por chave na Stripe) + $this->assertSame(['district' => '', 'country' => ''], $params['metadata']); + } + + public function testParsesNumberOnlyLine1IntoAddressNumber(): void + { + $response = $this->stripeCustomerResponse(); + $response['address']['line1'] = '123'; + RecordingStripeHttpClient::withResponses([$response]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $result = (new StripeGateway())->getCustomer($customer); + + $this->assertNull($result->address->street); + $this->assertSame('123', $result->address->number); + } + + public function testUnimplementedOperationThrowsClearGatewayException(): void + { + RecordingStripeHttpClient::withResponses([]); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('Operation [createInvoice] is not yet implemented by the stripe gateway'); + + (new StripeGateway())->createInvoice(new Invoice()); + } + + public function testAuthenticationErrorBecomesGatewayNotAvailable(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'invalid_request_error', 'message' => 'Invalid API Key provided']], 401], + ]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + + $this->expectException(GatewayNotAvailableException::class); + + (new StripeGateway())->getCustomer($customer); + } + + public function testApiErrorBecomesGatewayExceptionWithNormalizedErrors(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'invalid_request_error', + 'code' => 'parameter_unknown', + 'param' => 'foo', + 'message' => 'Received unknown parameter: foo', + ]], 400], + ]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + + try { + (new StripeGateway())->getCustomer($customer); + $this->fail('Expected GatewayException was not thrown'); + } catch (GatewayException $exception) { + $this->assertSame([ + 'type' => 'invalid_request_error', + 'code' => 'parameter_unknown', + 'param' => 'foo', + ], $exception->getErrors()); + } + } + + public function testToStringReturnsGatewayName(): void + { + $this->assertSame('stripe', (string) new StripeGateway()); + } + + private function customerModel(): Customer + { + $customer = new Customer(); + $customer->fill([ + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'tax_document' => '20176996915', + 'phone_area' => '71', + 'phone_number' => '982345678', + 'address' => [ + 'zip_code' => '41820330', + 'street' => 'Rua Deputado Mário Lima', + 'number' => '123', + 'district' => 'Caminho das Árvores', + 'complement' => 'Apto. 123', + 'city' => 'Salvador', + 'state' => 'BA', + 'country' => 'Brasil', + ], + ]); + $customer->birthDate = Carbon::createFromFormat('Y-m-d', '1980-01-01'); + + return $customer; + } + + private function stripeCustomerResponse(): array + { + return [ + 'id' => 'cus_fake123', + 'object' => 'customer', + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'phone' => '+5571982345678', + 'created' => 1786700000, + 'metadata' => [ + 'birth_date' => '1980-01-01', + 'district' => 'Caminho das Árvores', + 'country' => 'Brasil', + ], + 'address' => [ + 'line1' => 'Rua Deputado Mário Lima, 123', + 'line2' => 'Apto. 123', + 'city' => 'Salvador', + 'state' => 'BA', + 'postal_code' => '41820330', + 'country' => null, + ], + 'invoice_settings' => ['default_payment_method' => null], + 'tax_ids' => [ + 'object' => 'list', + 'data' => [ + ['id' => 'txi_fake1', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '20176996915'], + ], + ], + ]; + } +} + +/** + * Fake da camada HTTP do stripe-php, no molde do RecordingIuguApiRequest: devolve respostas + * enfileiradas e grava cada chamada para asserção. Cada resposta é um array (corpo JSON, + * status 200) ou um par [corpo, status]. + */ +class RecordingStripeHttpClient implements \Stripe\HttpClient\ClientInterface +{ + /** @var array */ + public array $calls = []; + + /** @var array */ + private array $responses; + + private function __construct(array $responses) + { + $this->responses = array_map(static function ($response) { + return isset($response[1]) && is_int($response[1]) + ? $response + : [$response, 200]; + }, $responses); + } + + /** + * Cria o fake e o instala como client HTTP global do stripe-php. + * + * @param array $responses + * @return static + */ + public static function withResponses(array $responses): self + { + $httpClient = new self($responses); + ApiRequestor::setHttpClient($httpClient); + + return $httpClient; + } + + public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null) + { + $this->calls[] = [$method, $absUrl, $params]; + + if (empty($this->responses)) { + throw new \RuntimeException("Unexpected Stripe request: {$method} {$absUrl}"); + } + [$body, $code] = array_shift($this->responses); + + return [json_encode($body), $code, []]; + } +} From ee3a43fcf1ceb968d25b51b4164813a758ee82cc Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Fri, 14 Aug 2026 21:01:37 -0300 Subject: [PATCH 03/32] =?UTF-8?q?feat(stripe):=20cobrar=20fatura=20com=20c?= =?UTF-8?q?art=C3=A3o=20token-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 2 do gateway Stripe: - createInvoice para credit_card: PaymentIntent confirm+off_session síncrono; um método por fatura (multi-método não tem equivalente server-side no Stripe); boleto lança exceção clara (fora do escopo) - createCreditCard token-only (dados crus lançam exceção orientando a tokenização client-side; tok_ legado vira PaymentMethod antes do attach; description em metadata; default via invoice_settings), getCreditCard/deleteCreditCard com validação de posse - getInvoice/parseInvoice com expand de latest_charge.balance_transaction (charge failed não alimenta paidAmount; fee de cartão é assíncrono) e status derivado do par PaymentIntent+charge (estorno não muda o status do PaymentIntent; pix expirado reporta pending e segue re-cobrável) - chargeInvoiceWithCreditCard atualiza o PaymentIntent (types + customer) antes do confirm, recusando cartão de outro customer - ChargingException ganha $reason normalizada (card_declined, brand_not_supported, authentication_required...) para a aplicação decidir fallback de gateway; recusa no attach (a Stripe valida o cartão nesse ponto) também vira ChargingException - GatewayException::getErrors() normaliza errors nulo/string/objeto (antes fatalava com TypeError quando nulo) - MultiPayment::setDefaultCard passa a propagar o gateway selecionado (antes caía no gateway default, ignorando setGateway) --- src/Exceptions/ChargingException.php | 9 + src/Exceptions/GatewayException.php | 9 +- src/Gateways/StripeGateway.php | 568 +++++++++++++++++- src/MultiPayment.php | 2 + tests/Integration/StripeGatewayTest.php | 166 +++++ .../Unit/Exceptions/GatewayExceptionTest.php | 23 + .../Gateways/RecordingStripeHttpClient.php | 57 ++ .../Gateways/StripeGatewayCreditCardTest.php | 216 +++++++ .../Gateways/StripeGatewayCustomerTest.php | 53 +- .../Gateways/StripeGatewayInvoiceTest.php | 549 +++++++++++++++++ tests/Unit/MultiPaymentGatewayRoutingTest.php | 61 ++ 11 files changed, 1646 insertions(+), 67 deletions(-) create mode 100644 tests/Integration/StripeGatewayTest.php create mode 100644 tests/Unit/Exceptions/GatewayExceptionTest.php create mode 100644 tests/Unit/Gateways/RecordingStripeHttpClient.php create mode 100644 tests/Unit/Gateways/StripeGatewayCreditCardTest.php create mode 100644 tests/Unit/Gateways/StripeGatewayInvoiceTest.php create mode 100644 tests/Unit/MultiPaymentGatewayRoutingTest.php diff --git a/src/Exceptions/ChargingException.php b/src/Exceptions/ChargingException.php index 26e0332..1ffd33c 100644 --- a/src/Exceptions/ChargingException.php +++ b/src/Exceptions/ChargingException.php @@ -8,4 +8,13 @@ class ChargingException extends MultiPaymentException * @var mixed $chargeResponse The charge response from the gateway */ public $chargeResponse; + + /** + * Razão normalizada da falha de cobrança, independente de gateway (ex.: `card_declined`, + * `brand_not_supported`, `authentication_required`), para a aplicação decidir + * programaticamente um fallback de gateway. Nula quando o gateway não a preenche. + * + * @var string|null + */ + public ?string $reason = null; } diff --git a/src/Exceptions/GatewayException.php b/src/Exceptions/GatewayException.php index 7934a98..aed6f0d 100644 --- a/src/Exceptions/GatewayException.php +++ b/src/Exceptions/GatewayException.php @@ -26,11 +26,18 @@ public function __construct(string $message = "", $errors = null) } /** + * Retorna os erros do gateway normalizados para array — podem ter sido + * informados como nulo, string, objeto ou array. + * * @return array */ public function getErrors(): array { - return $this->errors; + if (is_null($this->errors)) { + return []; + } + + return is_array($this->errors) ? $this->errors : (array) $this->errors; } /** diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index af3489a..7e5569e 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -5,19 +5,25 @@ use Carbon\Carbon; use Stripe\StripeClient; use Stripe\Customer as StripeCustomer; +use Stripe\PaymentIntent as StripePaymentIntent; +use Stripe\PaymentMethod as StripePaymentMethod; +use Stripe\Exception\CardException; use Stripe\Exception\ApiErrorException; use Stripe\Exception\ApiConnectionException; use Stripe\Exception\AuthenticationException; use Illuminate\Support\Facades\Config; +use Potelo\MultiPayment\Models\Pix; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Address; 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\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\ChargingException; use Potelo\MultiPayment\Exceptions\MultiPaymentException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -36,6 +42,19 @@ class StripeGateway implements GatewayContract */ private const DEFAULT_PHONE_COUNTRY_CODE = '55'; + /** + * Expand padrão em toda leitura/criação de PaymentIntent: sem latest_charge expandido, + * paidAmount/refundedAmount/fee ficam vazios no parse. + */ + private const PAYMENT_INTENT_EXPAND = ['latest_charge.balance_transaction']; + + /** Mapa de tipos de PaymentMethod da Stripe para os métodos genéricos do pacote. */ + private const PAYMENT_METHOD_TYPES = [ + 'card' => Invoice::PAYMENT_METHOD_CREDIT_CARD, + 'pix' => Invoice::PAYMENT_METHOD_PIX, + 'boleto' => Invoice::PAYMENT_METHOD_BANK_SLIP, + ]; + private StripeClient $client; /** @@ -128,21 +147,33 @@ public function setCustomerDefaultCard(Customer $customer, string $cardId): Cust } /** - * Garante o expand de tax_ids no payload sem descartar um expand vindo de - * gatewayAdicionalOptions — sem esse expand a Stripe não devolve os tax ids - * e o parse/sync de taxDocument corromperia silenciosamente. + * Garante os expands exigidos pelo parse no payload sem descartar um expand vindo de + * gatewayAdicionalOptions — sem eles a Stripe omite dados (tax ids, charge) e o + * parse/sync corromperia silenciosamente. * - * @param array $stripeCustomerData + * @param array $stripeData + * @param array $expand * @return array */ - private function withTaxIdsExpanded(array $stripeCustomerData): array + private function withExpand(array $stripeData, array $expand): array { - $stripeCustomerData['expand'] = array_values(array_unique(array_merge( - $stripeCustomerData['expand'] ?? [], - ['tax_ids'] + $stripeData['expand'] = array_values(array_unique(array_merge( + $stripeData['expand'] ?? [], + $expand ))); - return $stripeCustomerData; + return $stripeData; + } + + /** + * Garante o expand de tax_ids no payload de customer. + * + * @param array $stripeCustomerData + * @return array + */ + private function withTaxIdsExpanded(array $stripeCustomerData): array + { + return $this->withExpand($stripeCustomerData, ['tax_ids']); } /** @@ -368,6 +399,7 @@ private function stripeRequest(callable $request) throw new GatewayException($e->getMessage(), array_filter([ 'type' => $error?->type, 'code' => $error?->code, + 'decline_code' => $error?->decline_code ?? null, 'param' => $error?->param, ])); } catch (MultiPaymentException $e) { @@ -391,10 +423,155 @@ private function operationNotImplemented(string $operation): GatewayException /** * @inheritDoc + * @throws ChargingException|ModelAttributeValidationException */ public function createInvoice(Invoice $invoice): Invoice { - throw $this->operationNotImplemented('createInvoice'); + $paymentMethod = $this->invoicePaymentMethod($invoice); + switch ($paymentMethod) { + case Invoice::PAYMENT_METHOD_CREDIT_CARD: + return $this->createCreditCardInvoice($invoice); + case Invoice::PAYMENT_METHOD_BANK_SLIP: + throw new GatewayException('The stripe gateway does not support bank slip invoices; use the iugu gateway instead'); + default: + throw $this->operationNotImplemented("createInvoice with the [{$paymentMethod}] payment method"); + } + } + + /** + * Resolve o único método de pagamento da fatura — no Stripe um PaymentIntent confirmado + * server-side materializa a cobrança de um método só, então a fatura multi-método da + * Iugu não tem equivalente aqui e este gateway é restrito a um método por fatura. + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @return string + * @throws ModelAttributeValidationException + */ + private function invoicePaymentMethod(Invoice $invoice): string + { + if (!empty($invoice->availablePaymentMethods)) { + if (count($invoice->availablePaymentMethods) > 1) { + throw ModelAttributeValidationException::invalid( + 'Invoice', + 'availablePaymentMethods', + 'the stripe gateway supports exactly one payment method per invoice' + ); + } + + return reset($invoice->availablePaymentMethods); + } + + if (!empty($invoice->creditCard)) { + return Invoice::PAYMENT_METHOD_CREDIT_CARD; + } + + throw ModelAttributeValidationException::required('Invoice', 'availablePaymentMethods'); + } + + /** + * Cria e confirma um PaymentIntent de cartão (síncrono: succeeded ou recusa na hora). + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @return \Potelo\MultiPayment\Models\Invoice + * @throws ChargingException|GatewayException|ModelAttributeValidationException + */ + private function createCreditCardInvoice(Invoice $invoice): Invoice + { + if (empty($invoice->creditCard)) { + throw ModelAttributeValidationException::required('Invoice', 'creditCard'); + } + + if (empty($invoice->creditCard->id)) { + if (empty($invoice->creditCard->customer)) { + $invoice->creditCard->customer = $invoice->customer; + } + try { + $invoice->creditCard = $this->createCreditCard($invoice->creditCard); + } catch (GatewayException $e) { + // a Stripe valida o cartão já no attach: recusa nesse ponto é falha de + // cobrança para o consumidor, não erro genérico de gateway + $errors = $e->getErrors(); + if (($errors['type'] ?? null) !== 'card_error') { + throw $e; + } + $exception = new ChargingException('Error charging invoice: ' . $e->getMessage()); + $exception->chargeResponse = $errors; + $exception->reason = self::chargeFailureReason( + $errors['code'] ?? null, + $errors['decline_code'] ?? null + ); + throw $exception; + } + } + + $stripePaymentIntentData = $this->invoiceToStripeData($invoice); + $stripePaymentIntentData['payment_method_types'] = ['card']; + $stripePaymentIntentData['payment_method'] = $invoice->creditCard->id; + $stripePaymentIntentData['confirm'] = true; + $stripePaymentIntentData['off_session'] = true; + $stripePaymentIntentData = $this->mergeGatewayAdicionalOptions($stripePaymentIntentData, $invoice); + + $stripePaymentIntent = $this->stripeChargeRequest(function () use ($stripePaymentIntentData) { + return $this->client->paymentIntents->create( + $this->withExpand($stripePaymentIntentData, self::PAYMENT_INTENT_EXPAND) + ); + }); + + return $this->parseInvoice($stripePaymentIntent, $invoice); + } + + /** + * Converte os campos comuns da fatura para o payload de PaymentIntent da Stripe. + * + * O PaymentIntent não tem line items: os items são serializados em metadata + * (item_N_description/price/quantity) e reconstruídos no parseInvoice. + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @return array + */ + private function invoiceToStripeData(Invoice $invoice): array + { + $amount = $invoice->amount; + if (empty($amount)) { + $amount = array_sum(array_map( + static fn (InvoiceItem $item) => $item->price * ($item->quantity ?? 1), + $invoice->items ?? [] + )); + } + + $stripePaymentIntentData = [ + 'amount' => $amount, + 'currency' => 'brl', // o pacote inteiro é BRL implícito (valores em centavos) + ]; + + if (!empty($invoice->customer) && !empty($invoice->customer->id)) { + $stripePaymentIntentData['customer'] = $invoice->customer->id; + } + + foreach ($invoice->items ?? [] as $index => $item) { + $stripePaymentIntentData['metadata']["item_{$index}_description"] = $item->description; + $stripePaymentIntentData['metadata']["item_{$index}_price"] = $item->price; + $stripePaymentIntentData['metadata']["item_{$index}_quantity"] = $item->quantity; + } + + return $stripePaymentIntentData; + } + + /** + * Mescla as opções extras/override do consumidor por último, para que possam + * sobrescrever qualquer chave montada pelo gateway (válvula de escape do pacote). + * + * @param array $stripeData + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @return array + */ + private function mergeGatewayAdicionalOptions(array $stripeData, Invoice $invoice): array + { + foreach ($invoice->gatewayAdicionalOptions ?? [] as $option => $value) { + $stripeData[$option] = $value; + } + + return $stripeData; } /** @@ -402,7 +579,14 @@ public function createInvoice(Invoice $invoice): Invoice */ public function getInvoice(Invoice $invoice): Invoice { - throw $this->operationNotImplemented('getInvoice'); + $stripePaymentIntent = $this->stripeRequest(function () use ($invoice) { + return $this->client->paymentIntents->retrieve( + $invoice->id, + ['expand' => self::PAYMENT_INTENT_EXPAND] + ); + }); + + return $this->parseInvoice($stripePaymentIntent, $invoice); } /** @@ -415,10 +599,237 @@ public function refundInvoice(Invoice $invoice): Invoice /** * @inheritDoc + * @throws ChargingException|ModelAttributeValidationException */ public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice { - throw $this->operationNotImplemented('chargeInvoiceWithCreditCard'); + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + if (empty($invoice->creditCard)) { + throw ModelAttributeValidationException::required('Invoice', 'creditCard'); + } + if (empty($invoice->creditCard->token) && empty($invoice->creditCard->id)) { + throw new ModelAttributeValidationException('Credit card token or id is required'); + } + + // id = PaymentMethod salvo no customer; token = PaymentMethod criado client-side + $paymentMethodId = !empty($invoice->creditCard->id) + ? $invoice->creditCard->id + : $invoice->creditCard->token; + + $stripePaymentIntent = $this->stripeChargeRequest(function () use ($invoice, $paymentMethodId) { + $paymentMethodId = $this->resolvePaymentMethodId($paymentMethodId); + $stripePaymentMethod = $this->client->paymentMethods->retrieve($paymentMethodId); + + // o PaymentIntent pode ter sido criado para outro método (ex.: pix expirado): + // é preciso aceitar cartão nos types — e, quando o PaymentMethod é salvo, + // vincular o customer dele ao PaymentIntent antes do confirm; um PaymentIntent + // que já pertence a outro customer não pode ser reatribuído silenciosamente + $stripePaymentIntent = $this->client->paymentIntents->retrieve($invoice->id); + $paymentIntentCustomer = is_object($stripePaymentIntent->customer) + ? $stripePaymentIntent->customer->id + : $stripePaymentIntent->customer; + if (!empty($paymentIntentCustomer) + && !empty($stripePaymentMethod->customer) + && $stripePaymentMethod->customer !== $paymentIntentCustomer) { + throw new GatewayException( + "Credit card [{$paymentMethodId}] does not belong to customer [{$paymentIntentCustomer}]" + ); + } + + $updateParams = ['payment_method_types' => ['card']]; + if (empty($paymentIntentCustomer) && !empty($stripePaymentMethod->customer)) { + $updateParams['customer'] = $stripePaymentMethod->customer; + } + $this->client->paymentIntents->update($invoice->id, $updateParams); + + return $this->client->paymentIntents->confirm($invoice->id, [ + 'payment_method' => $paymentMethodId, + 'off_session' => true, + 'expand' => self::PAYMENT_INTENT_EXPAND, + ]); + }); + + return $this->parseInvoice($stripePaymentIntent, $invoice); + } + + /** + * Converte o PaymentIntent da Stripe em uma Invoice do MultiPayment. + * + * @param \Stripe\PaymentIntent $stripePaymentIntent + * @param \Potelo\MultiPayment\Models\Invoice|null $invoice + * @return \Potelo\MultiPayment\Models\Invoice + * @throws GatewayException + */ + private function parseInvoice(StripePaymentIntent $stripePaymentIntent, ?Invoice $invoice = null): Invoice + { + $invoice = $invoice ?? new Invoice(); + + // sem expand o latest_charge vem só como id; um charge failed (ex.: pix expirado) + // não pode alimentar paidAmount/refundedAmount + $stripeCharge = is_object($stripePaymentIntent->latest_charge) ? $stripePaymentIntent->latest_charge : null; + $paidCharge = ($stripeCharge && $stripeCharge->status === 'succeeded') ? $stripeCharge : null; + + $invoice->id = $stripePaymentIntent->id; + $invoice->gateway = 'stripe'; + $invoice->status = self::stripeStatusToMultiPayment($stripePaymentIntent, $paidCharge); + $invoice->amount = $stripePaymentIntent->amount; + $invoice->paidAmount = $paidCharge?->amount_captured; + $invoice->refundedAmount = $paidCharge?->amount_refunded; + $invoice->paidAt = $paidCharge ? Carbon::createFromTimestamp($paidCharge->created) : null; + $balanceTransaction = $paidCharge?->balance_transaction; + // a balance transaction do cartão é assíncrona: pode vir nula logo após o confirm + // e preenchida num getInvoice posterior + $invoice->fee = is_object($balanceTransaction) ? $balanceTransaction->fee : null; + $invoice->createdAt = Carbon::createFromTimestamp($stripePaymentIntent->created); + $invoice->original = $stripePaymentIntent; + + if (!empty($stripePaymentIntent->customer)) { + if (empty($invoice->customer)) { + $invoice->customer = new Customer(); + } + $invoice->customer->id = is_object($stripePaymentIntent->customer) + ? $stripePaymentIntent->customer->id + : $stripePaymentIntent->customer; + } + + $detailsType = $stripeCharge?->payment_method_details?->type; + if (!empty($detailsType)) { + $invoice->paymentMethod = self::PAYMENT_METHOD_TYPES[$detailsType] ?? null; + } elseif (count($stripePaymentIntent->payment_method_types ?? []) === 1) { + $invoice->paymentMethod = self::PAYMENT_METHOD_TYPES[$stripePaymentIntent->payment_method_types[0]] ?? null; + } + if (!empty($invoice->paymentMethod)) { + $invoice->availablePaymentMethods = [$invoice->paymentMethod]; + } + + // reconstrói os items serializados em metadata pelo invoiceToStripeData + $metadata = !empty($stripePaymentIntent->metadata) ? $stripePaymentIntent->metadata->toArray() : []; + $items = []; + for ($index = 0; isset($metadata["item_{$index}_price"]); $index++) { + $invoiceItem = new InvoiceItem(); + $invoiceItem->description = $metadata["item_{$index}_description"] ?? null; + $invoiceItem->price = (int) $metadata["item_{$index}_price"]; + $invoiceItem->quantity = (int) ($metadata["item_{$index}_quantity"] ?? 1); + $items[] = $invoiceItem; + } + if (!empty($items)) { + $invoice->items = $items; + } + + $cardDetails = $stripeCharge?->payment_method_details?->card; + if (!empty($cardDetails)) { + if (empty($invoice->creditCard)) { + $invoice->creditCard = new CreditCard(); + } + $invoice->creditCard->brand = $cardDetails->brand ?? null; + $invoice->creditCard->lastDigits = $cardDetails->last4 ?? null; + $invoice->creditCard->gateway = 'stripe'; + } + + $qrCode = $stripePaymentIntent->next_action?->pix_display_qr_code; + if (!empty($qrCode)) { + if (empty($invoice->pix)) { + $invoice->pix = new Pix(); + } + $invoice->pix->qrCodeText = $qrCode->data ?? null; + $invoice->pix->qrCodeImageUrl = $qrCode->image_url_png ?? null; + $invoice->url = $qrCode->hosted_instructions_url ?? null; + $invoice->expiresAt = !empty($qrCode->expires_at) + ? Carbon::createFromTimestamp($qrCode->expires_at) + : $invoice->expiresAt; + } else { + // sem next_action de pix não há QR utilizável — limpa dados velhos de um model + // reutilizado (ex.: fatura pix expirada re-cobrada com cartão) + $invoice->pix = null; + $invoice->url = null; + } + + return $invoice; + } + + /** + * Deriva o status genérico do par PaymentIntent + charge — estorno não muda o status + * do PaymentIntent na Stripe, então ele vem do charge. + * + * @param \Stripe\PaymentIntent $stripePaymentIntent + * @param object|null $paidCharge + * @return string + * @throws GatewayException + */ + private static function stripeStatusToMultiPayment(StripePaymentIntent $stripePaymentIntent, ?object $paidCharge): string + { + if ($paidCharge && $paidCharge->amount_refunded > 0) { + return $paidCharge->refunded + ? Invoice::STATUS_REFUNDED + : Invoice::STATUS_PARTIALLY_REFUNDED; + } + + switch ($stripePaymentIntent->status) { + case 'succeeded': + return Invoice::STATUS_PAID; + case 'canceled': + return Invoice::STATUS_CANCELED; + // pix expirado volta a requires_payment_method (não vira canceled) e segue + // re-cobrável — reportar PENDING preserva essa funcionalidade + case 'processing': + case 'requires_action': + case 'requires_confirmation': + case 'requires_payment_method': + case 'requires_capture': + return Invoice::STATUS_PENDING; + default: + throw new GatewayException('Unexpected Stripe payment intent status: ' . $stripePaymentIntent->status); + } + } + + /** + * Igual ao stripeRequest, mas traduz recusa de cartão para ChargingException com a + * resposta bruta e a razão normalizada (habilitador do fallback de gateway na aplicação). + * + * @param callable $request + * @return mixed + * @throws ChargingException|GatewayException|GatewayNotAvailableException + */ + private function stripeChargeRequest(callable $request) + { + return $this->stripeRequest(function () use ($request) { + try { + return $request(); + } catch (CardException $e) { + $exception = new ChargingException('Error charging invoice: ' . $e->getMessage()); + // array em vez do ErrorObject para manter o mesmo formato da recusa no attach + $exception->chargeResponse = $e->getError()?->toArray(); + $exception->reason = self::chargeFailureReason( + $e->getError()?->code, + $e->getError()?->decline_code ?? null + ); + throw $exception; + } + }); + } + + /** + * Normaliza o código de recusa da Stripe para as razões genéricas do pacote. + * + * @param string|null $code + * @param string|null $declineCode + * @return string|null + */ + private static function chargeFailureReason(?string $code, ?string $declineCode): ?string + { + $normalized = [ + 'card_not_supported' => 'brand_not_supported', + 'authentication_required' => 'authentication_required', + 'expired_card' => 'expired_card', + 'insufficient_funds' => 'insufficient_funds', + 'incorrect_cvc' => 'incorrect_cvc', + ]; + + return $normalized[$declineCode ?? ''] + ?? $normalized[$code ?? ''] + ?? $code; } /** @@ -439,10 +850,48 @@ public function cancelInvoice(Invoice $invoice): Invoice /** * @inheritDoc + * @throws ModelAttributeValidationException */ public function createCreditCard(CreditCard $creditCard): CreditCard { - throw $this->operationNotImplemented('createCreditCard'); + if (empty($creditCard->customer) || empty($creditCard->customer->id)) { + throw ModelAttributeValidationException::required('CreditCard', 'customer'); + } + if (empty($creditCard->token)) { + // token-only: dados crus exigiriam a liberação de raw card data APIs pela + // Stripe e escopo PCI SAQ D — o cartão é tokenizado client-side + throw new GatewayException( + 'The stripe gateway does not accept raw card data;' + . ' tokenize the card client-side with Stripe.js and provide the resulting id in the CreditCard token' + ); + } + + $stripePaymentMethod = $this->stripeRequest(function () use ($creditCard) { + $paymentMethodId = $this->resolvePaymentMethodId($creditCard->token); + + $stripePaymentMethod = $this->client->paymentMethods->attach( + $paymentMethodId, + ['customer' => $creditCard->customer->id] + ); + + // o PaymentMethod da Stripe não tem campo de descrição — vai para metadata + if (!empty($creditCard->description)) { + $stripePaymentMethod = $this->client->paymentMethods->update( + $stripePaymentMethod->id, + ['metadata' => ['description' => $creditCard->description]] + ); + } + + if (!empty($creditCard->default)) { + $this->client->customers->update($creditCard->customer->id, [ + 'invoice_settings' => ['default_payment_method' => $stripePaymentMethod->id], + ]); + } + + return $stripePaymentMethod; + }); + + return $this->parseStripeCard($stripePaymentMethod, $creditCard); } /** @@ -450,7 +899,14 @@ public function createCreditCard(CreditCard $creditCard): CreditCard */ public function getCreditCard(CreditCard $creditCard): CreditCard { - throw $this->operationNotImplemented('getCreditCard'); + $stripePaymentMethod = $this->stripeRequest(function () use ($creditCard) { + $stripePaymentMethod = $this->client->paymentMethods->retrieve($creditCard->id); + $this->assertCardBelongsToCustomer($stripePaymentMethod, $creditCard); + + return $stripePaymentMethod; + }); + + return $this->parseStripeCard($stripePaymentMethod, $creditCard); } /** @@ -458,7 +914,89 @@ public function getCreditCard(CreditCard $creditCard): CreditCard */ public function deleteCreditCard(CreditCard $creditCard): void { - throw $this->operationNotImplemented('deleteCreditCard'); + $this->stripeRequest(function () use ($creditCard) { + $stripePaymentMethod = $this->client->paymentMethods->retrieve($creditCard->id); + $this->assertCardBelongsToCustomer($stripePaymentMethod, $creditCard); + + return $this->client->paymentMethods->detach($creditCard->id); + }); + } + + /** + * Resolve o token do consumidor para um id de PaymentMethod: tokens legados da Stripe + * (tok_...) não são utilizáveis diretamente e viram PaymentMethod antes. + * + * @param string $token + * @return string + * @throws ApiErrorException + */ + private function resolvePaymentMethodId(string $token): string + { + if (str_starts_with($token, 'tok_')) { + return $this->client->paymentMethods->create([ + 'type' => 'card', + 'card' => ['token' => $token], + ])->id; + } + + return $token; + } + + /** + * Espelha a semântica da Iugu (cartão buscado/excluído via customer): quando o model + * informa o customer, a posse do PaymentMethod é validada antes da operação. + * + * @param \Stripe\PaymentMethod $stripePaymentMethod + * @param \Potelo\MultiPayment\Models\CreditCard $creditCard + * @return void + * @throws GatewayException + */ + private function assertCardBelongsToCustomer(StripePaymentMethod $stripePaymentMethod, CreditCard $creditCard): void + { + $customerId = $creditCard->customer->id ?? null; + if (!empty($customerId) && $stripePaymentMethod->customer !== $customerId) { + throw new GatewayException( + "Credit card [{$stripePaymentMethod->id}] does not belong to customer [{$customerId}]" + ); + } + } + + /** + * Converte o PaymentMethod de cartão da Stripe em um CreditCard do MultiPayment. + * + * @param \Stripe\PaymentMethod $stripePaymentMethod + * @param \Potelo\MultiPayment\Models\CreditCard|null $creditCard + * @return \Potelo\MultiPayment\Models\CreditCard + */ + private function parseStripeCard(StripePaymentMethod $stripePaymentMethod, ?CreditCard $creditCard = null): CreditCard + { + if (is_null($creditCard)) { + $creditCard = new CreditCard(); + } + + $card = $stripePaymentMethod->card; + $creditCard->id = $stripePaymentMethod->id; + $creditCard->brand = $card->brand ?? null; + $creditCard->lastDigits = $card->last4 ?? null; + $creditCard->month = isset($card->exp_month) + ? str_pad((string) $card->exp_month, 2, '0', STR_PAD_LEFT) + : null; + $creditCard->year = isset($card->exp_year) ? (string) $card->exp_year : null; + + $metadata = !empty($stripePaymentMethod->metadata) ? $stripePaymentMethod->metadata->toArray() : []; + $creditCard->description = $metadata['description'] ?? $creditCard->description; + + if (!empty($stripePaymentMethod->billing_details?->name)) { + $names = explode(' ', $stripePaymentMethod->billing_details->name); + $creditCard->firstName = $names[array_key_first($names)] ?? null; + $creditCard->lastName = $names[array_key_last($names)] ?? null; + } + + $creditCard->gateway = 'stripe'; + $creditCard->original = $stripePaymentMethod; + $creditCard->createdAt = Carbon::createFromTimestamp($stripePaymentMethod->created); + + return $creditCard; } /** diff --git a/src/MultiPayment.php b/src/MultiPayment.php index 048157d..6ddc567 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -286,6 +286,8 @@ public function setDefaultCard(string $customerId, string $creditCardId): Custom { $customer = new Customer(); $customer->id = $customerId; + // sem isso o model resolveria o gateway default, ignorando o setGateway() desta instância + $customer->gateway = $this->gateway; return $customer->setDefaultCard($creditCardId); } diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php new file mode 100644 index 0000000..148f8ec --- /dev/null +++ b/tests/Integration/StripeGatewayTest.php @@ -0,0 +1,166 @@ +newInvoice() + ->addCustomer( + $customerData['name'], + $customerData['email'], + $customerData['taxDocument'], + $customerData['birthDate'], + $customerData['phoneArea'], + $customerData['phoneNumber'] + ) + ->addItem('Assinatura mensal', 12345, 1) + ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_CREDIT_CARD]) + ->addCreditCardToken('pm_card_visa') + ->create(); + + $this->assertNotNull($invoice->id); + $this->assertEquals(Invoice::STATUS_PAID, $invoice->status); + $this->assertEquals(12345, $invoice->amount); + $this->assertEquals(12345, $invoice->paidAmount); + $this->assertEquals(Invoice::PAYMENT_METHOD_CREDIT_CARD, $invoice->paymentMethod); + $this->assertEquals('4242', $invoice->creditCard->lastDigits); + $this->assertNotNull($invoice->paidAt); + $this->assertCount(1, $invoice->items); + $this->assertEquals('Assinatura mensal', $invoice->items[0]->description); + + sleep(3); // a balance transaction (fee) do cartão é assíncrona logo após o confirm + + $invoiceFetched = MultiPayment::setGateway($gateway)->getInvoice($invoice->id); + $this->assertEquals(Invoice::STATUS_PAID, $invoiceFetched->status); + $this->assertEquals(12345, $invoiceFetched->paidAmount); + $this->assertNotNull($invoiceFetched->fee); + $this->assertEquals($invoice->id, $invoiceFetched->id); + } + + /** + * Recusa de cartão deve virar ChargingException com resposta bruta e razão normalizada. + * + * @dataProvider stripeGatewayDataProvider + * + * @return void + */ + public function testShouldRaiseChargingExceptionOnDeclinedCard($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoiceBuilder = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer( + $customerData['name'], + $customerData['email'], + $customerData['taxDocument'], + $customerData['birthDate'], + $customerData['phoneArea'], + $customerData['phoneNumber'] + ) + ->addItem('Assinatura mensal', 9900, 1) + ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_CREDIT_CARD]) + ->addCreditCardToken('pm_card_chargeDeclined'); + + try { + $invoiceBuilder->create(); + $this->fail('Expected ChargingException was not thrown'); + } catch (ChargingException $exception) { + $this->assertEquals('card_declined', $exception->reason); + $this->assertNotEmpty($exception->chargeResponse); + } + } + + /** + * Deve salvar, buscar, definir como padrão e excluir um cartão tokenizado. + * + * @dataProvider stripeGatewayDataProvider + * + * @return void + */ + public function testShouldManageCreditCardLifecycle($gateway) + { + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + + $creditCard = MultiPayment::setGateway($gateway)->newCreditCard() + ->setCustomerId($customer->id) + ->setToken('pm_card_visa') + ->setDescription('cartão de teste') + ->create(); + + $this->assertNotNull($creditCard->id); + $this->assertEquals('visa', $creditCard->brand); + $this->assertEquals('4242', $creditCard->lastDigits); + $this->assertEquals('cartão de teste', $creditCard->description); + $this->assertEquals($gateway, $creditCard->gateway); + + $cardFetched = MultiPayment::setGateway($gateway)->getCard($customer->id, $creditCard->id); + $this->assertEquals($creditCard->id, $cardFetched->id); + $this->assertEquals('4242', $cardFetched->lastDigits); + + $customerUpdated = MultiPayment::setGateway($gateway)->setDefaultCard($customer->id, $creditCard->id); + $this->assertEquals($creditCard->id, $customerUpdated->defaultCard->id); + + MultiPayment::setGateway($gateway)->deleteCard($customer->id, $creditCard->id); + + // após o detach o PaymentMethod não pertence mais ao customer + $this->expectException(GatewayException::class); + MultiPayment::setGateway($gateway)->getCard($customer->id, $creditCard->id); + } + + /** + * Boleto está fora do escopo do gateway Stripe e deve falhar com mensagem específica. + * + * @dataProvider stripeGatewayDataProvider + * + * @return void + */ + public function testShouldRejectBankSlipInvoice($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoiceBuilder = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer( + $customerData['name'], + $customerData['email'], + $customerData['taxDocument'] + ) + ->addItem('Assinatura mensal', 9900, 1) + ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_BANK_SLIP]); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('does not support bank slip'); + + $invoiceBuilder->create(); + } +} diff --git a/tests/Unit/Exceptions/GatewayExceptionTest.php b/tests/Unit/Exceptions/GatewayExceptionTest.php new file mode 100644 index 0000000..0c8abe4 --- /dev/null +++ b/tests/Unit/Exceptions/GatewayExceptionTest.php @@ -0,0 +1,23 @@ +assertSame([], (new GatewayException('erro'))->getErrors()); + $this->assertSame(['mensagem'], (new GatewayException('erro', 'mensagem'))->getErrors()); + $this->assertSame( + ['code' => 'invalid'], + (new GatewayException('erro', (object) ['code' => 'invalid']))->getErrors() + ); + $this->assertSame( + ['code' => 'invalid'], + (new GatewayException('erro', ['code' => 'invalid']))->getErrors() + ); + } +} diff --git a/tests/Unit/Gateways/RecordingStripeHttpClient.php b/tests/Unit/Gateways/RecordingStripeHttpClient.php new file mode 100644 index 0000000..ed21f1e --- /dev/null +++ b/tests/Unit/Gateways/RecordingStripeHttpClient.php @@ -0,0 +1,57 @@ + */ + public array $calls = []; + + /** @var array */ + private array $responses; + + private function __construct(array $responses) + { + $this->responses = array_map(static function ($response) { + return isset($response[1]) && is_int($response[1]) + ? $response + : [$response, 200]; + }, $responses); + } + + /** + * Cria o fake e o instala como client HTTP global do stripe-php. + * + * @param array $responses + * @return static + */ + public static function withResponses(array $responses): self + { + $httpClient = new self($responses); + ApiRequestor::setHttpClient($httpClient); + + return $httpClient; + } + + /** + * @inheritDoc + */ + public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null) + { + $this->calls[] = [$method, $absUrl, $params]; + + if (empty($this->responses)) { + throw new \RuntimeException("Unexpected Stripe request: {$method} {$absUrl}"); + } + [$body, $code] = array_shift($this->responses); + + return [json_encode($body), $code, []]; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayCreditCardTest.php b/tests/Unit/Gateways/StripeGatewayCreditCardTest.php new file mode 100644 index 0000000..40293d7 --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayCreditCardTest.php @@ -0,0 +1,216 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + + // fake vazio por padrão: teste que esquecer withResponses() estoura em vez de ir à rede + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreateCreditCardRejectsRawCardData(): void + { + $creditCard = $this->creditCardModel(); + $creditCard->token = null; + $creditCard->number = '4111111111111111'; + $creditCard->month = '12'; + $creditCard->year = '2030'; + $creditCard->cvv = '123'; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('does not accept raw card data'); + + (new StripeGateway())->createCreditCard($creditCard); + } + + public function testCreateCreditCardRequiresCustomer(): void + { + $creditCard = new CreditCard(); + $creditCard->token = 'pm_fake123'; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('customer'); + + (new StripeGateway())->createCreditCard($creditCard); + } + + public function testCreateCreditCardAttachesTokenizedPaymentMethod(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paymentMethodResponse()]); + + $result = (new StripeGateway())->createCreditCard($this->creditCardModel()); + + $this->assertCount(1, $httpClient->calls); + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/payment_methods/pm_fake123/attach', parse_url($url, PHP_URL_PATH)); + $this->assertSame(['customer' => 'cus_fake123'], $params); + + $this->assertSame('pm_fake123', $result->id); + $this->assertSame('visa', $result->brand); + $this->assertSame('4242', $result->lastDigits); + $this->assertSame('08', $result->month); + $this->assertSame('2027', $result->year); + $this->assertSame('Faker', $result->firstName); + $this->assertSame('Teste', $result->lastName); + $this->assertSame('stripe', $result->gateway); + $this->assertInstanceOf(Carbon::class, $result->createdAt); + } + + public function testCreateDefaultCreditCardWithDescriptionIssuesExtraUpdates(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(), + $this->paymentMethodResponse(metadata: ['description' => 'cartão principal']), + $this->stripeCustomerResponse(), + ]); + + $creditCard = $this->creditCardModel(); + $creditCard->description = 'cartão principal'; + $creditCard->default = true; + $result = (new StripeGateway())->createCreditCard($creditCard); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'post /v1/payment_methods/pm_fake123/attach', + 'post /v1/payment_methods/pm_fake123', + 'post /v1/customers/cus_fake123', + ], $paths); + $this->assertSame(['metadata' => ['description' => 'cartão principal']], $httpClient->calls[1][2]); + $this->assertSame( + ['invoice_settings' => ['default_payment_method' => 'pm_fake123']], + $httpClient->calls[2][2] + ); + $this->assertSame('cartão principal', $result->description); + } + + public function testCreateCreditCardConvertsLegacyTokenIntoPaymentMethod(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(), + $this->paymentMethodResponse(), + ]); + + $creditCard = $this->creditCardModel(); + $creditCard->token = 'tok_fake123'; + (new StripeGateway())->createCreditCard($creditCard); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'post /v1/payment_methods', + 'post /v1/payment_methods/pm_fake123/attach', + ], $paths); + $this->assertSame(['type' => 'card', 'card' => ['token' => 'tok_fake123']], $httpClient->calls[0][2]); + } + + public function testGetCreditCardValidatesOwnershipWhenCustomerIsInformed(): void + { + RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(customer: 'cus_other'), + ]); + + $creditCard = $this->creditCardModel(); + $creditCard->id = 'pm_fake123'; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('does not belong to customer'); + + (new StripeGateway())->getCreditCard($creditCard); + } + + public function testGetCreditCardSkipsOwnershipCheckWhenCustomerOmitted(): void + { + RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(customer: 'cus_other'), + ]); + + $creditCard = new CreditCard(); + $creditCard->id = 'pm_fake123'; + $result = (new StripeGateway())->getCreditCard($creditCard); + + $this->assertSame('pm_fake123', $result->id); + $this->assertSame('4242', $result->lastDigits); + } + + public function testDeleteCreditCardDetachesThePaymentMethod(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(customer: 'cus_fake123'), + $this->paymentMethodResponse(), + ]); + + $creditCard = $this->creditCardModel(); + $creditCard->id = 'pm_fake123'; + (new StripeGateway())->deleteCreditCard($creditCard); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'get /v1/payment_methods/pm_fake123', + 'post /v1/payment_methods/pm_fake123/detach', + ], $paths); + } + + private function creditCardModel(): CreditCard + { + $creditCard = new CreditCard(); + $creditCard->token = 'pm_fake123'; + $creditCard->customer = new Customer(); + $creditCard->customer->id = 'cus_fake123'; + + return $creditCard; + } + + private function paymentMethodResponse(?string $customer = null, array $metadata = []): array + { + return [ + 'id' => 'pm_fake123', + 'object' => 'payment_method', + 'type' => 'card', + 'customer' => $customer, + 'created' => 1786700000, + 'billing_details' => ['name' => 'Faker Teste'], + 'metadata' => $metadata, + 'card' => ['brand' => 'visa', 'last4' => '4242', 'exp_month' => 8, 'exp_year' => 2027], + ]; + } + + private function stripeCustomerResponse(): array + { + return [ + 'id' => 'cus_fake123', + 'object' => 'customer', + 'invoice_settings' => ['default_payment_method' => 'pm_fake123'], + ]; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayCustomerTest.php b/tests/Unit/Gateways/StripeGatewayCustomerTest.php index 9d6b782..fb5cd14 100644 --- a/tests/Unit/Gateways/StripeGatewayCustomerTest.php +++ b/tests/Unit/Gateways/StripeGatewayCustomerTest.php @@ -278,9 +278,9 @@ public function testUnimplementedOperationThrowsClearGatewayException(): void RecordingStripeHttpClient::withResponses([]); $this->expectException(GatewayException::class); - $this->expectExceptionMessage('Operation [createInvoice] is not yet implemented by the stripe gateway'); + $this->expectExceptionMessage('Operation [refundInvoice] is not yet implemented by the stripe gateway'); - (new StripeGateway())->createInvoice(new Invoice()); + (new StripeGateway())->refundInvoice(new Invoice()); } public function testAuthenticationErrorBecomesGatewayNotAvailable(): void @@ -385,52 +385,3 @@ private function stripeCustomerResponse(): array ]; } } - -/** - * Fake da camada HTTP do stripe-php, no molde do RecordingIuguApiRequest: devolve respostas - * enfileiradas e grava cada chamada para asserção. Cada resposta é um array (corpo JSON, - * status 200) ou um par [corpo, status]. - */ -class RecordingStripeHttpClient implements \Stripe\HttpClient\ClientInterface -{ - /** @var array */ - public array $calls = []; - - /** @var array */ - private array $responses; - - private function __construct(array $responses) - { - $this->responses = array_map(static function ($response) { - return isset($response[1]) && is_int($response[1]) - ? $response - : [$response, 200]; - }, $responses); - } - - /** - * Cria o fake e o instala como client HTTP global do stripe-php. - * - * @param array $responses - * @return static - */ - public static function withResponses(array $responses): self - { - $httpClient = new self($responses); - ApiRequestor::setHttpClient($httpClient); - - return $httpClient; - } - - public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null) - { - $this->calls[] = [$method, $absUrl, $params]; - - if (empty($this->responses)) { - throw new \RuntimeException("Unexpected Stripe request: {$method} {$absUrl}"); - } - [$body, $code] = array_shift($this->responses); - - return [json_encode($body), $code, []]; - } -} diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php new file mode 100644 index 0000000..10b1f1a --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -0,0 +1,549 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + + // fake vazio por padrão: teste que esquecer withResponses() estoura em vez de ir à rede + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreatesCreditCardInvoiceChargingSavedCard(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $result = (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + + $this->assertCount(1, $httpClient->calls); + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/payment_intents', parse_url($url, PHP_URL_PATH)); + $this->assertSame([ + 'amount' => 12345, + 'currency' => 'brl', + 'customer' => 'cus_fake123', + 'metadata' => [ + 'item_0_description' => 'Assinatura mensal', + 'item_0_price' => 12345, + 'item_0_quantity' => 1, + ], + 'payment_method_types' => ['card'], + 'payment_method' => 'pm_fake123', + // o encoder do stripe-php serializa booleanos como string antes da camada HTTP + 'confirm' => 'true', + 'off_session' => 'true', + 'expand' => ['latest_charge.balance_transaction'], + ], $params); + + $this->assertSame('pi_fake123', $result->id); + $this->assertSame(Invoice::STATUS_PAID, $result->status); + $this->assertSame(12345, $result->amount); + $this->assertSame(12345, $result->paidAmount); + $this->assertSame(0, $result->refundedAmount); + $this->assertSame(425, $result->fee); + $this->assertInstanceOf(Carbon::class, $result->paidAt); + $this->assertSame(Invoice::PAYMENT_METHOD_CREDIT_CARD, $result->paymentMethod); + $this->assertSame('visa', $result->creditCard->brand); + $this->assertSame('4242', $result->creditCard->lastDigits); + $this->assertCount(1, $result->items); + $this->assertSame('Assinatura mensal', $result->items[0]->description); + $this->assertSame(12345, $result->items[0]->price); + $this->assertSame('stripe', $result->gateway); + } + + public function testCreatesCreditCardInvoiceSavingTokenizedCardFirst(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(), + $this->paidCardPaymentIntentResponse(), + ]); + + $invoice = $this->creditCardInvoiceModel(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'pm_fake123'; + (new StripeGateway())->createInvoice($invoice); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'post /v1/payment_methods/pm_fake123/attach', + 'post /v1/payment_intents', + ], $paths); + $this->assertSame('cus_fake123', $httpClient->calls[0][2]['customer']); + $this->assertSame('pm_fake123', $httpClient->calls[1][2]['payment_method']); + } + + public function testRejectsInvoiceWithMultiplePaymentMethods(): void + { + $invoice = $this->creditCardInvoiceModel(); + $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_CREDIT_CARD, Invoice::PAYMENT_METHOD_PIX]; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('exactly one payment method'); + + (new StripeGateway())->createInvoice($invoice); + } + + public function testRejectsBankSlipInvoiceWithClearMessage(): void + { + $invoice = $this->creditCardInvoiceModel(); + $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_BANK_SLIP]; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('does not support bank slip'); + + (new StripeGateway())->createInvoice($invoice); + } + + public function testPixInvoiceIsNotImplementedYet(): void + { + $invoice = $this->creditCardInvoiceModel(); + $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_PIX]; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('not yet implemented'); + + (new StripeGateway())->createInvoice($invoice); + } + + public function testCardDeclineBecomesChargingExceptionWithNormalizedReason(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'generic_decline', + 'message' => 'Your card was declined.', + 'payment_intent' => ['id' => 'pi_fake123', 'object' => 'payment_intent', 'status' => 'requires_payment_method'], + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Expected ChargingException was not thrown'); + } catch (ChargingException $exception) { + $this->assertSame('card_declined', $exception->reason); + $this->assertNotEmpty($exception->chargeResponse); + $this->assertSame('pi_fake123', $exception->chargeResponse['payment_intent']['id']); + } + } + + public function testDeclineDuringCardAttachAlsoBecomesChargingException(): void + { + // a Stripe valida o cartão já no attach — recusa nesse ponto precisa manter a + // semântica de falha de cobrança + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'generic_decline', + 'message' => 'Your card was declined.', + ]], 402], + ]); + + $invoice = $this->creditCardInvoiceModel(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'pm_fake123'; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Expected ChargingException was not thrown'); + } catch (ChargingException $exception) { + $this->assertSame('card_declined', $exception->reason); + $this->assertSame('card_error', $exception->chargeResponse['type']); + } + } + + public function testAuthenticationRequiredDeclineIsNormalized(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'authentication_required', + 'decline_code' => 'authentication_required', + 'message' => 'This transaction requires authentication.', + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Expected ChargingException was not thrown'); + } catch (ChargingException $exception) { + $this->assertSame('authentication_required', $exception->reason); + } + } + + public function testBrandNotSupportedDeclineIsNormalized(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'card_not_supported', + 'message' => 'Your card is not supported.', + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Expected ChargingException was not thrown'); + } catch (ChargingException $exception) { + $this->assertSame('brand_not_supported', $exception->reason); + } + } + + public function testGetInvoiceParsesFullRefund(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 12345; + $response['latest_charge']['refunded'] = true; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice(); + + $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(12345, $result->refundedAmount); + } + + public function testGetInvoiceParsesPartialRefund(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 2345; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice(); + + $this->assertSame(Invoice::STATUS_PARTIALLY_REFUNDED, $result->status); + $this->assertSame(2345, $result->refundedAmount); + } + + public function testGetInvoiceReportsExpiredPixAsPendingIgnoringFailedCharge(): void + { + // pix expirado: o PI volta a requires_payment_method com o charge em failed + $response = $this->paidCardPaymentIntentResponse(); + $response['status'] = 'requires_payment_method'; + $response['payment_method_types'] = ['pix']; + $response['latest_charge']['status'] = 'failed'; + $response['latest_charge']['paid'] = false; + $response['latest_charge']['amount_captured'] = 0; + $response['latest_charge']['payment_method_details'] = ['type' => 'pix', 'pix' => []]; + $response['latest_charge']['balance_transaction'] = null; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice(); + + $this->assertSame(Invoice::STATUS_PENDING, $result->status); + $this->assertNull($result->paidAmount); + $this->assertNull($result->paidAt); + $this->assertSame(Invoice::PAYMENT_METHOD_PIX, $result->paymentMethod); + } + + public function testGetInvoiceRejectsUnexpectedStatus(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['status'] = 'partially_funded'; + $response['latest_charge'] = null; + RecordingStripeHttpClient::withResponses([$response]); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('Unexpected Stripe payment intent status: partially_funded'); + + $this->getInvoice(); + } + + public function testChargeInvoiceWithCreditCardUpdatesIntentBeforeConfirming(): void + { + // PI sem customer + PaymentMethod salvo: o customer do dono do cartão é vinculado + $pendingIntent = $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'); + $pendingIntent['customer'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(customer: 'cus_fake123'), + $pendingIntent, + $this->paidCardPaymentIntentResponse(), + $this->paidCardPaymentIntentResponse(), + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + $result = (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'get /v1/payment_methods/pm_fake123', + 'get /v1/payment_intents/pi_fake123', + 'post /v1/payment_intents/pi_fake123', + 'post /v1/payment_intents/pi_fake123/confirm', + ], $paths); + $this->assertSame( + ['payment_method_types' => ['card'], 'customer' => 'cus_fake123'], + $httpClient->calls[2][2] + ); + $this->assertSame('pm_fake123', $httpClient->calls[3][2]['payment_method']); + $this->assertSame('true', $httpClient->calls[3][2]['off_session']); + $this->assertSame(Invoice::STATUS_PAID, $result->status); + } + + public function testChargeInvoiceKeepsMatchingCustomerAndOmitsItFromUpdate(): void + { + // PI e PaymentMethod do mesmo customer: nada de customer no update + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(customer: 'cus_fake123'), + $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'), + $this->paidCardPaymentIntentResponse(), + $this->paidCardPaymentIntentResponse(), + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + + $this->assertSame(['payment_method_types' => ['card']], $httpClient->calls[2][2]); + } + + public function testChargeInvoiceRejectsCardFromAnotherCustomer(): void + { + RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(customer: 'cus_other'), + $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'), + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('does not belong to customer'); + + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + } + + public function testChargeInvoiceWithLegacyTokenConvertsItIntoPaymentMethod(): void + { + $pendingIntent = $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'); + $pendingIntent['customer'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(), + $this->paymentMethodResponse(), + $pendingIntent, + $this->paidCardPaymentIntentResponse(), + $this->paidCardPaymentIntentResponse(), + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'tok_fake123'; + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'post /v1/payment_methods', + 'get /v1/payment_methods/pm_fake123', + 'get /v1/payment_intents/pi_fake123', + 'post /v1/payment_intents/pi_fake123', + 'post /v1/payment_intents/pi_fake123/confirm', + ], $paths); + $this->assertSame('pm_fake123', $httpClient->calls[4][2]['payment_method']); + } + + public function testGatewayAdicionalOptionsOverrideAndExpandIsMerged(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $invoice = $this->creditCardInvoiceModel(); + $invoice->gatewayAdicionalOptions = [ + 'statement_descriptor_suffix' => 'POTELO', + 'off_session' => false, + 'expand' => ['customer'], + ]; + (new StripeGateway())->createInvoice($invoice); + + $params = $httpClient->calls[0][2]; + $this->assertSame('POTELO', $params['statement_descriptor_suffix']); + // a opção do consumidor vence a chave montada pelo gateway + $this->assertSame('false', $params['off_session']); + // o expand do consumidor é mesclado, não descartado + $this->assertSame(['customer', 'latest_charge.balance_transaction'], $params['expand']); + } + + /** + * Status do PaymentIntent sem estorno mapeado para o status genérico. + * + * @return array[] + */ + public static function paymentIntentStatusDataProvider(): array + { + return [ + ['succeeded', Invoice::STATUS_PAID], + ['canceled', Invoice::STATUS_CANCELED], + ['processing', Invoice::STATUS_PENDING], + ['requires_action', Invoice::STATUS_PENDING], + ['requires_confirmation', Invoice::STATUS_PENDING], + ['requires_capture', Invoice::STATUS_PENDING], + ['requires_payment_method', Invoice::STATUS_PENDING], + ]; + } + + /** + * @dataProvider paymentIntentStatusDataProvider + */ + public function testStatusMapping(string $stripeStatus, string $expected): void + { + $response = $this->paidCardPaymentIntentResponse(status: $stripeStatus); + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame($expected, $this->getInvoice()->status); + } + + public function testExplicitAmountTakesPrecedenceOverItemsSum(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $invoice = $this->creditCardInvoiceModel(); + $invoice->amount = 999; + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame(999, $httpClient->calls[0][2]['amount']); + } + + public function testAmountFallsBackToItemsSumMultiplyingQuantity(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $invoice = $this->creditCardInvoiceModel(); + $invoice->items[0]->quantity = 3; + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame(37035, $httpClient->calls[0][2]['amount']); + } + + public function testChargeInvoiceWithCreditCardRequiresTokenOrId(): void + { + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->creditCard = new CreditCard(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('Credit card token or id is required'); + + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + } + + private function getInvoice(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + return (new StripeGateway())->getInvoice($invoice); + } + + private function creditCardInvoiceModel(): Invoice + { + $invoice = new Invoice(); + $invoice->customer = new Customer(); + $invoice->customer->id = 'cus_fake123'; + $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_CREDIT_CARD]; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + $item = new InvoiceItem(); + $item->description = 'Assinatura mensal'; + $item->price = 12345; + $item->quantity = 1; + $invoice->items = [$item]; + + return $invoice; + } + + private function paidCardPaymentIntentResponse(string $status = 'succeeded'): array + { + return [ + 'id' => 'pi_fake123', + 'object' => 'payment_intent', + 'status' => $status, + 'amount' => 12345, + 'currency' => 'brl', + 'customer' => 'cus_fake123', + 'created' => 1786700000, + 'payment_method_types' => ['card'], + 'next_action' => null, + 'metadata' => [ + 'item_0_description' => 'Assinatura mensal', + 'item_0_price' => '12345', + 'item_0_quantity' => '1', + ], + 'latest_charge' => [ + 'id' => 'ch_fake123', + 'object' => 'charge', + 'status' => $status === 'succeeded' ? 'succeeded' : 'failed', + 'paid' => $status === 'succeeded', + 'amount' => 12345, + 'amount_captured' => 12345, + 'amount_refunded' => 0, + 'refunded' => false, + 'created' => 1786700010, + 'payment_method_details' => [ + 'type' => 'card', + 'card' => ['brand' => 'visa', 'last4' => '4242'], + ], + 'balance_transaction' => [ + 'id' => 'txn_fake123', + 'object' => 'balance_transaction', + 'fee' => 425, + 'currency' => 'brl', + ], + ], + ]; + } + + private function paymentMethodResponse(?string $customer = null): array + { + return [ + 'id' => 'pm_fake123', + 'object' => 'payment_method', + 'type' => 'card', + 'customer' => $customer, + 'created' => 1786700000, + 'billing_details' => ['name' => 'Faker Teste'], + 'metadata' => [], + 'card' => ['brand' => 'visa', 'last4' => '4242', 'exp_month' => 8, 'exp_year' => 2027], + ]; + } +} diff --git a/tests/Unit/MultiPaymentGatewayRoutingTest.php b/tests/Unit/MultiPaymentGatewayRoutingTest.php new file mode 100644 index 0000000..862d6b2 --- /dev/null +++ b/tests/Unit/MultiPaymentGatewayRoutingTest.php @@ -0,0 +1,61 @@ +instance('config', new Repository([ + 'multi-payment' => [ + // default proposital em iugu: o teste falharia se o gateway selecionado fosse ignorado + 'default' => 'iugu', + 'gateways' => [ + 'iugu' => ['api_key' => 'iugu-key', 'class' => \Potelo\MultiPayment\Gateways\IuguGateway::class], + 'stripe' => ['api_key' => 'sk_test_fake', 'class' => StripeGateway::class], + ], + ], + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testSetDefaultCardUsesTheSelectedGatewayInsteadOfTheDefault(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + [ + 'id' => 'cus_fake123', + 'object' => 'customer', + 'created' => 1786700000, + 'invoice_settings' => ['default_payment_method' => 'pm_fake123'], + ], + ]); + + $customer = (new MultiPayment('stripe'))->setDefaultCard('cus_fake123', 'pm_fake123'); + + // a chamada foi à API da Stripe — antes do fix, o model resolvia o gateway default (iugu) + $this->assertCount(1, $httpClient->calls); + $this->assertStringContainsString('api.stripe.com/v1/customers/cus_fake123', $httpClient->calls[0][1]); + $this->assertSame('pm_fake123', $customer->defaultCard->id); + } +} From 2073cd90e51e59b241b3cb34216b532e88ca1046 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Fri, 14 Aug 2026 21:12:38 -0300 Subject: [PATCH 04/32] feat(stripe): criar, cancelar e acompanhar fatura pix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 3 do gateway Stripe: - createInvoice para pix: PaymentIntent criado e confirmado 100% server-side (payment_method_data inline com billing_details name/email/tax_id); QR code, copia-e-cola, url hospedada e expiração parseados de next_action - validação antecipada de Customer::taxDocument (produção exige CPF/CNPJ no billing_details; a sandbox não valida) e da janela de expires_at aceita pela Stripe (mais de 10s e menos de 14 dias no futuro — verificado na sandbox; na Iugu expires_at é due_date date-only) - cancelInvoice (estados não-terminais; fatura paga vira GatewayException com payment_intent_unexpected_state) - idempotency key aceita via gatewayAdicionalOptions['idempotency_key'] e enviada como cabeçalho da requisição nos creates de PaymentIntent - fatura pix expirada segue pendente e re-cobrável com cartão (fluxo coberto por teste de integração na sandbox, com pagamento e expiração simulados pelos e-mails mágicos) --- src/Gateways/StripeGateway.php | 97 ++++++++- tests/Integration/StripeGatewayTest.php | 116 +++++++++++ .../Gateways/StripeGatewayInvoiceTest.php | 192 +++++++++++++++++- tests/Unit/MultiPaymentGatewayRoutingTest.php | 6 + 4 files changed, 403 insertions(+), 8 deletions(-) diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 7e5569e..53ee2e2 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -431,6 +431,8 @@ public function createInvoice(Invoice $invoice): Invoice switch ($paymentMethod) { case Invoice::PAYMENT_METHOD_CREDIT_CARD: return $this->createCreditCardInvoice($invoice); + case Invoice::PAYMENT_METHOD_PIX: + return $this->createPixInvoice($invoice); case Invoice::PAYMENT_METHOD_BANK_SLIP: throw new GatewayException('The stripe gateway does not support bank slip invoices; use the iugu gateway instead'); default: @@ -510,16 +512,91 @@ private function createCreditCardInvoice(Invoice $invoice): Invoice $stripePaymentIntentData['confirm'] = true; $stripePaymentIntentData['off_session'] = true; $stripePaymentIntentData = $this->mergeGatewayAdicionalOptions($stripePaymentIntentData, $invoice); + $requestOptions = $this->extractIdempotencyKey($stripePaymentIntentData); - $stripePaymentIntent = $this->stripeChargeRequest(function () use ($stripePaymentIntentData) { + $stripePaymentIntent = $this->stripeChargeRequest(function () use ($stripePaymentIntentData, $requestOptions) { return $this->client->paymentIntents->create( - $this->withExpand($stripePaymentIntentData, self::PAYMENT_INTENT_EXPAND) + $this->withExpand($stripePaymentIntentData, self::PAYMENT_INTENT_EXPAND), + $requestOptions ); }); return $this->parseInvoice($stripePaymentIntent, $invoice); } + /** + * Cria e confirma um PaymentIntent de pix 100% server-side. A fatura volta pendente + * com o QR code em next_action; o pagamento é assíncrono (acompanhar via getInvoice). + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @return \Potelo\MultiPayment\Models\Invoice + * @throws GatewayException|ModelAttributeValidationException + */ + private function createPixInvoice(Invoice $invoice): Invoice + { + // o pix exige CPF/CNPJ no billing_details em produção — falhar cedo evita um + // erro obscuro da API (a sandbox não valida, produção sim) + if (empty($invoice->customer) || empty($invoice->customer->taxDocument)) { + throw ModelAttributeValidationException::required('Customer', 'taxDocument'); + } + + $stripePaymentIntentData = $this->invoiceToStripeData($invoice); + $stripePaymentIntentData['payment_method_types'] = ['pix']; + $stripePaymentIntentData['payment_method_data'] = [ + 'type' => 'pix', + 'billing_details' => array_filter([ + 'name' => $invoice->customer->name, + 'email' => $invoice->customer->email, + 'tax_id' => $invoice->customer->taxDocument, + ]), + ]; + $stripePaymentIntentData['confirm'] = true; + if (!empty($invoice->expiresAt)) { + // janela aceita pela Stripe: mais de 10 segundos e menos de 14 dias no futuro. + // Na Iugu expires_at é due_date (date-only, "vence hoje" é válido) — falhar cedo + // evita o erro obscuro de parâmetro da API para quem vem dessa semântica + if ($invoice->expiresAt->lessThan(Carbon::now()->addSeconds(10)) + || $invoice->expiresAt->greaterThan(Carbon::now()->addDays(14))) { + throw ModelAttributeValidationException::invalid( + 'Invoice', + 'expiresAt', + 'expiresAt must be more than 10 seconds and less than 14 days in the future for pix invoices on the stripe gateway' + ); + } + $stripePaymentIntentData['payment_method_options']['pix']['expires_at'] = $invoice->expiresAt->getTimestamp(); + } + $stripePaymentIntentData = $this->mergeGatewayAdicionalOptions($stripePaymentIntentData, $invoice); + $requestOptions = $this->extractIdempotencyKey($stripePaymentIntentData); + + $stripePaymentIntent = $this->stripeRequest(function () use ($stripePaymentIntentData, $requestOptions) { + return $this->client->paymentIntents->create( + $this->withExpand($stripePaymentIntentData, self::PAYMENT_INTENT_EXPAND), + $requestOptions + ); + }); + + return $this->parseInvoice($stripePaymentIntent, $invoice); + } + + /** + * Extrai a idempotency key das opções do consumidor para enviá-la como cabeçalho da + * requisição (Idempotency-Key) — como parâmetro do payload a API a rejeitaria. + * + * @param array $stripeData recebe o payload por referência e remove a chave dele + * @return array + */ + private function extractIdempotencyKey(array &$stripeData): array + { + if (!array_key_exists('idempotency_key', $stripeData)) { + return []; + } + + $requestOptions = ['idempotency_key' => $stripeData['idempotency_key']]; + unset($stripeData['idempotency_key']); + + return $requestOptions; + } + /** * Converte os campos comuns da fatura para o payload de PaymentIntent da Stripe. * @@ -842,10 +919,24 @@ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gat /** * @inheritDoc + * @throws ModelAttributeValidationException */ public function cancelInvoice(Invoice $invoice): Invoice { - throw $this->operationNotImplemented('cancelInvoice'); + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + + // só estados não-terminais são canceláveis; PaymentIntent pago recusa o cancel + // com payment_intent_unexpected_state (vira GatewayException) + $stripePaymentIntent = $this->stripeRequest(function () use ($invoice) { + return $this->client->paymentIntents->cancel( + $invoice->id, + ['expand' => self::PAYMENT_INTENT_EXPAND] + ); + }); + + return $this->parseInvoice($stripePaymentIntent, $invoice); } /** diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index 148f8ec..ae991c5 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -139,6 +139,122 @@ public function testShouldManageCreditCardLifecycle($gateway) MultiPayment::setGateway($gateway)->getCard($customer->id, $creditCard->id); } + /** + * Deve criar fatura pix server-side com QR code e refletir o pagamento mágico da sandbox. + * + * @dataProvider stripeGatewayDataProvider + * + * @return void + */ + public function testShouldCreatePixInvoiceAndReceiveMagicPayment($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer( + $customerData['name'], + 'succeed_immediately@example.com', + $customerData['taxDocument'] + ) + ->addItem('Assinatura mensal', 12345, 1) + ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]) + ->setExpiresAt(\Carbon\Carbon::now()->addHour()) + ->create(); + + $this->assertNotNull($invoice->id); + $this->assertEquals(Invoice::STATUS_PENDING, $invoice->status); + $this->assertEquals(Invoice::PAYMENT_METHOD_PIX, $invoice->paymentMethod); + $this->assertNotNull($invoice->pix); + $this->assertNotNull($invoice->pix->qrCodeText); + $this->assertNotNull($invoice->pix->qrCodeImageUrl); + $this->assertNotNull($invoice->url); + $this->assertNotNull($invoice->expiresAt); + + // além do pagamento mágico, espera a balance transaction (fee) materializar + $invoiceFetched = $this->waitForInvoiceCondition($gateway, $invoice->id, function (Invoice $fetched) { + return $fetched->status === Invoice::STATUS_PAID && !is_null($fetched->fee); + }); + $this->assertEquals(Invoice::STATUS_PAID, $invoiceFetched->status); + $this->assertEquals(12345, $invoiceFetched->paidAmount); + $this->assertEquals(Invoice::PAYMENT_METHOD_PIX, $invoiceFetched->paymentMethod); + $this->assertNotNull($invoiceFetched->fee); + } + + /** + * Deve cancelar uma fatura pix pendente. + * + * @dataProvider stripeGatewayDataProvider + * + * @return void + */ + public function testShouldCancelPendingPixInvoice($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer($customerData['name'], $customerData['email'], $customerData['taxDocument']) + ->addItem('Assinatura mensal', 5000, 1) + ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]) + ->create(); + + $this->assertEquals(Invoice::STATUS_PENDING, $invoice->status); + + $invoiceCanceled = MultiPayment::setGateway($gateway)->cancelInvoice($invoice->id); + $this->assertEquals(Invoice::STATUS_CANCELED, $invoiceCanceled->status); + } + + /** + * Fatura pix expirada volta a pendente e deve aceitar cobrança com cartão. + * + * @dataProvider stripeGatewayDataProvider + * + * @return void + */ + public function testShouldChargeExpiredPixInvoiceWithCreditCard($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer($customerData['name'], 'expire_immediately@example.com', $customerData['taxDocument']) + ->addItem('Assinatura mensal', 9900, 1) + ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]) + ->create(); + + $this->assertEquals(Invoice::STATUS_PENDING, $invoice->status); + + // aguarda a sandbox processar a expiração mágica (o PI segue pendente e re-cobrável) + $invoiceExpired = $this->waitForInvoiceCondition($gateway, $invoice->id, function (Invoice $fetched) { + return empty($fetched->pix); + }); + $this->assertEquals(Invoice::STATUS_PENDING, $invoiceExpired->status); + + $invoicePaid = MultiPayment::setGateway($gateway) + ->chargeInvoiceWithCreditCard($invoice->id, 'pm_card_visa'); + $this->assertEquals(Invoice::STATUS_PAID, $invoicePaid->status); + $this->assertEquals(Invoice::PAYMENT_METHOD_CREDIT_CARD, $invoicePaid->paymentMethod); + $this->assertEquals('4242', $invoicePaid->creditCard->lastDigits); + } + + /** + * Busca a fatura até a condição ser satisfeita ou o tempo limite estourar — a sandbox + * processa os e-mails mágicos e a balance transaction de forma assíncrona. + * + * @param string $gateway + * @param string $invoiceId + * @param callable $condition + * @return \Potelo\MultiPayment\Models\Invoice + */ + private function waitForInvoiceCondition(string $gateway, string $invoiceId, callable $condition): Invoice + { + $invoice = MultiPayment::setGateway($gateway)->getInvoice($invoiceId); + foreach (range(1, 10) as $attempt) { + if ($condition($invoice)) { + return $invoice; + } + sleep(3); + $invoice = MultiPayment::setGateway($gateway)->getInvoice($invoiceId); + } + + $this->fail("Timeout aguardando a condição da fatura [{$invoiceId}] na sandbox"); + } + /** * Boleto está fora do escopo do gateway Stripe e deve falhar com mensagem específica. * diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index 10b1f1a..96ca8de 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -128,17 +128,167 @@ public function testRejectsBankSlipInvoiceWithClearMessage(): void (new StripeGateway())->createInvoice($invoice); } - public function testPixInvoiceIsNotImplementedYet(): void + public function testCreatesPixInvoiceFullyServerSideAndParsesQrCode(): void { - $invoice = $this->creditCardInvoiceModel(); - $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_PIX]; + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('not yet implemented'); + $invoice = $this->pixInvoiceModel(); + // o parse sobrescreve expiresAt com o valor devolvido pela Stripe — captura antes + $requestedExpiresAt = $invoice->expiresAt->getTimestamp(); + $result = (new StripeGateway())->createInvoice($invoice); + + $this->assertCount(1, $httpClient->calls); + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/payment_intents', parse_url($url, PHP_URL_PATH)); + $this->assertSame([ + 'amount' => 12345, + 'currency' => 'brl', + 'customer' => 'cus_fake123', + 'metadata' => [ + 'item_0_description' => 'Assinatura mensal', + 'item_0_price' => 12345, + 'item_0_quantity' => 1, + ], + 'payment_method_types' => ['pix'], + 'payment_method_data' => [ + 'type' => 'pix', + 'billing_details' => [ + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'tax_id' => '20176996915', + ], + ], + 'confirm' => 'true', + 'payment_method_options' => ['pix' => ['expires_at' => $requestedExpiresAt]], + 'expand' => ['latest_charge.balance_transaction'], + ], $params); + + $this->assertSame(Invoice::STATUS_PENDING, $result->status); + $this->assertSame(Invoice::PAYMENT_METHOD_PIX, $result->paymentMethod); + $this->assertSame('00020126pixcopiaecola', $result->pix->qrCodeText); + $this->assertSame('https://qr.stripe.com/test.png', $result->pix->qrCodeImageUrl); + $this->assertSame('https://payments.stripe.com/qr/instructions/test', $result->url); + $this->assertSame(1786800000, $result->expiresAt->getTimestamp()); + $this->assertNull($result->paidAmount); + } + + public function testPixInvoiceRequiresCustomerTaxDocument(): void + { + $invoice = $this->pixInvoiceModel(); + $invoice->customer->taxDocument = null; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('taxDocument'); + + (new StripeGateway())->createInvoice($invoice); + } + + public function testPixInvoiceRequiresCustomer(): void + { + $invoice = $this->pixInvoiceModel(); + $invoice->customer = null; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('taxDocument'); (new StripeGateway())->createInvoice($invoice); } + public function testPixInvoiceWithoutExpiresAtOmitsPaymentMethodOptions(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + $invoice->expiresAt = null; + (new StripeGateway())->createInvoice($invoice); + + $this->assertArrayNotHasKey('payment_method_options', $httpClient->calls[0][2]); + } + + public function testPixInvoiceRejectsExpiresAtOutsideStripeWindow(): void + { + $invoice = $this->pixInvoiceModel(); + $invoice->expiresAt = Carbon::now()->subMinute(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('more than 10 seconds and less than 14 days'); + + (new StripeGateway())->createInvoice($invoice); + } + + public function testPixInvoiceBillingDetailsOmitsMissingNameAndEmail(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + $invoice->customer->name = null; + $invoice->customer->email = null; + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame( + ['tax_id' => '20176996915'], + $httpClient->calls[0][2]['payment_method_data']['billing_details'] + ); + } + + public function testIdempotencyKeyFromGatewayAdicionalOptionsBecomesRequestHeader(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + $invoice->gatewayAdicionalOptions = ['idempotency_key' => 'chave-unica-123']; + (new StripeGateway())->createInvoice($invoice); + + // a chave não pode vazar como parâmetro do payload (a API a rejeitaria) + $this->assertArrayNotHasKey('idempotency_key', $httpClient->calls[0][2]); + } + + public function testCancelsPendingInvoice(): void + { + $response = $this->paidCardPaymentIntentResponse(status: 'canceled'); + $response['latest_charge'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $result = (new StripeGateway())->cancelInvoice($invoice); + + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/payment_intents/pi_fake123/cancel', parse_url($url, PHP_URL_PATH)); + $this->assertSame(['expand' => ['latest_charge.balance_transaction']], $params); + $this->assertSame(Invoice::STATUS_CANCELED, $result->status); + } + + public function testCancelPaidInvoiceBecomesGatewayException(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'invalid_request_error', + 'code' => 'payment_intent_unexpected_state', + 'message' => 'This PaymentIntent could not be canceled because it has a status of succeeded.', + ]], 400], + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + try { + (new StripeGateway())->cancelInvoice($invoice); + $this->fail('Expected GatewayException was not thrown'); + } catch (GatewayException $exception) { + $this->assertSame('payment_intent_unexpected_state', $exception->getErrors()['code']); + } + } + + public function testCancelInvoiceRequiresId(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new StripeGateway())->cancelInvoice(new Invoice()); + } + public function testCardDeclineBecomesChargingExceptionWithNormalizedReason(): void { RecordingStripeHttpClient::withResponses([ @@ -492,6 +642,38 @@ private function creditCardInvoiceModel(): Invoice return $invoice; } + private function pixInvoiceModel(): Invoice + { + $invoice = $this->creditCardInvoiceModel(); + $invoice->creditCard = null; + $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_PIX]; + $invoice->customer->name = 'Fake Customer'; + $invoice->customer->email = 'email@exemplo.com'; + $invoice->customer->taxDocument = '20176996915'; + $invoice->expiresAt = Carbon::now()->addHour(); + + return $invoice; + } + + private function pendingPixPaymentIntentResponse(): array + { + $response = $this->paidCardPaymentIntentResponse(status: 'requires_action'); + $response['payment_method_types'] = ['pix']; + $response['latest_charge'] = null; + $response['next_action'] = [ + 'type' => 'pix_display_qr_code', + 'pix_display_qr_code' => [ + 'data' => '00020126pixcopiaecola', + 'image_url_png' => 'https://qr.stripe.com/test.png', + 'image_url_svg' => 'https://qr.stripe.com/test.svg', + 'expires_at' => 1786800000, + 'hosted_instructions_url' => 'https://payments.stripe.com/qr/instructions/test', + ], + ]; + + return $response; + } + private function paidCardPaymentIntentResponse(string $status = 'succeeded'): array { return [ diff --git a/tests/Unit/MultiPaymentGatewayRoutingTest.php b/tests/Unit/MultiPaymentGatewayRoutingTest.php index 862d6b2..ca331cb 100644 --- a/tests/Unit/MultiPaymentGatewayRoutingTest.php +++ b/tests/Unit/MultiPaymentGatewayRoutingTest.php @@ -46,6 +46,12 @@ public function testSetDefaultCardUsesTheSelectedGatewayInsteadOfTheDefault(): v [ 'id' => 'cus_fake123', 'object' => 'customer', + // campos lidos pelo parseCustomer: ausentes, o StripeObject emite aviso "Undefined property" + 'name' => null, + 'email' => null, + 'phone' => null, + 'address' => null, + 'metadata' => [], 'created' => 1786700000, 'invoice_settings' => ['default_payment_method' => 'pm_fake123'], ], From f883149c973402a2b47dc2475c0f69c602fbec5f Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Fri, 14 Aug 2026 21:23:28 -0300 Subject: [PATCH 05/32] feat(stripe): estornar e duplicar faturas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 4 do gateway Stripe: - refundInvoice total e parcial via /v1/refunds (refundedAmount preenchido = parcial, mesma semântica da Iugu), com refetch do PaymentIntent para reparse e idempotency key opcional via gatewayAdicionalOptions - duplicateInvoice emulado (o PaymentIntent não tem duplicate nativo): restrito a faturas pix pendentes; recria com os dados da original (customer, valor, metadata preservado) e a nova expiração, e só então cancela a original — se a criação falhar, o consumidor não fica sem fatura; se o cancel falhar, a exceção informa o id da duplicata - CPF/CNPJ da duplicata cai para os billing_details do PaymentMethod original quando o customer da Stripe não tem tax id (fatura criada com o documento apenas no model) - MultiPayment::duplicateInvoice passa a propagar o gateway selecionado ao model (mesmo bug de gateway default corrigido em setDefaultCard) --- src/Gateways/StripeGateway.php | 110 +++++++- src/MultiPayment.php | 7 +- tests/Integration/StripeGatewayTest.php | 89 ++++++ .../Gateways/StripeGatewayCustomerTest.php | 4 +- .../Gateways/StripeGatewayInvoiceTest.php | 258 ++++++++++++++++++ tests/Unit/MultiPaymentGatewayRoutingTest.php | 42 +++ 6 files changed, 505 insertions(+), 5 deletions(-) diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 53ee2e2..4261f54 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -578,6 +578,24 @@ private function createPixInvoice(Invoice $invoice): Invoice return $this->parseInvoice($stripePaymentIntent, $invoice); } + /** + * Recupera o CPF/CNPJ dos billing_details do PaymentMethod de um PaymentIntent pix — + * o PaymentMethod pode já ter sido consumido (pix expirado), sobrando só a cópia + * embutida em last_payment_error. + * + * @param \Stripe\PaymentIntent $stripePaymentIntent com `payment_method` expandido + * @return string|null + */ + private function pixBillingTaxId(StripePaymentIntent $stripePaymentIntent): ?string + { + $stripePaymentMethod = $stripePaymentIntent->payment_method; + if (is_object($stripePaymentMethod) && !empty($stripePaymentMethod->billing_details?->tax_id)) { + return $stripePaymentMethod->billing_details->tax_id; + } + + return $stripePaymentIntent->last_payment_error?->payment_method?->billing_details?->tax_id ?? null; + } + /** * Extrai a idempotency key das opções do consumidor para enviá-la como cabeçalho da * requisição (Idempotency-Key) — como parâmetro do payload a API a rejeitaria. @@ -668,10 +686,28 @@ public function getInvoice(Invoice $invoice): Invoice /** * @inheritDoc + * @throws ModelAttributeValidationException */ public function refundInvoice(Invoice $invoice): Invoice { - throw $this->operationNotImplemented('refundInvoice'); + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + + // mesma semântica da Iugu: refundedAmount preenchido = estorno parcial; vazio = total + $stripeRefundData = ['payment_intent' => $invoice->id]; + if (!empty($invoice->refundedAmount)) { + $stripeRefundData['amount'] = $invoice->refundedAmount; + } + $stripeRefundData = $this->mergeGatewayAdicionalOptions($stripeRefundData, $invoice); + $requestOptions = $this->extractIdempotencyKey($stripeRefundData); + + $this->stripeRequest(function () use ($stripeRefundData, $requestOptions) { + return $this->client->refunds->create($stripeRefundData, $requestOptions); + }); + + // o refund não devolve o PaymentIntent — refetch para reparse com o charge atualizado + return $this->getInvoice($invoice); } /** @@ -911,10 +947,80 @@ private static function chargeFailureReason(?string $code, ?string $declineCode) /** * @inheritDoc + * + * O PaymentIntent não tem duplicate nativo: a fatura nova é criada com os dados da + * original (customer, items, valor) e a nova expiração, e só então a original é + * cancelada — se a criação falhar, o consumidor não fica sem fatura nenhuma. + * Restrito a faturas pix pendentes (cartão é síncrono, não há o que duplicar). + * + * @throws ModelAttributeValidationException */ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gatewayOptions = []): Invoice { - throw $this->operationNotImplemented('duplicateInvoice'); + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + + $original = $this->stripeRequest(function () use ($invoice) { + return $this->client->paymentIntents->retrieve( + $invoice->id, + ['expand' => array_merge(self::PAYMENT_INTENT_EXPAND, ['payment_method'])] + ); + }); + $parsedOriginal = $this->parseInvoice($original, new Invoice()); + + if ($parsedOriginal->status !== Invoice::STATUS_PENDING) { + throw new GatewayException( + "Only pending invoices can be duplicated on the stripe gateway; invoice [{$invoice->id}] is [{$parsedOriginal->status}]" + ); + } + if ($parsedOriginal->paymentMethod !== Invoice::PAYMENT_METHOD_PIX) { + throw new GatewayException('Only pix invoices can be duplicated on the stripe gateway'); + } + if (empty($parsedOriginal->customer) || empty($parsedOriginal->customer->id)) { + throw new GatewayException( + "Invoice [{$invoice->id}] has no customer on the stripe gateway and cannot be duplicated" + ); + } + + // o pix precisa dos billing_details (nome, e-mail, CPF/CNPJ), que vivem no customer + $customer = new Customer(); + $customer->id = $parsedOriginal->customer->id; + $customer = $this->getCustomer($customer); + if (empty($customer->taxDocument)) { + // a original pode ter sido criada com o CPF/CNPJ só no model (billing_details + // do PaymentMethod), sem tax id no customer da Stripe — recupera de lá + $customer->taxDocument = $this->pixBillingTaxId($original); + } + + $duplicated = new Invoice(); + $duplicated->customer = $customer; + $duplicated->amount = $parsedOriginal->amount; + $duplicated->items = $parsedOriginal->items; + $duplicated->availablePaymentMethods = [Invoice::PAYMENT_METHOD_PIX]; + $duplicated->expiresAt = $expiresAt; + // preserva o metadata da original (inclusive chaves custom do consumidor); + // as gatewayOptions do chamador vêm por último e podem sobrescrever + $originalMetadata = !empty($original->metadata) ? $original->metadata->toArray() : []; + if (!empty($originalMetadata)) { + $duplicated->gatewayAdicionalOptions['metadata'] = $originalMetadata; + } + if (!empty($gatewayOptions)) { + $duplicated->gatewayAdicionalOptions = array_merge($duplicated->gatewayAdicionalOptions, $gatewayOptions); + } + $duplicated = $this->createPixInvoice($duplicated); + + try { + $this->cancelInvoice($parsedOriginal); + } catch (MultiPaymentException $e) { + // a duplicata já existe — propaga o id dela para o consumidor não a perder + throw new GatewayException( + "Invoice duplicated as [{$duplicated->id}] but the original [{$invoice->id}] could not be canceled: " + . $e->getMessage() + ); + } + + return $duplicated; } /** diff --git a/src/MultiPayment.php b/src/MultiPayment.php index 6ddc567..8888575 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -131,6 +131,11 @@ public function duplicateInvoice(Invoice|string $invoice, Carbon $expiresAt, arr $invoice = $invoiceInstance; } + // sem isso o model resolveria o gateway default, ignorando o setGateway() desta instância + if (empty($invoice->gateway)) { + $invoice->gateway = $this->gateway; + } + return $invoice->duplicate($expiresAt, $gatewayOptions); } @@ -156,7 +161,7 @@ public function getCustomer(string $id): Customer * @param string $id * @param int|null $partialValueCents * - * @return void + * @return \Potelo\MultiPayment\Models\Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ public function refundInvoice(string $id, ?int $partialValueCents = null): Invoice diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index ae991c5..4640fb1 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -255,6 +255,95 @@ private function waitForInvoiceCondition(string $gateway, string $invoiceId, cal $this->fail("Timeout aguardando a condição da fatura [{$invoiceId}] na sandbox"); } + /** + * Deve estornar integralmente uma fatura de cartão paga. + * + * @dataProvider stripeGatewayDataProvider + * + * @return void + */ + public function testShouldRefundCreditCardInvoiceTotally($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer($customerData['name'], $customerData['email'], $customerData['taxDocument']) + ->addItem('Assinatura mensal', 9900, 1) + ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_CREDIT_CARD]) + ->addCreditCardToken('pm_card_visa') + ->create(); + + $this->assertEquals(Invoice::STATUS_PAID, $invoice->status); + + $invoiceRefunded = MultiPayment::setGateway($gateway)->refundInvoice($invoice->id); + $this->assertEquals(Invoice::STATUS_REFUNDED, $invoiceRefunded->status); + $this->assertEquals(9900, $invoiceRefunded->refundedAmount); + } + + /** + * Deve estornar parcialmente uma fatura pix paga. + * + * @dataProvider stripeGatewayDataProvider + * + * @return void + */ + public function testShouldRefundPixInvoicePartially($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer($customerData['name'], 'succeed_immediately@example.com', $customerData['taxDocument']) + ->addItem('Assinatura mensal', 12345, 1) + ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]) + ->create(); + + $invoicePaid = $this->waitForInvoiceCondition($gateway, $invoice->id, function (Invoice $fetched) { + return $fetched->status === Invoice::STATUS_PAID; + }); + $this->assertEquals(Invoice::STATUS_PAID, $invoicePaid->status); + + $invoiceRefunded = MultiPayment::setGateway($gateway)->refundInvoice($invoice->id, 2345); + $this->assertEquals(Invoice::STATUS_PARTIALLY_REFUNDED, $invoiceRefunded->status); + $this->assertEquals(2345, $invoiceRefunded->refundedAmount); + $this->assertEquals(12345, $invoiceRefunded->paidAmount); + } + + /** + * Deve duplicar uma fatura pix pendente com nova expiração, cancelando a original. + * + * @dataProvider stripeGatewayDataProvider + * + * @return void + */ + public function testShouldDuplicatePendingPixInvoice($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer($customerData['name'], $customerData['email'], $customerData['taxDocument']) + ->addItem('Assinatura mensal', 5000, 1) + ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]) + ->setExpiresAt(\Carbon\Carbon::now()->addHour()) + ->create(); + + $this->assertEquals(Invoice::STATUS_PENDING, $invoice->status); + + $newExpiresAt = \Carbon\Carbon::now()->addDays(2); + $invoiceDuplicated = MultiPayment::setGateway($gateway) + ->duplicateInvoice($invoice->id, $newExpiresAt); + + $this->assertNotEquals($invoice->id, $invoiceDuplicated->id); + $this->assertEquals(Invoice::STATUS_PENDING, $invoiceDuplicated->status); + $this->assertEquals(5000, $invoiceDuplicated->amount); + $this->assertNotNull($invoiceDuplicated->pix->qrCodeText); + $this->assertEqualsWithDelta( + $newExpiresAt->getTimestamp(), + $invoiceDuplicated->expiresAt->getTimestamp(), + 60 + ); + $this->assertEquals($invoice->customer->id, $invoiceDuplicated->customer->id); + + $originalFetched = MultiPayment::setGateway($gateway)->getInvoice($invoice->id); + $this->assertEquals(Invoice::STATUS_CANCELED, $originalFetched->status); + } + /** * Boleto está fora do escopo do gateway Stripe e deve falhar com mensagem específica. * diff --git a/tests/Unit/Gateways/StripeGatewayCustomerTest.php b/tests/Unit/Gateways/StripeGatewayCustomerTest.php index fb5cd14..b2ee7b6 100644 --- a/tests/Unit/Gateways/StripeGatewayCustomerTest.php +++ b/tests/Unit/Gateways/StripeGatewayCustomerTest.php @@ -278,9 +278,9 @@ public function testUnimplementedOperationThrowsClearGatewayException(): void RecordingStripeHttpClient::withResponses([]); $this->expectException(GatewayException::class); - $this->expectExceptionMessage('Operation [refundInvoice] is not yet implemented by the stripe gateway'); + $this->expectExceptionMessage('Operation [rescheduleAutomaticPixPayment] is not yet implemented by the stripe gateway'); - (new StripeGateway())->refundInvoice(new Invoice()); + (new StripeGateway())->rescheduleAutomaticPixPayment(new Invoice()); } public function testAuthenticationErrorBecomesGatewayNotAvailable(): void diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index 96ca8de..7b6a688 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -617,6 +617,264 @@ public function testChargeInvoiceWithCreditCardRequiresTokenOrId(): void (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); } + public function testRefundsInvoiceTotally(): void + { + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([ + ['id' => 're_fake123', 'object' => 'refund', 'status' => 'pending', 'amount' => 12345], + $refunded, + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $result = (new StripeGateway())->refundInvoice($invoice); + + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/refunds', parse_url($url, PHP_URL_PATH)); + // sem amount: estorno total + $this->assertSame(['payment_intent' => 'pi_fake123'], $params); + $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(12345, $result->refundedAmount); + } + + public function testRefundsInvoicePartially(): void + { + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 2345; + $httpClient = RecordingStripeHttpClient::withResponses([ + ['id' => 're_fake123', 'object' => 'refund', 'status' => 'pending', 'amount' => 2345], + $refunded, + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->refundedAmount = 2345; + $result = (new StripeGateway())->refundInvoice($invoice); + + $this->assertSame( + ['payment_intent' => 'pi_fake123', 'amount' => 2345], + $httpClient->calls[0][2] + ); + $this->assertSame(Invoice::STATUS_PARTIALLY_REFUNDED, $result->status); + $this->assertSame(2345, $result->refundedAmount); + } + + public function testRefundInvoiceRequiresId(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new StripeGateway())->refundInvoice(new Invoice()); + } + + public function testDuplicatesPendingPixInvoiceCancelingTheOriginal(): void + { + $newIntent = $this->pendingPixPaymentIntentResponse(); + $newIntent['id'] = 'pi_fake456'; + $canceled = $this->pendingPixPaymentIntentResponse(); + $canceled['status'] = 'canceled'; + $canceled['next_action'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->pendingPixPaymentIntentResponse(), + $this->duplicableCustomerResponse(), + $newIntent, + $canceled, + ]); + + $expiresAt = Carbon::now()->addDay(); + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $result = (new StripeGateway())->duplicateInvoice($invoice, $expiresAt); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'get /v1/customers/cus_fake123', + 'post /v1/payment_intents', + 'post /v1/payment_intents/pi_fake123/cancel', + ], $paths); + + // payload completo; o metadata é o da fatura original preservado (valores string, + // como a Stripe devolve), não a reserialização dos items + $this->assertSame([ + 'amount' => 12345, + 'currency' => 'brl', + 'customer' => 'cus_fake123', + 'metadata' => [ + 'item_0_description' => 'Assinatura mensal', + 'item_0_price' => '12345', + 'item_0_quantity' => '1', + ], + 'payment_method_types' => ['pix'], + 'payment_method_data' => [ + 'type' => 'pix', + 'billing_details' => [ + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'tax_id' => '20176996915', + ], + ], + 'confirm' => 'true', + 'payment_method_options' => ['pix' => ['expires_at' => $expiresAt->getTimestamp()]], + 'expand' => ['latest_charge.balance_transaction'], + ], $httpClient->calls[2][2]); + + $this->assertSame('pi_fake456', $result->id); + $this->assertSame(Invoice::STATUS_PENDING, $result->status); + } + + public function testDuplicateFallsBackToOriginalBillingTaxIdWhenCustomerHasNone(): void + { + $original = $this->pendingPixPaymentIntentResponse(); + $original['payment_method'] = [ + 'id' => 'pm_pix_fake', + 'object' => 'payment_method', + 'type' => 'pix', + 'billing_details' => ['name' => 'Fake Customer', 'email' => 'email@exemplo.com', 'tax_id' => '201.769.969-15'], + ]; + $customer = $this->duplicableCustomerResponse(); + $customer['tax_ids']['data'] = []; + $newIntent = $this->pendingPixPaymentIntentResponse(); + $newIntent['id'] = 'pi_fake456'; + $canceled = $this->pendingPixPaymentIntentResponse(); + $canceled['status'] = 'canceled'; + $canceled['next_action'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$original, $customer, $newIntent, $canceled]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + + $this->assertSame( + '201.769.969-15', + $httpClient->calls[2][2]['payment_method_data']['billing_details']['tax_id'] + ); + } + + public function testDuplicateWithoutAnyTaxDocumentFailsWithoutCancelingTheOriginal(): void + { + $customer = $this->duplicableCustomerResponse(); + $customer['tax_ids']['data'] = []; + $original = $this->pendingPixPaymentIntentResponse(); + $original['payment_method'] = null; + $original['last_payment_error'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$original, $customer]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + try { + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + $this->fail('Expected ModelAttributeValidationException was not thrown'); + } catch (ModelAttributeValidationException $exception) { + // a original não pode ter sido cancelada — só os dois GETs aconteceram + $this->assertCount(2, $httpClient->calls); + } + } + + public function testDuplicatePassesGatewayOptionsToTheNewIntent(): void + { + $newIntent = $this->pendingPixPaymentIntentResponse(); + $newIntent['id'] = 'pi_fake456'; + $canceled = $this->pendingPixPaymentIntentResponse(); + $canceled['status'] = 'canceled'; + $canceled['next_action'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->pendingPixPaymentIntentResponse(), + $this->duplicableCustomerResponse(), + $newIntent, + $canceled, + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay(), ['statement_descriptor' => 'DUP']); + + $this->assertSame('DUP', $httpClient->calls[2][2]['statement_descriptor']); + } + + public function testDuplicateReportsTheNewInvoiceWhenCancelingTheOriginalFails(): void + { + $newIntent = $this->pendingPixPaymentIntentResponse(); + $newIntent['id'] = 'pi_fake456'; + RecordingStripeHttpClient::withResponses([ + $this->pendingPixPaymentIntentResponse(), + $this->duplicableCustomerResponse(), + $newIntent, + [['error' => [ + 'type' => 'invalid_request_error', + 'code' => 'payment_intent_unexpected_state', + 'message' => 'This PaymentIntent could not be canceled.', + ]], 400], + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('Invoice duplicated as [pi_fake456]'); + + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + } + + public function testDuplicateRejectsPaidInvoice(): void + { + RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('Only pending invoices can be duplicated'); + + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + } + + public function testDuplicateRejectsNonPixInvoice(): void + { + $response = $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'); + $response['latest_charge'] = null; + RecordingStripeHttpClient::withResponses([$response]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('Only pix invoices can be duplicated'); + + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + } + + public function testDuplicateInvoiceRequiresId(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new StripeGateway())->duplicateInvoice(new Invoice(), Carbon::now()->addDay()); + } + + private function duplicableCustomerResponse(): array + { + return [ + 'id' => 'cus_fake123', + 'object' => 'customer', + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'phone' => null, + 'address' => null, + 'metadata' => [], + 'created' => 1786700000, + 'invoice_settings' => ['default_payment_method' => null], + 'tax_ids' => [ + 'object' => 'list', + 'data' => [ + ['id' => 'txi_fake1', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '20176996915'], + ], + ], + ]; + } + private function getInvoice(): Invoice { $invoice = new Invoice(); diff --git a/tests/Unit/MultiPaymentGatewayRoutingTest.php b/tests/Unit/MultiPaymentGatewayRoutingTest.php index ca331cb..55d843d 100644 --- a/tests/Unit/MultiPaymentGatewayRoutingTest.php +++ b/tests/Unit/MultiPaymentGatewayRoutingTest.php @@ -40,6 +40,48 @@ protected function tearDown(): void parent::tearDown(); } + public function testDuplicateInvoiceUsesTheSelectedGatewayInsteadOfTheDefault(): void + { + $pendingPix = [ + 'id' => 'pi_fake123', + 'object' => 'payment_intent', + 'status' => 'requires_action', + 'amount' => 5000, + 'currency' => 'brl', + 'customer' => 'cus_fake123', + 'created' => 1786700000, + 'payment_method_types' => ['pix'], + 'next_action' => null, + 'metadata' => [], + 'latest_charge' => null, + ]; + $customer = [ + 'id' => 'cus_fake123', + 'object' => 'customer', + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'phone' => null, + 'address' => null, + 'metadata' => [], + 'created' => 1786700000, + 'invoice_settings' => ['default_payment_method' => null], + 'tax_ids' => ['object' => 'list', 'data' => [ + ['id' => 'txi_fake1', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '20176996915'], + ]], + ]; + $newIntent = array_merge($pendingPix, ['id' => 'pi_fake456']); + $canceled = array_merge($pendingPix, ['status' => 'canceled']); + $httpClient = RecordingStripeHttpClient::withResponses([$pendingPix, $customer, $newIntent, $canceled]); + + $invoice = (new MultiPayment('stripe')) + ->duplicateInvoice('pi_fake123', \Carbon\Carbon::now()->addDay()); + + // antes do fix, o model resolveria o gateway default (iugu) + $this->assertStringContainsString('api.stripe.com/v1/payment_intents/pi_fake123', $httpClient->calls[0][1]); + $this->assertSame('pi_fake456', $invoice->id); + $this->assertSame('stripe', $invoice->gateway); + } + public function testSetDefaultCardUsesTheSelectedGatewayInsteadOfTheDefault(): void { $httpClient = RecordingStripeHttpClient::withResponses([ From 8834ded3aac22e6105cee856e7089d0a373fd1fe Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Fri, 14 Aug 2026 21:34:41 -0300 Subject: [PATCH 06/32] docs(stripe): documentar o gateway Stripe e completar a Facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: seção Gateways com a matriz de suporte por operação (Iugu × Stripe) e as particularidades do Stripe (cartão token-only e bandeiras aceitas, razões normalizadas de recusa para fallback de gateway, pix com tax_document obrigatório e janela de expiração, fatura expirada re-cobrável, url/fee, idempotency key); exemplos de refund/cancel/duplicate/chargeInvoiceWithCreditCard; STRIPE_APIKEY na configuração; tabela do charge anotada com as diferenças por gateway - Facade: anotações @method que faltavam (getInvoice, getCustomer, refundInvoice, duplicateInvoice) e tipo de retorno do charge corrigido --- README.md | 92 ++++++++++++++++++++++++++++++++---- src/Facades/MultiPayment.php | 6 ++- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c07bbe4..27c479c 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,21 @@ ## Introdução -MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atualmente suporta o Iugu. +MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atualmente suporta Iugu e Stripe. - [Introdução](#introdução) - [Requisitos](#requisitos) - [Instalação](#instalação) - [Configuração](#configuração) +- [Gateways](#gateways) + - [Suporte por gateway](#suporte-por-gateway) + - [Particularidades do Stripe](#particularidades-do-stripe) - [Utilizando](#utilizando) - [MultiPayment](#multipayment) - [InvoiceBuilder](#invoicebuilder) - [Pix Automático](#pix-automático) - [CustomerBuilder](#customerbuilder) - [getInvoice](#getinvoice) + - [Outras operações de fatura](#outras-operações-de-fatura) - [charge](#charge) - [Models](#models) - [Customer](#customer) @@ -46,6 +50,9 @@ MULTIPAYMENT_DEFAULT=iugu #iugu IUGU_ID= IUGU_APIKEY= + +#stripe +STRIPE_APIKEY= ``` Opcionalmente você pode configurar o Trait, para facilitar o uso do método `charge` junto a um usuário. @@ -68,6 +75,53 @@ Também é possível utilizar o Facade: \Potelo\MultiPayment\Facades\MultiPayment::charge($options); ``` +## Gateways + +### Suporte por gateway + +| Operação | Iugu | Stripe | +|---|---|---| +| Fatura com cartão de crédito | ✅ | ✅ (token-only) | +| Fatura com pix | ✅ | ✅ | +| Fatura com boleto | ✅ | ❌ lança `GatewayException` | +| Fatura multi-método (`available_payment_methods` com mais de um) | ✅ | ❌ exatamente 1 método por fatura | +| Estorno total e parcial | ✅ | ✅ | +| Cancelamento | ✅ | ✅ | +| Duplicar fatura (`duplicateInvoice`) | ✅ | ✅ somente pix pendente | +| Cobrar fatura pendente com cartão | ✅ | ✅ (inclusive pix expirado) | +| Customer (criar/atualizar/buscar) e cartões salvos | ✅ | ✅ | +| Pix Automático | ✅ | 🚧 em desenvolvimento | + +### Particularidades do Stripe + +- **Cartão é token-only.** O Stripe não aceita dados crus de cartão pela API (exigiria + liberação de "raw card data" e escopo PCI SAQ D). Tokenize o cartão no navegador com + Stripe.js e envie o id resultante (`pm_...`) em `credit_card.token` / `CreditCard::$token` + (tokens legados `tok_...` também são aceitos). O caminho com `number`/`cvv` lança + `GatewayException` orientando o uso de token. +- **Bandeiras aceitas no Brasil: somente Visa e Mastercard crédito.** Elo, Hipercard, Amex e + débito nacional não são suportados pelo Stripe BR. Para essas bandeiras, roteie a cobrança + para outro gateway (ex.: Iugu) — de preferência detectando a bandeira pelo BIN antes de + tokenizar. Para decidir o fallback programaticamente, use `ChargingException::$reason`, + que traz a razão normalizada da recusa (`card_declined`, `brand_not_supported`, + `authentication_required`, `expired_card`, `insufficient_funds`, `incorrect_cvc`...). + `GatewayNotAvailableException` também sinaliza "tente outro gateway". +- **Pix exige `tax_document` do cliente** (CPF/CNPJ vai nos billing details do pagamento). +- **`expires_at` do pix é opcional** (default do Stripe: 4 horas) e, quando informado, deve + ficar entre 10 segundos e 14 dias no futuro — diferente da Iugu, onde `expires_at` é a + data de vencimento e é obrigatório para pix/boleto. +- **Pix expirado continua pendente e re-cobrável.** Na Iugu, fatura expirada vira `canceled`; + no Stripe ela volta a aguardar pagamento (`pending`) e pode ser paga com cartão via + `chargeInvoiceWithCreditCard` ou duplicada com `duplicateInvoice` (nova expiração; + a original é cancelada). +- **`url` da fatura**: no pix é a página hospedada com instruções de pagamento + (`hosted_instructions_url`); em fatura de cartão é `null` — não assuma `url` preenchida + como na Iugu (`secure_url`). +- **`fee` é assíncrono para cartão**: pode vir `null` logo após a cobrança e preenchido em um + `getInvoice` posterior. +- **Idempotência**: envie `gateway_adicional_options['idempotency_key']` na criação de + faturas e estornos para repassar o cabeçalho `Idempotency-Key` da Stripe. + ## Utilizando ### MultiPayment: @@ -168,6 +222,25 @@ $payment = new \Potelo\MultiPayment\MultiPayment('iugu'); $foundInvoice = $payment->getInvoice($invoiceId); ``` +#### Outras operações de fatura +```php +$payment = new \Potelo\MultiPayment\MultiPayment('stripe'); + +// estorno total ou parcial (valor em centavos) +$payment->refundInvoice($invoiceId); +$payment->refundInvoice($invoiceId, 5000); + +// cancelamento de fatura pendente +$payment->cancelInvoice($invoiceId); + +// duplicar fatura pendente com nova expiração (no Stripe: somente pix; a original é cancelada) +$payment->duplicateInvoice($invoiceId, \Carbon\Carbon::now()->addDays(3)); + +// cobrar uma fatura pendente com cartão (token OU id de cartão salvo) +$payment->chargeInvoiceWithCreditCard($invoiceId, 'pm_...'); +$payment->chargeInvoiceWithCreditCard($invoiceId, null, $creditCardId); +``` + #### charge ```php @@ -222,7 +295,7 @@ $payment->setGateway('iugu')->charge($options); | `customer` | **obrigatório** | array | array com os dados do cliente | `['name' => 'Nome do cliente'...]` | | `customer.name` | **obrigatório** | string | nome do cliente | `'Nome do cliente'` | | `customer.email` | **obrigatório** | string | email do cliente | `'joaomaria@email.com'` | -| `customer.tax_document` | | string | cpf ou cnpj do cliente | `'12345678901'` | +| `customer.tax_document` | **obrigatório** no Stripe para faturas pix | string | cpf ou cnpj do cliente | `'12345678901'` | | `birth_date` | | string formato `yyyy-mm-dd` | data de nascimento | `'01/01/1990'` | | `customer.phone_number` | | string | telefone | `'999999999'` | | `customer.phone_area` | | string | DDD | `'999999999'` | @@ -238,14 +311,15 @@ $payment->setGateway('iugu')->charge($options); | `items.description` | **obrigatório** | string | descrição do item | `'Produto 1'` | | `items.quantity` | **obrigatório** | int | quantidade do item | `1` | | `items.price` | **obrigatório** | int | valor do item | `10000` | -| `payment_method` | | `'credit_card'`,`'bank_slip'` | método de pagamento | `'credit_card'` | -| `expires_at` | **obrigatório** caso `payment_method` seja `'bank_slip'` ou `'pix'` | string no formato `yyyy-mm-dd` | data de expiração da fatura | `2021-10-10` | +| `payment_method` | | `'credit_card'`,`'bank_slip'`,`'pix'` | método de pagamento | `'credit_card'` | +| `available_payment_methods` | **obrigatório** no Stripe (exatamente um método) quando não há `credit_card` | array de métodos | métodos aceitos pela fatura | `['pix']` | +| `expires_at` | **obrigatório** na Iugu caso `payment_method` seja `'bank_slip'` ou `'pix'`; opcional no Stripe (pix — a data precisa cair na janela de 10 segundos a 14 dias no futuro) | string no formato `yyyy-mm-dd` | data de expiração da fatura | `2021-10-10` | | `credit_card` | **obrigatório** caso `payment_method` seja `'credit_card'` | array | array com os dados do cartão de crédito | `['number' => '1234567890123456',...` | -| `credit_card.token` | | string | token do cartão para o gateway escolhido | `'abcdefghijklmnopqrstuvwxyz'` | -| `credit_card.number` | **obrigatório** caso `token` não tenha sido informado | string | número do cartão de crédito | `'1234567890123456'` | -| `credit_card.month` | **obrigatório** caso `token` não tenha sido informado | string | mês de expiração do cartão de crédito | `'12'` | -| `credit_card.year` | **obrigatório** caso `token` não tenha sido informado | string | ano de expiração do cartão de crédito | `'2022'` | -| `credit_card.cvv` | **obrigatório** caso `token` não tenha sido informado | string | código de segurança do cartão de crédito | `'123'` | +| `credit_card.token` | | string | token do cartão para o gateway escolhido | `'abc123...'` (Iugu) / `'pm_...'` (Stripe) | +| `credit_card.number` | **obrigatório** caso `token` não tenha sido informado (somente Iugu — o Stripe é token-only) | string | número do cartão de crédito | `'1234567890123456'` | +| `credit_card.month` | **obrigatório** caso `token` não tenha sido informado (somente Iugu) | string | mês de expiração do cartão de crédito | `'12'` | +| `credit_card.year` | **obrigatório** caso `token` não tenha sido informado (somente Iugu) | string | ano de expiração do cartão de crédito | `'2022'` | +| `credit_card.cvv` | **obrigatório** caso `token` não tenha sido informado (somente Iugu) | string | código de segurança do cartão de crédito | `'123'` | | `credit_card.first_name` | | string | primeiro nome no cartão de crédito | `'João'` | | `credit_card.last_name` | | string | último nome no cartão de crédito | `'Maria'` | | `bank_slip` | | array | array com os dados do boleto | `['expires_at' => '2022-12-31',...` | diff --git a/src/Facades/MultiPayment.php b/src/Facades/MultiPayment.php index 4a0b1c8..2c7f1fe 100644 --- a/src/Facades/MultiPayment.php +++ b/src/Facades/MultiPayment.php @@ -11,10 +11,14 @@ /** - * @method static invoice charge(array $attributes) + * @method static Invoice charge(array $attributes) * @method static InvoiceBuilder newInvoice() * @method static CustomerBuilder newCustomer() * @method static CreditCardBuilder newCreditCard() + * @method static Invoice getInvoice(string $id) + * @method static \Potelo\MultiPayment\Models\Customer getCustomer(string $id) + * @method static Invoice refundInvoice(string $id, ?int $partialValueCents = null) + * @method static Invoice duplicateInvoice(Invoice|string $invoice, \Carbon\Carbon $expiresAt, array $gatewayOptions = []) * @method static CreditCard getCard(string $customerId, string $creditCardId) * @method static void deleteCard(string $customerId, string $creditCardId) * @method static \Potelo\MultiPayment\MultiPayment setGateway($gateway) From 8f742f48dc5c9c3da161ba5c1fe339f4afaece3d Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Fri, 14 Aug 2026 21:56:03 -0300 Subject: [PATCH 07/32] =?UTF-8?q?feat(stripe):=20rejeitar=20fatura=20com?= =?UTF-8?q?=20Pix=20Autom=C3=A1tico=20at=C3=A9=20a=20integra=C3=A7=C3=A3o?= =?UTF-8?q?=20existir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sem a guarda, uma fatura pix criada no Stripe com automatic_pix preenchido era criada como pix comum, descartando a recorrência silenciosamente. Agora lança GatewayException clara, como as demais operações de Pix Automático (integração pendente da habilitação do recurso na conta). README registra a pendência na seção Pix Automático. --- README.md | 6 ++++-- src/Gateways/StripeGateway.php | 6 ++++++ tests/Unit/Gateways/StripeGatewayInvoiceTest.php | 11 +++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 27c479c..03e2d6b 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,9 @@ Confira `src/MultiPayment/Builders/InvoiceBuilder.php` para saber quais métodos #### Pix Automático -O Pix Automático está disponível no gateway Iugu e é configurado como parte da fatura: +O Pix Automático está disponível no gateway Iugu. No Stripe o suporte está **pendente** (aguardando a habilitação do recurso na conta): todas as operações de Pix Automático — inclusive criar fatura com `automatic_pix` — lançam `GatewayException` com mensagem "not yet implemented" até que essa integração seja concluída. + +Na Iugu, ele é configurado como parte da fatura: ```php use Potelo\MultiPayment\Models\AutomaticPix; @@ -196,7 +198,7 @@ STRIPE_APIKEY=sua_chave_sk_test \ ./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 +Atualmente, a sandbox da Iugu 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 diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 4261f54..e8e6cd0 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -427,6 +427,12 @@ private function operationNotImplemented(string $operation): GatewayException */ public function createInvoice(Invoice $invoice): Invoice { + // sem esta guarda a fatura seria criada como pix comum, descartando a recorrência + // silenciosamente — o suporte a Pix Automático no Stripe ainda não foi construído + if (!empty($invoice->automaticPix)) { + throw $this->operationNotImplemented('createInvoice with automatic pix'); + } + $paymentMethod = $this->invoicePaymentMethod($invoice); switch ($paymentMethod) { case Invoice::PAYMENT_METHOD_CREDIT_CARD: diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index 7b6a688..4d66a7e 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -173,6 +173,17 @@ public function testCreatesPixInvoiceFullyServerSideAndParsesQrCode(): void $this->assertNull($result->paidAmount); } + public function testRejectsInvoiceWithAutomaticPixUntilSupported(): void + { + $invoice = $this->pixInvoiceModel(); + $invoice->automaticPix = new \Potelo\MultiPayment\Models\AutomaticPix(); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('Operation [createInvoice with automatic pix] is not yet implemented'); + + (new StripeGateway())->createInvoice($invoice); + } + public function testPixInvoiceRequiresCustomerTaxDocument(): void { $invoice = $this->pixInvoiceModel(); From 94b8485b41d9c60197e980f822414d9b979d6317 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Mon, 31 Aug 2026 22:35:10 -0300 Subject: [PATCH 08/32] =?UTF-8?q?fix(stripe):=20n=C3=A3o=20ler=20proprieda?= =?UTF-8?q?de=20ausente=20de=20StripeObject=20em=20fatura=20pix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_015yJHkDNRMaKEtw96QaADP5 --- src/Gateways/StripeGateway.php | 8 +++- tests/Unit/Gateways/RecordingStripeLogger.php | 20 ++++++++++ .../Gateways/StripeGatewayInvoiceTest.php | 40 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Gateways/RecordingStripeLogger.php diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index e8e6cd0..0eb731c 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -837,7 +837,11 @@ private function parseInvoice(StripePaymentIntent $stripePaymentIntent, ?Invoice $invoice->items = $items; } - $cardDetails = $stripeCharge?->payment_method_details?->card; + // `?->` não basta: em cobrança pix o payment_method_details existe e apenas não tem + // a chave `card`, e o StripeObject loga "Undefined property" via Stripe::getLogger() + // ao ler propriedade ausente. isset() passa pelo __isset e não polui o log. + $paymentMethodDetails = $stripeCharge?->payment_method_details; + $cardDetails = isset($paymentMethodDetails->card) ? $paymentMethodDetails->card : null; if (!empty($cardDetails)) { if (empty($invoice->creditCard)) { $invoice->creditCard = new CreditCard(); @@ -1177,7 +1181,7 @@ private function parseStripeCard(StripePaymentMethod $stripePaymentMethod, ?Cred $creditCard = new CreditCard(); } - $card = $stripePaymentMethod->card; + $card = isset($stripePaymentMethod->card) ? $stripePaymentMethod->card : null; $creditCard->id = $stripePaymentMethod->id; $creditCard->brand = $card->brand ?? null; $creditCard->lastDigits = $card->last4 ?? null; diff --git a/tests/Unit/Gateways/RecordingStripeLogger.php b/tests/Unit/Gateways/RecordingStripeLogger.php new file mode 100644 index 0000000..1158df9 --- /dev/null +++ b/tests/Unit/Gateways/RecordingStripeLogger.php @@ -0,0 +1,20 @@ +messages[] = (string) $message; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index 4d66a7e..de70edb 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -924,6 +924,46 @@ private function pixInvoiceModel(): Invoice return $invoice; } + /** + * O StripeObject registra "Undefined property" no logger da Stripe ao ler uma chave + * ausente, e `?->` não protege contra isso porque o objeto pai existe. Em fatura pix + * paga, payment_method_details vem sem `card`, então o parse não pode acessar a chave + * às cegas sob pena de sujar o log de produção a cada fatura. + */ + public function testParsingPaidPixInvoiceDoesNotLogUndefinedProperty(): void + { + RecordingStripeHttpClient::withResponses([$this->paidPixPaymentIntentResponse()]); + + $loggerAnterior = \Stripe\Stripe::getLogger(); + $logger = new RecordingStripeLogger(); + \Stripe\Stripe::setLogger($logger); + + try { + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $result = (new StripeGateway())->getInvoice($invoice); + } finally { + \Stripe\Stripe::setLogger($loggerAnterior); + } + + $this->assertSame(Invoice::STATUS_PAID, $result->status); + $this->assertSame(Invoice::PAYMENT_METHOD_PIX, $result->paymentMethod); + $this->assertNull($result->creditCard); + $this->assertSame([], $logger->messages); + } + + private function paidPixPaymentIntentResponse(): array + { + $response = $this->paidCardPaymentIntentResponse(); + $response['payment_method_types'] = ['pix']; + $response['latest_charge']['payment_method_details'] = [ + 'type' => 'pix', + 'pix' => ['bank_transaction_id' => 'E00000000202601011200abcdef123456'], + ]; + + return $response; + } + private function pendingPixPaymentIntentResponse(): array { $response = $this->paidCardPaymentIntentResponse(status: 'requires_action'); From b33d181a05940022efbbc7b4d32cc87b82d3a385 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Tue, 1 Sep 2026 13:37:19 -0300 Subject: [PATCH 09/32] feat(subscription): assinatura recorrente e plano na Iugu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona o domínio de assinatura recorrente ao pacote: SubscriptionContract e PlanContract, os models genéricos (Subscription, SubscriptionItem, SubscriptionDiscount, SubscriptionPlanChange, Plan), o SubscriptionBuilder e a implementação completa no IuguGateway. Os dois contracts ficam fora da composição de GatewayContract enquanto só a Iugu os implementa; o StripeGateway entra numa fase seguinte. Decisões de vocabulário: - Desconto é conceito próprio, não item de preço negativo. Na Iugu vira subitem de price_cents negativo, e a volta separa item de desconto pelo sinal. - Itens e descontos são declarativos no update: a lista informada vira o estado da assinatura, e a que ficar em null é preservada. A Iugu recusa remover e adicionar subitens na mesma chamada, então a remoção sai numa requisição própria e anterior. - nextBillingAt mapeia para expires_at, que na Iugu é a data da próxima cobrança. O update só reenvia data e métodos de pagamento quando mudaram em relação à leitura. - past_due não existe na Iugu e é derivado de expires_at no passado somado a qualquer fatura de recent_invoices ainda em aberto, olhando todas e não só a escolhida como latestInvoice. A escolha do latestInvoice é regra separada: fatura em aberto tem preferência, e entre as do mesmo estado vence a de maior vencimento. - O que a Iugu não faz lança GatewayException: cancelar ao fim do período, desconto percentual, desconto limitado a mais de um ciclo, plano anual e desativar plano. As chamadas de assinatura usam iuguRequest() (antes automaticPixRequest), porque o SDK engole exceção em suspend, activate, change_plan e search, e não tem change_plan_simulation. Claude-Session: https://claude.ai/code/session_0144j5PoRvSrwbuv97gesV8q --- README.md | 141 ++ src/Builders/SubscriptionBuilder.php | 243 +++ src/Contracts/PlanContract.php | 52 + src/Contracts/SubscriptionContract.php | 126 ++ src/Facades/MultiPayment.php | 3 + src/Gateways/IuguGateway.php | 1058 +++++++++- src/Models/Plan.php | 153 ++ src/Models/Subscription.php | 463 +++++ src/Models/SubscriptionDiscount.php | 110 + src/Models/SubscriptionItem.php | 81 + src/Models/SubscriptionPlanChange.php | 71 + src/MultiPayment.php | 71 + tests/Integration/SubscriptionTest.php | 320 +++ .../Gateways/IuguGatewaySubscriptionTest.php | 1802 +++++++++++++++++ tests/Unit/MultiPaymentGatewayRoutingTest.php | 2 - tests/Unit/SubscriptionTest.php | 619 ++++++ 16 files changed, 5306 insertions(+), 9 deletions(-) create mode 100644 src/Builders/SubscriptionBuilder.php create mode 100644 src/Contracts/PlanContract.php create mode 100644 src/Contracts/SubscriptionContract.php create mode 100644 src/Models/Plan.php create mode 100644 src/Models/Subscription.php create mode 100644 src/Models/SubscriptionDiscount.php create mode 100644 src/Models/SubscriptionItem.php create mode 100644 src/Models/SubscriptionPlanChange.php create mode 100644 tests/Integration/SubscriptionTest.php create mode 100644 tests/Unit/Gateways/IuguGatewaySubscriptionTest.php create mode 100644 tests/Unit/SubscriptionTest.php diff --git a/README.md b/README.md index 03e2d6b..f7a3998 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [MultiPayment](#multipayment) - [InvoiceBuilder](#invoicebuilder) - [Pix Automático](#pix-automático) + - [Assinaturas e planos](#assinaturas-e-planos) - [CustomerBuilder](#customerbuilder) - [getInvoice](#getinvoice) - [Outras operações de fatura](#outras-operações-de-fatura) @@ -20,6 +21,8 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [Models](#models) - [Customer](#customer) - [Invoice](#invoice) + - [Subscription](#subscription) + - [Plan](#plan) ## Requisitos - PHP 8.0+ @@ -91,6 +94,14 @@ Também é possível utilizar o Facade: | Cobrar fatura pendente com cartão | ✅ | ✅ (inclusive pix expirado) | | Customer (criar/atualizar/buscar) e cartões salvos | ✅ | ✅ | | Pix Automático | ✅ | 🚧 em desenvolvimento | +| Assinatura (criar, buscar, atualizar, suspender, retomar, cancelar, listar) | ✅ | 🚧 em desenvolvimento | +| Cancelar assinatura ao fim do período (`cancel(atPeriodEnd: true)`) | ❌ lança `GatewayException` | 🚧 em desenvolvimento | +| Troca de plano e simulação (`changePlan`, `previewPlanChange`) | ✅ | 🚧 em desenvolvimento | +| Desconto na assinatura | ✅ somente valor fixo (`amountOff`), com `cycles` 1 ou `null` | 🚧 em desenvolvimento | +| Plano (criar, buscar, listar) | ✅ intervalos `week` e `month` | 🚧 em desenvolvimento | +| Desativar plano (`deactivatePlan`) | ❌ lança `GatewayException` | 🚧 em desenvolvimento | + +🚧 = ainda não implementado no gateway; hoje a chamada lança `GatewayException`. ### Particularidades do Stripe @@ -204,6 +215,117 @@ teste. Os cenários que dependem desse recurso estão identificados com o grupo 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. +#### Assinaturas e planos + +Assinatura recorrente está disponível no gateway Iugu. No Stripe as operações ainda não existem +e o `StripeGateway` não implementa `SubscriptionContract` nem `PlanContract`: `save()` e `get()` +lançam `GatewayException::methodNotFound`, e os métodos de domínio (`suspend()`, `resume()`, +`cancel()`, `changePlan()`, `previewPlanChange()`) lançam `GatewayException` avisando que o +gateway não implementa o contract. + +```php +use Potelo\MultiPayment\Models\Plan; + +$plan = new Plan(); +$plan->name = 'Mensal'; +$plan->identifier = 'plano_mensal'; +$plan->amount = 10000; // centavos +$plan->interval = Plan::INTERVAL_MONTH; // week ou month; a Iugu não aceita year +$plan->intervalCount = 1; +$plan->save('iugu'); + +$subscription = (new \Potelo\MultiPayment\MultiPayment('iugu')) + ->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomerId($customer->id) + ->setNextBillingAt('2026-10-01') + ->addItem('Consultas extras', 2500, 2) // item recorrente, valor em centavos + ->addAmountDiscount('Promo', 500, cycles: 1) // desconto só na próxima fatura + ->setAvailablePaymentMethods(['pix']) + ->create(); + +echo $subscription->status; // na Iugu: trialing, active, suspended, pending ou past_due +``` + +Operações sobre a assinatura: + +```php +$subscription->suspend(); +$subscription->resume(); +$subscription->cancel(); // na Iugu, cancelar é suspender +$subscription->changePlan('plano_anual'); // aplica a troca e gera cobrança imediata +$subscription->changePlan('plano_anual', charge: false); +$preview = $subscription->previewPlanChange('plano_anual'); // simula, não aplica + +// itens e descontos são declarativos: a lista informada vira o estado da assinatura, e a lista +// que ficar em null é preservada como está no gateway +$mantido = new SubscriptionItem(); +$mantido->id = $subscription->items[0]->id; +$subscription->items = [$mantido]; +$subscription->save(); + +$assinaturas = (new \Potelo\MultiPayment\MultiPayment('iugu'))->listSubscriptions($customer->id); +$planos = (new \Potelo\MultiPayment\MultiPayment('iugu'))->listPlans(); +``` + +Particularidades da Iugu: + +- **Cancelar é suspender.** `cancel(atPeriodEnd: true)` lança `GatewayException`; para encerrar + ao fim do período, suspenda na data. +- **Desconto é sempre valor fixo.** `percentOff` lança `GatewayException`, e `cycles` só aceita + `1` (uma fatura) ou `null` (até ser removido). +- **Planos são semanais ou mensais.** `Plan::INTERVAL_YEAR` lança `GatewayException`. +- **Planos não são desativáveis.** `deactivatePlan` lança `GatewayException`. +- **`nextBillingAt` e `trialEndsAt` são o mesmo campo** (`expires_at`); informar os dois com + datas diferentes lança `GatewayException`. Ao prorrogar um trial lido do gateway, zere + `nextBillingAt` antes, porque a leitura preenche os dois. +- **`past_due` é derivado.** A Iugu não tem esse estado: o pacote o reporta quando a data da + próxima cobrança já passou e **alguma** fatura de `recent_invoices` continua em aberto — + pendente, vencida (`expired`) ou parcialmente paga. Olha todas, e não só a que virou + `latestInvoice`, senão uma cancelada de vencimento posterior esconderia uma pendente anterior. + Em compensação, fatura antiga que deixou de ser dívida por fora do pacote continua contando + enquanto estiver em `recent_invoices`. +- **`latestInvoice` é a fatura mais recente, não a que gerou a inadimplência.** Vence a de maior + vencimento, com o menor id desempatando, e entrada sem id é descartada — a ordem em que a Iugu + devolve as faturas não influencia. Em `past_due` ela pode estar **quitada**, ou vir `null`: + para chegar na fatura a pagar, liste as faturas do cliente. Vem resumida — id, status, + vencimento e, quando a Iugu manda, a url; sem valor em centavos (`amount` fica `null`). Use + `getInvoice()` pelo id para a fatura completa. +- **Atualizar itens ou descontos custa chamadas extras.** A Iugu recusa remover e adicionar + subitens na mesma chamada, então o pacote lê a assinatura, envia a remoção sozinha e só depois + a atualização — até três requisições. Entre a remoção e a atualização a assinatura fica sem os + itens removidos, e se a segunda falhar eles não voltam sozinhos. +- **`paymentMethod`, `cancelAtPeriodEnd` e `canceledAt` não são mapeados** na Iugu, nas duas + direções. +- **Reativar exige data de cobrança.** Assinatura criada sem `nextBillingAt` fica sem data no + gateway e, por isso, não volta com `resume()`: a Iugu responde sem erro e sem mudar nada, e o + `status` devolvido segue `suspended`. +- **`active` e `suspended` são flags independentes.** Assinatura suspensa pode continuar com + `active: true` na Iugu; o pacote dá precedência a `suspended` e reporta `suspended`. +- **A simulação de troca não traz linhas.** `previewPlanChange()` preenche só `amount` e + `effectiveAt`; `items` fica `null` e o resto (`discount`, `cycles`, `old_plan`, `new_plan`) + está em `original`. +- **Trocar de plano com cobrança gera fatura pendente, não pagamento.** `changePlan()` com + `charge: true` (o padrão) faz a Iugu emitir a fatura na hora, com vencimento imediato e não na + data do próximo ciclo. Ela volta resumida em `latestInvoice`, com status `pending`; use + `getInvoice()` pelo id para o valor em centavos. Com `charge: false` nada é cobrado. +- **O plano de uma assinatura existente não muda por `save()`**; use `changePlan()`. +- **Plano não é atualizável.** `save()` num `Plan` que já tem `id` lança `GatewayException`; para + mudar preço ou intervalo, crie outro plano e troque as assinaturas com `changePlan()`. +- **Fatura vencida lê como `canceled`.** A Iugu chama de `expired` a fatura que venceu sem + pagamento, e o pacote a mapeia para `Invoice::STATUS_CANCELED` — mas ela ainda conta como + dívida na derivação de `past_due`. Para decidir se há pendência, olhe o `status` da assinatura, + não o da fatura. +- **A assinatura lida traz o cliente resumido.** `Subscription::get()` preenche `customer` com + id, nome e e-mail — documento, endereço e telefone não vêm da Iugu. Eles sobrevivem se o + `customer` local já tiver o mesmo id; se o id for outro, ou o local não tiver id, o pacote + troca o objeto para não misturar dados de dois clientes. + +No update, data de cobrança e métodos de pagamento só são enviados quando mudaram em relação +ao que veio na leitura — um `save()` que mexeu só nos itens não altera a data de cobrança. + +Confira `src/Builders/SubscriptionBuilder.php` para saber quais métodos estão disponíveis. + #### CustomerBuilder ```php $multiPayment = new \Potelo\MultiPayment\MultiPayment('iugu'); @@ -357,3 +479,22 @@ $invoice->creditCard->customer = $customer; $invoice->save('iugu'); echo $invoice->id; // CB1FA9B5BD1C42B287F4AC7F6259E45D ``` +#### Subscription +```php +$subscription = new Subscription(); +$subscription->planId = 'plano_mensal'; +$subscription->customer = $customer; +$subscription->save('iugu'); +echo $subscription->id; +``` +#### Plan +```php +$plan = new Plan(); +$plan->name = 'Mensal'; +$plan->identifier = 'plano_mensal'; +$plan->amount = 10000; +$plan->interval = Plan::INTERVAL_MONTH; +$plan->save('iugu'); +echo $plan->id; +``` + diff --git a/src/Builders/SubscriptionBuilder.php b/src/Builders/SubscriptionBuilder.php new file mode 100644 index 0000000..1916e29 --- /dev/null +++ b/src/Builders/SubscriptionBuilder.php @@ -0,0 +1,243 @@ +model = new Subscription(); + } + + /** + * @return Subscription + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + */ + public function create(): Subscription + { + return parent::create(); + } + + /** + * Define o plano da assinatura. + * + * @param string $planId + * + * @return $this + */ + public function setPlanId(string $planId): SubscriptionBuilder + { + $this->model->planId = $planId; + + return $this; + } + + /** + * Define o cliente da assinatura. + * + * @param Customer $customer + * + * @return $this + */ + public function setCustomer(Customer $customer): SubscriptionBuilder + { + $this->model->customer = $customer; + + return $this; + } + + /** + * Define o cliente da assinatura pelo id que ele tem no gateway. + * + * @param string $customerId + * + * @return $this + */ + public function setCustomerId(string $customerId): SubscriptionBuilder + { + $this->model->customer = new Customer(); + $this->model->customer->id = $customerId; + + return $this; + } + + /** + * Define a data da próxima cobrança. + * + * @param Carbon|string $nextBillingAt + * + * @return $this + */ + public function setNextBillingAt(Carbon|string $nextBillingAt): SubscriptionBuilder + { + $this->model->nextBillingAt = $nextBillingAt instanceof Carbon + ? $nextBillingAt + : Carbon::parse($nextBillingAt); + + return $this; + } + + /** + * Define até quando vai o período de teste. + * + * @param Carbon|string $trialEndsAt + * + * @return $this + */ + public function setTrialEndsAt(Carbon|string $trialEndsAt): SubscriptionBuilder + { + $this->model->trialEndsAt = $trialEndsAt instanceof Carbon + ? $trialEndsAt + : Carbon::parse($trialEndsAt); + + return $this; + } + + /** + * Define os métodos de pagamento aceitos pela assinatura. + * + * @param string[] $paymentMethods + * + * @return $this + */ + public function setAvailablePaymentMethods(array $paymentMethods): SubscriptionBuilder + { + $this->model->availablePaymentMethods = $paymentMethods; + + return $this; + } + + /** + * Substitui a lista de itens da assinatura. + * + * @param SubscriptionItem[] $items + * + * @return $this + */ + public function setItems(array $items): SubscriptionBuilder + { + $this->model->items = $items; + + return $this; + } + + /** + * Acrescenta um item à assinatura. + * + * @param string $description + * @param int $amount + * @param int $quantity + * @param bool $recurring + * + * @return $this + */ + public function addItem( + string $description, + int $amount, + int $quantity = 1, + bool $recurring = true + ): SubscriptionBuilder { + $item = new SubscriptionItem(); + $item->description = $description; + $item->amount = $amount; + $item->quantity = $quantity; + $item->recurring = $recurring; + $this->model->items[] = $item; + + return $this; + } + + /** + * Substitui a lista de descontos da assinatura. + * + * @param SubscriptionDiscount[] $discounts + * + * @return $this + */ + public function setDiscounts(array $discounts): SubscriptionBuilder + { + $this->model->discounts = $discounts; + + return $this; + } + + /** + * Acrescenta um desconto de valor fixo à assinatura. + * + * @param string $description + * @param int $amountOff Valor abatido, em centavos e positivo + * @param int|null $cycles null enquanto não for removido, 1 só na próxima fatura + * + * @return $this + */ + public function addAmountDiscount( + string $description, + int $amountOff, + ?int $cycles = null + ): SubscriptionBuilder { + $discount = new SubscriptionDiscount(); + $discount->description = $description; + $discount->amountOff = $amountOff; + $discount->cycles = $cycles; + $this->model->discounts[] = $discount; + + return $this; + } + + /** + * Acrescenta um desconto percentual à assinatura. + * + * @param string $description + * @param float $percentOff Percentual abatido, entre 0 e 100 + * @param int|null $cycles null enquanto não for removido, 1 só na próxima fatura + * + * @return $this + */ + public function addPercentDiscount( + string $description, + float $percentOff, + ?int $cycles = null + ): SubscriptionBuilder { + $discount = new SubscriptionDiscount(); + $discount->description = $description; + $discount->percentOff = $percentOff; + $discount->cycles = $cycles; + $this->model->discounts[] = $discount; + + return $this; + } + + /** + * Define os metadados enviados ao gateway junto da assinatura. + * + * @param array $metadata + * + * @return $this + */ + public function setMetadata(array $metadata): SubscriptionBuilder + { + $this->model->metadata = $metadata; + + return $this; + } +} diff --git a/src/Contracts/PlanContract.php b/src/Contracts/PlanContract.php new file mode 100644 index 0000000..e1bfef7 --- /dev/null +++ b/src/Contracts/PlanContract.php @@ -0,0 +1,52 @@ +id) . '/reschedule_automatic_pix_payment'; - $response = $this->automaticPixRequest('POST', $url, [], 'rescheduling automatic pix payment'); + $response = $this->iuguRequest('POST', $url, [], 'rescheduling automatic pix payment'); if (!empty($response->id) && !empty($response->status) && isset($response->total_cents)) { return $this->parseInvoice($response, $invoice); @@ -454,7 +461,7 @@ public function cancelAutomaticPixRecurrence( $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrences/' . rawurlencode($automaticPix->id) . '/cancel'; - $response = $this->automaticPixRequest('PUT', $url, [], 'cancelling automatic pix recurrence'); + $response = $this->iuguRequest('PUT', $url, [], 'cancelling automatic pix recurrence'); $cancellation = $this->parseAutomaticPixCancellation($response); $cancellation->recurrenceId = $automaticPix->id; @@ -479,7 +486,7 @@ public function cancelAutomaticPixScheduledPayment( 'end_to_end_id' => $charge->endToEndId, ], '', '&', PHP_QUERY_RFC3986); $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrence_payments/cancel?' . $query; - $response = $this->automaticPixRequest( + $response = $this->iuguRequest( 'POST', $url, [], @@ -508,7 +515,7 @@ public function getAutomaticPixCancellation( $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrences/' . rawurlencode($cancellation->recurrenceId) . '/cancellations/' . rawurlencode($cancellation->id); - $response = $this->automaticPixRequest('GET', $url, [], 'getting automatic pix cancellation'); + $response = $this->iuguRequest('GET', $url, [], 'getting automatic pix cancellation'); return $this->parseAutomaticPixCancellation($response, $cancellation); } @@ -532,7 +539,7 @@ public function listAutomaticPixCancellations( $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'); + $response = $this->iuguRequest('GET', $url, [], 'listing automatic pix cancellations'); $items = $this->automaticPixCancellationItems($response); @@ -663,7 +670,7 @@ private function parseAutomaticPixCharge( /** * Perform a raw Iugu request while preserving the package exception contract. */ - private function automaticPixRequest( + private function iuguRequest( string $method, string $url, array $data, @@ -1210,4 +1217,1041 @@ private function parseIuguCard(mixed $iuguCreditCard, ?CreditCard $creditCard = $creditCard->createdAt = new Carbon($iuguCreditCard->created_at_iso) ?? null; return $creditCard; } + + /** + * @inheritDoc + */ + public function createSubscription(Subscription $subscription): Subscription + { + $data = array_merge( + $this->subscriptionToIuguData($subscription), + $subscription->gatewayAdicionalOptions + ); + + $response = $this->iuguRequest( + 'POST', + Iugu::getBaseURI() . '/subscriptions', + $data, + 'creating subscription' + ); + + return $this->parseIuguSubscription($response, $subscription); + } + + /** + * @inheritDoc + */ + public function getSubscription(Subscription $subscription): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $response = $this->iuguRequest( + 'GET', + $this->subscriptionUrl($subscription->id), + [], + 'getting subscription' + ); + + return $this->parseIuguSubscription($response, $subscription); + } + + /** + * @inheritDoc + */ + public function updateSubscription(Subscription $subscription): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $data = array_merge( + $this->subscriptionToIuguData($subscription, false), + $subscription->gatewayAdicionalOptions + ); + $subitems = $data['subitems'] ?? null; + unset($data['subitems']); + + if (!is_null($subitems)) { + // a Iugu recusa remover e adicionar subitens na mesma requisição, então a remoção + // vai sozinha e antes; entre as duas a assinatura fica sem os itens removidos + $toDestroy = $this->iuguSubitemsToDestroy( + $subscription->id, + $subitems, + !is_null($subscription->items), + !is_null($subscription->discounts) + ); + + if (!empty($toDestroy)) { + $this->iuguRequest( + 'PUT', + $this->subscriptionUrl($subscription->id), + ['subitems' => $toDestroy], + 'removing subscription items' + ); + } + + if (!empty($subitems)) { + $data['subitems'] = $subitems; + } + } + + $response = $this->iuguRequest( + 'PUT', + $this->subscriptionUrl($subscription->id), + $data, + 'updating subscription' + ); + + return $this->parseIuguSubscription($response, $subscription); + } + + /** + * @inheritDoc + */ + public function suspendSubscription(Subscription $subscription): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $response = $this->iuguRequest( + 'POST', + $this->subscriptionUrl($subscription->id) . '/suspend', + [], + 'suspending subscription' + ); + + return $this->parseIuguSubscription($response, $subscription); + } + + /** + * @inheritDoc + */ + public function resumeSubscription(Subscription $subscription): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $response = $this->iuguRequest( + 'POST', + $this->subscriptionUrl($subscription->id) . '/activate', + [], + 'resuming subscription' + ); + + return $this->parseIuguSubscription($response, $subscription); + } + + /** + * @inheritDoc + */ + public function cancelSubscription(Subscription $subscription, bool $atPeriodEnd = false): Subscription + { + if ($atPeriodEnd) { + throw new GatewayException( + 'Iugu does not support cancelling a subscription at the end of the period. ' + . 'Suspend it on the date instead.' + ); + } + + return $this->suspendSubscription($subscription); + } + + /** + * @inheritDoc + */ + public function changeSubscriptionPlan( + Subscription $subscription, + string $planId, + bool $charge = true + ): Subscription { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + if ($charge) { + $this->iuguRequest( + 'POST', + $this->subscriptionUrl($subscription->id) . '/change_plan/' . rawurlencode($planId), + [], + 'changing subscription plan' + ); + + $subscription->planId = $planId; + + return $this->getSubscription($subscription); + } + + $data = ['plan_identifier' => $planId, 'skip_charge' => true]; + + if (!empty($subscription->nextBillingAt)) { + $data['expires_at'] = $subscription->nextBillingAt->format('Y-m-d'); + } + + $response = $this->iuguRequest( + 'PUT', + $this->subscriptionUrl($subscription->id), + $data, + 'changing subscription plan' + ); + + return $this->parseIuguSubscription($response, $subscription); + } + + /** + * @inheritDoc + */ + public function previewSubscriptionPlanChange( + Subscription $subscription, + string $planId + ): SubscriptionPlanChange { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $response = $this->iuguRequest( + 'GET', + $this->subscriptionUrl($subscription->id) + . '/change_plan_simulation/' . rawurlencode($planId), + [], + 'simulating subscription plan change' + ); + + return $this->parseIuguPlanChange($response); + } + + /** + * @inheritDoc + */ + public function listSubscriptions(Customer $customer, int $page = 1, int $limit = 100): array + { + if (empty($customer->id)) { + throw ModelAttributeValidationException::required('Customer', 'id'); + } + + if ($page < 1) { + throw new GatewayException('Subscription page must be at least 1'); + } + + if ($limit < 1 || $limit > 100) { + throw new GatewayException('Subscription limit must be between 1 and 100'); + } + + $query = http_build_query([ + 'customer_id' => $customer->id, + 'limit' => $limit, + 'start' => ($page - 1) * $limit, + ], '', '&', PHP_QUERY_RFC3986); + + $response = $this->iuguRequest( + 'GET', + Iugu::getBaseURI() . '/subscriptions?' . $query, + [], + 'listing subscriptions' + ); + + $items = is_array($response) ? $response : ($response->items ?? []); + + return array_map(fn($item) => $this->parseIuguSubscription($item), $items); + } + + /** + * @inheritDoc + */ + public function createPlan(Plan $plan): Plan + { + $response = $this->iuguRequest( + 'POST', + Iugu::getBaseURI() . '/plans', + array_merge($this->planToIuguData($plan), $plan->gatewayAdicionalOptions), + 'creating plan' + ); + + return $this->parseIuguPlan($response, $plan); + } + + /** + * @inheritDoc + */ + public function getPlan(Plan $plan): Plan + { + if (!empty($plan->id)) { + $url = Iugu::getBaseURI() . '/plans/' . rawurlencode($plan->id); + } elseif (!empty($plan->identifier)) { + $url = Iugu::getBaseURI() . '/plans/identifier/' . rawurlencode($plan->identifier); + } else { + throw ModelAttributeValidationException::required('Plan', 'id or identifier'); + } + + return $this->parseIuguPlan($this->iuguRequest('GET', $url, [], 'getting plan'), $plan); + } + + /** + * @inheritDoc + */ + public function listPlans(int $page = 1, int $limit = 100): array + { + if ($page < 1) { + throw new GatewayException('Plan page must be at least 1'); + } + + if ($limit < 1 || $limit > 100) { + throw new GatewayException('Plan limit must be between 1 and 100'); + } + + $query = http_build_query([ + 'limit' => $limit, + 'start' => ($page - 1) * $limit, + ], '', '&', PHP_QUERY_RFC3986); + + $response = $this->iuguRequest( + 'GET', + Iugu::getBaseURI() . '/plans?' . $query, + [], + 'listing plans' + ); + + $items = is_array($response) ? $response : ($response->items ?? []); + + return array_map(fn($item) => $this->parseIuguPlan($item), $items); + } + + /** + * Sempre lança: a Iugu não tem desativação de plano. + * + * @param Plan $plan + * + * @return Plan + * @throws GatewayException + */ + public function deactivatePlan(Plan $plan): Plan + { + throw new GatewayException( + 'Iugu plans have no active flag, so a plan cannot be deactivated. ' + . 'Stop referencing it when creating subscriptions instead.' + ); + } + + /** + * Monta o payload de assinatura da Iugu a partir do model. + * + * Itens e descontos viram uma única lista de `subitems`: desconto é subitem de `price_cents` + * negativo. Com $creating falso, só os atributos preenchidos entram no payload. + * + * @param Subscription $subscription + * @param bool $creating + * + * @return array + * @throws GatewayException|ModelAttributeValidationException + */ + private function subscriptionToIuguData(Subscription $subscription, bool $creating = true): array + { + $data = []; + + if ($creating) { + if (empty($subscription->customer) || empty($subscription->customer->id)) { + throw ModelAttributeValidationException::required('Subscription', 'customer'); + } + + $data['customer_id'] = $subscription->customer->id; + $data['plan_identifier'] = $subscription->planId; + } + + if ( + !empty($subscription->nextBillingAt) + && !empty($subscription->trialEndsAt) + && !$subscription->nextBillingAt->isSameDay($subscription->trialEndsAt) + ) { + throw new GatewayException( + 'Iugu stores the trial end and the next billing date in the same field, so ' + . 'nextBillingAt and trialEndsAt cannot hold different dates.' + ); + } + + $expiresAt = $subscription->nextBillingAt ?? $subscription->trialEndsAt; + + if (!empty($expiresAt) && ($creating || !$this->isOriginalExpiresAt($subscription, $expiresAt))) { + $data['expires_at'] = $expiresAt->format('Y-m-d'); + } + + if ( + !empty($subscription->availablePaymentMethods) + && ($creating || !$this->isOriginalPayableWith($subscription)) + ) { + $data['payable_with'] = $subscription->availablePaymentMethods; + } + + if (!empty($subscription->metadata)) { + $data['custom_variables'] = array_map( + fn($name, $value) => ['name' => $name, 'value' => $value], + array_keys($subscription->metadata), + array_values($subscription->metadata) + ); + } + + if (!is_null($subscription->items) || !is_null($subscription->discounts)) { + $data['subitems'] = array_merge( + array_map( + fn(SubscriptionItem $item) => $this->subscriptionItemToIuguData($item), + $subscription->items ?? [] + ), + array_map( + fn(SubscriptionDiscount $discount) => $this->subscriptionDiscountToIuguData($discount), + $subscription->discounts ?? [] + ) + ); + } + + return $data; + } + + /** + * Diz se a data informada é a mesma que veio do gateway na leitura. + * + * @param Subscription $subscription + * @param Carbon $expiresAt + * + * @return bool + */ + private function isOriginalExpiresAt(Subscription $subscription, Carbon $expiresAt): bool + { + $original = $subscription->original->expires_at ?? null; + + return !empty($original) + && (new Carbon($original))->format('Y-m-d') === $expiresAt->format('Y-m-d'); + } + + /** + * Diz se os métodos de pagamento informados são os mesmos que vieram do gateway na leitura. + * + * A comparação é feita depois da expansão, para que `all` não seja reenviado como a lista + * dos três métodos. + * + * @param Subscription $subscription + * + * @return bool + */ + private function isOriginalPayableWith(Subscription $subscription): bool + { + $original = $subscription->original->payable_with ?? null; + + if (empty($original)) { + return false; + } + + return $this->iuguPayableWithToPaymentMethods($original) + === array_values($subscription->availablePaymentMethods); + } + + /** + * Monta um subitem da Iugu a partir de um item de assinatura. + * + * @param SubscriptionItem $item + * + * @return array + */ + private function subscriptionItemToIuguData(SubscriptionItem $item): array + { + if (is_null($item->amount)) { + throw ModelAttributeValidationException::required('SubscriptionItem', 'amount'); + } + + $data = [ + 'description' => $item->description, + 'price_cents' => $item->amount, + 'quantity' => $item->quantity ?? 1, + // o encoder do SDK transforma false em string vazia, então vai como inteiro + 'recurrent' => (int) $item->recurring, + ]; + + if (!empty($item->id)) { + $data['id'] = $item->id; + } + + return $data; + } + + /** + * Monta um subitem da Iugu a partir de um desconto de assinatura. + * + * Desconto percentual e desconto limitado a mais de um ciclo não têm equivalente na Iugu. + * + * @param SubscriptionDiscount $discount + * + * @return array + * @throws GatewayException + */ + private function subscriptionDiscountToIuguData(SubscriptionDiscount $discount): array + { + if (!is_null($discount->percentOff)) { + throw new GatewayException( + 'Iugu does not support percentage discounts on subscriptions. Use amountOff.' + ); + } + + if (is_null($discount->amountOff)) { + throw ModelAttributeValidationException::required('SubscriptionDiscount', 'amountOff'); + } + + if (!is_null($discount->cycles) && $discount->cycles > 1) { + throw new GatewayException( + 'Iugu discounts last either one invoice or until removed, so cycles greater ' + . 'than 1 cannot be represented. Use cycles 1 or null.' + ); + } + + $data = [ + 'description' => $discount->description, + 'price_cents' => -abs($discount->amountOff), + 'quantity' => 1, + 'recurrent' => (int) is_null($discount->cycles), + ]; + + if (!empty($discount->id)) { + $data['id'] = $discount->id; + } + + return $data; + } + + /** + * Lista os subitens a destruir: os que a assinatura tem no gateway e que não aparecem, por + * id, na lista desejada. + * + * Só entram os subitens do tipo que está sendo substituído — item quando $replacingItems, e + * desconto, que na Iugu é subitem de `price_cents` negativo, quando $replacingDiscounts. + * Faz um GET na assinatura para descobrir o estado atual. + * + * @param string $subscriptionId + * @param array $desiredSubitems + * @param bool $replacingItems + * @param bool $replacingDiscounts + * + * @return array + * @throws GatewayException|GatewayNotAvailableException + */ + private function iuguSubitemsToDestroy( + string $subscriptionId, + array $desiredSubitems, + bool $replacingItems, + bool $replacingDiscounts + ): array { + $current = $this->iuguRequest( + 'GET', + $this->subscriptionUrl($subscriptionId), + [], + 'getting subscription items' + ); + + $keptIds = array_map('strval', array_filter(array_column($desiredSubitems, 'id'))); + + $toDestroy = []; + foreach ((array) ($current->subitems ?? []) as $subitem) { + $subitem = (object) $subitem; + $id = $subitem->id ?? null; + + if (empty($id) || in_array((string) $id, $keptIds, true)) { + continue; + } + + $replacing = ($subitem->price_cents ?? 0) < 0 ? $replacingDiscounts : $replacingItems; + + if ($replacing) { + $toDestroy[] = ['id' => $id, '_destroy' => true]; + } + } + + return $toDestroy; + } + + /** + * Converte a assinatura da Iugu numa assinatura do MultiPayment. + * + * Subitem de `price_cents` negativo vira desconto, não item. + * + * @param mixed $iuguSubscription + * @param Subscription|null $subscription + * + * @return Subscription + */ + private function parseIuguSubscription($iuguSubscription, ?Subscription $subscription = null): Subscription + { + $iuguSubscription = (object) $iuguSubscription; + $subscription = $subscription ?? new Subscription(); + + $subscription->id = $iuguSubscription->id ?? $subscription->id; + if (isset($iuguSubscription->recent_invoices)) { + $subscription->latestInvoice = $this->parseIuguRecentInvoice($iuguSubscription); + } + $subscription->status = $this->iuguToMultiPaymentSubscriptionStatus($iuguSubscription) + ?? $subscription->status; + $subscription->planId = $iuguSubscription->plan_identifier ?? $subscription->planId; + $subscription->amount = $iuguSubscription->price_cents ?? $subscription->amount; + + if (!empty($iuguSubscription->customer_id)) { + // cliente de outro id não é o mesmo cliente: manter os atributos antigos produziria + // um Customer com id de um e documento de outro + if ( + is_null($subscription->customer) + || $subscription->customer->id !== $iuguSubscription->customer_id + ) { + $subscription->customer = new Customer(); + } + + $subscription->customer->id = $iuguSubscription->customer_id; + $subscription->customer->name = $iuguSubscription->customer_name + ?? $subscription->customer->name; + $subscription->customer->email = $iuguSubscription->customer_email + ?? $subscription->customer->email; + } + + if (!empty($iuguSubscription->expires_at)) { + $subscription->nextBillingAt = new Carbon($iuguSubscription->expires_at); + + if (!empty($iuguSubscription->in_trial)) { + $subscription->trialEndsAt = $subscription->nextBillingAt->copy(); + } + } + + if (!empty($iuguSubscription->created_at)) { + $subscription->createdAt = new Carbon($iuguSubscription->created_at); + } + + if (isset($iuguSubscription->subitems)) { + $subscription->items = []; + $subscription->discounts = []; + + foreach ((array) $iuguSubscription->subitems as $iuguSubitem) { + $iuguSubitem = (object) $iuguSubitem; + + if (($iuguSubitem->price_cents ?? 0) < 0) { + $subscription->discounts[] = $this->parseIuguSubscriptionDiscount($iuguSubitem); + } else { + $subscription->items[] = $this->parseIuguSubscriptionItem($iuguSubitem); + } + } + } + + if (!empty($iuguSubscription->payable_with)) { + $subscription->availablePaymentMethods = $this->iuguPayableWithToPaymentMethods( + $iuguSubscription->payable_with + ); + } + + if (!empty($iuguSubscription->custom_variables)) { + $metadata = []; + foreach ((array) $iuguSubscription->custom_variables as $variable) { + $variable = (object) $variable; + if (isset($variable->name)) { + $metadata[$variable->name] = $variable->value ?? null; + } + } + $subscription->metadata = $metadata; + } + + $subscription->gateway = 'iugu'; + $subscription->original = $iuguSubscription; + + return $subscription; + } + + /** + * Converte um subitem de valor não negativo da Iugu num item de assinatura. + * + * @param object $iuguSubitem + * + * @return SubscriptionItem + */ + private function parseIuguSubscriptionItem(object $iuguSubitem): SubscriptionItem + { + $item = new SubscriptionItem(); + $item->id = $iuguSubitem->id ?? null; + $item->description = $iuguSubitem->description ?? null; + $item->amount = $iuguSubitem->price_cents ?? null; + $item->quantity = $iuguSubitem->quantity ?? null; + $item->recurring = (bool) ($iuguSubitem->recurrent ?? false); + + return $item; + } + + /** + * Converte um subitem de valor negativo da Iugu num desconto de assinatura. + * + * @param object $iuguSubitem + * + * @return SubscriptionDiscount + */ + private function parseIuguSubscriptionDiscount(object $iuguSubitem): SubscriptionDiscount + { + $discount = new SubscriptionDiscount(); + $discount->id = $iuguSubitem->id ?? null; + $discount->description = $iuguSubitem->description ?? null; + $discount->amountOff = abs($iuguSubitem->price_cents) * (int) ($iuguSubitem->quantity ?? 1); + $discount->cycles = empty($iuguSubitem->recurrent) ? 1 : null; + + return $discount; + } + + /** + * Converte as flags de estado da assinatura da Iugu no status do MultiPayment. + * + * A Iugu não tem estado de inadimplência: assinatura com fatura vencida em aberto continua + * `active` com `expires_at` no passado. PAST_DUE é derivado dessa combinação. + * + * @param object $iuguSubscription + * + * @return string|null + */ + private function iuguToMultiPaymentSubscriptionStatus(object $iuguSubscription): ?string + { + if (!empty($iuguSubscription->suspended)) { + return Subscription::STATUS_SUSPENDED; + } + + if (!empty($iuguSubscription->in_trial)) { + return Subscription::STATUS_TRIALING; + } + + if ($this->iuguSubscriptionIsPastDue($iuguSubscription)) { + return Subscription::STATUS_PAST_DUE; + } + + if (isset($iuguSubscription->active)) { + return $iuguSubscription->active + ? Subscription::STATUS_ACTIVE + : Subscription::STATUS_PENDING; + } + + return null; + } + + /** + * Diz se o resumo de fatura ainda tem valor a receber. + * + * `expired` conta: na Iugu a fatura vencida não foi paga nem cancelada, embora o pacote + * mapeie esse status para `Invoice::STATUS_CANCELED`. + * + * @param object $iuguInvoice + * + * @return bool + */ + private function iuguInvoiceIsOpen(object $iuguInvoice): bool + { + return in_array( + $iuguInvoice->status ?? null, + [self::STATUS_PENDING, self::STATUS_EXPIRED, self::STATUS_PARTIALLY_PAID], + true + ); + } + + /** + * Diz se a assinatura está com cobrança vencida: data da próxima cobrança no passado e + * alguma fatura ainda em aberto. + * + * Olha todas as faturas, e não só a escolhida como `latestInvoice`: uma fatura cancelada de + * vencimento posterior esconderia uma pendente anterior. + * + * @param object $iuguSubscription + * + * @return bool + */ + private function iuguSubscriptionIsPastDue(object $iuguSubscription): bool + { + if (empty($iuguSubscription->expires_at)) { + return false; + } + + if (!(new Carbon($iuguSubscription->expires_at))->endOfDay()->isPast()) { + return false; + } + + foreach ($this->iuguRecentInvoices($iuguSubscription) as $entrada) { + if ($this->iuguInvoiceIsOpen($entrada)) { + return true; + } + } + + return false; + } + + /** + * Normaliza `recent_invoices` numa lista de objetos. + * + * @param object $iuguSubscription + * + * @return object[] + */ + private function iuguRecentInvoices(object $iuguSubscription): array + { + $recent = $iuguSubscription->recent_invoices ?? null; + + if (empty($recent) || !is_array($recent)) { + return []; + } + + return array_map(fn($entrada) => (object) $entrada, array_values($recent)); + } + + /** + * Escolhe a entrada mais recente de `recent_invoices`. + * + * Vence a de maior `due_date`. Entrada sem `due_date` perde para qualquer uma com data, e + * empate é resolvido pelo menor id, para que a escolha não dependa da ordem da resposta. + * + * Entrada sem `id` é ignorada, porque não daria para buscar a fatura depois. + * + * @param array $recent + * + * @return object|null + */ + private function latestIuguRecentInvoice(array $recent): ?object + { + $escolhida = null; + + foreach ($recent as $entrada) { + if (empty($entrada->id)) { + continue; + } + + if (is_null($escolhida)) { + $escolhida = $entrada; + continue; + } + + $data = $entrada->due_date ?? null; + $atual = $escolhida->due_date ?? null; + + if ($data === $atual) { + if ((string) $entrada->id < (string) $escolhida->id) { + $escolhida = $entrada; + } + + continue; + } + + if (is_null($atual) || (!is_null($data) && $data > $atual)) { + $escolhida = $entrada; + } + } + + return $escolhida; + } + + /** + * Converte a entrada mais recente de `recent_invoices` numa fatura do MultiPayment. + * + * A Iugu devolve essas faturas resumidas, sem itens nem valores em centavos, então só os + * campos presentes são preenchidos; `original` guarda o resumo cru. + * + * @param object $iuguSubscription + * + * @return Invoice|null + */ + private function parseIuguRecentInvoice(object $iuguSubscription): ?Invoice + { + $iuguInvoice = $this->latestIuguRecentInvoice( + $this->iuguRecentInvoices($iuguSubscription) + ); + + if (is_null($iuguInvoice)) { + return null; + } + + $invoice = new Invoice(); + $invoice->id = $iuguInvoice->id; + + try { + $invoice->status = isset($iuguInvoice->status) + ? self::iuguStatusToMultiPayment($iuguInvoice->status) + : null; + } catch (GatewayException $e) { + // status fora do mapa não derruba a leitura da assinatura; o status cru continua + // em `original` + $invoice->status = null; + } + + $invoice->expiresAt = !empty($iuguInvoice->due_date) + ? new Carbon($iuguInvoice->due_date) + : null; + $invoice->url = $iuguInvoice->secure_url ?? null; + $invoice->gateway = 'iugu'; + $invoice->original = $iuguInvoice; + + return $invoice; + } + + /** + * Converte a simulação de troca de plano da Iugu no model do MultiPayment. + * + * @param mixed $response + * + * @return SubscriptionPlanChange + */ + private function parseIuguPlanChange($response): SubscriptionPlanChange + { + $response = (object) $response; + $planChange = new SubscriptionPlanChange(); + + // cost e total_cents podem vir formatados ("R$ 300,00"), então só numérico é aceito + foreach (['cost', 'price_cents', 'total_cents', 'cost_cents'] as $field) { + if (isset($response->{$field}) && is_numeric($response->{$field})) { + $planChange->amount = (int) $response->{$field}; + break; + } + } + + foreach (['subitems', 'items'] as $field) { + if (!empty($response->{$field}) && is_array($response->{$field})) { + $planChange->items = array_map(function ($line) { + $line = (object) $line; + $item = new InvoiceItem(); + $item->description = $line->description ?? null; + $item->price = $line->price_cents ?? null; + $item->quantity = $line->quantity ?? null; + + return $item; + }, $response->{$field}); + break; + } + } + + if (!empty($response->expires_at)) { + $planChange->effectiveAt = new Carbon($response->expires_at); + } + + $planChange->gateway = 'iugu'; + $planChange->original = $response; + + return $planChange; + } + + /** + * Monta o payload de plano da Iugu a partir do model. + * + * @param Plan $plan + * + * @return array + * @throws GatewayException + */ + private function planToIuguData(Plan $plan): array + { + $data = [ + 'name' => $plan->name, + 'identifier' => $plan->identifier ?? $plan->name, + 'interval' => $plan->intervalCount ?? 1, + 'interval_type' => $this->multiPaymentToIuguInterval($plan->interval), + 'value_cents' => $plan->amount, + ]; + + if (!empty($plan->currency)) { + $data['currency'] = $plan->currency; + } + + return $data; + } + + /** + * Converte o intervalo genérico no `interval_type` da Iugu. + * + * @param string|null $interval + * + * @return string + * @throws GatewayException + */ + private function multiPaymentToIuguInterval(?string $interval): string + { + return match ($interval) { + Plan::INTERVAL_WEEK => 'weeks', + Plan::INTERVAL_MONTH => 'months', + default => throw new GatewayException( + "Iugu only supports weekly and monthly plans, `{$interval}` given." + ), + }; + } + + /** + * Converte o plano da Iugu num plano do MultiPayment. + * + * @param mixed $iuguPlan + * @param Plan|null $plan + * + * @return Plan + */ + private function parseIuguPlan($iuguPlan, ?Plan $plan = null): Plan + { + $iuguPlan = (object) $iuguPlan; + $plan = $plan ?? new Plan(); + + $plan->id = $iuguPlan->id ?? $plan->id; + $plan->identifier = $iuguPlan->identifier ?? $plan->identifier; + $plan->name = $iuguPlan->name ?? $plan->name; + $plan->intervalCount = $iuguPlan->interval ?? $plan->intervalCount; + $plan->interval = match ($iuguPlan->interval_type ?? null) { + 'weeks' => Plan::INTERVAL_WEEK, + 'months' => Plan::INTERVAL_MONTH, + default => $plan->interval, + }; + + // o create recebe value_cents, mas a resposta traz os valores em prices[], um por moeda + if (isset($iuguPlan->value_cents)) { + $plan->amount = $iuguPlan->value_cents; + } elseif (!empty($iuguPlan->prices)) { + $price = (object) ((array) $iuguPlan->prices)[0]; + $plan->amount = $price->value_cents ?? $plan->amount; + $plan->currency = $price->currency ?? $plan->currency; + } + + $plan->gateway = 'iugu'; + $plan->original = $iuguPlan; + + return $plan; + } + + /** + * Converte o `payable_with` da Iugu na lista de métodos de pagamento do MultiPayment. + * + * O valor `all` expande para os três métodos. + * + * @param mixed $payableWith + * + * @return string[] + */ + private function iuguPayableWithToPaymentMethods($payableWith): array + { + $todos = [ + Invoice::PAYMENT_METHOD_CREDIT_CARD, + Invoice::PAYMENT_METHOD_BANK_SLIP, + Invoice::PAYMENT_METHOD_PIX, + ]; + + $methods = []; + foreach ((array) $payableWith as $iuguMethod) { + if ($iuguMethod === 'all') { + return $todos; + } + + $method = $this->iuguToMultiPaymentPaymentMethod($iuguMethod); + + if (!is_null($method)) { + $methods[] = $method; + } + } + + return $methods; + } + + /** + * Monta a url de uma assinatura na Iugu. + * + * @param string $id + * + * @return string + */ + private function subscriptionUrl(string $id): string + { + return Iugu::getBaseURI() . '/subscriptions/' . rawurlencode($id); + } } diff --git a/src/Models/Plan.php b/src/Models/Plan.php new file mode 100644 index 0000000..b5a518f --- /dev/null +++ b/src/Models/Plan.php @@ -0,0 +1,153 @@ +id)) { + throw new GatewayException( + 'A plan cannot be updated. Create a new plan instead.' + ); + } + + parent::save($gateway, $validate); + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateIntervalAttribute(): void + { + $intervals = [self::INTERVAL_WEEK, self::INTERVAL_MONTH, self::INTERVAL_YEAR]; + + if (!in_array($this->interval, $intervals, true)) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'interval', + 'interval must be one of: ' . implode(', ', $intervals) + ); + } + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateAmountAttribute(): void + { + if ($this->amount < 0) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'amount', + 'amount must not be negative.' + ); + } + } + + /** + * @inheritDoc + */ + protected function attributesExtraValidation(array $attributes): void + { + $model = $this->getClassName(); + + foreach (['name', 'amount', 'interval'] as $attribute) { + if (in_array($attribute, $attributes) && is_null($this->{$attribute})) { + throw ModelAttributeValidationException::required($model, $attribute); + } + } + + // validate() pula atributo vazio, então zero e negativo de intervalCount não chegariam + // a um validateIntervalCountAttribute() + if ( + in_array('intervalCount', $attributes) + && !is_null($this->intervalCount) + && $this->intervalCount < 1 + ) { + throw ModelAttributeValidationException::invalid( + $model, + 'intervalCount', + 'intervalCount must be at least 1.' + ); + } + } +} diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php new file mode 100644 index 0000000..4d78fac --- /dev/null +++ b/src/Models/Subscription.php @@ -0,0 +1,463 @@ + 'trialEndsAt', + 'next_billing_at' => 'nextBillingAt', + 'canceled_at' => 'canceledAt', + 'created_at' => 'createdAt', + ] as $key => $attribute + ) { + if (!empty($data[$key])) { + $this->{$attribute} = $data[$key] instanceof Carbon + ? $data[$key] + : Carbon::parse($data[$key]); + unset($data[$key]); + } + } + + if (!empty($data['customer']) && is_array($data['customer'])) { + $customer = new Customer(); + $customer->fill($data['customer']); + $data['customer'] = $customer; + } + + if (!empty($data['latest_invoice']) && is_array($data['latest_invoice'])) { + $invoice = new Invoice(); + $invoice->fill($data['latest_invoice']); + $data['latest_invoice'] = $invoice; + } + + $data['items'] = $this->fillCollection($data['items'] ?? null, SubscriptionItem::class); + $data['discounts'] = $this->fillCollection($data['discounts'] ?? null, SubscriptionDiscount::class); + + foreach (['items', 'discounts'] as $key) { + if (is_null($data[$key])) { + unset($data[$key]); + } + } + + parent::fill($data); + } + + /** + * Converte cada entrada de uma lista em instância da classe informada, mantendo as que já + * são instâncias. Devolve null quando a lista não é um array. + * + * @param mixed $values + * @param class-string $class + * + * @return array|null + */ + private function fillCollection($values, string $class): ?array + { + if (!is_array($values)) { + return null; + } + + return array_map(function ($value) use ($class) { + if ($value instanceof $class) { + return $value; + } + + /** @var Model $model */ + $model = new $class(); + $model->fill($value); + + return $model; + }, $values); + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + $array = parent::toArray(); + + foreach (['items', 'discounts'] as $key) { + if (!empty($array[$key])) { + $array[$key] = array_map(fn(Model $model) => $model->toArray(), $array[$key]); + } + } + + foreach (['customer', 'latest_invoice'] as $key) { + if (!empty($array[$key])) { + $array[$key] = $array[$key]->toArray(); + } + } + + return $array; + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateCustomerAttribute(): void + { + $this->customer->validate(); + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateItemsAttribute(): void + { + foreach ($this->items as $item) { + if (!$item instanceof SubscriptionItem) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'items', + 'items must be an array of SubscriptionItem' + ); + } + + $item->validate(); + } + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateDiscountsAttribute(): void + { + foreach ($this->discounts as $discount) { + if (!$discount instanceof SubscriptionDiscount) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'discounts', + 'discounts must be an array of SubscriptionDiscount' + ); + } + + $discount->validate(); + } + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateAvailablePaymentMethodsAttribute(): void + { + $methods = [ + Invoice::PAYMENT_METHOD_CREDIT_CARD, + Invoice::PAYMENT_METHOD_BANK_SLIP, + Invoice::PAYMENT_METHOD_PIX, + ]; + + if (!is_array($this->availablePaymentMethods)) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'availablePaymentMethods', + 'availablePaymentMethods must be an array of payment methods' + ); + } + + foreach ($this->availablePaymentMethods as $method) { + if (!in_array($method, $methods, true)) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'availablePaymentMethods', + 'availablePaymentMethods must be one of: ' . implode(', ', $methods) + ); + } + } + } + + /** + * @inheritDoc + */ + protected function attributesExtraValidation(array $attributes): void + { + // com id preenchido é update, que aceita atributo parcial: cliente e plano não vão no + // payload e não são exigidos + if (!empty($this->id)) { + return; + } + + $model = $this->getClassName(); + + if (in_array('customer', $attributes) && empty($this->customer)) { + throw ModelAttributeValidationException::required($model, 'customer'); + } + + if (in_array('planId', $attributes) && empty($this->planId)) { + throw ModelAttributeValidationException::required($model, 'planId'); + } + } + + /** + * Salva a assinatura, criando antes o cliente quando ele ainda não tem id. + * + * Com `id` preenchido é update: o cliente não é tocado, e cliente e plano deixam de ser + * obrigatórios — as demais validações continuam valendo. + * + * @param GatewayContract|string|null $gateway + * @param bool $validate + * + * @return void + * @throws GatewayException|\Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function save(GatewayContract|string $gateway = null, bool $validate = true): void + { + if ($validate) { + $this->validate(); + } + + if (empty($this->id) && !empty($this->customer) && empty($this->customer->id)) { + $this->customer->save($gateway, $validate); + } + + parent::save($gateway, false); + } + + /** + * Resolve o gateway e garante que ele implementa as operações de assinatura. + * + * @param GatewayContract|string|null $gateway + * + * @return GatewayContract&SubscriptionContract + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws GatewayException + */ + private function resolveSubscriptionGateway(GatewayContract|string|null $gateway) + { + $resolved = ConfigurationHelper::resolveGateway($gateway ?? $this->gateway); + + if (!$resolved instanceof SubscriptionContract) { + throw new GatewayException( + 'Gateway [' . get_class($resolved) . '] does not implement SubscriptionContract' + ); + } + + return $resolved; + } + + /** + * Suspende a cobrança da assinatura, mantendo-a reativável por resume(). + * + * @param GatewayContract|string|null $gateway + * + * @return Subscription + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + */ + public function suspend(GatewayContract|string|null $gateway = null): Subscription + { + return $this->resolveSubscriptionGateway($gateway)->suspendSubscription($this); + } + + /** + * Volta a cobrar uma assinatura suspensa. + * + * @param GatewayContract|string|null $gateway + * + * @return Subscription + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + */ + public function resume(GatewayContract|string|null $gateway = null): Subscription + { + return $this->resolveSubscriptionGateway($gateway)->resumeSubscription($this); + } + + /** + * Cancela a assinatura, imediatamente ou ao fim do período corrente. + * + * @param bool $atPeriodEnd + * @param GatewayContract|string|null $gateway + * + * @return Subscription + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + */ + public function cancel(bool $atPeriodEnd = false, GatewayContract|string|null $gateway = null): Subscription + { + return $this->resolveSubscriptionGateway($gateway)->cancelSubscription($this, $atPeriodEnd); + } + + /** + * Troca o plano da assinatura. + * + * Com $charge, a troca gera a cobrança na hora e a fatura resultante volta em + * `latestInvoice`; sem ele, nada é cobrado. + * + * @param string $planId + * @param bool $charge + * @param GatewayContract|string|null $gateway + * + * @return Subscription + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + */ + public function changePlan( + string $planId, + bool $charge = true, + GatewayContract|string|null $gateway = null + ): Subscription { + return $this->resolveSubscriptionGateway($gateway) + ->changeSubscriptionPlan($this, $planId, $charge); + } + + /** + * Simula a troca de plano sem aplicá-la. + * + * @param string $planId + * @param GatewayContract|string|null $gateway + * + * @return SubscriptionPlanChange + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + */ + public function previewPlanChange( + string $planId, + GatewayContract|string|null $gateway = null + ): SubscriptionPlanChange { + return $this->resolveSubscriptionGateway($gateway) + ->previewSubscriptionPlanChange($this, $planId); + } +} diff --git a/src/Models/SubscriptionDiscount.php b/src/Models/SubscriptionDiscount.php new file mode 100644 index 0000000..565430e --- /dev/null +++ b/src/Models/SubscriptionDiscount.php @@ -0,0 +1,110 @@ +amountOff <= 0) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'amountOff', + 'amountOff must be a positive amount in cents.' + ); + } + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validatePercentOffAttribute(): void + { + if ($this->percentOff <= 0 || $this->percentOff > 100) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'percentOff', + 'percentOff must be greater than 0 and at most 100.' + ); + } + } + + /** + * @inheritDoc + */ + protected function attributesExtraValidation(array $attributes): void + { + $model = $this->getClassName(); + + if (in_array('amountOff', $attributes) && in_array('percentOff', $attributes)) { + if (is_null($this->amountOff) && is_null($this->percentOff)) { + throw ModelAttributeValidationException::required($model, 'amountOff or percentOff'); + } + + if (!is_null($this->amountOff) && !is_null($this->percentOff)) { + throw ModelAttributeValidationException::invalid( + $model, + 'amountOff', + 'amountOff and percentOff are mutually exclusive.' + ); + } + } + + if (in_array('description', $attributes) && empty($this->description)) { + throw ModelAttributeValidationException::required($model, 'description'); + } + + // validate() pula atributo vazio, então zero e negativo de cycles não chegariam a um + // validateCyclesAttribute() + if (in_array('cycles', $attributes) && !is_null($this->cycles) && $this->cycles < 1) { + throw ModelAttributeValidationException::invalid( + $model, + 'cycles', + 'cycles must be null or at least 1.' + ); + } + } +} diff --git a/src/Models/SubscriptionItem.php b/src/Models/SubscriptionItem.php new file mode 100644 index 0000000..264060b --- /dev/null +++ b/src/Models/SubscriptionItem.php @@ -0,0 +1,81 @@ +amount < 0) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'amount', + 'amount must not be negative. Use SubscriptionDiscount to reduce the subscription value.' + ); + } + } + + /** + * @inheritDoc + */ + protected function attributesExtraValidation(array $attributes): void + { + $model = $this->getClassName(); + + if (in_array('description', $attributes) && empty($this->description)) { + throw ModelAttributeValidationException::required($model, 'description'); + } + + if (in_array('amount', $attributes) && is_null($this->amount)) { + throw ModelAttributeValidationException::required($model, 'amount'); + } + + // validate() só chama validate{Attr}Attribute() para atributo não vazio, então zero + // e negativo de quantity não chegariam a um validateQuantityAttribute() + if (in_array('quantity', $attributes) && !is_null($this->quantity) && $this->quantity < 1) { + throw ModelAttributeValidationException::invalid( + $model, + 'quantity', + 'quantity must be at least 1.' + ); + } + } +} diff --git a/src/Models/SubscriptionPlanChange.php b/src/Models/SubscriptionPlanChange.php new file mode 100644 index 0000000..faf3057 --- /dev/null +++ b/src/Models/SubscriptionPlanChange.php @@ -0,0 +1,71 @@ +fill($item); + + return $invoiceItem; + }, $data['items']); + } + + parent::fill($data); + } +} diff --git a/src/MultiPayment.php b/src/MultiPayment.php index 8888575..43de9b2 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -7,13 +7,18 @@ use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Customer; +use Potelo\MultiPayment\Models\Plan; +use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Models\AutomaticPixCancellation; +use Potelo\MultiPayment\Contracts\PlanContract; use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Contracts\SubscriptionContract; use Potelo\MultiPayment\Builders\InvoiceBuilder; use Potelo\MultiPayment\Builders\CustomerBuilder; use Potelo\MultiPayment\Builders\CreditCardBuilder; +use Potelo\MultiPayment\Builders\SubscriptionBuilder; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; @@ -96,6 +101,72 @@ public function newCreditCard(): CreditCardBuilder return new CreditCardBuilder($this->gateway); } + /** + * Return a SubscriptionBuilder instance + * + * @return SubscriptionBuilder + */ + public function newSubscription(): SubscriptionBuilder + { + return new SubscriptionBuilder($this->gateway); + } + + /** + * List the subscriptions of a customer + * + * @param Customer|string $customer + * @param int $page + * @param int $limit + * + * @return Subscription[] + * @throws GatewayException|GatewayNotAvailableException + */ + public function listSubscriptions(Customer|string $customer, int $page = 1, int $limit = 100): array + { + if (is_string($customer)) { + $customerModel = new Customer(); + $customerModel->id = $customer; + $customer = $customerModel; + } + + return $this->gatewayImplementing(SubscriptionContract::class) + ->listSubscriptions($customer, $page, $limit); + } + + /** + * List the gateway plans + * + * @param int $page + * @param int $limit + * + * @return Plan[] + * @throws GatewayException|GatewayNotAvailableException + */ + public function listPlans(int $page = 1, int $limit = 100): array + { + return $this->gatewayImplementing(PlanContract::class)->listPlans($page, $limit); + } + + /** + * Ensure this instance's gateway implements the given contract. + * + * @param class-string $contract + * + * @return GatewayContract + * @throws GatewayException + */ + private function gatewayImplementing(string $contract): GatewayContract + { + if (!$this->gateway instanceof $contract) { + throw new GatewayException( + 'Gateway [' . get_class($this->gateway) . '] does not implement ' + . substr(strrchr($contract, '\\'), 1) + ); + } + + return $this->gateway; + } + /** * Return an invoice based on the invoice ID * diff --git a/tests/Integration/SubscriptionTest.php b/tests/Integration/SubscriptionTest.php new file mode 100644 index 0000000..cd631cc --- /dev/null +++ b/tests/Integration/SubscriptionTest.php @@ -0,0 +1,320 @@ + ids criados, removidos no tearDown */ + private array $criados = ['subscriptions' => [], 'plans' => []]; + + /** @var string[] ids de fatura, que a Iugu cancela em vez de apagar */ + private array $faturasCriadas = []; + + /** + * @inheritDoc + */ + protected function tearDown(): void + { + foreach ($this->criados as $recurso => $ids) { + foreach ($ids as $id) { + try { + (new Iugu_APIRequest())->request( + 'DELETE', + Iugu::getBaseURI() . "/{$recurso}/" . rawurlencode($id), + [] + ); + } catch (\Throwable $e) { + // limpeza é best effort: falha ao remover não invalida o teste + } + } + } + + foreach ($this->faturasCriadas as $id) { + try { + (new Iugu_APIRequest())->request( + 'PUT', + Iugu::getBaseURI() . '/invoices/' . rawurlencode($id) . '/cancel', + [] + ); + } catch (\Throwable $e) { + // limpeza é best effort, como acima + } + } + + parent::tearDown(); + } + + private function createPlan(int $amount, string $sufixo): Plan + { + $plan = new Plan(); + $plan->name = 'MultiPayment teste ' . $sufixo; + $plan->identifier = 'multipayment-teste-' . $sufixo . '-' . now()->format('YmdHisu'); + $plan->amount = $amount; + $plan->interval = Plan::INTERVAL_MONTH; + $plan->intervalCount = 1; + $plan->save(self::GATEWAY); + $this->criados['plans'][] = $plan->id; + + return $plan; + } + + private function createSubscription(Plan $plan, ?Carbon $nextBillingAt = null): Subscription + { + $customer = $this->createCustomer(self::GATEWAY, $this->customerWithoutAddress()); + + $builder = MultiPayment::setGateway(self::GATEWAY)->newSubscription() + ->setPlanId($plan->identifier) + ->setCustomerId($customer->id) + ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]); + + if ($nextBillingAt) { + $builder->setNextBillingAt($nextBillingAt); + } + + $subscription = $builder->create(); + $this->criados['subscriptions'][] = $subscription->id; + + return $subscription; + } + + /** + * Deve criar o plano, buscá-lo por id e por identifier e paginar a listagem. + * + * @return void + */ + public function testShouldCreateGetAndListPlans(): void + { + $plan = $this->createPlan(12345, 'plano'); + + $this->assertNotEmpty($plan->id); + $this->assertSame(12345, $plan->amount); + $this->assertSame(Plan::INTERVAL_MONTH, $plan->interval); + $this->assertSame('iugu', $plan->gateway); + + $porIdentifier = new Plan(); + $porIdentifier->identifier = $plan->identifier; + $this->assertSame($plan->id, $porIdentifier->get(self::GATEWAY)->id); + + $porId = new Plan(); + $porId->id = $plan->id; + $this->assertSame($plan->identifier, $porId->get(self::GATEWAY)->identifier); + + $this->createPlan(500, 'plano2'); + + $primeira = MultiPayment::setGateway(self::GATEWAY)->listPlans(1, 1); + $segunda = MultiPayment::setGateway(self::GATEWAY)->listPlans(2, 1); + + $this->assertCount(1, $primeira); + $this->assertCount(1, $segunda); + $this->assertInstanceOf(Plan::class, $primeira[0]); + $this->assertNotSame($primeira[0]->id, $segunda[0]->id); + } + + /** + * Deve criar, ler, suspender, reativar, cancelar e listar a assinatura. + * + * @return void + */ + public function testShouldRunTheSubscriptionLifecycle(): void + { + $nextBillingAt = now()->addMonth(); + $plan = $this->createPlan(10000, 'ciclo'); + $subscription = $this->createSubscription($plan, $nextBillingAt); + + $this->assertNotEmpty($subscription->id); + $this->assertSame($plan->identifier, $subscription->planId); + $this->assertSame('iugu', $subscription->gateway); + $this->assertSame( + $nextBillingAt->format('Y-m-d'), + $subscription->nextBillingAt->format('Y-m-d') + ); + $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); + + $lida = new Subscription(); + $lida->id = $subscription->id; + $lida = $lida->get(self::GATEWAY); + $this->assertSame($subscription->id, $lida->id); + $this->assertSame($subscription->planId, $lida->planId); + + $suspensa = $lida->suspend(self::GATEWAY); + $this->assertSame(Subscription::STATUS_SUSPENDED, $suspensa->status); + + $reativada = $suspensa->resume(self::GATEWAY); + $this->assertSame(Subscription::STATUS_ACTIVE, $reativada->status); + + $cancelada = $reativada->cancel(false, self::GATEWAY); + $this->assertSame(Subscription::STATUS_SUSPENDED, $cancelada->status); + + $doCliente = MultiPayment::setGateway(self::GATEWAY) + ->listSubscriptions($subscription->customer->id); + $this->assertCount(1, $doCliente); + $this->assertSame($subscription->id, $doCliente[0]->id); + } + + /** + * Assinatura sem data de cobrança não volta com resume(): a Iugu responde sem erro e sem + * mudar o estado. + * + * @return void + */ + public function testShouldNotResumeASubscriptionWithoutABillingDate(): void + { + $plan = $this->createPlan(10000, 'semdata'); + $subscription = $this->createSubscription($plan); + + $this->assertNull($subscription->nextBillingAt); + + $suspensa = $subscription->suspend(self::GATEWAY); + $this->assertSame(Subscription::STATUS_SUSPENDED, $suspensa->status); + + $reativada = $suspensa->resume(self::GATEWAY); + $this->assertSame(Subscription::STATUS_SUSPENDED, $reativada->status); + } + + /** + * Itens e descontos são declarativos: a lista informada vira o estado da assinatura, e a + * lista que ficar em `null` é preservada. + * + * @return void + */ + public function testShouldReplaceItemsWithoutTouchingDiscounts(): void + { + $plan = $this->createPlan(10000, 'itens'); + $subscription = $this->createSubscription($plan, now()->addMonth()); + + $avulso = $this->item('Setup', 900, 1); + $avulso->recurring = false; + + $subscription->items = [$this->item('Consultas', 2500, 2), $avulso]; + $subscription->discounts = [$this->discount('Promo', 500)]; + $subscription->save(self::GATEWAY); + + $lida = new Subscription(); + $lida->id = $subscription->id; + $lida = $lida->get(self::GATEWAY); + + $this->assertCount(2, $lida->items); + $porDescricao = []; + foreach ($lida->items as $item) { + $porDescricao[$item->description] = $item; + } + $this->assertSame(2500, $porDescricao['Consultas']->amount); + $this->assertSame(2, $porDescricao['Consultas']->quantity); + $this->assertTrue($porDescricao['Consultas']->recurring); + // o encoder do SDK transforma false em string vazia, por isso o gateway manda inteiro + $this->assertFalse($porDescricao['Setup']->recurring); + $this->assertCount(1, $lida->discounts); + $this->assertSame(500, $lida->discounts[0]->amountOff); + + $idDoDesconto = $lida->discounts[0]->id; + + $lida->items = [$this->item('Monitoramentos', 700, 1)]; + $lida->discounts = null; + $lida->save(self::GATEWAY); + + $depois = new Subscription(); + $depois->id = $subscription->id; + $depois = $depois->get(self::GATEWAY); + + $this->assertCount(1, $depois->items); + $this->assertSame('Monitoramentos', $depois->items[0]->description); + $this->assertCount(1, $depois->discounts); + $this->assertSame($idDoDesconto, $depois->discounts[0]->id); + $this->assertSame(500, $depois->discounts[0]->amountOff); + } + + /** + * Deve simular a troca de plano e aplicá-la sem gerar cobrança. + * + * @return void + */ + public function testShouldChangePlanAndPreviewIt(): void + { + $plan = $this->createPlan(10000, 'origem'); + $planoNovo = $this->createPlan(30000, 'destino'); + $subscription = $this->createSubscription($plan, now()->addMonth()); + + $preview = $subscription->previewPlanChange($planoNovo->identifier, self::GATEWAY); + $this->assertSame('iugu', $preview->gateway); + $this->assertSame(30000, $preview->amount); + $this->assertNull($preview->items); + $this->assertSame($planoNovo->identifier, $preview->original->new_plan); + $this->assertSame($plan->identifier, $preview->original->old_plan); + + $trocada = $subscription->changePlan($planoNovo->identifier, false, self::GATEWAY); + $this->assertSame($planoNovo->identifier, $trocada->planId); + $this->assertSame(30000, $trocada->amount); + } + + /** + * Deve trocar o plano pelo endpoint change_plan, que gera na hora uma fatura pendente, + * resumida e com vencimento anterior ao próximo ciclo. + * + * @return void + */ + public function testShouldChangePlanGeneratingTheCharge(): void + { + $proximaCobranca = now()->addMonth(); + $plan = $this->createPlan(10000, 'cobra-origem'); + $planoNovo = $this->createPlan(30000, 'cobra-destino'); + $subscription = $this->createSubscription($plan, $proximaCobranca); + + // guarda: sem isto a asserção de latestInvoice abaixo passaria com a da leitura anterior + $this->assertNull($subscription->latestInvoice); + + $trocada = $subscription->changePlan($planoNovo->identifier, true, self::GATEWAY); + + $this->assertSame($planoNovo->identifier, $trocada->planId); + $this->assertSame(30000, $trocada->amount); + $this->assertSame(Subscription::STATUS_ACTIVE, $trocada->status); + + $this->assertNotNull($trocada->latestInvoice); + $this->faturasCriadas[] = $trocada->latestInvoice->id; + + $this->assertSame(Invoice::STATUS_PENDING, $trocada->latestInvoice->status); + // a cobrança é imediata, e não a do próximo ciclo; comparar contra now() traria o fuso + // do gateway para dentro do teste + $this->assertTrue($trocada->latestInvoice->expiresAt->lessThan($proximaCobranca)); + // o resumo de recent_invoices traz o valor formatado, sem centavos, e sem secure_url + $this->assertSame('R$ 300,00', $trocada->latestInvoice->original->total); + $this->assertNull($trocada->latestInvoice->amount); + $this->assertNull($trocada->latestInvoice->url); + } + + private function item(string $description, int $amount, int $quantity): SubscriptionItem + { + $item = new SubscriptionItem(); + $item->description = $description; + $item->amount = $amount; + $item->quantity = $quantity; + + return $item; + } + + private function discount(string $description, int $amountOff): SubscriptionDiscount + { + $discount = new SubscriptionDiscount(); + $discount->description = $description; + $discount->amountOff = $amountOff; + + return $discount; + } +} diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php new file mode 100644 index 0000000..cc0366a --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -0,0 +1,1802 @@ +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(); + } + + private function subscriptionResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'sub_1', + 'customer_id' => 'cus_1', + 'plan_identifier' => 'plano_mensal', + 'price_cents' => 10000, + 'expires_at' => '2026-10-01', + 'created_at' => '2026-09-01T10:00:00-03:00', + 'active' => true, + 'suspended' => false, + 'in_trial' => false, + ], $overrides); + } + + public function testCreateSubscriptionMapsGenericFieldsToIuguPayload(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'next_billing_at' => '2026-10-01', + 'available_payment_methods' => [Invoice::PAYMENT_METHOD_PIX], + 'metadata' => ['origem' => 'teste'], + 'items' => [['description' => 'Consultas', 'amount' => 2500, 'quantity' => 2]], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500]], + ]); + + $gateway->createSubscription($subscription); + + $this->assertCount(1, $api->calls); + $call = $api->calls[0]; + $this->assertSame('POST', $call['method']); + $this->assertStringEndsWith('/subscriptions', $call['url']); + $this->assertSame([ + 'customer_id' => 'cus_1', + 'plan_identifier' => 'plano_mensal', + 'expires_at' => '2026-10-01', + 'payable_with' => [Invoice::PAYMENT_METHOD_PIX], + 'custom_variables' => [['name' => 'origem', 'value' => 'teste']], + 'subitems' => [ + ['description' => 'Consultas', 'price_cents' => 2500, 'quantity' => 2, 'recurrent' => 1], + ['description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => 1], + ], + ], $call['data']); + } + + public function testGatewayAdicionalOptionsOverrideTheGeneratedPayload(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1']]); + $subscription->gatewayAdicionalOptions = [ + 'only_on_charge_success' => true, + 'plan_identifier' => 'outro_plano', + ]; + + (new IuguGateway($api))->createSubscription($subscription); + + $this->assertSame([ + 'customer_id' => 'cus_1', + 'plan_identifier' => 'outro_plano', + 'only_on_charge_success' => true, + ], $api->calls[0]['data']); + } + + public function testParseSplitsNegativeSubitemsIntoDiscounts(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_1', 'description' => 'Consultas', 'price_cents' => 2500, 'quantity' => 2, 'recurrent' => true], + (object) ['id' => 'si_2', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true], + (object) ['id' => 'si_3', 'description' => 'Bonus', 'price_cents' => -300, 'quantity' => 1, 'recurrent' => false], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertCount(1, $subscription->items); + $this->assertInstanceOf(SubscriptionItem::class, $subscription->items[0]); + $this->assertSame('si_1', $subscription->items[0]->id); + $this->assertSame(2500, $subscription->items[0]->amount); + + $this->assertCount(2, $subscription->discounts); + $this->assertInstanceOf(SubscriptionDiscount::class, $subscription->discounts[0]); + $this->assertSame(500, $subscription->discounts[0]->amountOff); + $this->assertNull($subscription->discounts[0]->cycles); + $this->assertSame(300, $subscription->discounts[1]->amountOff); + $this->assertSame(1, $subscription->discounts[1]->cycles); + $this->assertTrue($subscription->items[0]->recurring); + } + + public function testParseReadsRecurrentFalseAsANonRecurringItem(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_1', 'description' => 'Setup', 'price_cents' => 500, 'quantity' => 1, 'recurrent' => false], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertFalse( + (new IuguGateway($api))->getSubscription($subscription)->items[0]->recurring + ); + } + + public function testParseReadsNextBillingFromExpiresAt(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('2026-10-01', $subscription->nextBillingAt->format('Y-m-d')); + $this->assertNull($subscription->trialEndsAt); + $this->assertSame(10000, $subscription->amount); + $this->assertSame('cus_1', $subscription->customer->id); + $this->assertSame('iugu', $subscription->gateway); + } + + /** + * @dataProvider statusProvider + */ + public function testParseMapsIuguFlagsToGenericStatus(array $flags, ?string $expected): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse($flags)]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame($expected, $subscription->status); + } + + public static function statusProvider(): array + { + return [ + 'suspensa' => [['suspended' => true, 'active' => false], Subscription::STATUS_SUSPENDED], + 'suspensa tem precedencia sobre trial' => [ + ['suspended' => true, 'in_trial' => true], + Subscription::STATUS_SUSPENDED, + ], + 'em trial' => [['in_trial' => true], Subscription::STATUS_TRIALING], + 'ativa' => [['active' => true], Subscription::STATUS_ACTIVE], + 'inativa' => [['active' => false], Subscription::STATUS_PENDING], + 'sem flag nenhuma' => [['active' => null, 'suspended' => null, 'in_trial' => null], null], + ]; + } + + public function testParseFillsTrialEndsAtWhileInTrial(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['in_trial' => true])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('2026-10-01', $subscription->trialEndsAt->format('Y-m-d')); + } + + /** + * A Iugu recusa remover e adicionar subitens na mesma requisição. + */ + public function testUpdateRemovesItemsInAnEarlierRequest(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_antigo', 'description' => 'Antigo', 'price_cents' => 100, 'quantity' => 1, 'recurrent' => true], + (object) ['id' => 'si_mantido', 'description' => 'Mantido', 'price_cents' => 200, 'quantity' => 1, 'recurrent' => true], + ], + ]), + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'items' => [ + ['id' => 'si_mantido', 'description' => 'Mantido', 'amount' => 200, 'quantity' => 1], + ['description' => 'Novo', 'amount' => 300, 'quantity' => 1], + ], + ]); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(3, $api->calls); + + $this->assertSame('GET', $api->calls[0]['method']); + + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertSame( + [['id' => 'si_antigo', '_destroy' => true]], + $api->calls[1]['data']['subitems'] + ); + + $this->assertSame('PUT', $api->calls[2]['method']); + $this->assertSame([ + ['description' => 'Mantido', 'price_cents' => 200, 'quantity' => 1, 'recurrent' => 1, 'id' => 'si_mantido'], + ['description' => 'Novo', 'price_cents' => 300, 'quantity' => 1, 'recurrent' => 1], + ], $api->calls[2]['data']['subitems']); + } + + public function testUpdateWithoutItemsToRemoveSkipsTheRemovalRequest(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => []]), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'items' => [['description' => 'Novo', 'amount' => 300, 'quantity' => 1]], + ]); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertSame('PUT', $api->calls[1]['method']); + } + + public function testUpdateWithoutItemsAtAllDoesNotReadTheCurrentState(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'next_billing_at' => '2026-11-01']); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(1, $api->calls); + $this->assertSame('PUT', $api->calls[0]['method']); + $this->assertSame('2026-11-01', $api->calls[0]['data']['expires_at']); + } + + public function testSuspendAndResumeHitTheirOwnEndpoints(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['suspended' => true]), + $this->subscriptionResponse(), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $suspended = $gateway->suspendSubscription($subscription); + $this->assertStringEndsWith('/subscriptions/sub_1/suspend', $api->calls[0]['url']); + $this->assertSame(Subscription::STATUS_SUSPENDED, $suspended->status); + + $resumed = $gateway->resumeSubscription($subscription); + $this->assertStringEndsWith('/subscriptions/sub_1/activate', $api->calls[1]['url']); + $this->assertSame(Subscription::STATUS_ACTIVE, $resumed->status); + } + + public function testCancelWithoutPeriodEndSuspendsTheSubscription(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['suspended' => true])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + (new IuguGateway($api))->cancelSubscription($subscription); + + $this->assertStringEndsWith('/subscriptions/sub_1/suspend', $api->calls[0]['url']); + } + + public function testCancelAtPeriodEndIsRejected(): void + { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/does not support cancelling a subscription at the end/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->cancelSubscription($subscription, true); + } + + public function testChangePlanWithoutChargeSendsSkipChargeAndTheNewBillingDate(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'next_billing_at' => '2026-12-01']); + + (new IuguGateway($api))->changeSubscriptionPlan($subscription, 'plano_anual', false); + + $this->assertCount(1, $api->calls); + $this->assertSame('PUT', $api->calls[0]['method']); + $this->assertSame('plano_anual', $api->calls[0]['data']['plan_identifier']); + $this->assertTrue($api->calls[0]['data']['skip_charge']); + $this->assertSame('2026-12-01', $api->calls[0]['data']['expires_at']); + } + + public function testChangePlanWithChargeUsesTheChangePlanEndpointThenReloads(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['success' => true], + $this->subscriptionResponse(['plan_identifier' => 'plano_anual']), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $changed = (new IuguGateway($api))->changeSubscriptionPlan($subscription, 'plano_anual'); + + $this->assertCount(2, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1/change_plan/plano_anual', $api->calls[0]['url']); + $this->assertSame('GET', $api->calls[1]['method']); + $this->assertSame('plano_anual', $changed->planId); + } + + /** + * O plano da assinatura devolvida vem do gateway; o plano pedido só é mantido quando a + * resposta não traz `plan_identifier`. + */ + public function testChangePlanKeepsTheRequestedPlanWhenTheReloadOmitsIt(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['success' => true], + (object) ['id' => 'sub_1', 'active' => true], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $changed = (new IuguGateway($api))->changeSubscriptionPlan($subscription, 'plano_anual'); + + $this->assertSame('plano_anual', $changed->planId); + } + + /** + * Simulação com `cost` em centavos e sem linhas: o parse lê o valor e deixa `items` nulo. + */ + public function testPreviewPlanChangeReadsTheSimulationResponse(): void + { + $api = new QueuedIuguApiRequest([ + (object) [ + 'cost' => 30000, + 'discount' => 0, + 'cycles' => 1, + 'expires_at' => '2026-12-01', + 'new_plan' => 'plano_anual', + 'old_plan' => 'plano_mensal', + ], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'plano_anual'); + + $this->assertStringEndsWith( + '/subscriptions/sub_1/change_plan_simulation/plano_anual', + $api->calls[0]['url'] + ); + $this->assertSame(30000, $planChange->amount); + $this->assertSame('2026-12-01', $planChange->effectiveAt->format('Y-m-d')); + $this->assertNull($planChange->items); + $this->assertSame('plano_anual', $planChange->original->new_plan); + } + + public function testPreviewPlanChangeFallsBackToPriceCentsAndSubitems(): void + { + $api = new QueuedIuguApiRequest([ + (object) [ + 'price_cents' => 30000, + 'expires_at' => '2026-12-01', + 'subitems' => [ + (object) ['description' => 'Plano anual', 'price_cents' => 30000, 'quantity' => 1], + ], + ], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'plano_anual'); + + $this->assertSame(30000, $planChange->amount); + $this->assertSame('2026-12-01', $planChange->effectiveAt->format('Y-m-d')); + $this->assertCount(1, $planChange->items); + $this->assertSame(30000, $planChange->items[0]->price); + } + + public function testListSubscriptionsPaginatesByCustomer(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['items' => [$this->subscriptionResponse(), $this->subscriptionResponse(['id' => 'sub_2'])]], + ]); + + $customer = new Customer(); + $customer->id = 'cus_1'; + + $subscriptions = (new IuguGateway($api))->listSubscriptions($customer, 2, 50); + + $this->assertStringContainsString('customer_id=cus_1', $api->calls[0]['url']); + $this->assertStringContainsString('limit=50', $api->calls[0]['url']); + $this->assertStringContainsString('start=50', $api->calls[0]['url']); + $this->assertCount(2, $subscriptions); + $this->assertSame('sub_2', $subscriptions[1]->id); + } + + public function testPercentageDiscountIsRejected(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->percentOff = 10; + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano', 'customer' => ['id' => 'cus_1']]); + $subscription->discounts = [$discount]; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/does not support percentage discounts/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); + } + + /** + * A Iugu só expressa desconto de uma fatura ou até ser removido, então guardar cycles maior + * que 1 devolveria um limite que o gateway não mantém. + */ + public function testDiscountLimitedToMoreThanOneCycleIsRejected(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->cycles = 3; + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano', 'customer' => ['id' => 'cus_1']]); + $subscription->discounts = [$discount]; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/cycles greater than 1/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); + } + + public function testDiscountWithoutAmountOffIsRejectedByTheMapper(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->discounts = [$discount]; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`amountOff` attribute is required/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->updateSubscription($subscription); + } + + public function testItemWithoutAmountIsRejectedByTheMapper(): void + { + $item = new SubscriptionItem(); + $item->description = 'X'; + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->items = [$item]; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`amount` attribute is required/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->updateSubscription($subscription); + } + + public function testCreatePlanMapsIntervalToIuguIntervalType(): void + { + $api = new QueuedIuguApiRequest([ + (object) [ + 'id' => 'plan_1', + 'identifier' => 'mensal', + 'name' => 'Mensal', + 'interval' => 1, + 'interval_type' => 'months', + 'prices' => [(object) ['value_cents' => 10000, 'currency' => 'BRL']], + ], + ]); + + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->identifier = 'mensal'; + $plan->amount = 10000; + $plan->interval = Plan::INTERVAL_MONTH; + $plan->intervalCount = 1; + + $created = (new IuguGateway($api))->createPlan($plan); + + $this->assertSame('months', $api->calls[0]['data']['interval_type']); + $this->assertSame(1, $api->calls[0]['data']['interval']); + $this->assertSame(10000, $api->calls[0]['data']['value_cents']); + $this->assertSame(Plan::INTERVAL_MONTH, $created->interval); + $this->assertSame(10000, $created->amount); + $this->assertSame('BRL', $created->currency); + } + + public function testYearlyPlanIsRejected(): void + { + $plan = new Plan(); + $plan->name = 'Anual'; + $plan->amount = 100000; + $plan->interval = Plan::INTERVAL_YEAR; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/only supports weekly and monthly plans/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->createPlan($plan); + } + + public function testDeactivatePlanIsRejected(): void + { + $plan = new Plan(); + $plan->id = 'plan_1'; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/no active flag/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->deactivatePlan($plan); + } + + public function testGatewayErrorsBecomeGatewayException(): void + { + $api = new QueuedIuguApiRequest([(object) ['errors' => ['plan_identifier' => 'não encontrado']]]); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'inexistente', 'customer' => ['id' => 'cus_1']]); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/creating subscription/'); + + (new IuguGateway($api))->createSubscription($subscription); + } + + /** + * A Iugu não tem estado de inadimplência: fatura vencida em aberto deixa a assinatura + * `active` com `expires_at` no passado. + */ + public function testDerivesPastDueFromOverdueDateAndUnpaidInvoice(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [ + (object) ['id' => 'inv_1', 'status' => 'pending', 'due_date' => '2026-08-01', 'secure_url' => 'https://iugu/inv_1'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertInstanceOf(Invoice::class, $subscription->latestInvoice); + $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertSame('https://iugu/inv_1', $subscription->latestInvoice->url); + } + + /** + * `expired` conta como dívida, mas o status genérico da fatura é canceled. + */ + public function testExpiredInvoiceAlsoCountsAsPastDue(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'expired']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertSame(Invoice::STATUS_CANCELED, $subscription->latestInvoice->status); + } + + public function testPaidInvoiceWithOverdueDateIsNotPastDue(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'paid']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + Subscription::STATUS_ACTIVE, + (new IuguGateway($api))->getSubscription($subscription)->status + ); + } + + public function testUnpaidInvoiceWithFutureBillingDateIsNotPastDue(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2099-01-01', + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'pending']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + Subscription::STATUS_ACTIVE, + (new IuguGateway($api))->getSubscription($subscription)->status + ); + } + + public function testSuspendedTakesPrecedenceOverPastDue(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'suspended' => true, + 'expires_at' => '2026-08-01', + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'pending']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + Subscription::STATUS_SUSPENDED, + (new IuguGateway($api))->getSubscription($subscription)->status + ); + } + + /** + * `recent_invoices` é um resumo. + */ + public function testUnknownInvoiceStatusDoesNotBreakTheSubscriptionRead(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'status_novo_da_iugu']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); + $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertNull($subscription->latestInvoice->status); + $this->assertSame('status_novo_da_iugu', $subscription->latestInvoice->original->status); + } + + public function testSubscriptionWithoutRecentInvoicesHasNoLatestInvoice(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertNull((new IuguGateway($api))->getSubscription($subscription)->latestInvoice); + } + + /** + * Lista não informada mantém os subitens daquele tipo. + */ + public function testUpdatingOnlyItemsDoesNotDestroyDiscounts(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_item_antigo', 'description' => 'Antigo', 'price_cents' => 100, 'quantity' => 1, 'recurrent' => true], + (object) ['id' => 'si_desconto', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true], + ], + ]), + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'items' => [['description' => 'Novo', 'amount' => 300, 'quantity' => 1]], + ]); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(3, $api->calls); + $this->assertSame( + [['id' => 'si_item_antigo', '_destroy' => true]], + $api->calls[1]['data']['subitems'] + ); + $this->assertSame( + [['description' => 'Novo', 'price_cents' => 300, 'quantity' => 1, 'recurrent' => 1]], + $api->calls[2]['data']['subitems'] + ); + } + + public function testUpdatingOnlyDiscountsDoesNotDestroyItems(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_item', 'description' => 'Consultas', 'price_cents' => 100, 'quantity' => 1, 'recurrent' => true], + (object) ['id' => 'si_desconto_antigo', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true], + ], + ]), + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'discounts' => [['description' => 'Nova promo', 'amount_off' => 300]], + ]); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(3, $api->calls); + $this->assertSame( + [['id' => 'si_desconto_antigo', '_destroy' => true]], + $api->calls[1]['data']['subitems'] + ); + $this->assertSame( + [['description' => 'Nova promo', 'price_cents' => -300, 'quantity' => 1, 'recurrent' => 1]], + $api->calls[2]['data']['subitems'] + ); + } + + public function testEmptyItemListRemovesEveryItemAndKeepsDiscounts(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_a', 'price_cents' => 100], + (object) ['id' => 'si_b', 'price_cents' => 200], + (object) ['id' => 'si_desconto', 'price_cents' => -50], + ], + ]), + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->items = []; + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertSame([ + ['id' => 'si_a', '_destroy' => true], + ['id' => 'si_b', '_destroy' => true], + ], $api->calls[1]['data']['subitems']); + $this->assertArrayNotHasKey('subitems', $api->calls[2]['data']); + } + + public function testSubitemsFromTheGatewayAreAcceptedAsAssociativeArrays(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => [['id' => 'si_a', 'price_cents' => 100]]]), + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->items = []; + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertSame([['id' => 'si_a', '_destroy' => true]], $api->calls[1]['data']['subitems']); + } + + public function testGatewayAdicionalOptionsAlsoOverrideTheUpdatePayload(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'next_billing_at' => '2026-11-01']); + $subscription->gatewayAdicionalOptions = ['expires_at' => '2026-12-25', 'ignore_due_email' => true]; + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertSame( + ['expires_at' => '2026-12-25', 'ignore_due_email' => true], + $api->calls[0]['data'] + ); + } + + /** + * Na Iugu o fim do trial e a próxima cobrança são o mesmo campo. + */ + public function testTrialEndsAtIsSentAsExpiresAt(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'trial_ends_at' => '2026-09-15', + ]); + + (new IuguGateway($api))->createSubscription($subscription); + + $this->assertSame('2026-09-15', $api->calls[0]['data']['expires_at']); + } + + public function testNextBillingAtAndTrialEndsAtTogetherAreRejected(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'next_billing_at' => '2026-10-01', + 'trial_ends_at' => '2026-09-15', + ]); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/same field/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); + } + + /** + * @dataProvider payableWithProvider + */ + public function testParseMapsPayableWithBackToGenericMethods($payableWith, array $expected): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['payable_with' => $payableWith])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + $expected, + (new IuguGateway($api))->getSubscription($subscription)->availablePaymentMethods + ); + } + + public static function payableWithProvider(): array + { + return [ + 'lista' => [ + ['credit_card', 'pix'], + [Invoice::PAYMENT_METHOD_CREDIT_CARD, Invoice::PAYMENT_METHOD_PIX], + ], + 'metodo desconhecido e ignorado' => [ + ['pix', 'crypto'], + [Invoice::PAYMENT_METHOD_PIX], + ], + 'all expande nos tres' => [ + 'all', + [ + Invoice::PAYMENT_METHOD_CREDIT_CARD, + Invoice::PAYMENT_METHOD_BANK_SLIP, + Invoice::PAYMENT_METHOD_PIX, + ], + ], + ]; + } + + public function testParseReadsMetadataAndCreatedAt(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'custom_variables' => [(object) ['name' => 'origem', 'value' => 'teste']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(['origem' => 'teste'], $subscription->metadata); + $this->assertSame('2026-09-01', $subscription->createdAt->format('Y-m-d')); + } + + public function testGetPlanUsesTheIdentifierEndpointWhenThereIsNoId(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'plan_1', 'identifier' => 'mensal', 'name' => 'Mensal', 'interval' => 1, 'interval_type' => 'months', 'value_cents' => 10000], + ]); + + $plan = new Plan(); + $plan->identifier = 'mensal'; + + $found = (new IuguGateway($api))->getPlan($plan); + + $this->assertStringEndsWith('/plans/identifier/mensal', $api->calls[0]['url']); + $this->assertSame(10000, $found->amount); + $this->assertSame(Plan::INTERVAL_MONTH, $found->interval); + } + + public function testGetPlanRequiresIdOrIdentifier(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new IuguGateway(new QueuedIuguApiRequest([])))->getPlan(new Plan()); + } + + public function testListPlansPaginates(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['items' => [(object) ['id' => 'plan_1', 'name' => 'Mensal', 'interval_type' => 'months']]], + ]); + + $plans = (new IuguGateway($api))->listPlans(3, 10); + + $this->assertStringContainsString('limit=10', $api->calls[0]['url']); + $this->assertStringContainsString('start=20', $api->calls[0]['url']); + $this->assertCount(1, $plans); + } + + /** + * @dataProvider methodsThatRequireSubscriptionIdProvider + */ + public function testMethodsRequireTheSubscriptionId(callable $call): void + { + $this->expectException(ModelAttributeValidationException::class); + + $call(new IuguGateway(new QueuedIuguApiRequest([])), new Subscription()); + } + + public static function methodsThatRequireSubscriptionIdProvider(): array + { + return [ + 'get' => [fn(IuguGateway $g, Subscription $s) => $g->getSubscription($s)], + 'update' => [fn(IuguGateway $g, Subscription $s) => $g->updateSubscription($s)], + 'suspend' => [fn(IuguGateway $g, Subscription $s) => $g->suspendSubscription($s)], + 'resume' => [fn(IuguGateway $g, Subscription $s) => $g->resumeSubscription($s)], + 'cancel' => [fn(IuguGateway $g, Subscription $s) => $g->cancelSubscription($s)], + 'changePlan' => [fn(IuguGateway $g, Subscription $s) => $g->changeSubscriptionPlan($s, 'p')], + 'preview' => [fn(IuguGateway $g, Subscription $s) => $g->previewSubscriptionPlanChange($s, 'p')], + ]; + } + + public function testCreateRequiresACustomerWithId(): void + { + $subscription = new Subscription(); + $subscription->planId = 'plano'; + + $this->expectException(ModelAttributeValidationException::class); + + (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); + } + + public function testListSubscriptionsRequiresTheCustomerId(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new IuguGateway(new QueuedIuguApiRequest([])))->listSubscriptions(new Customer()); + } + + /** + * @dataProvider invalidPaginationProvider + */ + public function testPaginationBoundsAreRejected(int $page, int $limit, string $mensagem): void + { + $customer = new Customer(); + $customer->id = 'cus_1'; + $gateway = new IuguGateway(new QueuedIuguApiRequest([])); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches($mensagem); + + $gateway->listSubscriptions($customer, $page, $limit); + } + + /** + * @dataProvider invalidPaginationProvider + */ + public function testPlanPaginationBoundsAreRejected(int $page, int $limit, string $mensagem): void + { + $gateway = new IuguGateway(new QueuedIuguApiRequest([])); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches(str_replace('Subscription', 'Plan', $mensagem)); + + $gateway->listPlans($page, $limit); + } + + public static function invalidPaginationProvider(): array + { + return [ + 'pagina zero' => [0, 100, '/Subscription page must be at least 1/'], + 'limite zero' => [1, 0, '/Subscription limit must be between 1 and 100/'], + 'limite acima de 100' => [1, 101, '/Subscription limit must be between 1 and 100/'], + ]; + } + + public function testCancelReturnsTheSuspendedSubscription(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['suspended' => true])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $canceled = (new IuguGateway($api))->cancelSubscription($subscription); + + $this->assertCount(1, $api->calls); + $this->assertSame(Subscription::STATUS_SUSPENDED, $canceled->status); + } + + public function testListSubscriptionsAcceptsAPlainArrayResponse(): void + { + $api = new QueuedIuguApiRequest([[$this->subscriptionResponse()]]); + + $customer = new Customer(); + $customer->id = 'cus_1'; + + $this->assertCount(1, (new IuguGateway($api))->listSubscriptions($customer)); + } + + /** + * O parse preenche nextBillingAt e trialEndsAt do mesmo `expires_at`, então ler e regravar + * uma assinatura em trial não pode ser recusado por conflito. + */ + public function testSubscriptionReadWhileInTrialCanBeUpdatedBack(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['in_trial' => true]), + $this->subscriptionResponse(['in_trial' => true]), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + + $this->assertEquals($subscription->nextBillingAt, $subscription->trialEndsAt); + $this->assertNotSame($subscription->nextBillingAt, $subscription->trialEndsAt); + + $subscription->metadata = ['origem' => 'teste']; + $gateway->updateSubscription($subscription); + + $this->assertArrayNotHasKey('expires_at', $api->calls[1]['data']); + } + + /** + * Update reafirmando o que veio da leitura rebobinaria a data de cobrança quando o model + * está velho. + */ + public function testUpdateDoesNotResendTheBillingDateItJustRead(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + + $subscription->metadata = ['origem' => 'teste']; + $gateway->updateSubscription($subscription); + + $this->assertArrayNotHasKey('expires_at', $api->calls[1]['data']); + $this->assertSame( + [['name' => 'origem', 'value' => 'teste']], + $api->calls[1]['data']['custom_variables'] + ); + } + + public function testUpdateSendsTheBillingDateWhenItActuallyChanged(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + + $subscription->nextBillingAt = \Carbon\Carbon::parse('2026-12-01'); + $subscription->trialEndsAt = null; + $gateway->updateSubscription($subscription); + + $this->assertSame('2026-12-01', $api->calls[1]['data']['expires_at']); + } + + /** + * `all` na Iugu significa os métodos habilitados na conta; reenviá-lo como lista fixa + * trocaria a configuração da assinatura sem que ninguém pedisse. + */ + public function testUpdateDoesNotTurnPayableWithAllIntoAFixedList(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['payable_with' => 'all']), + $this->subscriptionResponse(), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + + $this->assertCount(3, $subscription->availablePaymentMethods); + + $subscription->metadata = ['origem' => 'teste']; + $gateway->updateSubscription($subscription); + + $this->assertArrayNotHasKey('payable_with', $api->calls[1]['data']); + } + + public function testUpdateSendsPaymentMethodsWhenTheyActuallyChanged(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['payable_with' => 'all']), + $this->subscriptionResponse(), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + + $subscription->availablePaymentMethods = [Invoice::PAYMENT_METHOD_PIX]; + $gateway->updateSubscription($subscription); + + $this->assertSame([Invoice::PAYMENT_METHOD_PIX], $api->calls[1]['data']['payable_with']); + } + + public function testDivergingNextBillingAndTrialEndAreStillRejected(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'p', + 'customer' => ['id' => 'cus_1'], + 'next_billing_at' => '2026-10-01', + 'trial_ends_at' => '2026-09-15', + ]); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/different dates/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); + } + + /** + * `price_cents` do subitem é unitário, então o desconto abatido é o valor vezes a + * quantidade. + */ + public function testDiscountAmountAccountsForTheSubitemQuantity(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_d', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 3, 'recurrent' => true], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(1500, $subscription->discounts[0]->amountOff); + } + + /** + * `false` vira string vazia no encoder do SDK. + */ + public function testRecurrentGoesAsIntegerInThePayload(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'p', + 'customer' => ['id' => 'cus_1'], + 'items' => [['description' => 'Setup', 'amount' => 500, 'quantity' => 1, 'recurring' => false]], + 'discounts' => [['description' => 'Promo', 'amount_off' => 300, 'cycles' => 1]], + ]); + + (new IuguGateway($api))->createSubscription($subscription); + + $this->assertSame(0, $api->calls[0]['data']['subitems'][0]['recurrent']); + $this->assertSame(0, $api->calls[0]['data']['subitems'][1]['recurrent']); + } + + public function testSubitemIdComparisonDoesNotDependOnTheJsonType(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => [(object) ['id' => 123, 'price_cents' => 100]]]), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'items' => [['id' => '123', 'description' => 'Mantido', 'amount' => 100, 'quantity' => 1]], + ]); + + (new IuguGateway($api))->updateSubscription($subscription); + + // nada a destruir: o id 123 do gateway é o mesmo '123' da lista desejada + $this->assertCount(2, $api->calls); + $this->assertSame('PUT', $api->calls[1]['method']); + } + + /** + * O `total_cents` da Iugu pode vir formatado, e valor não numérico não vira amount. + */ + public function testPlanChangeIgnoresNonNumericAmountFields(): void + { + $api = new QueuedIuguApiRequest([(object) ['total_cents' => 'R$ 300,00']]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertNull($planChange->amount); + } + + public function testStatusKeepsThePreviousValueWhenTheResponseHasNoFlags(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'paid']], + ]), + (object) ['id' => 'sub_1'], + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); + + $subscription = $gateway->updateSubscription($subscription); + + $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); + $this->assertSame('inv_1', $subscription->latestInvoice->id); + } + + public function testPlanChangeFallsBackWhenCostIsNotNumeric(): void + { + $api = new QueuedIuguApiRequest([(object) ['cost' => 'R$ 300,00', 'price_cents' => 30000]]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + 30000, + (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p')->amount + ); + } + + /** + * Entre faturas do mesmo estado, vence a de maior vencimento, em qualquer ordem de resposta. + * + * @dataProvider ordemProvider + */ + public function testLatestInvoiceDoesNotDependOnTheOrderIuguReturns(array $recentInvoices): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => $recentInvoices, + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('inv_recente', $subscription->latestInvoice->id); + $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + } + + public static function ordemProvider(): array + { + $antiga = (object) ['id' => 'inv_antiga', 'status' => 'pending', 'due_date' => '2026-06-01']; + $recente = (object) ['id' => 'inv_recente', 'status' => 'pending', 'due_date' => '2026-08-01']; + + return [ + 'antiga primeiro' => [[$antiga, $recente]], + 'recente primeiro' => [[$recente, $antiga]], + ]; + } + + /** + * Vencimento igual é desempatado pelo menor id, qualquer que seja o estado das faturas. + * + * @dataProvider tieProvider + */ + public function testSameDueDateIsBrokenByTheSmallestId(array $recentInvoices): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => $recentInvoices, + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('inv_a_paga', $subscription->latestInvoice->id); + $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + } + + public static function tieProvider(): array + { + return [ + 'paga primeiro' => [[ + (object) ['id' => 'inv_a_paga', 'status' => 'paid', 'due_date' => '2026-08-01'], + (object) ['id' => 'inv_z_aberta', 'status' => 'pending', 'due_date' => '2026-08-01'], + ]], + 'aberta primeiro' => [[ + (object) ['id' => 'inv_z_aberta', 'status' => 'pending', 'due_date' => '2026-08-01'], + (object) ['id' => 'inv_a_paga', 'status' => 'paid', 'due_date' => '2026-08-01'], + ]], + 'paga como externally_paid' => [[ + (object) ['id' => 'inv_a_paga', 'status' => 'externally_paid', 'due_date' => '2026-08-01'], + (object) ['id' => 'inv_z_aberta', 'status' => 'expired', 'due_date' => '2026-08-01'], + ]], + ]; + } + + /** + * Cliente de outro id não é o mesmo cliente: o parse troca o objeto em vez de misturar os + * atributos. + */ + public function testParseReplacesTheCustomerWhenTheGatewayReturnsAnotherId(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['customer_id' => 'cus_outro', 'customer_name' => 'Beltrano']), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'customer' => ['id' => 'cus_1', 'tax_document' => '20176996915'], + ]); + + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('cus_outro', $subscription->customer->id); + $this->assertSame('Beltrano', $subscription->customer->name); + $this->assertNull($subscription->customer->taxDocument); + } + + /** + * O parse preenche o cliente existente em vez de trocá-lo por um só com id. + */ + public function testParseKeepsTheCustomerItAlreadyHad(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'customer_name' => 'Fulano da Silva', + 'customer_email' => 'fulano@exemplo.com', + ]), + ]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'customer' => ['id' => 'cus_1', 'tax_document' => '20176996915']]); + $original = $subscription->customer; + + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame($original, $subscription->customer); + $this->assertSame('20176996915', $subscription->customer->taxDocument); + $this->assertSame('Fulano da Silva', $subscription->customer->name); + $this->assertSame('fulano@exemplo.com', $subscription->customer->email); + } + + /** + * Empate de vencimento e de estado é resolvido pelo menor id, em qualquer ordem. + * + * @dataProvider mesmaSituacaoProvider + */ + public function testLatestInvoiceIsStableWhenDueDateAndStateTie(array $recentInvoices): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['recent_invoices' => $recentInvoices]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + 'inv_a', + (new IuguGateway($api))->getSubscription($subscription)->latestInvoice->id + ); + } + + public static function mesmaSituacaoProvider(): array + { + return [ + 'ordem crescente' => [[ + (object) ['id' => 'inv_a', 'status' => 'pending', 'due_date' => '2026-08-01'], + (object) ['id' => 'inv_b', 'status' => 'pending', 'due_date' => '2026-08-01'], + ]], + 'ordem inversa' => [[ + (object) ['id' => 'inv_b', 'status' => 'pending', 'due_date' => '2026-08-01'], + (object) ['id' => 'inv_a', 'status' => 'pending', 'due_date' => '2026-08-01'], + ]], + ]; + } + + public function testParseKeepsTheLocalNameWhenTheResponseOmitsIt(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'customer' => ['id' => 'cus_1', 'name' => 'Fulano', 'email' => 'fulano@exemplo.com'], + ]); + + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('Fulano', $subscription->customer->name); + $this->assertSame('fulano@exemplo.com', $subscription->customer->email); + } + + /** + * Entrada sem id não serve como latestInvoice, porque não dá para buscá-la, mas continua + * valendo como fatura em aberto para a inadimplência. + */ + public function testRecentInvoiceWithoutIdIsNotSelectableButStillCountsAsDebt(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [(object) ['status' => 'pending', 'due_date' => '2026-08-01']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertNull($subscription->latestInvoice); + $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + } + + /** + * Vencimento hoje ainda não é inadimplência: a carência vai até o fim do dia. + */ + public function testSubscriptionDueTodayWithAnOpenInvoiceIsNotPastDueYet(): void + { + $hoje = Carbon::today()->toDateString(); + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => $hoje, + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'pending', 'due_date' => $hoje]], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + Subscription::STATUS_ACTIVE, + (new IuguGateway($api))->getSubscription($subscription)->status + ); + } + + /** + * Fatura cancelada não é dívida. + */ + public function testCanceledInvoiceDoesNotMakeTheSubscriptionPastDue(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'canceled', 'due_date' => '2026-08-01']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + Subscription::STATUS_ACTIVE, + (new IuguGateway($api))->getSubscription($subscription)->status + ); + } + + /** + * Cliente local sem id não é o mesmo cliente: o parse devolve a visão do gateway em vez de + * misturar os atributos. + */ + public function testParseReplacesTheCustomerWhenTheLocalOneHasNoId(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['customer_name' => 'Beltrano'])]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'customer' => ['tax_document' => '20176996915']]); + + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('cus_1', $subscription->customer->id); + $this->assertSame('Beltrano', $subscription->customer->name); + $this->assertNull($subscription->customer->taxDocument); + } + + /** + * Fatura cancelada de vencimento posterior não esconde a pendente anterior, nem na + * inadimplência nem na escolha da fatura que representa a assinatura. + */ + public function testPastDueLooksAtEveryInvoiceNotOnlyTheChosenOne(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [ + (object) ['id' => 'inv_pendente', 'status' => 'pending', 'due_date' => '2026-08-01'], + (object) ['id' => 'inv_cancelada', 'status' => 'canceled', 'due_date' => '2026-08-05'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('inv_cancelada', $subscription->latestInvoice->id); + $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + } + + /** + * `latestInvoice` é a mais recente, não a que gerou a inadimplência: em `past_due` ela pode + * estar quitada, e quem precisa da fatura a pagar tem de buscá-la pelo id. + */ + public function testPastDueCanPointAtAnInvoiceThatIsAlreadyPaid(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [ + (object) ['id' => 'inv_aberta', 'status' => 'pending', 'due_date' => '2026-07-01'], + (object) ['id' => 'inv_paga', 'status' => 'paid', 'due_date' => '2026-08-01'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertSame('inv_paga', $subscription->latestInvoice->id); + $this->assertSame(Invoice::STATUS_PAID, $subscription->latestInvoice->status); + } + + /** + * Sem fatura em aberto, a escolhida é a de maior vencimento. + */ + public function testWithoutAnyOpenInvoiceTheLatestOneIsChosen(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'recent_invoices' => [ + (object) ['id' => 'inv_antiga', 'status' => 'paid', 'due_date' => '2026-07-01'], + (object) ['id' => 'inv_recente', 'status' => 'paid', 'due_date' => '2026-08-01'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + 'inv_recente', + (new IuguGateway($api))->getSubscription($subscription)->latestInvoice->id + ); + } + + /** + * Fatura paga pela metade continua com valor a receber. + */ + public function testPartiallyPaidInvoiceCountsAsOpen(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [ + (object) ['id' => 'inv_parcial', 'status' => 'partially_paid', 'due_date' => '2026-08-01'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(Invoice::STATUS_PENDING, $subscription->latestInvoice->status); + $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + } + + /** + * Entrada sem id não vence a escolha, mesmo com vencimento maior. + */ + public function testRecentInvoiceWithoutIdDoesNotHijackTheChoice(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [ + (object) ['id' => 'inv_1', 'status' => 'pending', 'due_date' => '2026-08-01'], + (object) ['status' => 'pending', 'due_date' => '2026-08-02'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + } + /** + * Entrada sem vencimento perde para qualquer uma com data, em qualquer ordem. + * + * @dataProvider semDataProvider + */ + public function testEntryWithoutDueDateLosesToOneWithIt(array $recentInvoices): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['recent_invoices' => $recentInvoices]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + 'inv_com_data', + (new IuguGateway($api))->getSubscription($subscription)->latestInvoice->id + ); + } + + public static function semDataProvider(): array + { + $semData = (object) ['id' => 'inv_a_sem_data', 'status' => 'pending']; + $comData = (object) ['id' => 'inv_com_data', 'status' => 'pending', 'due_date' => '2026-08-01']; + + return [ + 'sem data primeiro' => [[$semData, $comData]], + 'com data primeiro' => [[$comData, $semData]], + ]; + } + + public function testEntryWithoutStatusIsNotOpen(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [(object) ['id' => 'inv_1', 'due_date' => '2026-08-01']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); + $this->assertNull($subscription->latestInvoice->status); + } + + public function testRecentInvoicesFromTheGatewayAreAcceptedAsAssociativeArrays(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [ + ['id' => 'inv_1', 'status' => 'pending', 'due_date' => '2026-08-01'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + } + + /** + * Resposta que traz `recent_invoices` sem entrada utilizável zera a fatura, em vez de manter + * a da leitura anterior, que pode nem estar mais na resposta. + */ + public function testAResponseListingNoUsableInvoiceClearsTheStoredOne(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'paid', 'due_date' => '2026-08-01']], + ]), + $this->subscriptionResponse([ + 'recent_invoices' => [(object) ['status' => 'pending', 'due_date' => '2026-09-01']], + ]), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + $this->assertSame('inv_1', $subscription->latestInvoice->id); + + $this->assertNull($gateway->getSubscription($subscription)->latestInvoice); + } +} + +/** + * Devolve uma resposta por chamada, na ordem, e guarda todas as chamadas feitas. + */ +class QueuedIuguApiRequest extends Iugu_APIRequest +{ + public array $calls = []; + + /** + * @param array $responses + */ + public function __construct(private array $responses) + { + } + + public function request($method, $url, $data = []) + { + $this->calls[] = ['method' => $method, 'url' => $url, 'data' => $data]; + + if (empty($this->responses)) { + throw new \RuntimeException("Sem resposta enfileirada para {$method} {$url}"); + } + + return array_shift($this->responses); + } +} diff --git a/tests/Unit/MultiPaymentGatewayRoutingTest.php b/tests/Unit/MultiPaymentGatewayRoutingTest.php index 55d843d..9e9938a 100644 --- a/tests/Unit/MultiPaymentGatewayRoutingTest.php +++ b/tests/Unit/MultiPaymentGatewayRoutingTest.php @@ -76,7 +76,6 @@ public function testDuplicateInvoiceUsesTheSelectedGatewayInsteadOfTheDefault(): $invoice = (new MultiPayment('stripe')) ->duplicateInvoice('pi_fake123', \Carbon\Carbon::now()->addDay()); - // antes do fix, o model resolveria o gateway default (iugu) $this->assertStringContainsString('api.stripe.com/v1/payment_intents/pi_fake123', $httpClient->calls[0][1]); $this->assertSame('pi_fake456', $invoice->id); $this->assertSame('stripe', $invoice->gateway); @@ -101,7 +100,6 @@ public function testSetDefaultCardUsesTheSelectedGatewayInsteadOfTheDefault(): v $customer = (new MultiPayment('stripe'))->setDefaultCard('cus_fake123', 'pm_fake123'); - // a chamada foi à API da Stripe — antes do fix, o model resolvia o gateway default (iugu) $this->assertCount(1, $httpClient->calls); $this->assertStringContainsString('api.stripe.com/v1/customers/cus_fake123', $httpClient->calls[0][1]); $this->assertSame('pm_fake123', $customer->defaultCard->id); diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php new file mode 100644 index 0000000..ae1822f --- /dev/null +++ b/tests/Unit/SubscriptionTest.php @@ -0,0 +1,619 @@ +fill([ + 'id' => 'sub_1', + 'plan_id' => 'plano_mensal', + 'customer' => ['name' => 'Fulano', 'email' => 'fulano@exemplo.com'], + 'items' => [['description' => 'Consultas', 'amount' => 1000, 'quantity' => 2]], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'cycles' => 1]], + 'next_billing_at' => '2026-10-01', + 'trial_ends_at' => '2026-09-15', + 'metadata' => ['origem' => 'teste'], + ]); + + $this->assertSame('plano_mensal', $subscription->planId); + $this->assertInstanceOf(Customer::class, $subscription->customer); + $this->assertSame('fulano@exemplo.com', $subscription->customer->email); + $this->assertInstanceOf(SubscriptionItem::class, $subscription->items[0]); + $this->assertSame(1000, $subscription->items[0]->amount); + $this->assertTrue($subscription->items[0]->recurring); + $this->assertInstanceOf(SubscriptionDiscount::class, $subscription->discounts[0]); + $this->assertSame(500, $subscription->discounts[0]->amountOff); + $this->assertInstanceOf(Carbon::class, $subscription->nextBillingAt); + $this->assertSame('2026-10-01', $subscription->nextBillingAt->format('Y-m-d')); + $this->assertSame('2026-09-15', $subscription->trialEndsAt->format('Y-m-d')); + $this->assertSame(['origem' => 'teste'], $subscription->metadata); + } + + public function testFillKeepsInstancesAlreadyBuilt(): void + { + $item = new SubscriptionItem(); + $item->description = 'Consultas'; + $item->amount = 100; + + $subscription = new Subscription(); + $subscription->fill(['items' => [$item]]); + + $this->assertSame($item, $subscription->items[0]); + } + + public function testFillWithoutItemsDoesNotClearTheExistingOnes(): void + { + $subscription = new Subscription(); + $subscription->items = [new SubscriptionItem()]; + + $subscription->fill(['status' => Subscription::STATUS_ACTIVE]); + + $this->assertCount(1, $subscription->items); + } + + public function testToArrayFlattensItemsDiscountsAndCustomer(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'customer' => ['name' => 'Fulano'], + 'items' => [['description' => 'Consultas', 'amount' => 1000]], + 'discounts' => [['description' => 'Promo', 'percent_off' => 10.5]], + ]); + + $array = $subscription->toArray(); + + $this->assertSame('Consultas', $array['items'][0]['description']); + $this->assertSame(10.5, $array['discounts'][0]['percent_off']); + $this->assertSame('Fulano', $array['customer']['name']); + } + + public function testSubscriptionRequiresCustomerAndPlan(): void + { + $subscription = new Subscription(); + $subscription->planId = 'plano'; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`customer` attribute is required/'); + + $subscription->validate(); + } + + public function testSubscriptionRequiresPlanId(): void + { + $subscription = new Subscription(); + $subscription->fill(['customer' => ['name' => 'Fulano']]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`planId` attribute is required/'); + + $subscription->validate(); + } + + public function testSubscriptionRejectsUnknownPaymentMethod(): void + { + $subscription = new Subscription(); + $subscription->fill(['customer' => ['name' => 'Fulano'], 'plan_id' => 'plano']); + $subscription->availablePaymentMethods = ['bitcoin']; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/availablePaymentMethods must be one of/'); + + $subscription->validate(); + } + + public function testSubscriptionPropagatesItemValidation(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'customer' => ['name' => 'Fulano'], + 'plan_id' => 'plano', + 'items' => [['description' => 'Desconto', 'amount' => -100]], + ]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/must not be negative/'); + + $subscription->validate(); + } + + public function testSubscriptionRejectsItemsOfTheWrongType(): void + { + $subscription = new Subscription(); + $subscription->fill(['customer' => ['name' => 'Fulano'], 'plan_id' => 'plano']); + $subscription->items = ['não é um item']; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/array of SubscriptionItem/'); + + $subscription->validate(); + } + + public function testValidSubscriptionPassesValidation(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'customer' => ['name' => 'Fulano'], + 'plan_id' => 'plano', + 'items' => [['description' => 'Consultas', 'amount' => 1000, 'quantity' => 1]], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500]], + 'available_payment_methods' => [Invoice::PAYMENT_METHOD_CREDIT_CARD, Invoice::PAYMENT_METHOD_PIX], + ]); + + $subscription->validate(); + + $this->assertSame('plano', $subscription->planId); + } + + public function testItemRejectsNegativeAmount(): void + { + $item = new SubscriptionItem(); + $item->description = 'Desconto'; + $item->amount = -100; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/Use SubscriptionDiscount/'); + + $item->validate(); + } + + public function testItemRequiresDescriptionAndAmount(): void + { + $item = new SubscriptionItem(); + $item->amount = 100; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`description` attribute is required/'); + + $item->validate(); + } + + /** + * Zero é vazio para o validate() do Model, então a regra de quantity mínima não pode + * depender de validateQuantityAttribute(). + */ + public function testItemRejectsZeroQuantity(): void + { + $item = new SubscriptionItem(); + $item->description = 'Consultas'; + $item->amount = 100; + $item->quantity = 0; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/quantity must be at least 1/'); + + $item->validate(); + } + + public function testItemAcceptsZeroAmount(): void + { + $item = new SubscriptionItem(); + $item->description = 'Cortesia'; + $item->amount = 0; + $item->quantity = 1; + + $item->validate(); + + $this->assertSame(0, $item->amount); + } + + public function testDiscountRequiresOneOfAmountOffOrPercentOff(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`amountOff or percentOff` attribute is required/'); + + $discount->validate(); + } + + public function testDiscountRejectsAmountOffAndPercentOffTogether(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->percentOff = 10; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/mutually exclusive/'); + + $discount->validate(); + } + + public function testDiscountRejectsNegativeAmountOff(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = -500; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/positive amount in cents/'); + + $discount->validate(); + } + + public function testDiscountRejectsPercentOffAboveOneHundred(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->percentOff = 101; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/at most 100/'); + + $discount->validate(); + } + + public function testDiscountRejectsZeroCycles(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->cycles = 0; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/cycles must be null or at least 1/'); + + $discount->validate(); + } + + public function testPlanRejectsUnknownInterval(): void + { + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + $plan->interval = 'day'; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/interval must be one of/'); + + $plan->validate(); + } + + public function testPlanRequiresNameAmountAndInterval(): void + { + $plan = new Plan(); + $plan->amount = 10000; + $plan->interval = Plan::INTERVAL_MONTH; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`name` attribute is required/'); + + $plan->validate(); + } + + public function testValidPlanPassesValidation(): void + { + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + $plan->interval = Plan::INTERVAL_MONTH; + $plan->intervalCount = 1; + + $plan->validate(); + + $this->assertSame(Plan::INTERVAL_MONTH, $plan->interval); + } + + /** + * As linhas de uma simulação são linhas de fatura: crédito de período não usado vem com + * valor negativo. + */ + public function testPlanChangeParsesInvoiceLinesAndDate(): void + { + $planChange = new SubscriptionPlanChange(); + $planChange->fill([ + 'amount' => 50000, + 'items' => [['description' => 'Tempo não utilizado', 'price' => -10000, 'quantity' => 1]], + 'effective_at' => '2026-10-01', + ]); + + $this->assertInstanceOf(InvoiceItem::class, $planChange->items[0]); + $this->assertSame(-10000, $planChange->items[0]->price); + $this->assertSame('2026-10-01', $planChange->effectiveAt->format('Y-m-d')); + } + + public function testBuilderAssemblesTheSubscription(): void + { + $gateway = Mockery::mock(GatewayContract::class); + + $subscription = (new SubscriptionBuilder($gateway)) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->addItem('Consultas', 1000, 2) + ->addItem('Setup', 500, 1, false) + ->addAmountDiscount('Promo', 300, 1) + ->addPercentDiscount('Anual', 10.0) + ->setNextBillingAt('2026-10-01') + ->setMetadata(['origem' => 'teste']) + ->get(); + + $this->assertSame('plano_mensal', $subscription->planId); + $this->assertSame('cus_1', $subscription->customer->id); + $this->assertCount(2, $subscription->items); + $this->assertTrue($subscription->items[0]->recurring); + $this->assertFalse($subscription->items[1]->recurring); + $this->assertCount(2, $subscription->discounts); + $this->assertSame(300, $subscription->discounts[0]->amountOff); + $this->assertSame(1, $subscription->discounts[0]->cycles); + $this->assertSame(10.0, $subscription->discounts[1]->percentOff); + $this->assertNull($subscription->discounts[1]->cycles); + $this->assertSame('2026-10-01', $subscription->nextBillingAt->format('Y-m-d')); + $this->assertSame(['origem' => 'teste'], $subscription->metadata); + } + + public function testBuilderCreateDelegatesToTheGateway(): void + { + $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway->shouldReceive('createSubscription') + ->once() + ->andReturnUsing(function (Subscription $subscription) { + $subscription->id = 'sub_criada'; + + return $subscription; + }); + + $subscription = (new SubscriptionBuilder($gateway)) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->create(); + + $this->assertSame('sub_criada', $subscription->id); + } + + public function testFillAndToArrayHandleTheLatestInvoice(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'latest_invoice' => ['id' => 'inv_1', 'status' => Invoice::STATUS_PENDING], + ]); + + $this->assertInstanceOf(Invoice::class, $subscription->latestInvoice); + $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertSame('inv_1', $subscription->toArray()['latest_invoice']['id']); + } + + /** + * @dataProvider lifecycleProvider + */ + public function testModelDelegatesLifecycleToTheGateway( + string $gatewayMethod, + array $gatewayArgs, + string $modelMethod, + array $modelArgs + ): void { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway->shouldReceive($gatewayMethod) + ->once() + ->with($subscription, ...$gatewayArgs) + ->andReturn($subscription); + + $this->assertSame( + $subscription, + $subscription->{$modelMethod}(...array_merge($modelArgs, [$gateway])) + ); + } + + public static function lifecycleProvider(): array + { + return [ + 'suspend' => ['suspendSubscription', [], 'suspend', []], + 'resume' => ['resumeSubscription', [], 'resume', []], + 'cancel imediato' => ['cancelSubscription', [false], 'cancel', [false]], + 'cancel ao fim do periodo' => ['cancelSubscription', [true], 'cancel', [true]], + 'changePlan cobrando' => ['changeSubscriptionPlan', ['plano_anual', true], 'changePlan', ['plano_anual', true]], + 'changePlan sem cobrar' => ['changeSubscriptionPlan', ['plano_anual', false], 'changePlan', ['plano_anual', false]], + ]; + } + + public function testPreviewPlanChangeDelegatesToTheGateway(): void + { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $planChange = new SubscriptionPlanChange(); + + $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway->shouldReceive('previewSubscriptionPlanChange') + ->once() + ->with($subscription, 'plano_anual') + ->andReturn($planChange); + + $this->assertSame($planChange, $subscription->previewPlanChange('plano_anual', $gateway)); + } + + public function testCreateSavesTheCustomerBeforeTheSubscription(): void + { + $ordem = []; + + $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway->shouldReceive('createCustomer') + ->once() + ->andReturnUsing(function (Customer $customer) use (&$ordem) { + $ordem[] = 'customer'; + $customer->id = 'cus_novo'; + + return $customer; + }); + $gateway->shouldReceive('createSubscription') + ->once() + ->andReturnUsing(function (Subscription $subscription) use (&$ordem) { + $ordem[] = 'subscription'; + $subscription->id = 'sub_1'; + + return $subscription; + }); + + $customer = new Customer(); + $customer->name = 'Fulano'; + $customer->email = 'fulano@exemplo.com'; + + $subscription = (new SubscriptionBuilder($gateway)) + ->setPlanId('plano_mensal') + ->setCustomer($customer) + ->create(); + + $this->assertSame(['customer', 'subscription'], $ordem); + $this->assertSame('cus_novo', $subscription->customer->id); + } + + public function testSetItemsAndSetDiscountsReplaceInsteadOfAppending(): void + { + $gateway = Mockery::mock(GatewayContract::class); + + $item = new SubscriptionItem(); + $item->description = 'Único'; + $item->amount = 100; + + $subscription = (new SubscriptionBuilder($gateway)) + ->addItem('Descartado', 999, 1) + ->setItems([$item]) + ->addAmountDiscount('Descartado', 999) + ->setDiscounts([]) + ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]) + ->setTrialEndsAt('2026-09-15') + ->get(); + + $this->assertSame([$item], $subscription->items); + $this->assertSame([], $subscription->discounts); + $this->assertSame([Invoice::PAYMENT_METHOD_PIX], $subscription->availablePaymentMethods); + $this->assertSame('2026-09-15', $subscription->trialEndsAt->format('Y-m-d')); + } + + /** + * Com id preenchido o save() faz update, que aceita atributo parcial. + */ + public function testUpdateDoesNotRequireCustomerOrPlanId(): void + { + $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway->shouldReceive('updateSubscription')->once()->andReturnUsing(fn($s) => $s); + $gateway->shouldNotReceive('createCustomer'); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'items' => [['description' => 'Consultas', 'amount' => 100, 'quantity' => 1]], + ]); + + $subscription->save($gateway); + + $this->assertSame('sub_1', $subscription->id); + } + + /** + * Método de domínio recusa gateway sem SubscriptionContract com GatewayException, e não com + * Error do PHP. + */ + public function testDomainMethodsRejectAGatewayWithoutTheSubscriptionContract(): void + { + $gateway = Mockery::mock(GatewayContract::class); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/does not implement SubscriptionContract/'); + + $subscription->suspend($gateway); + } + + /** + * O update dispensa cliente e plano, mas não as demais validações: item de valor negativo + * seria relido como desconto. + */ + public function testUpdateStillValidatesItemsAndPaymentMethods(): void + { + $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway->shouldNotReceive('updateSubscription'); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'items' => [['description' => 'X', 'amount' => -500, 'quantity' => 1]], + ]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/must not be negative/'); + + $subscription->save($gateway); + } + + public function testUpdateStillValidatesAvailablePaymentMethods(): void + { + $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway->shouldNotReceive('updateSubscription'); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->availablePaymentMethods = ['bitcoin']; + + $this->expectException(ModelAttributeValidationException::class); + + $subscription->save($gateway); + } + + public function testPlanWithIdCannotBeSavedAgain(): void + { + $plan = new Plan(); + $plan->id = 'plan_1'; + $plan->name = 'Mensal'; + $plan->amount = 10000; + $plan->interval = Plan::INTERVAL_MONTH; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/cannot be updated/'); + + $plan->save(Mockery::mock(GatewayContract::class)); + } + + /** + * @dataProvider listOperationsProvider + */ + public function testListOperationsRejectAGatewayWithoutTheContract(string $metodo, array $args, string $contract): void + { + $multiPayment = new \Potelo\MultiPayment\MultiPayment(Mockery::mock(GatewayContract::class)); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches("/does not implement {$contract}/"); + + $multiPayment->{$metodo}(...$args); + } + + public static function listOperationsProvider(): array + { + return [ + 'assinaturas' => ['listSubscriptions', ['cus_1'], 'SubscriptionContract'], + 'planos' => ['listPlans', [], 'PlanContract'], + ]; + } +} From 4b6dc62bcf238f8a350b684aafbf5bde4746343d Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Tue, 1 Sep 2026 22:15:08 -0300 Subject: [PATCH 10/32] =?UTF-8?q?build:=20sobe=20o=20m=C3=ADnimo=20de=20PH?= =?UTF-8?q?P=20para=208.3=20e=20atualiza=20depend=C3=AAncias=20e=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - composer.json: php ^8.3, illuminate ^10|^11|^12, stripe-php ^21.3, phpunit ^12.0, orchestra/testbench ^10 (lock resolvido em PHP 8.3) - phpunit.xml.dist migrado para o schema 12.5, com failOnWarning e failOnNotice preservando o comportamento do PHPUnit 9 - testes: @dataProvider e @group viram atributos, data providers estáticos, TestCase sem construtor (final no PHPUnit 12) e sem o construtor legado que bootava a aplicação na carga da suíte - models: parâmetro $gateway com nullable explícito (deprecação do PHP 8.4) - Dockerfile com ARG PHP_VERSION e workflow em matriz 8.3 e 8.4 - README: requisitos PHP 8.3+ e Laravel 10+ --- .github/workflows/test.yml | 18 +- Dockerfile | 6 +- README.md | 4 +- composer.json | 12 +- composer.lock | 3951 +++++++++++------ phpunit.xml.dist | 25 +- src/Models/Customer.php | 6 +- src/Models/Invoice.php | 2 +- src/Models/Model.php | 9 +- src/Models/Plan.php | 2 +- src/Models/Subscription.php | 2 +- .../Builders/CreditCardBuilderTest.php | 18 +- .../Builders/CustomerBuilderTest.php | 9 +- .../Builders/InvoiceBuilderTest.php | 15 +- tests/Integration/MultiPaymentTest.php | 35 +- tests/Integration/StripeGatewayTest.php | 31 +- tests/TestCase.php | 11 +- .../Gateways/IuguGatewaySubscriptionTest.php | 33 +- .../Gateways/StripeGatewayInvoiceTest.php | 5 +- tests/Unit/SubscriptionTest.php | 9 +- 20 files changed, 2619 insertions(+), 1584 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 255c642..976cee6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,15 @@ on: jobs: phpunit-tests: + name: PHP ${{ matrix.php }} runs-on: ubuntu-latest + strategy: + fail-fast: false + # Os jobs rodam um de cada vez: a suíte Integration bate na sandbox da Iugu, + # que tem rate limit por conta, e mais de um job em paralelo estouraria esse limite. + max-parallel: 1 + matrix: + php: ['8.3', '8.4'] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -40,12 +48,12 @@ jobs: with: context: . load: true - tags: multi-payment:latest - # Define o diretório de cache do composer dentro do build + tags: multi-payment:${{ matrix.php }} build-args: | + PHP_VERSION=${{ matrix.php }} COMPOSER_CACHE_DIR=/tmp/composer-cache - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=php-${{ matrix.php }} + cache-to: type=gha,mode=max,scope=php-${{ matrix.php }} - name: Execute tests via PHPUnit env: @@ -61,4 +69,4 @@ jobs: --env IUGU_ID \ --env IUGU_APIKEY \ --env STRIPE_APIKEY \ - multi-payment:latest composer test + multi-payment:${{ matrix.php }} composer test diff --git a/Dockerfile b/Dockerfile index 86ae292..43f774e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ -FROM php:8.3.0-cli +# Versão do PHP da imagem; a CI sobrescreve via build-arg para cada entrada da matriz. +ARG PHP_VERSION=8.3 +FROM php:${PHP_VERSION}-cli RUN apt-get update && \ apt-get install -y --no-install-recommends \ @@ -27,4 +29,4 @@ RUN mkdir -p /var/www/.composer && chown -R www-data:www-data /var/www/.composer USER www-data -CMD ["composer", "test"] \ No newline at end of file +CMD ["composer", "test"] diff --git a/README.md b/README.md index f7a3998..f24398f 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [Plan](#plan) ## Requisitos - - PHP 8.0+ - - Laravel 8.0+ + - PHP 8.3+ + - Laravel 10.0+ ## Instalação diff --git a/composer.json b/composer.json index 039e425..c09403a 100644 --- a/composer.json +++ b/composer.json @@ -43,14 +43,14 @@ } ], "require": { - "php": "^8.0|^8.1|^8.2|^8.3", - "illuminate/config": "^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^8.3", + "illuminate/config": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", "iugu/iugu": "dev-master", - "stripe/stripe-php": "^21.2" + "stripe/stripe-php": "^21.3" }, "require-dev": { - "phpunit/phpunit": "^9.5", - "orchestra/testbench": "~8.0" + "phpunit/phpunit": "^12.0", + "orchestra/testbench": "^10.0" } } diff --git a/composer.lock b/composer.lock index 0d17e7d..fbd26af 100644 --- a/composer.lock +++ b/composer.lock @@ -4,29 +4,29 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "47b0f53e9707493e12692a7257266e54", + "content-hash": "7055c2a35e1cca2dcb66b2d18906cbad", "packages": [ { "name": "brick/math", - "version": "0.12.3", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.1", - "vimeo/psalm": "6.8.8" + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.3" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -64,30 +64,30 @@ "type": "github" } ], - "time": "2025-02-28T13:11:00+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", - "version": "2.1.0", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", - "reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb" + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/99f76ffa36cce3b70a4a6abce41dba15ca2e84cb", - "reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "php": "^8.1" }, "conflict": { - "doctrine/dbal": "<3.7.0 || >=4.0.0" + "doctrine/dbal": "<4.0.0 || >=5.0.0" }, "require-dev": { - "doctrine/dbal": "^3.7.0", + "doctrine/dbal": "^4.0.0", "nesbot/carbon": "^2.71.0 || ^3.0.0", "phpunit/phpunit": "^10.3" }, @@ -117,7 +117,7 @@ ], "support": { "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", - "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/2.1.0" + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" }, "funding": [ { @@ -133,11 +133,11 @@ "type": "tidelift" } ], - "time": "2023-12-11T17:09:12+00:00" + "time": "2024-02-09T16:56:22+00:00" }, { "name": "dflydev/dot-access-data", - "version": "dev-main", + "version": "v3.0.3", "source": { "type": "git", "url": "https://github.com/dflydev/dflydev-dot-access-data.git", @@ -159,7 +159,6 @@ "squizlabs/php_codesniffer": "^3.5", "vimeo/psalm": "^4.0.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -213,27 +212,27 @@ }, { "name": "doctrine/inflector", - "version": "2.1.x-dev", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "02ab81d3a147210a2bb1ceaccf014aee099f54a9" + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/02ab81d3a147210a2bb1ceaccf014aee099f54a9", - "reference": "02ab81d3a147210a2bb1ceaccf014aee099f54a9", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^11.0", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^8.5 || ^9.5" + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" }, "type": "library", "autoload": { @@ -283,7 +282,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.1.x" + "source": "https://github.com/doctrine/inflector/tree/2.1.0" }, "funding": [ { @@ -299,20 +298,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T06:44:27+00:00" + "time": "2025-08-10T19:31:58+00:00" }, { "name": "doctrine/lexer", - "version": "3.1.x-dev", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "042e47e28c5e03f1cf6772fdf3dd4e0785433e05" + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/042e47e28c5e03f1cf6772fdf3dd4e0785433e05", - "reference": "042e47e28c5e03f1cf6772fdf3dd4e0785433e05", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", "shasum": "" }, "require": { @@ -360,7 +359,7 @@ ], "support": { "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/3.1.x" + "source": "https://github.com/doctrine/lexer/tree/3.0.1" }, "funding": [ { @@ -376,34 +375,33 @@ "type": "tidelift" } ], - "time": "2024-07-29T08:29:21+00:00" + "time": "2024-02-05T11:56:58+00:00" }, { "name": "dragonmantank/cron-expression", - "version": "dev-master", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "f37e405332cd1cca9d287f5044ae2a72c2f53239" + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/f37e405332cd1cca9d287f5044ae2a72c2f53239", - "reference": "f37e405332cd1cca9d287f5044ae2a72c2f53239", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^8.2|^8.3|^8.4|^8.5" }, "replace": { "mtdowling/cron-expression": "^1.0" }, "require-dev": { - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^7.0|^8.0|^9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -433,7 +431,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/master" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" }, "funding": [ { @@ -441,11 +439,11 @@ "type": "github" } ], - "time": "2024-10-21T12:57:55+00:00" + "time": "2025-10-31T18:51:33+00:00" }, { "name": "egulias/email-validator", - "version": "4.x-dev", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/egulias/EmailValidator.git", @@ -469,7 +467,6 @@ "suggest": { "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -513,28 +510,27 @@ }, { "name": "fruitcake/php-cors", - "version": "dev-master", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/fruitcake/php-cors.git", - "reference": "0eaf5f588eb34f15a6eca4fd8e62a35cbee43f64" + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/0eaf5f588eb34f15a6eca4fd8e62a35cbee43f64", - "reference": "0eaf5f588eb34f15a6eca4fd8e62a35cbee43f64", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", "shasum": "" }, "require": { - "php": "^7.4|^8.0", - "symfony/http-foundation": "^4.4|^5.4|^6|^7" + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" }, "require-dev": { "phpstan/phpstan": "^2", "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^3.5" + "squizlabs/php_codesniffer": "^4" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -569,7 +565,7 @@ ], "support": { "issues": "https://github.com/fruitcake/php-cors/issues", - "source": "https://github.com/fruitcake/php-cors/tree/master" + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" }, "funding": [ { @@ -581,30 +577,29 @@ "type": "github" } ], - "time": "2025-03-13T13:43:06+00:00" + "time": "2025-12-03T09:33:47+00:00" }, { "name": "graham-campbell/result-type", - "version": "1.1.x-dev", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "bdd52c41913b414f4ca7dcb34482babcd0e9bd58" + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/bdd52c41913b414f4ca7dcb34482babcd0e9bd58", - "reference": "bdd52c41913b414f4ca7dcb34482babcd0e9bd58", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3" + "phpoption/phpoption": "^1.10" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -632,7 +627,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/1.1" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" }, "funding": [ { @@ -644,32 +639,362 @@ "type": "tidelift" } ], - "time": "2025-03-02T21:31:24+00:00" + "time": "2026-08-24T09:06:52+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.15.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:21:06+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:11:28+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.13.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "95e7828100de18b4e269fb1703be530082d5166d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.13.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:13:11+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "1.0.x-dev", + "version": "v1.0.11", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "30e286560c137526eccd4ce21b2de477ab0676d2" + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/30e286560c137526eccd4ce21b2de477ab0676d2", - "reference": "30e286560c137526eccd4ce21b2de477ab0676d2", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d0058dccf4299d70c3d9da3378b8908b32780368", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "uri-template/tests": "1.0.0" }, - "default-branch": true, "type": "library", "extra": { "bamarni-bin": { @@ -715,7 +1040,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.4" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.11" }, "funding": [ { @@ -731,7 +1056,7 @@ "type": "tidelift" } ], - "time": "2025-02-03T10:55:03+00:00" + "time": "2026-08-24T09:15:32+00:00" }, { "name": "iugu/iugu", @@ -783,23 +1108,23 @@ }, { "name": "laravel/framework", - "version": "10.x-dev", + "version": "v12.69.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "37455bbd9ece2ab48443b4ad2af85abf2140e326" + "reference": "0c07b0b1f88af44d8558ffadf66900a860f93c23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/37455bbd9ece2ab48443b4ad2af85abf2140e326", - "reference": "37455bbd9ece2ab48443b4ad2af85abf2140e326", + "url": "https://api.github.com/repos/laravel/framework/zipball/0c07b0b1f88af44d8558ffadf66900a860f93c23", + "reference": "0c07b0b1f88af44d8558ffadf66900a860f93c23", "shasum": "" }, "require": { - "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12", + "brick/math": "^0.11|^0.12|^0.13|^0.14", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", - "dragonmantank/cron-expression": "^3.3.2", + "dragonmantank/cron-expression": "^3.4", "egulias/email-validator": "^3.2.1|^4.0", "ext-ctype": "*", "ext-filter": "*", @@ -808,44 +1133,47 @@ "ext-openssl": "*", "ext-session": "*", "ext-tokenizer": "*", - "fruitcake/php-cors": "^1.2", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", "guzzlehttp/uri-template": "^1.0", - "laravel/prompts": "^0.1.9", - "laravel/serializable-closure": "^1.3", - "league/commonmark": "^2.2.1", - "league/flysystem": "^3.8.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", "monolog/monolog": "^3.0", - "nesbot/carbon": "^2.67", - "nunomaduro/termwind": "^1.13", - "php": "^8.1", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", "psr/container": "^1.1.1|^2.0.1", "psr/log": "^1.0|^2.0|^3.0", "psr/simple-cache": "^1.0|^2.0|^3.0", "ramsey/uuid": "^4.7", - "symfony/console": "^6.2", - "symfony/error-handler": "^6.2", - "symfony/finder": "^6.2", - "symfony/http-foundation": "^6.4", - "symfony/http-kernel": "^6.2", - "symfony/mailer": "^6.2", - "symfony/mime": "^6.2", - "symfony/process": "^6.2", - "symfony/routing": "^6.2", - "symfony/uid": "^6.2", - "symfony/var-dumper": "^6.2", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", "tijsverkoyen/css-to-inline-styles": "^2.2.5", - "vlucas/phpdotenv": "^5.4.1", - "voku/portable-ascii": "^2.0" + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" }, "conflict": { - "carbonphp/carbon-doctrine-types": ">=3.0", - "doctrine/dbal": ">=4.0", - "mockery/mockery": "1.6.8", - "phpunit/phpunit": ">=11.0.0", "tightenco/collect": "<5.5.33" }, "provide": { "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", "psr/simple-cache-implementation": "1.0|2.0|3.0" }, "replace": { @@ -854,6 +1182,7 @@ "illuminate/bus": "self.version", "illuminate/cache": "self.version", "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", "illuminate/conditionable": "self.version", "illuminate/config": "self.version", "illuminate/console": "self.version", @@ -866,6 +1195,7 @@ "illuminate/filesystem": "self.version", "illuminate/hashing": "self.version", "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", "illuminate/log": "self.version", "illuminate/macroable": "self.version", "illuminate/mail": "self.version", @@ -875,42 +1205,47 @@ "illuminate/process": "self.version", "illuminate/queue": "self.version", "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", "illuminate/routing": "self.version", "illuminate/session": "self.version", "illuminate/support": "self.version", "illuminate/testing": "self.version", "illuminate/translation": "self.version", "illuminate/validation": "self.version", - "illuminate/view": "self.version" + "illuminate/view": "self.version", + "spatie/once": "*" }, "require-dev": { "ably/ably-php": "^1.0", - "aws/aws-sdk-php": "^3.235.5", - "doctrine/dbal": "^3.5.1", + "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", - "fakerphp/faker": "^1.21", - "guzzlehttp/guzzle": "^7.5", - "league/flysystem-aws-s3-v3": "^3.0", - "league/flysystem-ftp": "^3.0", - "league/flysystem-path-prefixing": "^3.3", - "league/flysystem-read-only": "^3.3", - "league/flysystem-sftp-v3": "^3.0", - "mockery/mockery": "^1.5.1", - "nyholm/psr7": "^1.2", - "orchestra/testbench-core": "^8.23.4", - "pda/pheanstalk": "^4.0", - "phpstan/phpstan": "~1.11.11", - "phpunit/phpunit": "^10.0.7", - "predis/predis": "^2.0.2", - "symfony/cache": "^6.2", - "symfony/http-client": "^6.2.4", - "symfony/psr-http-message-bridge": "^2.0" + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.9.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.1.41", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", - "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.235.5).", - "brianium/paratest": "Required to run tests in parallel (^6.0).", - "doctrine/dbal": "Required to rename columns and drop SQLite columns (^3.5.1).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", "ext-apcu": "Required to use the APC cache driver.", "ext-fileinfo": "Required to use the Filesystem class.", "ext-ftp": "Required to use the Flysystem FTP driver.", @@ -919,42 +1254,46 @@ "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", "ext-pdo": "Required to use all database features.", "ext-posix": "Required to use all features of the queue worker.", - "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0).", - "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", - "guzzlehttp/guzzle": "Required to use the HTTP Client and the ping methods on schedules (^7.5).", "laravel/tinker": "Required to use the tinker console command (^2.0).", - "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.0).", - "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.0).", - "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.3).", - "league/flysystem-read-only": "Required to use read-only disks (^3.3)", - "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.0).", - "mockery/mockery": "Required to use mocking (^1.5.1).", - "nyholm/psr7": "Required to use PSR-7 bridging features (^1.2).", - "pda/pheanstalk": "Required to use the beanstalk queue driver (^4.0).", - "phpunit/phpunit": "Required to use assertions and run tests (^9.5.8|^10.0.7).", - "predis/predis": "Required to use the predis connector (^2.0.2).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^6.2).", - "symfony/filesystem": "Required to enable support for relative symbolic links (^6.2).", - "symfony/http-client": "Required to enable support for the Symfony API mail transports (^6.2).", - "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^6.2).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^6.2).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^2.0)." + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "10.x-dev" + "dev-master": "12.x-dev" } }, "autoload": { "files": [ + "src/Illuminate/Collections/functions.php", "src/Illuminate/Collections/helpers.php", "src/Illuminate/Events/functions.php", "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", "src/Illuminate/Support/helpers.php" ], "psr-4": { @@ -962,7 +1301,8 @@ "Illuminate\\Support\\": [ "src/Illuminate/Macroable/", "src/Illuminate/Collections/", - "src/Illuminate/Conditionable/" + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" ] } }, @@ -986,37 +1326,38 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-03-24T11:51:20+00:00" + "time": "2026-09-01T21:34:37+00:00" }, { "name": "laravel/prompts", - "version": "v0.1.25", + "version": "v0.3.24", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95" + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/7b4029a84c37cb2725fc7f011586e2997040bc95", - "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", "shasum": "" }, "require": { + "composer-runtime-api": "^2.2", "ext-mbstring": "*", - "illuminate/collections": "^10.0|^11.0", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", - "pestphp/pest": "^2.3", - "phpstan/phpstan": "^1.11", - "phpstan/phpstan-mockery": "^1.1" + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" }, "suggest": { "ext-pcntl": "Required for the spinner to be animated." @@ -1024,7 +1365,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "0.1.x-dev" + "dev-main": "0.3.x-dev" } }, "autoload": { @@ -1042,38 +1383,38 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.1.25" + "source": "https://github.com/laravel/prompts/tree/v0.3.24" }, - "time": "2024-08-12T22:06:33+00:00" + "time": "2026-08-20T12:55:36+00:00" }, { "name": "laravel/serializable-closure", - "version": "1.x-dev", + "version": "v2.0.16", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "8be68b9a31863c40ccccac14f7e885e274b8ea62" + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8be68b9a31863c40ccccac14f7e885e274b8ea62", - "reference": "8be68b9a31863c40ccccac14f7e885e274b8ea62", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", "shasum": "" }, "require": { - "php": "^7.3|^8.0" + "php": "^8.1" }, "require-dev": { - "illuminate/support": "^8.0|^9.0|^10.0|^11.0", - "nesbot/carbon": "^2.61|^3.0", - "pestphp/pest": "^1.21.3", - "phpstan/phpstan": "^1.8.2", - "symfony/var-dumper": "^5.4.11|^6.2.0|^7.0.0" + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { @@ -1105,20 +1446,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2024-11-19T20:48:19+00:00" + "time": "2026-08-18T20:28:54+00:00" }, { "name": "league/commonmark", - "version": "dev-main", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405" + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", - "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4", "shasum": "" }, "require": { @@ -1140,14 +1481,14 @@ "github/gfm": "0.29.0", "michelf/php-markdown": "^1.4 || ^2.0", "nyholm/psr7": "^1.5", - "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", - "vimeo/psalm": "^4.24.0 || ^5.0.0" + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, "suggest": { "symfony/yaml": "v2.3+ required if using the Front Matter extension" @@ -1155,7 +1496,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.8-dev" + "dev-main": "2.11-dev" } }, "autoload": { @@ -1212,20 +1553,20 @@ "type": "tidelift" } ], - "time": "2025-05-05T12:20:28+00:00" + "time": "2026-08-11T16:06:25+00:00" }, { "name": "league/config", - "version": "dev-main", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/thephpleague/config.git", - "reference": "708b87250055f20a21cc6dbe232369b79a71a4fa" + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/config/zipball/708b87250055f20a21cc6dbe232369b79a71a4fa", - "reference": "708b87250055f20a21cc6dbe232369b79a71a4fa", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", "shasum": "" }, "require": { @@ -1235,12 +1576,11 @@ }, "require-dev": { "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.5 || ^10.0.0 || ^11.0.0", + "phpunit/phpunit": "^9.5.5", "scrutinizer/ocular": "^1.8.1", "unleashedtech/php-coding-standard": "^3.1", "vimeo/psalm": "^4.7.3" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -1295,20 +1635,20 @@ "type": "github" } ], - "time": "2024-09-23T00:17:42+00:00" + "time": "2022-12-11T20:36:23+00:00" }, { "name": "league/flysystem", - "version": "3.x-dev", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "2203e3151755d874bb2943649dae1eb8533ac93e" + "reference": "5fc8404762179ae514678487b23494fd69b2309c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e", - "reference": "2203e3151755d874bb2943649dae1eb8533ac93e", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5fc8404762179ae514678487b23494fd69b2309c", + "reference": "5fc8404762179ae514678487b23494fd69b2309c", "shasum": "" }, "require": { @@ -1344,7 +1684,6 @@ "phpunit/phpunit": "^9.5.11|^10.0", "sabre/dav": "^4.6.0" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -1377,22 +1716,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.30.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.3" }, - "time": "2025-06-25T13:29:59+00:00" + "time": "2026-08-22T12:55:54+00:00" }, { "name": "league/flysystem-local", - "version": "3.x-dev", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", - "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", "shasum": "" }, "require": { @@ -1401,7 +1740,6 @@ "league/mime-type-detection": "^1.0.0", "php": "^8.0.2" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -1427,22 +1765,22 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" }, - "time": "2025-05-21T10:34:19+00:00" + "time": "2026-08-12T13:29:21+00:00" }, { "name": "league/mime-type-detection", - "version": "1.16.0", + "version": "1.17.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { @@ -1452,7 +1790,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { @@ -1473,7 +1811,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, "funding": [ { @@ -1485,20 +1823,202 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" }, { "name": "monolog/monolog", - "version": "dev-main", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "2e97231b969e0ffdeff03329b808945b4ba55e38" + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/2e97231b969e0ffdeff03329b808945b4ba55e38", - "reference": "2e97231b969e0ffdeff03329b808945b4ba55e38", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { @@ -1516,7 +2036,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -1545,7 +2065,6 @@ "rollbar/rollbar": "Allow sending log messages to Rollbar", "ruflin/elastica": "Allow sending log messages to an Elastic Search server" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -1577,7 +2096,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/main" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -1589,46 +2108,44 @@ "type": "tidelift" } ], - "time": "2025-04-03T11:44:45+00:00" + "time": "2026-01-02T08:56:05+00:00" }, { "name": "nesbot/carbon", - "version": "2.x-dev", + "version": "3.13.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "52474f548a433965fe30ff91bc2c641cdda75885" + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/52474f548a433965fe30ff91bc2c641cdda75885", - "reference": "52474f548a433965fe30ff91bc2c641cdda75885", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", "shasum": "" }, "require": { - "carbonphp/carbon-doctrine-types": "*", + "carbonphp/carbon-doctrine-types": "<100.0", "ext-json": "*", - "php": "^7.1.8 || ^8.0", + "php": "^8.1", "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php80": "^1.16", - "symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.0" + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" }, "provide": { "psr/clock-implementation": "1.0" }, "require-dev": { - "doctrine/dbal": "^2.0 || ^3.1.4 || ^4.0", - "doctrine/orm": "^2.7 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.0", - "kylekatarnls/multi-tester": "^2.0", - "ondrejmirtes/better-reflection": "<6", - "phpmd/phpmd": "^2.9", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.99 || ^1.7.14", - "phpunit/php-file-iterator": "^2.0.5 || ^3.0.6", - "phpunit/phpunit": "^7.5.20 || ^8.5.26 || ^9.5.20", - "squizlabs/php_codesniffer": "^3.4" + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" }, "bin": [ "bin/carbon" @@ -1671,16 +2188,16 @@ } ], "description": "An API extension for DateTime that supports 281 different languages.", - "homepage": "https://carbon.nesbot.com", + "homepage": "https://carbonphp.github.io/carbon/", "keywords": [ "date", "datetime", "time" ], "support": { - "docs": "https://carbon.nesbot.com/docs", - "issues": "https://github.com/briannesbitt/Carbon/issues", - "source": "https://github.com/briannesbitt/Carbon" + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" }, "funding": [ { @@ -1696,29 +2213,31 @@ "type": "tidelift" } ], - "time": "2025-04-07T07:51:39+00:00" + "time": "2026-08-08T11:40:35+00:00" }, { "name": "nette/schema", - "version": "v1.3.x-dev", + "version": "v1.3.6", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2c2f5e46be6f655f63a3bfbb1f91f867d6194bc6" + "reference": "c54350438cd6914616f790a49cb424605f421562" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2c2f5e46be6f655f63a3bfbb1f91f867d6194bc6", - "reference": "2c2f5e46be6f655f63a3bfbb1f91f867d6194bc6", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", "shasum": "" }, "require": { "nette/utils": "^4.0", - "php": "8.1 - 8.4" + "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -1759,26 +2278,26 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3" + "source": "https://github.com/nette/schema/tree/v1.3.6" }, - "time": "2025-06-19T17:50:07+00:00" + "time": "2026-08-16T21:58:41+00:00" }, { "name": "nette/utils", - "version": "dev-master", + "version": "v4.1.5", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "5ab770f2f467ef1081b40d21c3647a724b7809d7" + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/5ab770f2f467ef1081b40d21c3647a724b7809d7", - "reference": "5ab770f2f467ef1081b40d21c3647a724b7809d7", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", "shasum": "" }, "require": { - "php": "8.1 - 8.4" + "php": "8.2 - 8.5" }, "conflict": { "nette/finder": "<3", @@ -1786,19 +2305,20 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { "ext-gd": "to use Image", - "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", "ext-json": "to use Nette\\Utils\\Json", "ext-mbstring": "to use Strings::lower() etc...", "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -1849,38 +2369,37 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/master" + "source": "https://github.com/nette/utils/tree/v4.1.5" }, - "time": "2025-06-19T18:59:42+00:00" + "time": "2026-07-17T23:02:45+00:00" }, { "name": "nunomaduro/termwind", - "version": "1.x-dev", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/5369ef84d8142c1d87e4ec278711d4ece3cbf301", - "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": "^8.1", - "symfony/console": "^6.4.15" + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^10.48.24", - "illuminate/support": "^10.48.24", - "laravel/pint": "^1.18.2", - "pestphp/pest": "^2.36.0", - "pestphp/pest-plugin-mock": "2.0.0", - "phpstan/phpstan": "^1.12.11", - "phpstan/phpstan-strict-rules": "^1.6.1", - "symfony/var-dumper": "^6.4.15", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -1889,6 +2408,9 @@ "providers": [ "Termwind\\Laravel\\TermwindServiceProvider" ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -1909,7 +2431,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -1920,7 +2442,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/1.x" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -1936,20 +2458,20 @@ "type": "github" } ], - "time": "2024-11-21T10:36:35+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "phpoption/phpoption", - "version": "dev-master", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54" + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54", - "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", "shasum": "" }, "require": { @@ -1957,9 +2479,8 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" }, - "default-branch": true, "type": "library", "extra": { "bamarni-bin": { @@ -2000,7 +2521,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.3" + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" }, "funding": [ { @@ -2012,7 +2533,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:41:07+00:00" + "time": "2026-08-24T00:54:40+00:00" }, { "name": "psr/clock", @@ -2064,22 +2585,21 @@ }, { "name": "psr/container", - "version": "dev-master", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/container.git", - "reference": "707984727bd5b2b670e59559d3ed2500240cf875" + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/707984727bd5b2b670e59559d3ed2500240cf875", - "reference": "707984727bd5b2b670e59559d3ed2500240cf875", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { "php": ">=7.4.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -2112,31 +2632,27 @@ ], "support": { "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container" + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "time": "2023-09-22T11:11:30+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { "name": "psr/event-dispatcher", - "version": "dev-master", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "bbd9eacc080d33861e5b5c75b3b8c4d7e6d01874" + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/bbd9eacc080d33861e5b5c75b3b8c4d7e6d01874", - "reference": "bbd9eacc080d33861e5b5c75b3b8c4d7e6d01874", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", "shasum": "" }, "require": { "php": ">=7.2.0" }, - "suggest": { - "fig/event-dispatcher-util": "Provides some useful PSR-14 utilities" - }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -2155,7 +2671,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "homepage": "http://www.php-fig.org/" } ], "description": "Standard interfaces for event handling.", @@ -2165,13 +2681,174 @@ "psr-14" ], "support": { - "source": "https://github.com/php-fig/event-dispatcher" + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" }, - "time": "2024-03-17T21:29:03+00:00" + "time": "2023-04-04T09:54:51+00:00" }, { "name": "psr/log", - "version": "dev-master", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", @@ -2186,7 +2863,6 @@ "require": { "php": ">=8.0.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -2222,22 +2898,21 @@ }, { "name": "psr/simple-cache", - "version": "dev-master", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/php-fig/simple-cache.git", - "reference": "2d280c2aaa23a120f35d55cfde8581954a8e77fa" + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/2d280c2aaa23a120f35d55cfde8581954a8e77fa", - "reference": "2d280c2aaa23a120f35d55cfde8581954a8e77fa", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", "shasum": "" }, "require": { "php": ">=8.0.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -2268,9 +2943,53 @@ "simple-cache" ], "support": { - "source": "https://github.com/php-fig/simple-cache/tree/master" + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" }, - "time": "2022-04-08T16:41:45+00:00" + "time": "2019-03-08T08:55:37+00:00" }, { "name": "ramsey/collection", @@ -2350,20 +3069,20 @@ }, { "name": "ramsey/uuid", - "version": "4.x-dev", + "version": "4.9.3", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "f239d6c806d5cd2c006fbd40db8ed2a375a14f9a" + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/f239d6c806d5cd2c006fbd40db8ed2a375a14f9a", - "reference": "f239d6c806d5cd2c006fbd40db8ed2a375a14f9a", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", + "brick/math": ">=0.8.16 <=0.18", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -2396,7 +3115,6 @@ "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." }, - "default-branch": true, "type": "library", "extra": { "captainhook": { @@ -2423,22 +3141,22 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.x" + "source": "https://github.com/ramsey/uuid/tree/4.9.3" }, - "time": "2025-06-27T02:21:05+00:00" + "time": "2026-06-18T03:57:49+00:00" }, { "name": "stripe/stripe-php", - "version": "v21.2.0", + "version": "v21.3.1", "source": { "type": "git", "url": "https://github.com/stripe/stripe-php.git", - "reference": "edf8118f0b96d69f06f372da9168d613d1aed072" + "reference": "12986995cd5e229cc094d4b57de056f8e2e6e5a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/stripe/stripe-php/zipball/edf8118f0b96d69f06f372da9168d613d1aed072", - "reference": "edf8118f0b96d69f06f372da9168d613d1aed072", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/12986995cd5e229cc094d4b57de056f8e2e6e5a9", + "reference": "12986995cd5e229cc094d4b57de056f8e2e6e5a9", "shasum": "" }, "require": { @@ -2460,8 +3178,7 @@ }, "autoload": { "files": [ - "lib/version_check.php", - "lib/agent_plugin_hint.php" + "lib/version_check.php" ], "psr-4": { "Stripe\\": "lib/" @@ -2486,53 +3203,131 @@ ], "support": { "issues": "https://github.com/stripe/stripe-php/issues", - "source": "https://github.com/stripe/stripe-php/tree/v21.2.0" + "source": "https://github.com/stripe/stripe-php/tree/v21.3.1" }, - "time": "2026-08-10T22:11:35+00:00" + "time": "2026-09-01T18:42:58+00:00" }, { - "name": "symfony/console", - "version": "6.4.x-dev", + "name": "symfony/clock", + "version": "v7.4.8", "source": { "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "9056771b8eca08d026cd3280deeec3cfd99c4d93" + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9056771b8eca08d026cd3280deeec3cfd99c4d93", - "reference": "9056771b8eca08d026cd3280deeec3cfd99c4d93", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^5.4|^6.0|^7.0" + "symfony/string": "^7.2|^8.0" }, "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/dotenv": "<5.4", - "symfony/event-dispatcher": "<5.4", - "symfony/lock": "<5.4", - "symfony/process": "<5.4" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2566,7 +3361,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/6.4" + "source": "https://github.com/symfony/console/tree/v7.4.18" }, "funding": [ { @@ -2577,25 +3372,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:37:22+00:00" + "time": "2026-08-25T14:18:37+00:00" }, { "name": "symfony/css-selector", - "version": "7.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", - "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0", "shasum": "" }, "require": { @@ -2631,7 +3430,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.3.0-RC1" + "source": "https://github.com/symfony/css-selector/tree/v7.4.18" }, "funding": [ { @@ -2642,31 +3441,34 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-08-23T10:03:40+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "dev-main", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { "php": ">=8.1" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -2674,7 +3476,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2699,7 +3501,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -2710,40 +3512,47 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/error-handler", - "version": "6.4.x-dev", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "b088e0b175c30b4e06d8085200fa465b586f44fa" + "reference": "8373921e231e190a88e2ad526951bbaa791576fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/b088e0b175c30b4e06d8085200fa465b586f44fa", - "reference": "b088e0b175c30b4e06d8085200fa465b586f44fa", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", "symfony/http-kernel": "<6.4" }, "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^5.4|^6.0|^7.0" + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ "Resources/bin/patch-type-declarations" @@ -2774,7 +3583,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/6.4" + "source": "https://github.com/symfony/error-handler/tree/v7.4.17" }, "funding": [ { @@ -2785,25 +3594,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:39:48+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher", - "version": "7.4.x-dev", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "b1275294c9e511d5b1a507cb0c314a068d401d83" + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b1275294c9e511d5b1a507cb0c314a068d401d83", - "reference": "b1275294c9e511d5b1a507cb0c314a068d401d83", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", "shasum": "" }, "require": { @@ -2824,6 +3637,7 @@ "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/error-handler": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", "symfony/stopwatch": "^6.4|^7.0|^8.0" @@ -2854,7 +3668,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/7.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" }, "funding": [ { @@ -2865,32 +3679,35 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-02T14:13:49+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "dev-main", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { "php": ">=8.1", "psr/event-dispatcher": "^1" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -2898,7 +3715,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2931,7 +3748,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -2942,32 +3759,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/finder", - "version": "6.4.x-dev", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7" + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", - "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", + "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.0|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2995,7 +3816,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/6.4" + "source": "https://github.com/symfony/finder/tree/v7.4.17" }, "funding": [ { @@ -3006,45 +3827,50 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-29T13:51:37+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { "name": "symfony/http-foundation", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "452d19f945ee41345fd8a50c18b60783546b7bd3" + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/452d19f945ee41345fd8a50c18b60783546b7bd3", - "reference": "452d19f945ee41345fd8a50c18b60783546b7bd3", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php83": "^1.27" + "symfony/polyfill-mbstring": "^1.1" }, "conflict": { + "doctrine/dbal": "<3.6", "symfony/cache": "<6.4.12|>=7.0,<7.1.5" }, "require-dev": { - "doctrine/dbal": "^2.13.1|^3|^4", + "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", - "symfony/mime": "^5.4|^6.0|^7.0", - "symfony/rate-limiter": "^5.4|^6.0|^7.0" + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3072,7 +3898,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/6.4" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.18" }, "funding": [ { @@ -3083,82 +3909,87 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-26T09:17:58+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "symfony/http-kernel", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "9e9c725272b51aaddb863dcd59ca3ab4070a6df3" + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9e9c725272b51aaddb863dcd59ca3ab4070a6df3", - "reference": "9e9c725272b51aaddb863dcd59ca3ab4070a6df3", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<5.4", - "symfony/cache": "<5.4", - "symfony/config": "<6.1", - "symfony/console": "<5.4", + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<5.4", - "symfony/form": "<5.4", - "symfony/http-client": "<5.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<5.4", - "symfony/messenger": "<5.4", - "symfony/translation": "<5.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<5.4", + "symfony/twig-bridge": "<6.4", "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.3", - "twig/twig": "<2.13" + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^5.4|^6.0|^7.0", - "symfony/clock": "^6.2|^7.0", - "symfony/config": "^6.1|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/css-selector": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/dom-crawler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/property-access": "^5.4.5|^6.0.5|^7.0", - "symfony/routing": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.4|^7.0.4", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^5.4|^6.0|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.4|^7.0", - "symfony/var-exporter": "^6.2|^7.0", - "twig/twig": "^2.13|^3.0.4" + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" }, "type": "library", "autoload": { @@ -3186,7 +4017,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/6.4" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.18" }, "funding": [ { @@ -3197,48 +4028,52 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:10:59+00:00" + "time": "2026-08-30T21:24:29+00:00" }, { "name": "symfony/mailer", - "version": "6.4.x-dev", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "a480322ddf8e54de262c9bca31fdcbe26b553de5" + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/a480322ddf8e54de262c9bca31fdcbe26b553de5", - "reference": "a480322ddf8e54de262c9bca31fdcbe26b553de5", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584", + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.1", + "php": ">=8.2", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/mime": "^6.2|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", - "symfony/messenger": "<6.2", - "symfony/mime": "<6.2", - "symfony/twig-bridge": "<6.2.1" + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/messenger": "^6.2|^7.0", - "symfony/twig-bridge": "^6.2|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3266,7 +4101,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/6.4" + "source": "https://github.com/symfony/mailer/tree/v7.4.17" }, "funding": [ { @@ -3277,49 +4112,53 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T21:24:02+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/mime", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "fec8aa5231f3904754955fad33c2db50594d22d1" + "reference": "bf328d82105831db3e409195db0540ff57f27c80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/fec8aa5231f3904754955fad33c2db50594d22d1", - "reference": "fec8aa5231f3904754955fad33c2db50594d22d1", + "url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80", + "reference": "bf328d82105831db3e409195db0540ff57f27c80", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", - "symfony/mailer": "<5.4", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.4|^7.0", - "symfony/property-access": "^5.4|^6.0|^7.0", - "symfony/property-info": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3" + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" }, "type": "library", "autoload": { @@ -3351,7 +4190,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/6.4" + "source": "https://github.com/symfony/mime/tree/v7.4.18" }, "funding": [ { @@ -3362,25 +4201,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-27T13:27:38+00:00" + "time": "2026-08-22T09:04:42+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "1.x-dev", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -3392,7 +4235,6 @@ "suggest": { "ext-ctype": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3431,7 +4273,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -3442,25 +4284,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "1.x-dev", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -3469,7 +4315,6 @@ "suggest": { "ext-intl": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3510,7 +4355,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/1.x" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -3521,25 +4366,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "1.x-dev", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", "shasum": "" }, "require": { @@ -3549,7 +4398,6 @@ "suggest": { "ext-intl": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3594,7 +4442,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" }, "funding": [ { @@ -3605,25 +4453,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-08-24T10:51:20+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "1.x-dev", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -3632,7 +4484,6 @@ "suggest": { "ext-intl": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3676,7 +4527,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -3687,25 +4538,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "1.x-dev", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -3718,7 +4573,6 @@ "suggest": { "ext-mbstring": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3758,7 +4612,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -3769,31 +4623,34 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", - "version": "1.x-dev", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { "php": ">=7.2" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3839,7 +4696,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -3850,31 +4707,34 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "1.x-dev", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", "shasum": "" }, "require": { "php": ">=7.2" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3916,7 +4776,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" }, "funding": [ { @@ -3927,37 +4787,34 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { - "name": "symfony/polyfill-uuid", - "version": "1.x-dev", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { "php": ">=7.2" }, - "provide": { - "ext-uuid": "*" - }, - "suggest": { - "ext-uuid": "For best performance" - }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3970,8 +4827,11 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Uuid\\": "" - } + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3979,24 +4839,24 @@ ], "authors": [ { - "name": "Grégoire Pineau", - "email": "lyrixx@lyrixx.info" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for uuid functions", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", "polyfill", "portable", - "uuid" + "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -4007,37 +4867,50 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "symfony/process", - "version": "6.4.x-dev", + "name": "symfony/polyfill-php85", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "e2a61c16af36c9a07e5c9906498b73e091949a20" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/e2a61c16af36c9a07e5c9906498b73e091949a20", - "reference": "e2a61c16af36c9a07e5c9906498b73e091949a20", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=7.2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Process\\": "" + "Symfony\\Polyfill\\Php85\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -4046,18 +4919,24 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Executes commands in sub-processes", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/process/tree/6.4" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -4068,54 +4947,54 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-10T17:11:00+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { - "name": "symfony/routing", - "version": "6.4.x-dev", + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/routing.git", - "reference": "1f5234e8457164a3a0038a4c0a4ba27876a9c670" + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/1f5234e8457164a3a0038a4c0a4ba27876a9c670", - "reference": "1f5234e8457164a3a0038a4c0a4ba27876a9c670", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=7.2" }, - "conflict": { - "doctrine/annotations": "<1.12", - "symfony/config": "<6.2", - "symfony/dependency-injection": "<5.4", - "symfony/yaml": "<5.4" + "provide": { + "ext-uuid": "*" }, - "require-dev": { - "doctrine/annotations": "^1.12|^2", - "psr/log": "^1|^2|^3", - "symfony/config": "^6.2|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" + "suggest": { + "ext-uuid": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Routing\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Polyfill\\Uuid\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4123,25 +5002,175 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Maps an HTTP request to a set of configuration variables", + "description": "Symfony polyfill for uuid functions", "homepage": "https://symfony.com", "keywords": [ - "router", - "routing", - "uri", - "url" - ], - "support": { - "source": "https://github.com/symfony/routing/tree/6.4" - }, + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "058d17fc284cce14efb2385783b55014a461b176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", + "reference": "058d17fc284cce14efb2385783b55014a461b176", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.18" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -4151,25 +5180,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-27T16:08:38+00:00" + "time": "2026-08-17T13:12:36+00:00" }, { "name": "symfony/service-contracts", - "version": "dev-main", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -4180,7 +5213,6 @@ "conflict": { "ext-psr": "<1.1|>=2" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -4188,7 +5220,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4224,7 +5256,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -4235,29 +5267,34 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/string", - "version": "7.4.x-dev", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "4afdf1988ecf90190ca6704bfcb4b085a0b795e0" + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/4afdf1988ecf90190ca6704bfcb4b085a0b795e0", - "reference": "4afdf1988ecf90190ca6704bfcb4b085a0b795e0", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", @@ -4268,7 +5305,6 @@ }, "require-dev": { "symfony/emoji": "^7.1|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", "symfony/http-client": "^6.4|^7.0|^8.0", "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", @@ -4311,7 +5347,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/7.4" + "source": "https://github.com/symfony/string/tree/v7.4.15" }, "funding": [ { @@ -4322,60 +5358,65 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-24T08:16:47+00:00" + "time": "2026-07-28T07:33:02+00:00" }, { "name": "symfony/translation", - "version": "6.4.x-dev", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "de8afa521e04a5220e9e58a1dc99971ab7cac643" + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/de8afa521e04a5220e9e58a1dc99971ab7cac643", - "reference": "de8afa521e04a5220e9e58a1dc99971ab7cac643", + "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5|^3.0" + "symfony/translation-contracts": "^2.5.3|^3.3" }, "conflict": { - "symfony/config": "<5.4", - "symfony/console": "<5.4", - "symfony/dependency-injection": "<5.4", + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", + "symfony/http-kernel": "<6.4", "symfony/service-contracts": "<2.5", - "symfony/twig-bundle": "<5.4", - "symfony/yaml": "<5.4" + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" }, "provide": { "symfony/translation-implementation": "2.3|3.0" }, "require-dev": { - "nikic/php-parser": "^4.18|^5.0", + "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/routing": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4406,7 +5447,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/6.4" + "source": "https://github.com/symfony/translation/tree/v7.4.17" }, "funding": [ { @@ -4417,31 +5458,34 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T21:24:02+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/translation-contracts", - "version": "dev-main", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { "php": ">=8.1" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -4449,7 +5493,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4485,7 +5529,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -4496,33 +5540,37 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-27T08:32:26+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/uid", - "version": "6.4.x-dev", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "9c8592da78d7ee6af52011eef593350d87e814c0" + "reference": "69d732355a139c6f8881337d28515aa01f12b8be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/9c8592da78d7ee6af52011eef593350d87e814c0", - "reference": "9c8592da78d7ee6af52011eef593350d87e814c0", + "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4559,7 +5607,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/6.4" + "source": "https://github.com/symfony/uid/tree/v7.4.17" }, "funding": [ { @@ -4570,43 +5618,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T08:06:12+00:00" + "time": "2026-08-11T07:38:58+00:00" }, { "name": "symfony/var-dumper", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "d55b1834cdbfcc31bc2cd7e095ba5ed9a88f6600" + "reference": "e088da50b813f32473a76871616cbb8fa54653a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/d55b1834cdbfcc31bc2cd7e095ba5ed9a88f6600", - "reference": "d55b1834cdbfcc31bc2cd7e095ba5ed9a88f6600", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "symfony/console": "<5.4" + "symfony/console": "<6.4" }, "require-dev": { - "ext-iconv": "*", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^6.3|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/uid": "^5.4|^6.0|^7.0", - "twig/twig": "^2.13|^3.0.4" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -4644,7 +5694,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/6.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.18" }, "funding": [ { @@ -4655,39 +5705,42 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T15:05:27+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "dev-master", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^2.0", "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^8.5.21 || ^9.5.10" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -4714,32 +5767,32 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" }, - "time": "2024-12-21T16:25:41+00:00" + "time": "2025-12-02T11:56:42+00:00" }, { "name": "vlucas/phpdotenv", - "version": "dev-master", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.3", + "graham-campbell/result-type": "^1.2", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3", - "symfony/polyfill-ctype": "^1.24", - "symfony/polyfill-mbstring": "^1.24", - "symfony/polyfill-php80": "^1.24" + "phpoption/phpoption": "^1.10", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -4749,7 +5802,6 @@ "suggest": { "ext-filter": "Required to use the boolean validator." }, - "default-branch": true, "type": "library", "extra": { "bamarni-bin": { @@ -4781,7 +5833,7 @@ "homepage": "https://github.com/vlucas" } ], - "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", "keywords": [ "dotenv", "env", @@ -4789,7 +5841,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" }, "funding": [ { @@ -4801,27 +5853,27 @@ "type": "tidelift" } ], - "time": "2025-04-30T23:37:27+00:00" + "time": "2026-08-24T18:07:49+00:00" }, { "name": "voku/portable-ascii", - "version": "2.0.3", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", "shasum": "" }, "require": { - "php": ">=7.0.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" }, "suggest": { "ext-intl": "Use Intl for transliterator_transliterate() support" @@ -4851,7 +5903,7 @@ ], "support": { "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" }, "funding": [ { @@ -4875,22 +5927,22 @@ "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" + "time": "2026-04-26T05:33:54+00:00" } ], "packages-dev": [ { "name": "composer/semver", - "version": "dev-main", + "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { @@ -4900,7 +5952,6 @@ "phpstan/phpstan": "^1.11", "symfony/phpunit-bridge": "^3 || ^7" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -4943,7 +5994,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.3" + "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { @@ -4953,96 +6004,22 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-09-19T14:15:21+00:00" - }, - { - "name": "doctrine/instantiator", - "version": "2.0.x-dev", - "source": { - "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "62eb6d408ec36853962daa00d056a14276442371" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/62eb6d408ec36853962daa00d056a14276442371", - "reference": "62eb6d408ec36853962daa00d056a14276442371", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "doctrine/coding-standard": "^12", - "ext-pdo": "*", - "ext-phar": "*", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.9.4", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^10.5" - }, - "default-branch": true, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "https://ocramius.github.io/" - } - ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", - "keywords": [ - "constructor", - "instantiate" - ], - "support": { - "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.0.x" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", - "type": "tidelift" } ], - "time": "2025-06-09T20:15:13+00:00" + "time": "2025-08-20T19:15:30+00:00" }, { "name": "fakerphp/faker", - "version": "1.24.x-dev", + "version": "v1.24.1", "source": { "type": "git", "url": "https://github.com/FakerPHP/Faker.git", - "reference": "f7ac50712e417f008402c8fc889c964e75eecfe9" + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/f7ac50712e417f008402c8fc889c964e75eecfe9", - "reference": "f7ac50712e417f008402c8fc889c964e75eecfe9", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", "shasum": "" }, "require": { @@ -5090,22 +6067,22 @@ ], "support": { "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/1.24" + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" }, - "time": "2025-02-22T09:07:46+00:00" + "time": "2024-11-21T13:46:39+00:00" }, { "name": "filp/whoops", - "version": "2.18.3", + "version": "2.18.4", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "59a123a3d459c5a23055802237cb317f609867e5" + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/59a123a3d459c5a23055802237cb317f609867e5", - "reference": "59a123a3d459c5a23055802237cb317f609867e5", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", "shasum": "" }, "require": { @@ -5155,7 +6132,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.3" + "source": "https://github.com/filp/whoops/tree/2.18.4" }, "funding": [ { @@ -5163,20 +6140,20 @@ "type": "github" } ], - "time": "2025-06-16T00:02:10+00:00" + "time": "2025-08-08T12:00:00+00:00" }, { "name": "hamcrest/hamcrest-php", - "version": "dev-master", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "2f2740a1915ea0ab998e1f46bb9deb11698c80ba" + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/2f2740a1915ea0ab998e1f46bb9deb11698c80ba", - "reference": "2f2740a1915ea0ab998e1f46bb9deb11698c80ba", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", "shasum": "" }, "require": { @@ -5190,14 +6167,15 @@ "kodova/hamcrest-php": "*" }, "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -5215,52 +6193,131 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/master" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" }, - "time": "2025-06-27T11:23:59+00:00" + "time": "2026-03-17T11:56:53+00:00" }, { - "name": "laravel/tinker", - "version": "2.x-dev", + "name": "laravel/pail", + "version": "v1.2.7", "source": { "type": "git", - "url": "https://github.com/laravel/tinker.git", - "reference": "102bfc19b79817022e9fb1d3dd235d43d42f1954" + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/102bfc19b79817022e9fb1d3dd235d43d42f1954", - "reference": "102bfc19b79817022e9fb1d3dd235d43d42f1954", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^7.2.5|^8.0", - "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { - "mockery/mockery": "~1.3.3|^1.4.2", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" }, - "suggest": { - "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." - }, - "default-branch": true, "type": "library", "extra": { "laravel": { "providers": [ - "Laravel\\Tinker\\TinkerServiceProvider" + "Laravel\\Pail\\PailServiceProvider" ] + }, + "branch-alias": { + "dev-main": "1.x-dev" } }, "autoload": { "psr-4": { - "Laravel\\Tinker\\": "src/" + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -5282,34 +6339,34 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/2.x" + "source": "https://github.com/laravel/tinker/tree/v2.11.1" }, - "time": "2025-02-12T01:36:47+00:00" + "time": "2026-02-06T14:12:35+00:00" }, { "name": "mockery/mockery", - "version": "1.7.x-dev", + "version": "1.6.15", "source": { "type": "git", "url": "https://github.com/mockery/mockery.git", - "reference": "3f8d3ff1ffe4c552d45c5690c6d825e9310769bf" + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/3f8d3ff1ffe4c552d45c5690c6d825e9310769bf", - "reference": "3f8d3ff1ffe4c552d45c5690c6d825e9310769bf", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", "shasum": "" }, "require": { - "hamcrest/hamcrest-php": "^2.0.1", - "lib-pcre": ">=7.0", + "hamcrest/hamcrest-php": "^2.0 || ^3.0", "php": ">=7.3" }, "conflict": { "phpunit/phpunit": "<8.0" }, "require-dev": { - "phpunit/phpunit": ">=9.6.11 <10.4" + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" }, "type": "library", "autoload": { @@ -5366,24 +6423,24 @@ "security": "https://github.com/mockery/mockery/security/advisories", "source": "https://github.com/mockery/mockery" }, - "time": "2023-10-01T17:31:30+00:00" + "time": "2026-08-19T19:37:52+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.x-dev", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -5395,7 +6452,6 @@ "phpspec/prophecy": "^1.10", "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, - "default-branch": true, "type": "library", "autoload": { "files": [ @@ -5419,32 +6475,31 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-04-29T12:36:36+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nikic/php-parser", - "version": "v5.5.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", - "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -5459,7 +6514,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -5483,46 +6538,42 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-05-31T08:24:38+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "nunomaduro/collision", - "version": "v7.x-dev", + "version": "v8.9.5", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "995245421d3d7593a6960822063bdba4f5d7cf1a" + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/995245421d3d7593a6960822063bdba4f5d7cf1a", - "reference": "995245421d3d7593a6960822063bdba4f5d7cf1a", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", "shasum": "" }, "require": { - "filp/whoops": "^2.17.0", - "nunomaduro/termwind": "^1.17.0", - "php": "^8.1.0", - "symfony/console": "^6.4.17" + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.14 || ^8.1.1" }, "conflict": { - "laravel/framework": ">=11.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.4.8", - "laravel/framework": "^10.48.29", - "laravel/pint": "^1.21.2", - "laravel/sail": "^1.41.0", - "laravel/sanctum": "^3.3.3", - "laravel/tinker": "^2.10.1", - "nunomaduro/larastan": "^2.10.0", - "orchestra/testbench-core": "^8.35.0", - "pestphp/pest": "^2.36.0", - "phpunit/phpunit": "^10.5.36", - "sebastian/environment": "^6.1.0", - "spatie/laravel-ignition": "^2.9.1" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" }, "type": "library", "extra": { @@ -5530,6 +6581,9 @@ "providers": [ "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" } }, "autoload": { @@ -5556,6 +6610,7 @@ "cli", "command-line", "console", + "dev", "error", "handling", "laravel", @@ -5581,42 +6636,46 @@ "type": "patreon" } ], - "time": "2025-03-14T22:35:49+00:00" + "time": "2026-07-15T19:09:14+00:00" }, { "name": "orchestra/canvas", - "version": "8.x-dev", + "version": "v10.2.1", "source": { "type": "git", "url": "https://github.com/orchestral/canvas.git", - "reference": "76385dfcf96efae5f8533a4d522d14c3c946ac5a" + "reference": "1323620d48f2b05bc7dd99eadd0403ef29293d6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/canvas/zipball/76385dfcf96efae5f8533a4d522d14c3c946ac5a", - "reference": "76385dfcf96efae5f8533a4d522d14c3c946ac5a", + "url": "https://api.github.com/repos/orchestral/canvas/zipball/1323620d48f2b05bc7dd99eadd0403ef29293d6f", + "reference": "1323620d48f2b05bc7dd99eadd0403ef29293d6f", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "composer/semver": "^3.0", - "illuminate/console": "^10.48.25", - "illuminate/database": "^10.48.25", - "illuminate/filesystem": "^10.48.25", - "illuminate/support": "^10.48.25", - "orchestra/canvas-core": "^8.10.2", - "orchestra/testbench-core": "^8.30", - "php": "^8.1", - "symfony/polyfill-php83": "^1.31", - "symfony/yaml": "^6.2" + "illuminate/console": "^12.64.0", + "illuminate/database": "^12.64.0", + "illuminate/filesystem": "^12.64.0", + "illuminate/support": "^12.64.0", + "orchestra/canvas-core": "^10.2.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "orchestra/testbench-core": "^10.8.0", + "php": "^8.2", + "symfony/polyfill-php83": "^1.33", + "symfony/yaml": "^7.2.0" + }, + "conflict": { + "laravel/framework": "<12.40.0|>=13.0.0" }, "require-dev": { - "laravel/framework": "^10.48.25", - "laravel/pint": "^1.17", - "mockery/mockery": "^1.5.1", - "phpstan/phpstan": "^1.11", - "phpunit/phpunit": "^10.5", - "spatie/laravel-ray": "^1.33" + "laravel/framework": "^12.64.0", + "laravel/pint": "^1.24", + "mockery/mockery": "^1.6.12", + "phpstan/phpstan": "^2.1.14", + "phpunit/phpunit": "^11.5.18|^12.0", + "spatie/laravel-ray": "^1.42.0" }, "bin": [ "canvas" @@ -5651,44 +6710,42 @@ "description": "Code Generators for Laravel Applications and Packages", "support": { "issues": "https://github.com/orchestral/canvas/issues", - "source": "https://github.com/orchestral/canvas/tree/8.x" + "source": "https://github.com/orchestral/canvas/tree/v10.2.1" }, - "time": "2024-11-30T15:38:25+00:00" + "time": "2026-07-22T01:59:39+00:00" }, { "name": "orchestra/canvas-core", - "version": "8.x-dev", + "version": "v10.2.0", "source": { "type": "git", "url": "https://github.com/orchestral/canvas-core.git", - "reference": "c6cfe55bff0d9fe9485c87ce996ca72281173e27" + "reference": "11fdb579f4f2d4bd68a22bd206cabc32e7856e32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/canvas-core/zipball/c6cfe55bff0d9fe9485c87ce996ca72281173e27", - "reference": "c6cfe55bff0d9fe9485c87ce996ca72281173e27", + "url": "https://api.github.com/repos/orchestral/canvas-core/zipball/11fdb579f4f2d4bd68a22bd206cabc32e7856e32", + "reference": "11fdb579f4f2d4bd68a22bd206cabc32e7856e32", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "composer/semver": "^3.0", - "illuminate/console": "^10.48.22", - "illuminate/filesystem": "^10.48.22", - "php": "^8.1", - "symfony/polyfill-php83": "^1.28" - }, - "conflict": { - "orchestra/canvas": "<8.11.0", - "orchestra/testbench-core": "<8.2.0" + "illuminate/console": "^12.40.0", + "illuminate/support": "^12.40.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "php": "^8.2", + "symfony/polyfill-php83": "^1.33" }, "require-dev": { - "laravel/framework": "^10.48.22", - "laravel/pint": "^1.17", - "mockery/mockery": "^1.5.1", - "orchestra/testbench-core": "^8.19", - "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^10.1", - "symfony/yaml": "^6.2" + "laravel/framework": "^12.40.0", + "laravel/pint": "^1.24", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^10.8.0", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^11.5.12|^12.0.1", + "spatie/laravel-ray": "^1.40.2", + "symfony/yaml": "^7.2" }, "type": "library", "extra": { @@ -5696,9 +6753,6 @@ "providers": [ "Orchestra\\Canvas\\Core\\LaravelServiceProvider" ] - }, - "branch-alias": { - "dev-master": "9.0-dev" } }, "autoload": { @@ -5723,26 +6777,27 @@ "description": "Code Generators Builder for Laravel Applications and Packages", "support": { "issues": "https://github.com/orchestral/canvas/issues", - "source": "https://github.com/orchestral/canvas-core/tree/8.x" + "source": "https://github.com/orchestral/canvas-core/tree/v10.2.0" }, - "time": "2024-12-09T02:04:59+00:00" + "time": "2026-03-06T13:48:13+00:00" }, { "name": "orchestra/sidekick", - "version": "1.2.x-dev", + "version": "v1.2.21", "source": { "type": "git", "url": "https://github.com/orchestral/sidekick.git", - "reference": "aa41994f872cc49a420da42f50886605c0d85f15" + "reference": "fcf69fe227b52beca2e0fe9777d4af4d44ab46a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/sidekick/zipball/aa41994f872cc49a420da42f50886605c0d85f15", - "reference": "aa41994f872cc49a420da42f50886605c0d85f15", + "url": "https://api.github.com/repos/orchestral/sidekick/zipball/fcf69fe227b52beca2e0fe9777d4af4d44ab46a5", + "reference": "fcf69fe227b52beca2e0fe9777d4af4d44ab46a5", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", + "composer/semver": "^3.0", "php": "^8.1", "symfony/polyfill-php83": "^1.32" }, @@ -5751,15 +6806,16 @@ "laravel/framework": "^10.48.29|^11.44.7|^12.1.1|^13.0", "laravel/pint": "^1.4", "mockery/mockery": "^1.5.1", - "orchestra/testbench-core": "^8.37.0|^9.14.0|^10.0|^11.0", + "orchestra/testbench-core": "^8.37.0|^9.14.0|^10.2.0|^11.0", "phpstan/phpstan": "^2.1.14", - "phpunit/phpunit": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.0|^11.0|^12.0|^13.0", "symfony/process": "^6.0|^7.0" }, "type": "library", "autoload": { "files": [ "src/Eloquent/functions.php", + "src/Filesystem/functions.php", "src/Http/functions.php", "src/functions.php" ], @@ -5780,36 +6836,36 @@ "description": "Packages Toolkit Utilities and Helpers for Laravel", "support": { "issues": "https://github.com/orchestral/sidekick/issues", - "source": "https://github.com/orchestral/sidekick/tree/1.2.x" + "source": "https://github.com/orchestral/sidekick/tree/v1.2.21" }, - "time": "2025-06-23T05:09:50+00:00" + "time": "2026-08-04T23:01:29+00:00" }, { "name": "orchestra/testbench", - "version": "8.x-dev", + "version": "v10.11.0", "source": { "type": "git", "url": "https://github.com/orchestral/testbench.git", - "reference": "1e765c32c37ceb1acdf189c2804fa1ed738f451c" + "reference": "d73b4426dacddd2c1f5e671e0efd7665b16d2b84" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/testbench/zipball/1e765c32c37ceb1acdf189c2804fa1ed738f451c", - "reference": "1e765c32c37ceb1acdf189c2804fa1ed738f451c", + "url": "https://api.github.com/repos/orchestral/testbench/zipball/d73b4426dacddd2c1f5e671e0efd7665b16d2b84", + "reference": "d73b4426dacddd2c1f5e671e0efd7665b16d2b84", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", - "fakerphp/faker": "^1.21", - "laravel/framework": "^10.48.29", - "mockery/mockery": "^1.5.1", - "orchestra/testbench-core": "^8.37.0", - "orchestra/workbench": "^8.17.5", - "php": "^8.1", - "phpunit/phpunit": "^9.6|^10.1", - "symfony/process": "^6.2", - "symfony/yaml": "^6.2", - "vlucas/phpdotenv": "^5.4.1" + "fakerphp/faker": "^1.23", + "laravel/framework": "^12.55.0", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^10.11.0", + "orchestra/workbench": "^10.0.8", + "php": "^8.2", + "phpunit/phpunit": "^11.5.3|^12.0.1|^13.0.0", + "symfony/process": "^7.2", + "symfony/yaml": "^7.2", + "vlucas/phpdotenv": "^5.6.1" }, "type": "library", "notification-url": "https://packagist.org/downloads/", @@ -5835,66 +6891,63 @@ ], "support": { "issues": "https://github.com/orchestral/testbench/issues", - "source": "https://github.com/orchestral/testbench/tree/8.x" + "source": "https://github.com/orchestral/testbench/tree/v10.11.0" }, - "time": "2025-05-12T06:24:52+00:00" + "time": "2026-03-18T13:08:23+00:00" }, { "name": "orchestra/testbench-core", - "version": "8.x-dev", + "version": "v10.14.1", "source": { "type": "git", "url": "https://github.com/orchestral/testbench-core.git", - "reference": "88942ff0c3969553f0dc77327c9991f49abd799f" + "reference": "6b88b608ba794fcac18094c7d191591c852c286d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/88942ff0c3969553f0dc77327c9991f49abd799f", - "reference": "88942ff0c3969553f0dc77327c9991f49abd799f", + "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/6b88b608ba794fcac18094c7d191591c852c286d", + "reference": "6b88b608ba794fcac18094c7d191591c852c286d", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", - "orchestra/sidekick": "~1.1.14|^1.2.10", - "php": "^8.1", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "php": "^8.2", "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-php83": "^1.32" + "symfony/polyfill-php83": "^1.33" }, "conflict": { - "brianium/paratest": "<6.4.0|>=7.0.0 <7.1.4|>=8.0.0", - "laravel/framework": "<10.48.29|>=11.0.0", - "laravel/serializable-closure": "<1.3.0|>=3.0.0", - "nunomaduro/collision": "<6.4.0|>=7.0.0 <7.4.0|>=8.0.0", - "orchestra/testbench-dusk": "<8.32.0|>=9.0.0", - "orchestra/workbench": "<1.0.0", - "phpunit/phpunit": "<9.6.0|>=10.3.0 <10.3.3|>=10.6.0" + "brianium/paratest": "<7.3.0|>=8.0.0", + "laravel/framework": "<12.55.0|>=13.0.0", + "laravel/serializable-closure": "<1.3.0|>=2.0.0 <2.0.3|>=3.0.0", + "nunomaduro/collision": "<8.0.0|>=9.0.0", + "phpunit/phpunit": "<10.5.35|>=11.0.0 <11.5.3|12.0.0|>=13.2.0" }, "require-dev": { - "fakerphp/faker": "^1.21", - "laravel/framework": "^10.48.29", - "laravel/pint": "^1.20", - "laravel/serializable-closure": "^1.3|^2.0", - "mockery/mockery": "^1.5.1", - "phpstan/phpstan": "^2.1.14", - "phpunit/phpunit": "^10.1", - "spatie/laravel-ray": "^1.40.2", - "symfony/process": "^6.2", - "symfony/yaml": "^6.2", - "vlucas/phpdotenv": "^5.4.1" + "fakerphp/faker": "^1.24", + "laravel/framework": "^12.55.0", + "laravel/pint": "^1.24", + "laravel/serializable-closure": "^1.3|^2.0.4", + "mockery/mockery": "^1.6.10", + "phpstan/phpstan": "^2.1.38", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1|^13.0.0", + "spatie/laravel-ray": "^1.42.0", + "symfony/process": "^7.2.0", + "symfony/yaml": "^7.2.0", + "vlucas/phpdotenv": "^5.6.1" }, "suggest": { - "brianium/paratest": "Allow using parallel testing (^6.4|^7.1.4).", + "brianium/paratest": "Allow using parallel testing (^7.3).", "ext-pcntl": "Required to use all features of the console signal trapping.", - "fakerphp/faker": "Allow using Faker for testing (^1.21).", - "laravel/framework": "Required for testing (^10.48.29).", - "mockery/mockery": "Allow using Mockery for testing (^1.5.1).", - "nunomaduro/collision": "Allow using Laravel style tests output and parallel testing (^6.4|^7.4).", - "orchestra/testbench-browser-kit": "Allow using legacy Laravel BrowserKit for testing (^8.0).", - "orchestra/testbench-dusk": "Allow using Laravel Dusk for testing (^8.0).", - "phpunit/phpunit": "Allow using PHPUnit for testing (^9.6|^10.1).", - "symfony/process": "Required to use Orchestra\\Testbench\\remote function (^6.2).", - "symfony/yaml": "Required for Testbench CLI (^6.2).", - "vlucas/phpdotenv": "Required for Testbench CLI (^5.4.1)." + "fakerphp/faker": "Allow using Faker for testing (^1.23).", + "laravel/framework": "Required for testing (^12.55.0).", + "mockery/mockery": "Allow using Mockery for testing (^1.6).", + "nunomaduro/collision": "Allow using Laravel style tests output and parallel testing (^8.0).", + "orchestra/testbench-dusk": "Allow using Laravel Dusk for testing (^10.0).", + "phpunit/phpunit": "Allow using PHPUnit for testing (^10.5.35|^11.5.3|^12.0.1|^13.0.0).", + "symfony/process": "Required to use Orchestra\\Testbench\\remote function (^7.2).", + "symfony/yaml": "Required for Testbench CLI (^7.2).", + "vlucas/phpdotenv": "Required for Testbench CLI (^5.6.1)." }, "bin": [ "testbench" @@ -5933,42 +6986,44 @@ "issues": "https://github.com/orchestral/testbench/issues", "source": "https://github.com/orchestral/testbench-core" }, - "time": "2025-06-14T10:35:36+00:00" + "time": "2026-04-24T08:35:55+00:00" }, { "name": "orchestra/workbench", - "version": "8.x-dev", + "version": "v10.2.0", "source": { "type": "git", "url": "https://github.com/orchestral/workbench.git", - "reference": "04da1d8c28948e0bd78bc771db0026ccbbba449d" + "reference": "01212434bc1e3bd6c2c96f1977d0951b9fb91e97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/workbench/zipball/04da1d8c28948e0bd78bc771db0026ccbbba449d", - "reference": "04da1d8c28948e0bd78bc771db0026ccbbba449d", + "url": "https://api.github.com/repos/orchestral/workbench/zipball/01212434bc1e3bd6c2c96f1977d0951b9fb91e97", + "reference": "01212434bc1e3bd6c2c96f1977d0951b9fb91e97", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", - "fakerphp/faker": "^1.21", - "laravel/framework": "^10.48.28", - "laravel/tinker": "^2.8.2", - "nunomaduro/collision": "^6.4|^7.10", - "orchestra/canvas": "^8.12.0", - "orchestra/sidekick": "^1.2.0", - "orchestra/testbench-core": "^8.35.0", - "php": "^8.1", - "symfony/polyfill-php83": "^1.32", - "symfony/process": "^6.2", - "symfony/yaml": "^6.2" + "fakerphp/faker": "^1.23", + "laravel/framework": "^12.40.0", + "laravel/pail": "^1.2.2", + "laravel/tinker": "^2.10.1", + "nunomaduro/collision": "^8.6", + "orchestra/canvas": "^10.2.1", + "orchestra/canvas-core": "^10.2.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "orchestra/testbench-core": "^10.12.0", + "php": "^8.2", + "symfony/polyfill-php83": "^1.33", + "symfony/process": "^7.2", + "symfony/yaml": "^7.2" }, "require-dev": { - "laravel/pint": "^1.20", - "mockery/mockery": "^1.5.1", - "phpstan/phpstan": "^2.1.14", - "phpunit/phpunit": "^10.1", - "spatie/laravel-ray": "^1.40.2" + "laravel/pint": "^1.22.0", + "mockery/mockery": "^1.6.12", + "phpstan/phpstan": "^2.1.33", + "phpunit/phpunit": "^11.5.3|^12.0.1|^13.0", + "spatie/laravel-ray": "^1.42.0" }, "suggest": { "ext-pcntl": "Required to use all features of the console signal trapping." @@ -5998,13 +7053,13 @@ ], "support": { "issues": "https://github.com/orchestral/workbench/issues", - "source": "https://github.com/orchestral/workbench/tree/8.x" + "source": "https://github.com/orchestral/workbench/tree/v10.2.0" }, - "time": "2025-06-14T10:53:43+00:00" + "time": "2026-07-22T02:17:12+00:00" }, { "name": "phar-io/manifest", - "version": "dev-master", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", @@ -6024,7 +7079,6 @@ "phar-io/version": "^3.0.1", "php": "^7.2 || ^8.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -6123,35 +7177,33 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.x-dev", + "version": "12.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "0448d60087a382392a1b2a1abe434466e03dcc87" + "reference": "186dab580576598076de6818596d12b61801880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/0448d60087a382392a1b2a1abe434466e03dcc87", - "reference": "0448d60087a382392a1b2a1abe434466e03dcc87", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.19.1 || ^5.1.0", - "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-text-template": "^2.0.4", - "sebastian/code-unit-reverse-lookup": "^2.0.3", - "sebastian/complexity": "^2.0.3", - "sebastian/environment": "^5.1.5", - "sebastian/lines-of-code": "^1.0.4", - "sebastian/version": "^3.0.2", - "theseer/tokenizer": "^1.2.3" + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^12.5.28" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -6160,7 +7212,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.2.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -6189,40 +7241,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2024-10-31T05:58:25+00:00" + "time": "2026-06-01T13:24:19+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "3.0.x-dev", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "38b24367e1b340aa78b96d7cab042942d917bb84" + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/38b24367e1b340aa78b96d7cab042942d917bb84", - "reference": "38b24367e1b340aa78b96d7cab042942d917bb84", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.33" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -6249,36 +7313,49 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0" + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2022-02-11T16:23:04+00:00" + "time": "2026-08-25T14:40:53+00:00" }, { "name": "phpunit/php-invoker", - "version": "3.1.1", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-pcntl": "*" @@ -6286,7 +7363,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -6312,7 +7389,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" }, "funding": [ { @@ -6320,32 +7398,32 @@ "type": "github" } ], - "time": "2020-09-28T05:58:55+00:00" + "time": "2025-02-07T04:58:58+00:00" }, { "name": "phpunit/php-text-template", - "version": "2.0.4", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -6371,7 +7449,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" }, "funding": [ { @@ -6379,32 +7458,32 @@ "type": "github" } ], - "time": "2020-10-26T05:33:50+00:00" + "time": "2025-02-07T04:59:16+00:00" }, { "name": "phpunit/php-timer", - "version": "5.0.3", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -6430,7 +7509,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" }, "funding": [ { @@ -6438,54 +7518,49 @@ "type": "github" } ], - "time": "2020-10-26T13:16:10+00:00" + "time": "2025-02-07T04:59:38+00:00" }, { "name": "phpunit/phpunit", - "version": "9.6.x-dev", + "version": "12.5.34", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "a9e1112505effabbc6a0b3dde09e62999280f029" + "reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a9e1112505effabbc6a0b3dde09e62999280f029", - "reference": "a9e1112505effabbc6a0b3dde09e62999280f029", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f", + "reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.1", + "myclabs/deep-copy": "^1.14.0", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.32", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.4", - "phpunit/php-timer": "^5.0.3", - "sebastian/cli-parser": "^1.0.2", - "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.8", - "sebastian/diff": "^4.0.6", - "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", - "sebastian/global-state": "^5.0.7", - "sebastian/object-enumerator": "^4.0.4", - "sebastian/resource-operations": "^3.0.4", - "sebastian/type": "^3.2.1", - "sebastian/version": "^3.0.2" - }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" }, "bin": [ "phpunit" @@ -6493,7 +7568,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.6-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -6525,44 +7600,28 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.34" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2025-06-19T14:51:41+00:00" + "time": "2026-08-27T08:38:28+00:00" }, { "name": "psy/psysh", - "version": "dev-main", + "version": "v0.12.24", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "1b801844becfe648985372cb4b12ad6840245ace" + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace", - "reference": "1b801844becfe648985372cb4b12ad6840245ace", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", "shasum": "" }, "require": { @@ -6570,21 +7629,21 @@ "ext-tokenizer": "*", "nikic/php-parser": "^5.0 || ^4.0", "php": "^8.0 || ^7.4", - "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" }, "conflict": { "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.2" + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" }, "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", - "ext-pdo-sqlite": "The doc command requires SQLite to work.", "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." }, - "default-branch": true, "bin": [ "bin/psysh" ], @@ -6613,12 +7672,11 @@ "authors": [ { "name": "Justin Hileman", - "email": "justin@justinhileman.info", - "homepage": "http://justinhileman.com" + "email": "justin@justinhileman.info" } ], "description": "An interactive shell for modern PHP.", - "homepage": "http://psysh.org", + "homepage": "https://psysh.org", "keywords": [ "REPL", "console", @@ -6627,34 +7685,34 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.9" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" }, - "time": "2025-06-23T02:35:06+00:00" + "time": "2026-06-29T15:41:09+00:00" }, { "name": "sebastian/cli-parser", - "version": "1.0.x-dev", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "4.2-dev" } }, "autoload": { @@ -6677,153 +7735,60 @@ "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - } - ], - "time": "2024-03-02T06:27:43+00:00" - }, - { - "name": "sebastian/code-unit", - "version": "1.0.8", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" - }, - "funding": [ + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2020-10-26T13:08:54+00:00" - }, - { - "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.3", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" - }, - "funding": [ + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "time": "2020-09-28T05:30:19+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.x-dev", + "version": "7.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "b247957a1c8dc81a671770f74b479c0a78a818f1" + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b247957a1c8dc81a671770f74b479c0a78a818f1", - "reference": "b247957a1c8dc81a671770f74b479c0a78a818f1", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/diff": "^4.0", - "sebastian/exporter": "^4.0" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.1-dev" } }, "autoload": { @@ -6862,41 +7827,54 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0" + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2022-09-14T12:46:14+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", - "version": "2.0.x-dev", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -6919,7 +7897,8 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" }, "funding": [ { @@ -6927,33 +7906,33 @@ "type": "github" } ], - "time": "2023-12-22T06:19:30+00:00" + "time": "2025-02-07T04:55:25+00:00" }, { "name": "sebastian/diff", - "version": "4.0.x-dev", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3", - "symfony/process": "^4.2 || ^5" + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -6985,35 +7964,48 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2024-03-02T06:30:58+00:00" + "time": "2026-08-25T15:35:54+00:00" }, { "name": "sebastian/environment", - "version": "5.1.x-dev", + "version": "8.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.26" }, "suggest": { "ext-posix": "*" @@ -7021,7 +8013,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.1-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -7040,7 +8032,7 @@ } ], "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", + "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ "Xdebug", "environment", @@ -7048,42 +8040,55 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1" + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2023-02-03T06:03:51+00:00" + "time": "2026-05-25T13:40:20+00:00" }, { "name": "sebastian/exporter", - "version": "4.0.x-dev", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/recursion-context": "^4.0" + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -7125,46 +8130,56 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.x-dev", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-uopz": "*" + "phpunit/phpunit": "^12.5.28" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -7183,47 +8198,60 @@ } ], "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ "global state" ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2026-06-01T15:10:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "1.0.x-dev", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -7246,42 +8274,55 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2023-12-22T06:20:34+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", - "version": "4.0.4", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -7303,7 +8344,8 @@ "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" }, "funding": [ { @@ -7311,32 +8353,32 @@ "type": "github" } ], - "time": "2020-10-26T13:12:34+00:00" + "time": "2025-02-07T04:57:48+00:00" }, { "name": "sebastian/object-reflector", - "version": "2.0.4", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -7358,7 +8400,8 @@ "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" }, "funding": [ { @@ -7366,32 +8409,32 @@ "type": "github" } ], - "time": "2020-10-26T13:14:26+00:00" + "time": "2025-02-07T04:58:17+00:00" }, { "name": "sebastian/recursion-context", - "version": "4.0.x-dev", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -7421,41 +8464,53 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T06:07:39+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { - "name": "sebastian/resource-operations", - "version": "dev-main", + "name": "sebastian/type", + "version": "6.0.4", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "ff553e7482dcee39fa4acc2b175d6ddeb0f7bc25" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ff553e7482dcee39fa4acc2b175d6ddeb0f7bc25", - "reference": "ff553e7482dcee39fa4acc2b175d6ddeb0f7bc25", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^12.5.25" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -7470,46 +8525,58 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "source": "https://github.com/sebastianbergmann/resource-operations/tree/main" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2024-03-14T18:47:08+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { - "name": "sebastian/type", - "version": "3.2.x-dev", + "name": "sebastian/version", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", "shasum": "" }, "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.5" + "php": ">=8.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -7528,11 +8595,12 @@ "role": "lead" } ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" }, "funding": [ { @@ -7540,85 +8608,84 @@ "type": "github" } ], - "time": "2023-02-03T06:13:03+00:00" + "time": "2025-02-07T05:00:38+00:00" }, { - "name": "sebastian/version", - "version": "3.0.x-dev", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { - "php": ">=7.3" + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" }, + "type": "library", "autoload": { "classmap": [ - "src/" + "lib/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/staabm", "type": "github" } ], - "time": "2020-09-28T06:39:44+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { "name": "symfony/yaml", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "93e29e0deb5f1b2e360adfb389a20d25eb81a27b" + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/93e29e0deb5f1b2e360adfb389a20d25eb81a27b", - "reference": "93e29e0deb5f1b2e360adfb389a20d25eb81a27b", + "url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<5.4" + "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -7649,7 +8716,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/6.4" + "source": "https://github.com/symfony/yaml/tree/v7.4.18" }, "funding": [ { @@ -7660,32 +8727,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-03T06:46:12+00:00" + "time": "2026-08-30T00:47:26+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -7707,7 +8778,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -7715,7 +8786,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], @@ -7726,7 +8797,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.0|^8.1|^8.2|^8.3" + "php": "^8.3" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2f7d124..6b1c0f9 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,23 +1,21 @@ - - - src/ - - ./tests/Unit @@ -26,6 +24,11 @@ ./tests/Integration + + + src/ + + diff --git a/src/Models/Customer.php b/src/Models/Customer.php index fc1babb..09ab125 100644 --- a/src/Models/Customer.php +++ b/src/Models/Customer.php @@ -182,12 +182,12 @@ public function setDefaultCard(string $cardId): Customer * @param string $creditCardId * @param string|GatewayContract|null $gateway * - * @return static + * @return CreditCard * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException */ - public function getCreditCard(string $creditCardId, GatewayContract|string $gateway = null): CreditCard + public function getCreditCard(string $creditCardId, GatewayContract|string|null $gateway = null): CreditCard { $gateway = ConfigurationHelper::resolveGateway($gateway); $creditCard = new CreditCard(); @@ -207,7 +207,7 @@ public function getCreditCard(string $creditCardId, GatewayContract|string $gate * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException */ - public function deleteCreditCard(string $creditCardId, GatewayContract|string $gateway = null): void + public function deleteCreditCard(string $creditCardId, GatewayContract|string|null $gateway = null): void { $gateway = ConfigurationHelper::resolveGateway($gateway); $creditCard = new CreditCard(); diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index b9c3848..373331b 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -271,7 +271,7 @@ public function validateAutomaticPixAttribute(): void /** * @inheritDoc */ - public function save(GatewayContract|string $gateway = null, bool $validate = true): void + public function save(GatewayContract|string|null $gateway = null, bool $validate = true): void { if ($validate) { $this->validate(); diff --git a/src/Models/Model.php b/src/Models/Model.php index 36db79b..5b62564 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -42,7 +42,7 @@ public function create(array $data, $gateway = null): void * @return void * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException */ - public function save(GatewayContract|string $gateway = null, bool $validate = true): void + public function save(GatewayContract|string|null $gateway = null, bool $validate = true): void { $class = $this->getClassName(); if (property_exists($this, 'id') && !empty($this->id)) { @@ -154,14 +154,13 @@ protected static function getClassName(): string /** * Get the model instance by id in the gateway. * - * @param string $id * @param string|GatewayContract|null $gateway * * @return static * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ - public function get(GatewayContract|string $gateway = null): static + public function get(GatewayContract|string|null $gateway = null): static { $method = 'get' . static::getClassName(); $gateway = ConfigurationHelper::resolveGateway($gateway); @@ -179,7 +178,7 @@ public function get(GatewayContract|string $gateway = null): static * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ - public function delete(GatewayContract|string $gateway = null): void + public function delete(GatewayContract|string|null $gateway = null): void { $method = 'delete' . static::getClassName(); $gateway = ConfigurationHelper::resolveGateway($gateway); @@ -192,7 +191,7 @@ public function delete(GatewayContract|string $gateway = null): void /** * Refresh the model instance with the latest data from the gateway. */ - public function refresh(GatewayContract|string $gateway = null): static + public function refresh(GatewayContract|string|null $gateway = null): static { $gateway = ConfigurationHelper::resolveGateway($gateway); return $this->get($gateway); diff --git a/src/Models/Plan.php b/src/Models/Plan.php index b5a518f..236a830 100644 --- a/src/Models/Plan.php +++ b/src/Models/Plan.php @@ -80,7 +80,7 @@ class Plan extends Model * @throws GatewayException|\Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException */ - public function save(GatewayContract|string $gateway = null, bool $validate = true): void + public function save(GatewayContract|string|null $gateway = null, bool $validate = true): void { if (!empty($this->id)) { throw new GatewayException( diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index 4d78fac..b35c6ae 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -332,7 +332,7 @@ protected function attributesExtraValidation(array $attributes): void * @throws GatewayException|\Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException */ - public function save(GatewayContract|string $gateway = null, bool $validate = true): void + public function save(GatewayContract|string|null $gateway = null, bool $validate = true): void { if ($validate) { $this->validate(); diff --git a/tests/Integration/Builders/CreditCardBuilderTest.php b/tests/Integration/Builders/CreditCardBuilderTest.php index e618b2b..c9f950b 100644 --- a/tests/Integration/Builders/CreditCardBuilderTest.php +++ b/tests/Integration/Builders/CreditCardBuilderTest.php @@ -4,23 +4,16 @@ use Potelo\MultiPayment\Tests\TestCase; use Potelo\MultiPayment\Facades\MultiPayment; +use PHPUnit\Framework\Attributes\DataProvider; class CreditCardBuilderTest extends TestCase { - - public function __construct(?string $name = null, array $data = [], $dataName = '') - { - parent::__construct($name, $data, $dataName); - $this->createApplication(); - } - /** * Should create a credit card. * - * @dataProvider shouldCreateACreditCardDataProvider - * * @return void */ + #[DataProvider('shouldCreateACreditCardDataProvider')] public function testShouldCreateACreditCard($gateway, $data) { @@ -77,7 +70,7 @@ public function testShouldCreateACreditCard($gateway, $data) $this->assertEquals($gateway, $creditCard->gateway); } - public function shouldCreateACreditCardDataProvider(): array + public static function shouldCreateACreditCardDataProvider(): array { return [ 'iugu - with credit card data' => [ @@ -92,7 +85,7 @@ public function shouldCreateACreditCardDataProvider(): array * * @return array[] */ - public function shouldCreateACreditCardWithHashDataProvider(): array + public static function shouldCreateACreditCardWithHashDataProvider(): array { return [ 'iugu - with hash' => [ @@ -109,13 +102,12 @@ public function shouldCreateACreditCardWithHashDataProvider(): array /** * Should create a credit card using token. * - * @dataProvider shouldCreateACreditCardWithHashDataProvider - * * @param $gateway * @param $data * * @return void */ + #[DataProvider('shouldCreateACreditCardWithHashDataProvider')] public function testShouldCreateACreditCardWithHash($gateway, $data) { diff --git a/tests/Integration/Builders/CustomerBuilderTest.php b/tests/Integration/Builders/CustomerBuilderTest.php index fbcfe01..bd450b0 100644 --- a/tests/Integration/Builders/CustomerBuilderTest.php +++ b/tests/Integration/Builders/CustomerBuilderTest.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Potelo\MultiPayment\Tests\TestCase; use Potelo\MultiPayment\Facades\MultiPayment; +use PHPUnit\Framework\Attributes\DataProvider; class CustomerBuilderTest extends TestCase { @@ -25,10 +26,9 @@ public static function shouldCreateACustomerDataProvider(): array /** * Should create a credit card. * - * @dataProvider shouldCreateACustomerDataProvider - * * @return void */ + #[DataProvider('shouldCreateACustomerDataProvider')] public function testShouldCreateACustomer($gateway) { $data = self::customerWithAddress(); @@ -109,10 +109,9 @@ public function testShouldCreateACustomer($gateway) /** * Should create a customer without address. * - * @dataProvider shouldCreateACustomerDataProvider - * * @return void */ + #[DataProvider('shouldCreateACustomerDataProvider')] public function testShouldCreateACustomerWithoutAddress($gateway) { $data = self::customerWithoutAddress(); @@ -157,10 +156,10 @@ public function testShouldCreateACustomerWithoutAddress($gateway) /** * Should update a customer. * - * @dataProvider shouldCreateACustomerDataProvider * @param string $gateway * @return void */ + #[DataProvider('shouldCreateACustomerDataProvider')] public function testShouldUpdateACustomer($gateway) { $data = self::customerWithAddress(); diff --git a/tests/Integration/Builders/InvoiceBuilderTest.php b/tests/Integration/Builders/InvoiceBuilderTest.php index 50aa027..422ef05 100644 --- a/tests/Integration/Builders/InvoiceBuilderTest.php +++ b/tests/Integration/Builders/InvoiceBuilderTest.php @@ -7,17 +7,18 @@ use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Exceptions\ChargingException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; class InvoiceBuilderTest extends TestCase { /** - * @group iugu-sandbox-limitation - * * A sandbox da Iugu rejeita a criação de faturas com Pix Automático. O * cenário permanece completo para ser reativado quando o recurso estiver * disponível no ambiente de testes. */ + #[Group('iugu-sandbox-limitation')] public function testShouldCreateAutomaticPixInvoice(): void { $this->markTestSkipped( @@ -119,8 +120,6 @@ private function createInvoice(string $gateway, array $data): Invoice /** * Create invoice test. * - * @dataProvider shouldCreateInvoiceDataProvider - * * @param string $gateway * @param array $data * @@ -128,6 +127,7 @@ private function createInvoice(string $gateway, array $data): Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException */ + #[DataProvider('shouldCreateInvoiceDataProvider')] public function testShouldCreateInvoice(string $gateway, array $data): void { $invoice = $this->createInvoice($gateway, $data); @@ -259,7 +259,7 @@ public function testShouldCreateInvoice(string $gateway, array $data): void /** * @return array[] */ - public function shouldCreateInvoiceDataProvider(): array + public static function shouldCreateInvoiceDataProvider(): array { return [ 'iugu - without payment method' => [ @@ -351,20 +351,19 @@ public function shouldCreateInvoiceDataProvider(): array /** * Fail to create invoice test. * - * @dataProvider shouldNotCreateInvoiceDataProvider - * * @param string $gateway * @param array $data * * @return void */ + #[DataProvider('shouldNotCreateInvoiceDataProvider')] public function testShouldNotCreateInvoice(string $gateway, array $data): void { $this->expectException(ChargingException::class); $this->createInvoice($gateway, $data); } - public function shouldNotCreateInvoiceDataProvider(): array + public static function shouldNotCreateInvoiceDataProvider(): array { return [ 'iugu - credit card - charge fail' => [ diff --git a/tests/Integration/MultiPaymentTest.php b/tests/Integration/MultiPaymentTest.php index c6badcc..e86239e 100644 --- a/tests/Integration/MultiPaymentTest.php +++ b/tests/Integration/MultiPaymentTest.php @@ -7,16 +7,17 @@ use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Facades\MultiPayment; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; class MultiPaymentTest extends TestCase { /** - * @group iugu-sandbox-limitation - * * A consulta depende de uma fatura com Pix Automático criada no próprio * teste, mas a sandbox da Iugu ainda rejeita essa criação. */ + #[Group('iugu-sandbox-limitation')] public function testShouldGetAutomaticPixInvoice(): void { $this->markTestSkipped( @@ -58,11 +59,10 @@ public function testShouldGetAutomaticPixInvoice(): void } /** - * @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. */ + #[Group('iugu-sandbox-limitation')] public function testShouldRescheduleAutomaticPixPayment(): void { $this->markTestSkipped( @@ -71,11 +71,10 @@ public function testShouldRescheduleAutomaticPixPayment(): void } /** - * @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. */ + #[Group('iugu-sandbox-limitation')] public function testShouldCancelAutomaticPixRecurrence(): void { $this->markTestSkipped( @@ -84,11 +83,10 @@ public function testShouldCancelAutomaticPixRecurrence(): void } /** - * @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. */ + #[Group('iugu-sandbox-limitation')] public function testShouldCancelAutomaticPixScheduledPayment(): void { $this->markTestSkipped( @@ -97,11 +95,10 @@ public function testShouldCancelAutomaticPixScheduledPayment(): void } /** - * @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. */ + #[Group('iugu-sandbox-limitation')] public function testShouldGetAutomaticPixCancellation(): void { $this->markTestSkipped( @@ -110,11 +107,10 @@ public function testShouldGetAutomaticPixCancellation(): void } /** - * @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. */ + #[Group('iugu-sandbox-limitation')] public function testShouldListAutomaticPixCancellations(): void { $this->markTestSkipped( @@ -286,14 +282,13 @@ public function testShouldDuplicateInvoice() /** * Test if thorws an exception when not find the invoice * - * @dataProvider shouldNotGetInvoiceDataProvider - * * @param $gateway * @param $id * * @return void * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ + #[DataProvider('shouldNotGetInvoiceDataProvider')] public function testShouldNotGetInvoice($gateway, $id) { $this->expectException(\Potelo\MultiPayment\Exceptions\GatewayException::class); @@ -304,7 +299,7 @@ public function testShouldNotGetInvoice($gateway, $id) /** * @return array */ - public function shouldNotGetInvoiceDataProvider(): array + public static function shouldNotGetInvoiceDataProvider(): array { return [ 'iugu' => ['iugu', '4DAF50DDAA1E461CBA9ECF813111FC0B'], @@ -314,8 +309,6 @@ public function shouldNotGetInvoiceDataProvider(): array /** * Test if can refund the invoice * - * @dataProvider shouldRefundInvoiceDataProvider - * * @param string $gateway * @param array $data * @@ -324,6 +317,7 @@ public function shouldNotGetInvoiceDataProvider(): array * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException */ + #[DataProvider('shouldRefundInvoiceDataProvider')] public function testShouldRefundInvoice(string $gateway, array $data, string $status, ?int $refundedAmount) { $multiPayment = new \Potelo\MultiPayment\MultiPayment($gateway); @@ -368,7 +362,7 @@ public function testShouldRefundInvoice(string $gateway, array $data, string $st /** * @return array */ - public function shouldRefundInvoiceDataProvider(): array + public static function shouldRefundInvoiceDataProvider(): array { return [ 'iugu - credit card - full refund' => [ @@ -389,8 +383,6 @@ public function shouldRefundInvoiceDataProvider(): array /** * Test if can refund the invoice * - * @dataProvider shouldChargeInvoiceWithCreditCard - * * @param string $gateway * @param array $data * @param string $status @@ -403,6 +395,7 @@ public function shouldRefundInvoiceDataProvider(): array * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException * @throws \Potelo\MultiPayment\Exceptions\MultiPaymentException */ + #[DataProvider('shouldChargeInvoiceWithCreditCard')] public function testShouldChargeInvoiceWithCreditCard(string $gateway, array $data, string $status, string $creditCardDataMethod) { $multiPayment = new \Potelo\MultiPayment\MultiPayment($gateway); @@ -446,7 +439,7 @@ public function testShouldChargeInvoiceWithCreditCard(string $gateway, array $da /** * @return array */ - public function shouldChargeInvoiceWithCreditCard(): array + public static function shouldChargeInvoiceWithCreditCard(): array { return [ 'iugu - credit card object' => [ diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index 4640fb1..44aa1ce 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -7,6 +7,7 @@ use Potelo\MultiPayment\Facades\MultiPayment; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; +use PHPUnit\Framework\Attributes\DataProvider; /** * Cenários específicos do gateway Stripe na sandbox real. O fluxo de cartão é token-only: @@ -30,10 +31,9 @@ public static function stripeGatewayDataProvider(): array /** * Deve cobrar uma fatura de cartão com PaymentMethod de teste e refletir em getInvoice. * - * @dataProvider stripeGatewayDataProvider - * * @return void */ + #[DataProvider('stripeGatewayDataProvider')] public function testShouldChargeCreditCardInvoice($gateway) { $customerData = self::customerWithoutAddress(); @@ -73,10 +73,9 @@ public function testShouldChargeCreditCardInvoice($gateway) /** * Recusa de cartão deve virar ChargingException com resposta bruta e razão normalizada. * - * @dataProvider stripeGatewayDataProvider - * * @return void */ + #[DataProvider('stripeGatewayDataProvider')] public function testShouldRaiseChargingExceptionOnDeclinedCard($gateway) { $customerData = self::customerWithoutAddress(); @@ -105,10 +104,9 @@ public function testShouldRaiseChargingExceptionOnDeclinedCard($gateway) /** * Deve salvar, buscar, definir como padrão e excluir um cartão tokenizado. * - * @dataProvider stripeGatewayDataProvider - * * @return void */ + #[DataProvider('stripeGatewayDataProvider')] public function testShouldManageCreditCardLifecycle($gateway) { $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); @@ -142,10 +140,9 @@ public function testShouldManageCreditCardLifecycle($gateway) /** * Deve criar fatura pix server-side com QR code e refletir o pagamento mágico da sandbox. * - * @dataProvider stripeGatewayDataProvider - * * @return void */ + #[DataProvider('stripeGatewayDataProvider')] public function testShouldCreatePixInvoiceAndReceiveMagicPayment($gateway) { $customerData = self::customerWithoutAddress(); @@ -182,10 +179,9 @@ public function testShouldCreatePixInvoiceAndReceiveMagicPayment($gateway) /** * Deve cancelar uma fatura pix pendente. * - * @dataProvider stripeGatewayDataProvider - * * @return void */ + #[DataProvider('stripeGatewayDataProvider')] public function testShouldCancelPendingPixInvoice($gateway) { $customerData = self::customerWithoutAddress(); @@ -204,10 +200,9 @@ public function testShouldCancelPendingPixInvoice($gateway) /** * Fatura pix expirada volta a pendente e deve aceitar cobrança com cartão. * - * @dataProvider stripeGatewayDataProvider - * * @return void */ + #[DataProvider('stripeGatewayDataProvider')] public function testShouldChargeExpiredPixInvoiceWithCreditCard($gateway) { $customerData = self::customerWithoutAddress(); @@ -258,10 +253,9 @@ private function waitForInvoiceCondition(string $gateway, string $invoiceId, cal /** * Deve estornar integralmente uma fatura de cartão paga. * - * @dataProvider stripeGatewayDataProvider - * * @return void */ + #[DataProvider('stripeGatewayDataProvider')] public function testShouldRefundCreditCardInvoiceTotally($gateway) { $customerData = self::customerWithoutAddress(); @@ -282,10 +276,9 @@ public function testShouldRefundCreditCardInvoiceTotally($gateway) /** * Deve estornar parcialmente uma fatura pix paga. * - * @dataProvider stripeGatewayDataProvider - * * @return void */ + #[DataProvider('stripeGatewayDataProvider')] public function testShouldRefundPixInvoicePartially($gateway) { $customerData = self::customerWithoutAddress(); @@ -309,10 +302,9 @@ public function testShouldRefundPixInvoicePartially($gateway) /** * Deve duplicar uma fatura pix pendente com nova expiração, cancelando a original. * - * @dataProvider stripeGatewayDataProvider - * * @return void */ + #[DataProvider('stripeGatewayDataProvider')] public function testShouldDuplicatePendingPixInvoice($gateway) { $customerData = self::customerWithoutAddress(); @@ -347,10 +339,9 @@ public function testShouldDuplicatePendingPixInvoice($gateway) /** * Boleto está fora do escopo do gateway Stripe e deve falhar com mensagem específica. * - * @dataProvider stripeGatewayDataProvider - * * @return void */ + #[DataProvider('stripeGatewayDataProvider')] public function testShouldRejectBankSlipInvoice($gateway) { $customerData = self::customerWithoutAddress(); diff --git a/tests/TestCase.php b/tests/TestCase.php index c843767..f001fe7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,23 +8,18 @@ class TestCase extends \Orchestra\Testbench\TestCase { - public function __construct(?string $name = null, array $data = [], $dataName = '') - { - parent::__construct($name, $data, $dataName); - \Iugu::setLogErrors(false); - } - protected function setUp(): void { parent::setUp(); + \Iugu::setLogErrors(false); - if (in_array('iugu-sandbox-limitation', $this->getGroups(), true)) { + if (in_array('iugu-sandbox-limitation', $this->groups(), true)) { return; } // pausa para respeitar o rate limit da sandbox da Iugu — a da Stripe não tem esse limite; // o gateway do teste vem do dataProvider (primeiro argumento, posicional ou chave 'gateway') - $providedData = $this->getProvidedData(); + $providedData = $this->providedData(); if (($providedData[0] ?? $providedData['gateway'] ?? null) !== 'stripe') { sleep(12); } diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index cc0366a..251d50b 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -17,6 +17,7 @@ use Potelo\MultiPayment\Models\SubscriptionDiscount; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; +use PHPUnit\Framework\Attributes\DataProvider; class IuguGatewaySubscriptionTest extends TestCase { @@ -172,9 +173,7 @@ public function testParseReadsNextBillingFromExpiresAt(): void $this->assertSame('iugu', $subscription->gateway); } - /** - * @dataProvider statusProvider - */ + #[DataProvider('statusProvider')] public function testParseMapsIuguFlagsToGenericStatus(array $flags, ?string $expected): void { $api = new QueuedIuguApiRequest([$this->subscriptionResponse($flags)]); @@ -886,9 +885,7 @@ public function testNextBillingAtAndTrialEndsAtTogetherAreRejected(): void (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); } - /** - * @dataProvider payableWithProvider - */ + #[DataProvider('payableWithProvider')] public function testParseMapsPayableWithBackToGenericMethods($payableWith, array $expected): void { $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['payable_with' => $payableWith])]); @@ -976,9 +973,7 @@ public function testListPlansPaginates(): void $this->assertCount(1, $plans); } - /** - * @dataProvider methodsThatRequireSubscriptionIdProvider - */ + #[DataProvider('methodsThatRequireSubscriptionIdProvider')] public function testMethodsRequireTheSubscriptionId(callable $call): void { $this->expectException(ModelAttributeValidationException::class); @@ -1016,9 +1011,7 @@ public function testListSubscriptionsRequiresTheCustomerId(): void (new IuguGateway(new QueuedIuguApiRequest([])))->listSubscriptions(new Customer()); } - /** - * @dataProvider invalidPaginationProvider - */ + #[DataProvider('invalidPaginationProvider')] public function testPaginationBoundsAreRejected(int $page, int $limit, string $mensagem): void { $customer = new Customer(); @@ -1031,9 +1024,7 @@ public function testPaginationBoundsAreRejected(int $page, int $limit, string $m $gateway->listSubscriptions($customer, $page, $limit); } - /** - * @dataProvider invalidPaginationProvider - */ + #[DataProvider('invalidPaginationProvider')] public function testPlanPaginationBoundsAreRejected(int $page, int $limit, string $mensagem): void { $gateway = new IuguGateway(new QueuedIuguApiRequest([])); @@ -1317,9 +1308,8 @@ public function testPlanChangeFallsBackWhenCostIsNotNumeric(): void /** * Entre faturas do mesmo estado, vence a de maior vencimento, em qualquer ordem de resposta. - * - * @dataProvider ordemProvider */ + #[DataProvider('ordemProvider')] public function testLatestInvoiceDoesNotDependOnTheOrderIuguReturns(array $recentInvoices): void { $api = new QueuedIuguApiRequest([ @@ -1350,9 +1340,8 @@ public static function ordemProvider(): array /** * Vencimento igual é desempatado pelo menor id, qualquer que seja o estado das faturas. - * - * @dataProvider tieProvider */ + #[DataProvider('tieProvider')] public function testSameDueDateIsBrokenByTheSmallestId(array $recentInvoices): void { $api = new QueuedIuguApiRequest([ @@ -1437,9 +1426,8 @@ public function testParseKeepsTheCustomerItAlreadyHad(): void /** * Empate de vencimento e de estado é resolvido pelo menor id, em qualquer ordem. - * - * @dataProvider mesmaSituacaoProvider */ + #[DataProvider('mesmaSituacaoProvider')] public function testLatestInvoiceIsStableWhenDueDateAndStateTie(array $recentInvoices): void { $api = new QueuedIuguApiRequest([ @@ -1685,9 +1673,8 @@ public function testRecentInvoiceWithoutIdDoesNotHijackTheChoice(): void } /** * Entrada sem vencimento perde para qualquer uma com data, em qualquer ordem. - * - * @dataProvider semDataProvider */ + #[DataProvider('semDataProvider')] public function testEntryWithoutDueDateLosesToOneWithIt(array $recentInvoices): void { $api = new QueuedIuguApiRequest([ diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index de70edb..9b25dfb 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -16,6 +16,7 @@ use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; +use PHPUnit\Framework\Attributes\DataProvider; class StripeGatewayInvoiceTest extends TestCase { @@ -583,9 +584,7 @@ public static function paymentIntentStatusDataProvider(): array ]; } - /** - * @dataProvider paymentIntentStatusDataProvider - */ + #[DataProvider('paymentIntentStatusDataProvider')] public function testStatusMapping(string $stripeStatus, string $expected): void { $response = $this->paidCardPaymentIntentResponse(status: $stripeStatus); diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php index ae1822f..fcb7e8e 100644 --- a/tests/Unit/SubscriptionTest.php +++ b/tests/Unit/SubscriptionTest.php @@ -18,6 +18,7 @@ use Potelo\MultiPayment\Models\SubscriptionPlanChange; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; +use PHPUnit\Framework\Attributes\DataProvider; class SubscriptionTest extends TestCase { @@ -401,9 +402,7 @@ public function testFillAndToArrayHandleTheLatestInvoice(): void $this->assertSame('inv_1', $subscription->toArray()['latest_invoice']['id']); } - /** - * @dataProvider lifecycleProvider - */ + #[DataProvider('lifecycleProvider')] public function testModelDelegatesLifecycleToTheGateway( string $gatewayMethod, array $gatewayArgs, @@ -596,9 +595,7 @@ public function testPlanWithIdCannotBeSavedAgain(): void $plan->save(Mockery::mock(GatewayContract::class)); } - /** - * @dataProvider listOperationsProvider - */ + #[DataProvider('listOperationsProvider')] public function testListOperationsRejectAGatewayWithoutTheContract(string $metodo, array $args, string $contract): void { $multiPayment = new \Potelo\MultiPayment\MultiPayment(Mockery::mock(GatewayContract::class)); From 633753bab181e75e564fc5863598e09ce9e05731 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 06:30:30 -0300 Subject: [PATCH 11/32] fix(status): separa disputa e chargeback de pago e estornado nos dois drivers --- README.md | 40 ++++ src/Gateways/IuguGateway.php | 6 +- src/Gateways/StripeGateway.php | 67 ++++++- src/Models/Invoice.php | 44 +++++ .../Gateways/IuguGatewayInvoiceStatusTest.php | 164 ++++++++++++++++ .../Gateways/IuguGatewaySubscriptionTest.php | 27 --- tests/Unit/Gateways/QueuedIuguApiRequest.php | 31 +++ .../Gateways/StripeGatewayInvoiceTest.php | 182 ++++++++++++++++++ tests/Unit/InvoiceTest.php | 50 +++++ 9 files changed, 577 insertions(+), 34 deletions(-) create mode 100644 tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php create mode 100644 tests/Unit/Gateways/QueuedIuguApiRequest.php create mode 100644 tests/Unit/InvoiceTest.php diff --git a/README.md b/README.md index f24398f..4221954 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [Configuração](#configuração) - [Gateways](#gateways) - [Suporte por gateway](#suporte-por-gateway) + - [Status da fatura](#status-da-fatura) - [Particularidades do Stripe](#particularidades-do-stripe) - [Utilizando](#utilizando) - [MultiPayment](#multipayment) @@ -103,6 +104,41 @@ Também é possível utilizar o Facade: 🚧 = ainda não implementado no gateway; hoje a chamada lança `GatewayException`. +### Status da fatura + +`Invoice::$status` usa sempre o vocabulário do pacote; o status específico de cada gateway fica +em `original`. Mapa atual: + +| Status genérico | Significado | Iugu | Stripe | +|---|---|---|---| +| `pending` | Aguardando pagamento | `pending`, `in_analysis`, `draft`, `partially_paid` | PaymentIntent em `processing`, `requires_action`, `requires_confirmation`, `requires_payment_method`, `requires_capture` | +| `paid` | Valor recebido | `paid`, `externally_paid`, `authorized` | PaymentIntent `succeeded` sem estorno nem contestação | +| `canceled` | Cancelada ou vencida sem pagamento | `canceled`, `expired` | PaymentIntent `canceled` | +| `refunded` | Estorno voluntário, integral | `refunded` | charge com `refunded = true` | +| `partially_refunded` | Estorno voluntário, parcial | `partially_refunded` | charge com `amount_refunded` menor que o total | +| `disputed` | Contestação aberta sobre fatura paga, resolução pendente | `in_protest` | charge `disputed` com dispute em `warning_needs_response`, `warning_under_review`, `needs_response` ou `under_review` | +| `chargeback` | Contestação perdida: valor devolvido ao cliente pelo gateway. Terminal | `chargeback` | dispute em `lost` | + +Dispute ganha (`won`), encerrada sem virar chargeback (`warning_closed`) ou prevenida +(`prevented`) não altera o status: a fatura volta a ler como `paid` (ou como estornada, se +houve estorno). Um status fora do mapa lança `GatewayException`. Estados próprios para captura +tardia, vencimento e pagamento parcial estão planejados para uma versão futura. + +Para não comparar status um a um, a `Invoice` traz dois helpers estáticos: + +```php +Invoice::isSettled($invoice->status); // recebi o dinheiro? paid ou partially_refunded +Invoice::isContested($invoice->status); // tem briga aberta? disputed ou chargeback +``` + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, fatura Iugu em `in_protest` lia como +> `paid` e fatura em `chargeback` lia como `refunded`; no Stripe, charge contestado lia como +> `paid`. A partir desta versão elas leem como `disputed` e `chargeback`. Quem compara com +> `Invoice::STATUS_PAID` para decidir se recebeu **deixa de ver faturas em disputa como pagas**, +> e quem compara com `STATUS_REFUNDED` deixa de confundir chargeback com estorno voluntário. Se +> a aplicação precisava do comportamento antigo, use `Invoice::isSettled()` para "pago" e trate +> `disputed` e `chargeback` explicitamente. + ### Particularidades do Stripe - **Cartão é token-only.** O Stripe não aceita dados crus de cartão pela API (exigiria @@ -130,6 +166,10 @@ Também é possível utilizar o Facade: como na Iugu (`secure_url`). - **`fee` é assíncrono para cartão**: pode vir `null` logo após a cobrança e preenchido em um `getInvoice` posterior. +- **Contestação custa uma requisição a mais.** O charge da Stripe só traz a flag `disputed`; + quando ela é verdadeira, o pacote consulta `/v1/disputes` do charge para decidir entre + `disputed` e `chargeback` (ver [Status da fatura](#status-da-fatura)). Fatura sem contestação + não paga esse GET. - **Idempotência**: envie `gateway_adicional_options['idempotency_key']` na criação de faturas e estornos para repassar o cabeçalho `Idempotency-Key` da Stripe. diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index f92ff12..259cc2e 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -198,14 +198,16 @@ private static function iuguStatusToMultiPayment($iuguStatus): string case self::STATUS_PAID: case self::STATUS_EXTERNALLY_PAID: case self::STATUS_AUTHORIZED: - case self::STATUS_IN_PROTEST: return Invoice::STATUS_PAID; + case self::STATUS_IN_PROTEST: + return Invoice::STATUS_DISPUTED; case self::STATUS_CANCELED: case self::STATUS_EXPIRED: return Invoice::STATUS_CANCELED; case self::STATUS_REFUNDED: - case self::STATUS_CHARGEBACK: return Invoice::STATUS_REFUNDED; + case self::STATUS_CHARGEBACK: + return Invoice::STATUS_CHARGEBACK; case self::STATUS_PARTIALLY_REFUNDED: return Invoice::STATUS_PARTIALLY_REFUNDED; default: diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 0eb731c..7930ea9 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -48,6 +48,22 @@ class StripeGateway implements GatewayContract */ private const PAYMENT_INTENT_EXPAND = ['latest_charge.balance_transaction']; + /** + * Status de dispute da Stripe que significam contestação em aberto: inquiry ou chargeback + * formal aguardando resposta ou em análise. Lista oficial dos oito status em + * https://docs.stripe.com/api/disputes/object#dispute_object-status; os demais são `won`, + * `lost`, `warning_closed` e `prevented`. + */ + private const OPEN_DISPUTE_STATUSES = [ + 'warning_needs_response', + 'warning_under_review', + 'needs_response', + 'under_review', + ]; + + /** Dispute resolvida a favor do cliente: a Stripe devolveu o valor. */ + private const LOST_DISPUTE_STATUS = 'lost'; + /** Mapa de tipos de PaymentMethod da Stripe para os métodos genéricos do pacote. */ private const PAYMENT_METHOD_TYPES = [ 'card' => Invoice::PAYMENT_METHOD_CREDIT_CARD, @@ -779,7 +795,7 @@ public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice * @param \Stripe\PaymentIntent $stripePaymentIntent * @param \Potelo\MultiPayment\Models\Invoice|null $invoice * @return \Potelo\MultiPayment\Models\Invoice - * @throws GatewayException + * @throws GatewayException|GatewayNotAvailableException */ private function parseInvoice(StripePaymentIntent $stripePaymentIntent, ?Invoice $invoice = null): Invoice { @@ -789,10 +805,11 @@ private function parseInvoice(StripePaymentIntent $stripePaymentIntent, ?Invoice // não pode alimentar paidAmount/refundedAmount $stripeCharge = is_object($stripePaymentIntent->latest_charge) ? $stripePaymentIntent->latest_charge : null; $paidCharge = ($stripeCharge && $stripeCharge->status === 'succeeded') ? $stripeCharge : null; + $disputeStatus = $paidCharge ? $this->disputeStatus($paidCharge) : null; $invoice->id = $stripePaymentIntent->id; $invoice->gateway = 'stripe'; - $invoice->status = self::stripeStatusToMultiPayment($stripePaymentIntent, $paidCharge); + $invoice->status = self::stripeStatusToMultiPayment($stripePaymentIntent, $paidCharge, $disputeStatus); $invoice->amount = $stripePaymentIntent->amount; $invoice->paidAmount = $paidCharge?->amount_captured; $invoice->refundedAmount = $paidCharge?->amount_refunded; @@ -873,16 +890,56 @@ private function parseInvoice(StripePaymentIntent $stripePaymentIntent, ?Invoice } /** - * Deriva o status genérico do par PaymentIntent + charge — estorno não muda o status - * do PaymentIntent na Stripe, então ele vem do charge. + * Deriva o status genérico de contestação de um charge pago. O Charge da Stripe só traz a + * flag `disputed`; a dispute não é expansível a partir dele, então um charge disputado + * custa um GET a mais em /v1/disputes. Contestação em aberto tem precedência sobre + * perdida; dispute ganha, encerrada sem virar chargeback (`warning_closed`) ou prevenida + * não altera o status da fatura. + * + * @param object $stripeCharge + * @return string|null `Invoice::STATUS_DISPUTED`, `Invoice::STATUS_CHARGEBACK` ou null + * @throws GatewayException|GatewayNotAvailableException + */ + private function disputeStatus(object $stripeCharge): ?string + { + // isset() passa pelo __isset e não loga "Undefined property" quando a chave falta + if (!isset($stripeCharge->disputed) || !$stripeCharge->disputed) { + return null; + } + + $disputes = $this->stripeRequest(function () use ($stripeCharge) { + // uma página basta: um charge não acumula dezenas de disputes + return $this->client->disputes->all(['charge' => $stripeCharge->id, 'limit' => 100]); + }); + + $statuses = array_map(static fn ($dispute) => $dispute->status, $disputes->data ?? []); + if (!empty(array_intersect($statuses, self::OPEN_DISPUTE_STATUSES))) { + return Invoice::STATUS_DISPUTED; + } + if (in_array(self::LOST_DISPUTE_STATUS, $statuses, true)) { + return Invoice::STATUS_CHARGEBACK; + } + + return null; + } + + /** + * Deriva o status genérico do par PaymentIntent + charge. Estorno não muda o status do + * PaymentIntent na Stripe, então ele vem do charge. Contestação, quando existe, vence os + * dois: uma fatura disputada não lê como paga nem como estornada. * * @param \Stripe\PaymentIntent $stripePaymentIntent * @param object|null $paidCharge + * @param string|null $disputeStatus resultado de disputeStatus() para o charge pago * @return string * @throws GatewayException */ - private static function stripeStatusToMultiPayment(StripePaymentIntent $stripePaymentIntent, ?object $paidCharge): string + private static function stripeStatusToMultiPayment(StripePaymentIntent $stripePaymentIntent, ?object $paidCharge, ?string $disputeStatus = null): string { + if ($disputeStatus !== null) { + return $disputeStatus; + } + if ($paidCharge && $paidCharge->amount_refunded > 0) { return $paidCharge->refunded ? Invoice::STATUS_REFUNDED diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 373331b..6eedd4a 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -18,6 +18,19 @@ class Invoice extends Model public const STATUS_REFUNDED = 'refunded'; public const STATUS_PARTIALLY_REFUNDED = 'partially_refunded'; + /** + * Contestação aberta sobre uma fatura paga, com resolução pendente. Enquanto a disputa + * corre, o gateway pode ou não reter o valor (a Stripe retém no chargeback formal, não + * na inquiry). Se ganha, a fatura volta a `paid`; se perdida, vira `chargeback`. + */ + public const STATUS_DISPUTED = 'disputed'; + + /** + * Contestação perdida: o valor foi devolvido ao cliente pelo gateway. Estado terminal, + * distinto de `refunded`, que é o estorno voluntário feito pela aplicação. + */ + public const STATUS_CHARGEBACK = 'chargeback'; + public const PAYMENT_METHOD_CREDIT_CARD = 'credit_card'; public const PAYMENT_METHOD_BANK_SLIP = 'bank_slip'; public const PAYMENT_METHOD_PIX = 'pix'; @@ -285,6 +298,37 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate parent::save($gateway, false); } + /** + * Responde "o dinheiro desta fatura foi recebido?" sem que o consumidor precise conhecer + * cada status: verdadeiro para `paid` e `partially_refunded`. Fatura em disputa não conta + * como recebida enquanto a contestação estiver aberta. + * + * @param string $status + * @return bool + */ + public static function isSettled(string $status): bool + { + return in_array($status, [ + self::STATUS_PAID, + self::STATUS_PARTIALLY_REFUNDED, + ], true); + } + + /** + * Responde "existe contestação sobre esta fatura?": verdadeiro para `disputed` (aberta) e + * `chargeback` (perdida). + * + * @param string $status + * @return bool + */ + public static function isContested(string $status): bool + { + return in_array($status, [ + self::STATUS_DISPUTED, + self::STATUS_CHARGEBACK, + ], true); + } + /** * Refund the invoice * diff --git a/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php b/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php new file mode 100644 index 0000000..cd2b338 --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php @@ -0,0 +1,164 @@ +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(); + } + + /** + * Mapa completo dos onze status oficiais da fatura Iugu, mais `partially_refunded` e + * `authorized`, que o gateway já conhecia, para o status genérico. + */ + public static function statusProvider(): array + { + return [ + 'pending' => ['pending', Invoice::STATUS_PENDING], + 'in_analysis' => ['in_analysis', Invoice::STATUS_PENDING], + 'draft' => ['draft', Invoice::STATUS_PENDING], + 'partially_paid' => ['partially_paid', Invoice::STATUS_PENDING], + 'paid' => ['paid', Invoice::STATUS_PAID], + 'externally_paid' => ['externally_paid', Invoice::STATUS_PAID], + 'authorized' => ['authorized', Invoice::STATUS_PAID], + 'in_protest' => ['in_protest', Invoice::STATUS_DISPUTED], + 'canceled' => ['canceled', Invoice::STATUS_CANCELED], + 'expired' => ['expired', Invoice::STATUS_CANCELED], + 'refunded' => ['refunded', Invoice::STATUS_REFUNDED], + 'partially_refunded' => ['partially_refunded', Invoice::STATUS_PARTIALLY_REFUNDED], + 'chargeback' => ['chargeback', Invoice::STATUS_CHARGEBACK], + ]; + } + + #[DataProvider('statusProvider')] + public function testMapsEveryIuguInvoiceStatusToTheGenericOne(string $iuguStatus, string $expected): void + { + $this->assertSame($expected, $this->mapStatus($iuguStatus)); + } + + public function testInProtestNoLongerReadsAsPaid(): void + { + $status = $this->mapStatus('in_protest'); + + $this->assertNotSame(Invoice::STATUS_PAID, $status); + $this->assertSame(Invoice::STATUS_DISPUTED, $status); + $this->assertFalse(Invoice::isSettled($status)); + $this->assertTrue(Invoice::isContested($status)); + } + + public function testChargebackNoLongerReadsAsRefunded(): void + { + $status = $this->mapStatus('chargeback'); + + $this->assertNotSame(Invoice::STATUS_REFUNDED, $status); + $this->assertSame(Invoice::STATUS_CHARGEBACK, $status); + $this->assertFalse(Invoice::isSettled($status)); + $this->assertTrue(Invoice::isContested($status)); + } + + public function testUnknownStatusStillThrows(): void + { + $this->expectException(GatewayException::class); + $this->expectExceptionMessage('Unexpected Iugu status: status_novo'); + + $this->mapStatus('status_novo'); + } + + /** + * Caminho público: a fatura resumida em `recent_invoices` da assinatura passa pelo mesmo + * mapa, então uma resposta real da Iugu com `in_protest` chega como `disputed`. + */ + public function testDisputedInvoiceFromTheGatewayResponseIsParsedAsDisputed(): void + { + $subscription = $this->readSubscriptionWithLatestInvoiceStatus('in_protest'); + + $this->assertSame(Invoice::STATUS_DISPUTED, $subscription->latestInvoice->status); + $this->assertSame('in_protest', $subscription->latestInvoice->original->status); + } + + public function testChargebackInvoiceFromTheGatewayResponseIsParsedAsChargeback(): void + { + $subscription = $this->readSubscriptionWithLatestInvoiceStatus('chargeback'); + + $this->assertSame(Invoice::STATUS_CHARGEBACK, $subscription->latestInvoice->status); + $this->assertSame('chargeback', $subscription->latestInvoice->original->status); + } + + public static function contestedStatusProvider(): array + { + return [ + 'in_protest' => ['in_protest'], + 'chargeback' => ['chargeback'], + ]; + } + + /** + * Disputa e chargeback não são dívida em aberto: a assinatura não vira `past_due` por + * causa deles, mesmo com a data de cobrança no passado. + */ + #[DataProvider('contestedStatusProvider')] + public function testContestedInvoicesDoNotMakeTheSubscriptionPastDue(string $iuguStatus): void + { + $subscription = $this->readSubscriptionWithLatestInvoiceStatus($iuguStatus, '2026-08-01'); + + $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); + } + + private function mapStatus(string $iuguStatus): string + { + $method = new \ReflectionMethod(IuguGateway::class, 'iuguStatusToMultiPayment'); + + return $method->invoke(null, $iuguStatus); + } + + private function readSubscriptionWithLatestInvoiceStatus(string $iuguStatus, string $expiresAt = '2026-10-01'): Subscription + { + $api = new QueuedIuguApiRequest([ + (object) [ + 'id' => 'sub_1', + 'customer_id' => 'cus_1', + 'plan_identifier' => 'plano_mensal', + 'price_cents' => 10000, + 'expires_at' => $expiresAt, + 'created_at' => '2026-09-01T10:00:00-03:00', + 'active' => true, + 'suspended' => false, + 'in_trial' => false, + 'recent_invoices' => [ + (object) ['id' => 'inv_1', 'status' => $iuguStatus, 'due_date' => '2026-09-01'], + ], + ], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + return (new IuguGateway($api))->getSubscription($subscription); + } +} diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index 251d50b..dbd77c0 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -3,7 +3,6 @@ namespace Potelo\MultiPayment\Tests\Unit\Gateways; use Carbon\Carbon; -use Iugu_APIRequest; use PHPUnit\Framework\TestCase; use Illuminate\Config\Repository; use Illuminate\Container\Container; @@ -1761,29 +1760,3 @@ public function testAResponseListingNoUsableInvoiceClearsTheStoredOne(): void $this->assertNull($gateway->getSubscription($subscription)->latestInvoice); } } - -/** - * Devolve uma resposta por chamada, na ordem, e guarda todas as chamadas feitas. - */ -class QueuedIuguApiRequest extends Iugu_APIRequest -{ - public array $calls = []; - - /** - * @param array $responses - */ - public function __construct(private array $responses) - { - } - - public function request($method, $url, $data = []) - { - $this->calls[] = ['method' => $method, 'url' => $url, 'data' => $data]; - - if (empty($this->responses)) { - throw new \RuntimeException("Sem resposta enfileirada para {$method} {$url}"); - } - - return array_shift($this->responses); - } -} diff --git a/tests/Unit/Gateways/QueuedIuguApiRequest.php b/tests/Unit/Gateways/QueuedIuguApiRequest.php new file mode 100644 index 0000000..e5f1754 --- /dev/null +++ b/tests/Unit/Gateways/QueuedIuguApiRequest.php @@ -0,0 +1,31 @@ + $responses + */ + public function __construct(private array $responses) + { + } + + public function request($method, $url, $data = []) + { + $this->calls[] = ['method' => $method, 'url' => $url, 'data' => $data]; + + if (empty($this->responses)) { + throw new \RuntimeException("Sem resposta enfileirada para {$method} {$url}"); + } + + return array_shift($this->responses); + } +} diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index 9b25dfb..d9136e4 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -446,6 +446,148 @@ public function testGetInvoiceRejectsUnexpectedStatus(): void $this->getInvoice(); } + /** + * Status de dispute em aberto, conforme docs.stripe.com/api/disputes/object. + */ + public static function openDisputeStatusProvider(): array + { + return [ + ['warning_needs_response'], + ['warning_under_review'], + ['needs_response'], + ['under_review'], + ]; + } + + #[DataProvider('openDisputeStatusProvider')] + public function testGetInvoiceReportsOpenDisputeAsDisputed(string $disputeStatus): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->disputedCardPaymentIntentResponse(), + $this->disputeListResponse([$disputeStatus]), + ]); + + $result = $this->getInvoice(); + + $this->assertSame(Invoice::STATUS_DISPUTED, $result->status); + $this->assertNotSame(Invoice::STATUS_PAID, $result->status); + // o dinheiro continua contabilizado no charge até a resolução + $this->assertSame(12345, $result->paidAmount); + + $this->assertCount(2, $httpClient->calls); + [$method, $url, $params] = $httpClient->calls[1]; + $this->assertSame('get', $method); + $this->assertSame('/v1/disputes', parse_url($url, PHP_URL_PATH)); + $this->assertSame(['charge' => 'ch_fake123', 'limit' => 100], $params); + } + + public function testGetInvoiceReportsLostDisputeAsChargeback(): void + { + RecordingStripeHttpClient::withResponses([ + $this->disputedCardPaymentIntentResponse(), + $this->disputeListResponse(['lost']), + ]); + + $result = $this->getInvoice(); + + $this->assertSame(Invoice::STATUS_CHARGEBACK, $result->status); + $this->assertNotSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertTrue(Invoice::isContested($result->status)); + $this->assertFalse(Invoice::isSettled($result->status)); + } + + /** + * Status de dispute encerrada sem devolução ao cliente: a fatura volta ao status normal. + */ + public static function closedDisputeStatusProvider(): array + { + return [ + ['won'], + ['warning_closed'], + ['prevented'], + ]; + } + + #[DataProvider('closedDisputeStatusProvider')] + public function testGetInvoiceKeepsDerivedStatusWhenDisputeWasWonOrClosed(string $disputeStatus): void + { + RecordingStripeHttpClient::withResponses([ + $this->disputedCardPaymentIntentResponse(), + $this->disputeListResponse([$disputeStatus]), + ]); + + $this->assertSame(Invoice::STATUS_PAID, $this->getInvoice()->status); + } + + public static function disputeOverRefundProvider(): array + { + return [ + 'aberta sobre estorno parcial' => ['needs_response', 2345, false, Invoice::STATUS_DISPUTED], + 'perdida sobre estorno total' => ['lost', 12345, true, Invoice::STATUS_CHARGEBACK], + ]; + } + + #[DataProvider('disputeOverRefundProvider')] + public function testGetInvoiceDisputeTakesPrecedenceOverRefund(string $disputeStatus, int $refunded, bool $fully, string $expected): void + { + $response = $this->disputedCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = $refunded; + $response['latest_charge']['refunded'] = $fully; + RecordingStripeHttpClient::withResponses([ + $response, + $this->disputeListResponse([$disputeStatus]), + ]); + + $result = $this->getInvoice(); + + $this->assertSame($expected, $result->status); + $this->assertSame($refunded, $result->refundedAmount); + } + + public function testGetInvoiceFallsBackToDerivedStatusWhenDisputedChargeHasNoDisputes(): void + { + // a flag pode chegar antes da dispute aparecer na listagem; sem dispute a fatura + // segue a derivação normal + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->disputedCardPaymentIntentResponse(), + $this->disputeListResponse([]), + ]); + + $this->assertSame(Invoice::STATUS_PAID, $this->getInvoice()->status); + $this->assertCount(2, $httpClient->calls); + } + + public function testGetInvoiceOpenDisputeWinsOverAnEarlierWonOne(): void + { + RecordingStripeHttpClient::withResponses([ + $this->disputedCardPaymentIntentResponse(), + $this->disputeListResponse(['won', 'needs_response']), + ]); + + $this->assertSame(Invoice::STATUS_DISPUTED, $this->getInvoice()->status); + } + + public function testGetInvoiceDoesNotListDisputesWhenChargeIsNotDisputed(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $this->assertSame(Invoice::STATUS_PAID, $this->getInvoice()->status); + $this->assertCount(1, $httpClient->calls); + } + + public function testGetInvoiceDoesNotListDisputesForFailedCharge(): void + { + // charge failed marcado como disputed não existe na prática, mas o parse só consulta + // disputes de charge pago + $response = $this->disputedCardPaymentIntentResponse(); + $response['status'] = 'requires_payment_method'; + $response['latest_charge']['status'] = 'failed'; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame(Invoice::STATUS_PENDING, $this->getInvoice()->status); + $this->assertCount(1, $httpClient->calls); + } + public function testChargeInvoiceWithCreditCardUpdatesIntentBeforeConfirming(): void { // PI sem customer + PaymentMethod salvo: o customer do dono do cartão é vinculado @@ -1008,6 +1150,7 @@ private function paidCardPaymentIntentResponse(string $status = 'succeeded'): ar 'amount_captured' => 12345, 'amount_refunded' => 0, 'refunded' => false, + 'disputed' => false, 'created' => 1786700010, 'payment_method_details' => [ 'type' => 'card', @@ -1023,6 +1166,45 @@ private function paidCardPaymentIntentResponse(string $status = 'succeeded'): ar ]; } + private function disputedCardPaymentIntentResponse(): array + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['disputed'] = true; + + return $response; + } + + /** + * Resposta de GET /v1/disputes?charge=..., uma dispute por status informado. + * + * @param string[] $statuses + * @return array + */ + private function disputeListResponse(array $statuses): array + { + $data = []; + foreach ($statuses as $index => $status) { + $data[] = [ + 'id' => "du_fake{$index}", + 'object' => 'dispute', + 'amount' => 12345, + 'charge' => 'ch_fake123', + 'payment_intent' => 'pi_fake123', + 'currency' => 'brl', + 'reason' => 'fraudulent', + 'status' => $status, + 'created' => 1786700020, + ]; + } + + return [ + 'object' => 'list', + 'url' => '/v1/disputes', + 'has_more' => false, + 'data' => $data, + ]; + } + private function paymentMethodResponse(?string $customer = null): array { return [ diff --git a/tests/Unit/InvoiceTest.php b/tests/Unit/InvoiceTest.php new file mode 100644 index 0000000..a4411d2 --- /dev/null +++ b/tests/Unit/InvoiceTest.php @@ -0,0 +1,50 @@ + [Invoice::STATUS_PAID, true], + 'parcialmente estornada' => [Invoice::STATUS_PARTIALLY_REFUNDED, true], + 'pendente' => [Invoice::STATUS_PENDING, false], + 'cancelada' => [Invoice::STATUS_CANCELED, false], + 'estornada' => [Invoice::STATUS_REFUNDED, false], + 'em disputa' => [Invoice::STATUS_DISPUTED, false], + 'chargeback' => [Invoice::STATUS_CHARGEBACK, false], + 'status desconhecido' => ['qualquer_coisa', false], + ]; + } + + #[DataProvider('settledProvider')] + public function testIsSettledOnlyForStatusesWhereTheMoneyWasReceived(string $status, bool $expected): void + { + $this->assertSame($expected, Invoice::isSettled($status)); + } + + public static function contestedProvider(): array + { + return [ + 'em disputa' => [Invoice::STATUS_DISPUTED, true], + 'chargeback' => [Invoice::STATUS_CHARGEBACK, true], + 'paga' => [Invoice::STATUS_PAID, false], + 'estornada' => [Invoice::STATUS_REFUNDED, false], + 'parcialmente estornada' => [Invoice::STATUS_PARTIALLY_REFUNDED, false], + 'pendente' => [Invoice::STATUS_PENDING, false], + 'cancelada' => [Invoice::STATUS_CANCELED, false], + 'status desconhecido' => ['qualquer_coisa', false], + ]; + } + + #[DataProvider('contestedProvider')] + public function testIsContestedOnlyForOpenOrLostDisputes(string $status, bool $expected): void + { + $this->assertSame($expected, Invoice::isContested($status)); + } +} From da5d013975d464067b715d7f6201ad4ce3bbff4a Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 06:47:15 -0300 Subject: [PATCH 12/32] =?UTF-8?q?fix(iugu):=20traduz=20intervalo=20anual?= =?UTF-8?q?=20para=2012=20meses=20na=20cria=C3=A7=C3=A3o=20e=20no=20parse?= =?UTF-8?q?=20de=20planos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Iugu só aceita interval_type weeks e months, mas suporta plano anual como interval = 12 meses. O driver recusava Plan::INTERVAL_YEAR por não traduzir. - na ida, year vira 12 * intervalCount com interval_type months - na volta, months com interval múltiplo de 12 lê como year com intervalCount / 12 (heurística documentada: 24 meses lê como 2 anos) - guarda de faixa 1 a 599 do interval da Iugu antes da requisição - intervalo desconhecido continua lançando antes da rede - testes unitários de ida e volta e teste de integração na sandbox Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk --- README.md | 12 +- src/Gateways/IuguGateway.php | 89 +++++++-- tests/Integration/SubscriptionTest.php | 36 +++- .../Gateways/IuguGatewaySubscriptionTest.php | 176 +++++++++++++++++- 4 files changed, 282 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 4221954..2c71b32 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Também é possível utilizar o Facade: | Cancelar assinatura ao fim do período (`cancel(atPeriodEnd: true)`) | ❌ lança `GatewayException` | 🚧 em desenvolvimento | | Troca de plano e simulação (`changePlan`, `previewPlanChange`) | ✅ | 🚧 em desenvolvimento | | Desconto na assinatura | ✅ somente valor fixo (`amountOff`), com `cycles` 1 ou `null` | 🚧 em desenvolvimento | -| Plano (criar, buscar, listar) | ✅ intervalos `week` e `month` | 🚧 em desenvolvimento | +| Plano (criar, buscar, listar) | ✅ (`year` é enviado como 12 meses) | 🚧 em desenvolvimento | | Desativar plano (`deactivatePlan`) | ❌ lança `GatewayException` | 🚧 em desenvolvimento | 🚧 = ainda não implementado no gateway; hoje a chamada lança `GatewayException`. @@ -270,7 +270,7 @@ $plan = new Plan(); $plan->name = 'Mensal'; $plan->identifier = 'plano_mensal'; $plan->amount = 10000; // centavos -$plan->interval = Plan::INTERVAL_MONTH; // week ou month; a Iugu não aceita year +$plan->interval = Plan::INTERVAL_MONTH; // week, month ou year $plan->intervalCount = 1; $plan->save('iugu'); @@ -314,7 +314,13 @@ Particularidades da Iugu: ao fim do período, suspenda na data. - **Desconto é sempre valor fixo.** `percentOff` lança `GatewayException`, e `cycles` só aceita `1` (uma fatura) ou `null` (até ser removido). -- **Planos são semanais ou mensais.** `Plan::INTERVAL_YEAR` lança `GatewayException`. +- **Plano anual é 12 meses.** A Iugu só tem intervalos em semanas e meses, então + `Plan::INTERVAL_YEAR` é enviado como `12 * intervalCount` meses. Na leitura vale a heurística + inversa: todo plano em meses cujo intervalo é múltiplo de 12 volta como `year` com + `intervalCount` dividido por 12 (um plano criado direto na Iugu com 24 meses lê como 2 anos). + Quem precisar do valor cru lê `original`. A Iugu aceita intervalo de 1 a 599, então um plano + anual vai até `intervalCount` 49; acima disso o driver lança `GatewayException` antes de + chamar a API. - **Planos não são desativáveis.** `deactivatePlan` lança `GatewayException`. - **`nextBillingAt` e `trialEndsAt` são o mesmo campo** (`expires_at`); informar os dois com datas diferentes lança `GatewayException`. Ao prorrogar um trial lido do gateway, zere diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index 259cc2e..7184f92 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -49,6 +49,10 @@ class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract private const STATUS_CHARGEBACK = 'chargeback'; private const STATUS_AUTHORIZED = 'authorized'; + /** Faixa de `interval` aceita pela Iugu na criação de plano. */ + private const PLAN_INTERVAL_MIN = 1; + private const PLAN_INTERVAL_MAX = 599; + private Iugu_APIRequest $apiRequest; /** @@ -2140,13 +2144,14 @@ private function parseIuguPlanChange($response): SubscriptionPlanChange */ private function planToIuguData(Plan $plan): array { - $data = [ - 'name' => $plan->name, - 'identifier' => $plan->identifier ?? $plan->name, - 'interval' => $plan->intervalCount ?? 1, - 'interval_type' => $this->multiPaymentToIuguInterval($plan->interval), - 'value_cents' => $plan->amount, - ]; + $data = array_merge( + [ + 'name' => $plan->name, + 'identifier' => $plan->identifier ?? $plan->name, + ], + $this->intervalToIuguData($plan->interval, $plan->intervalCount ?? 1), + ['value_cents' => $plan->amount] + ); if (!empty($plan->currency)) { $data['currency'] = $plan->currency; @@ -2156,22 +2161,66 @@ private function planToIuguData(Plan $plan): array } /** - * Converte o intervalo genérico no `interval_type` da Iugu. + * Converte o intervalo genérico no par `interval` e `interval_type` da Iugu. + * + * A Iugu só tem `weeks` e `months`, então o intervalo anual é enviado como múltiplo de 12 + * meses. A leitura inversa fica em `iuguIntervalToMultiPayment()`. * * @param string|null $interval + * @param int $intervalCount * - * @return string + * @return array{interval: int, interval_type: string} * @throws GatewayException */ - private function multiPaymentToIuguInterval(?string $interval): string + private function intervalToIuguData(?string $interval, int $intervalCount): array { - return match ($interval) { - Plan::INTERVAL_WEEK => 'weeks', - Plan::INTERVAL_MONTH => 'months', + $data = match ($interval) { + Plan::INTERVAL_WEEK => ['interval' => $intervalCount, 'interval_type' => 'weeks'], + Plan::INTERVAL_MONTH => ['interval' => $intervalCount, 'interval_type' => 'months'], + Plan::INTERVAL_YEAR => ['interval' => 12 * $intervalCount, 'interval_type' => 'months'], default => throw new GatewayException( - "Iugu only supports weekly and monthly plans, `{$interval}` given." + "Iugu driver does not support the `{$interval}` plan interval; " + . 'use week, month or year (sent as 12 months).' ), }; + + // a Iugu aceita interval de 1 a 599; a tradução de ano pode estourar o teto + if ($data['interval'] < self::PLAN_INTERVAL_MIN || $data['interval'] > self::PLAN_INTERVAL_MAX) { + throw new GatewayException( + "Iugu accepts a plan interval from " . self::PLAN_INTERVAL_MIN . ' to ' + . self::PLAN_INTERVAL_MAX . " {$data['interval_type']}, {$data['interval']} given." + ); + } + + return $data; + } + + /** + * Converte o par `interval_type` e `interval` da Iugu no intervalo genérico e na contagem. + * + * Heurística de leitura: como `year` vai para a Iugu como múltiplo de 12 meses, todo plano + * em `months` cujo `interval` é múltiplo de 12 volta como `year` com `intervalCount` igual + * a `interval / 12`. Um plano criado direto na Iugu com 24 meses é lido como 2 anos; quem + * precisar do valor cru lê `original`. + * + * @param string|null $intervalType + * @param int|null $interval + * + * @return array{0: string|null, 1: int|null} + */ + private function iuguIntervalToMultiPayment(?string $intervalType, ?int $interval): array + { + if ($intervalType === 'months' && $interval > 0 && $interval % 12 === 0) { + return [Plan::INTERVAL_YEAR, intdiv($interval, 12)]; + } + + $genericInterval = match ($intervalType) { + 'weeks' => Plan::INTERVAL_WEEK, + 'months' => Plan::INTERVAL_MONTH, + default => null, + }; + + return [$genericInterval, $interval]; } /** @@ -2190,12 +2239,12 @@ private function parseIuguPlan($iuguPlan, ?Plan $plan = null): Plan $plan->id = $iuguPlan->id ?? $plan->id; $plan->identifier = $iuguPlan->identifier ?? $plan->identifier; $plan->name = $iuguPlan->name ?? $plan->name; - $plan->intervalCount = $iuguPlan->interval ?? $plan->intervalCount; - $plan->interval = match ($iuguPlan->interval_type ?? null) { - 'weeks' => Plan::INTERVAL_WEEK, - 'months' => Plan::INTERVAL_MONTH, - default => $plan->interval, - }; + [$interval, $intervalCount] = $this->iuguIntervalToMultiPayment( + $iuguPlan->interval_type ?? null, + isset($iuguPlan->interval) ? (int) $iuguPlan->interval : null + ); + $plan->interval = $interval ?? $plan->interval; + $plan->intervalCount = $intervalCount ?? $plan->intervalCount; // o create recebe value_cents, mas a resposta traz os valores em prices[], um por moeda if (isset($iuguPlan->value_cents)) { diff --git a/tests/Integration/SubscriptionTest.php b/tests/Integration/SubscriptionTest.php index cd631cc..469816d 100644 --- a/tests/Integration/SubscriptionTest.php +++ b/tests/Integration/SubscriptionTest.php @@ -62,14 +62,18 @@ protected function tearDown(): void parent::tearDown(); } - private function createPlan(int $amount, string $sufixo): Plan - { + private function createPlan( + int $amount, + string $sufixo, + string $interval = Plan::INTERVAL_MONTH, + int $intervalCount = 1 + ): Plan { $plan = new Plan(); $plan->name = 'MultiPayment teste ' . $sufixo; $plan->identifier = 'multipayment-teste-' . $sufixo . '-' . now()->format('YmdHisu'); $plan->amount = $amount; - $plan->interval = Plan::INTERVAL_MONTH; - $plan->intervalCount = 1; + $plan->interval = $interval; + $plan->intervalCount = $intervalCount; $plan->save(self::GATEWAY); $this->criados['plans'][] = $plan->id; @@ -128,6 +132,30 @@ public function testShouldCreateGetAndListPlans(): void $this->assertNotSame($primeira[0]->id, $segunda[0]->id); } + /** + * A Iugu não tem intervalo anual; o plano anual deve ser aceito como 12 meses e voltar como + * `year` tanto na resposta da criação quanto numa leitura posterior. + * + * @return void + */ + public function testShouldCreateAYearlyPlanAsTwelveMonths(): void + { + $plan = $this->createPlan(120000, 'anual', Plan::INTERVAL_YEAR); + + $this->assertNotEmpty($plan->id); + $this->assertSame(Plan::INTERVAL_YEAR, $plan->interval); + $this->assertSame(1, $plan->intervalCount); + $this->assertSame(12, $plan->original->interval); + $this->assertSame('months', $plan->original->interval_type); + + $lido = new Plan(); + $lido->id = $plan->id; + $lido = $lido->get(self::GATEWAY); + + $this->assertSame(Plan::INTERVAL_YEAR, $lido->interval); + $this->assertSame(1, $lido->intervalCount); + } + /** * Deve criar, ler, suspender, reativar, cancelar e listar a assinatura. * diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index dbd77c0..5574ea0 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -554,17 +554,183 @@ public function testCreatePlanMapsIntervalToIuguIntervalType(): void $this->assertSame('BRL', $created->currency); } - public function testYearlyPlanIsRejected(): void + /** + * A Iugu não tem intervalo anual: `year` vai como múltiplo de 12 meses e volta como `year`. + */ + #[DataProvider('intervalRoundTripProvider')] + public function testCreatePlanTranslatesTheIntervalBothWays( + string $interval, + int $intervalCount, + int $iuguInterval, + string $iuguIntervalType + ): void { + $api = new QueuedIuguApiRequest([ + (object) [ + 'id' => 'plan_1', + 'identifier' => 'plano', + 'name' => 'Plano', + 'interval' => $iuguInterval, + 'interval_type' => $iuguIntervalType, + 'prices' => [(object) ['value_cents' => 100000, 'currency' => 'BRL']], + ], + ]); + + $plan = new Plan(); + $plan->name = 'Plano'; + $plan->identifier = 'plano'; + $plan->amount = 100000; + $plan->interval = $interval; + $plan->intervalCount = $intervalCount; + + $created = (new IuguGateway($api))->createPlan($plan); + + $this->assertSame([ + 'name' => 'Plano', + 'identifier' => 'plano', + 'interval' => $iuguInterval, + 'interval_type' => $iuguIntervalType, + 'value_cents' => 100000, + ], $api->calls[0]['data']); + $this->assertSame($interval, $created->interval); + $this->assertSame($intervalCount, $created->intervalCount); + } + + public static function intervalRoundTripProvider(): array + { + return [ + 'anual' => [Plan::INTERVAL_YEAR, 1, 12, 'months'], + 'bianual' => [Plan::INTERVAL_YEAR, 2, 24, 'months'], + 'mensal' => [Plan::INTERVAL_MONTH, 1, 1, 'months'], + 'semestral' => [Plan::INTERVAL_MONTH, 6, 6, 'months'], + 'quinzenal' => [Plan::INTERVAL_WEEK, 2, 2, 'weeks'], + ]; + } + + public function testYearlyPlanWithoutIntervalCountIsSentAsTwelveMonths(): void { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'plan_1', 'identifier' => 'anual', 'name' => 'Anual', 'interval' => 12, 'interval_type' => 'months', 'value_cents' => 100000], + ]); + $plan = new Plan(); $plan->name = 'Anual'; + $plan->identifier = 'anual'; $plan->amount = 100000; $plan->interval = Plan::INTERVAL_YEAR; - $this->expectException(GatewayException::class); - $this->expectExceptionMessageMatches('/only supports weekly and monthly plans/'); + $created = (new IuguGateway($api))->createPlan($plan); - (new IuguGateway(new QueuedIuguApiRequest([])))->createPlan($plan); + $this->assertSame(12, $api->calls[0]['data']['interval']); + $this->assertSame('months', $api->calls[0]['data']['interval_type']); + $this->assertSame(Plan::INTERVAL_YEAR, $created->interval); + $this->assertSame(1, $created->intervalCount); + } + + /** + * A heurística de leitura vale também para o model que o chamador mandou: um plano criado + * como 12 meses sai do createPlan como 1 ano. + */ + public function testTwelveMonthPlanIsReadBackAsYearly(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'plan_1', 'identifier' => 'doze', 'name' => 'Doze', 'interval' => 12, 'interval_type' => 'months', 'value_cents' => 100000], + ]); + + $plan = new Plan(); + $plan->name = 'Doze'; + $plan->identifier = 'doze'; + $plan->amount = 100000; + $plan->interval = Plan::INTERVAL_MONTH; + $plan->intervalCount = 12; + + $created = (new IuguGateway($api))->createPlan($plan); + + $this->assertSame(12, $api->calls[0]['data']['interval']); + $this->assertSame(Plan::INTERVAL_YEAR, $created->interval); + $this->assertSame(1, $created->intervalCount); + $this->assertSame(12, $created->original->interval); + } + + #[DataProvider('invalidIntervalProvider')] + public function testInvalidIntervalIsRejectedBeforeTheRequest(string $interval, int $intervalCount, string $message): void + { + $api = new QueuedIuguApiRequest([]); + + $plan = new Plan(); + $plan->name = 'Plano'; + $plan->amount = 100000; + $plan->interval = $interval; + $plan->intervalCount = $intervalCount; + + try { + (new IuguGateway($api))->createPlan($plan); + $this->fail('Expected GatewayException'); + } catch (GatewayException $e) { + $this->assertMatchesRegularExpression($message, $e->getMessage()); + } + + $this->assertCount(0, $api->calls); + } + + public static function invalidIntervalProvider(): array + { + return [ + 'intervalo desconhecido' => ['day', 1, '/does not support the `day` plan interval/'], + 'anual acima do teto da Iugu' => [Plan::INTERVAL_YEAR, 50, '/from 1 to 599 months, 600 given/'], + 'mensal acima do teto da Iugu' => [Plan::INTERVAL_MONTH, 600, '/from 1 to 599 months, 600 given/'], + ]; + } + + public function testGetPlanKeepsTheLocalIntervalWhenTheResponseOmitsIt(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'plan_1', 'name' => 'Plano', 'interval_type' => 'days', 'value_cents' => 10000], + ]); + + $plan = new Plan(); + $plan->id = 'plan_1'; + $plan->interval = Plan::INTERVAL_YEAR; + $plan->intervalCount = 1; + + $found = (new IuguGateway($api))->getPlan($plan); + + $this->assertSame(Plan::INTERVAL_YEAR, $found->interval); + $this->assertSame(1, $found->intervalCount); + } + + /** + * Na leitura, múltiplo de 12 meses vira `year`; o resto mantém o tipo da Iugu. + */ + #[DataProvider('iuguIntervalParseProvider')] + public function testGetPlanParsesTheIuguInterval( + int|string $iuguInterval, + string $iuguIntervalType, + string $interval, + int $intervalCount + ): void { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'plan_1', 'identifier' => 'plano', 'name' => 'Plano', 'interval' => $iuguInterval, 'interval_type' => $iuguIntervalType, 'value_cents' => 10000], + ]); + + $plan = new Plan(); + $plan->id = 'plan_1'; + + $found = (new IuguGateway($api))->getPlan($plan); + + $this->assertSame($interval, $found->interval); + $this->assertSame($intervalCount, $found->intervalCount); + } + + public static function iuguIntervalParseProvider(): array + { + return [ + '12 meses vira 1 ano' => [12, 'months', Plan::INTERVAL_YEAR, 1], + '24 meses vira 2 anos' => [24, 'months', Plan::INTERVAL_YEAR, 2], + '6 meses continua mensal' => [6, 'months', Plan::INTERVAL_MONTH, 6], + '1 mês continua mensal' => [1, 'months', Plan::INTERVAL_MONTH, 1], + '12 semanas continua semanal' => [12, 'weeks', Plan::INTERVAL_WEEK, 12], + '12 como string vira 1 ano' => ['12', 'months', Plan::INTERVAL_YEAR, 1], + ]; } public function testDeactivatePlanIsRejected(): void @@ -970,6 +1136,8 @@ public function testListPlansPaginates(): void $this->assertStringContainsString('limit=10', $api->calls[0]['url']); $this->assertStringContainsString('start=20', $api->calls[0]['url']); $this->assertCount(1, $plans); + $this->assertSame(Plan::INTERVAL_MONTH, $plans[0]->interval); + $this->assertNull($plans[0]->intervalCount); } #[DataProvider('methodsThatRequireSubscriptionIdProvider')] From c9cbdf1f29d15ee2465a4132501d890c31c5ee7b Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 07:11:01 -0300 Subject: [PATCH 13/32] feat(refund): rejeita estorno de boleto e estorno parcial de Pix antes de chamar o gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cria RefundNotSupportedException (paymentMethod, reason, manualRefundRequired) e lança antes de qualquer requisição: boleto nos dois drivers, Pix parcial, fatura já estornada e prazo de 90 dias na Iugu. O driver Iugu lê a fatura antes (numa cópia) quando o model não traz método, status, data de pagamento ou valor pago, e passa a usar iuguRequest() para GET e POST de fatura, preservando o corpo de erro que o refund() do SDK engolia. O Stripe guarda o id do refund em Invoice::$lastRefundId. --- README.md | 56 +- src/Contracts/InvoiceContract.php | 5 + .../RefundNotSupportedException.php | 139 +++++ src/Gateways/IuguGateway.php | 106 +++- src/Gateways/StripeGateway.php | 20 +- src/Models/Invoice.php | 11 + src/MultiPayment.php | 1 + tests/Integration/MultiPaymentTest.php | 12 + .../RefundNotSupportedExceptionTest.php | 64 +++ tests/Unit/Gateways/IuguGatewayRefundTest.php | 490 ++++++++++++++++++ tests/Unit/Gateways/QueuedIuguApiRequest.php | 12 +- .../Gateways/StripeGatewayInvoiceTest.php | 111 ++++ 12 files changed, 995 insertions(+), 32 deletions(-) create mode 100644 src/Exceptions/RefundNotSupportedException.php create mode 100644 tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php create mode 100644 tests/Unit/Gateways/IuguGatewayRefundTest.php diff --git a/README.md b/README.md index 2c71b32..153b505 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [CustomerBuilder](#customerbuilder) - [getInvoice](#getinvoice) - [Outras operações de fatura](#outras-operações-de-fatura) + - [Estorno](#estorno) - [charge](#charge) - [Models](#models) - [Customer](#customer) @@ -89,7 +90,9 @@ Também é possível utilizar o Facade: | Fatura com pix | ✅ | ✅ | | Fatura com boleto | ✅ | ❌ lança `GatewayException` | | Fatura multi-método (`available_payment_methods` com mais de um) | ✅ | ❌ exatamente 1 método por fatura | -| Estorno total e parcial | ✅ | ✅ | +| Estorno de cartão (total e parcial) | ✅ | ✅ | +| Estorno de Pix | ✅ somente integral; parcial lança `RefundNotSupportedException` | ✅ total e parcial | +| Estorno de boleto | ❌ lança `RefundNotSupportedException` (devolução manual) | ❌ lança `RefundNotSupportedException` (devolução manual) | | Cancelamento | ✅ | ✅ | | Duplicar fatura (`duplicateInvoice`) | ✅ | ✅ somente pix pendente | | Cobrar fatura pendente com cartão | ✅ | ✅ (inclusive pix expirado) | @@ -396,7 +399,7 @@ $foundInvoice = $payment->getInvoice($invoiceId); ```php $payment = new \Potelo\MultiPayment\MultiPayment('stripe'); -// estorno total ou parcial (valor em centavos) +// estorno total ou parcial (valor em centavos); guardas e exceção na seção "Estorno" $payment->refundInvoice($invoiceId); $payment->refundInvoice($invoiceId, 5000); @@ -411,6 +414,55 @@ $payment->chargeInvoiceWithCreditCard($invoiceId, 'pm_...'); $payment->chargeInvoiceWithCreditCard($invoiceId, null, $creditCardId); ``` +#### Estorno + +Sem valor, o estorno é integral; com valor em centavos, é parcial. O pacote recusa, **antes de +chamar o gateway**, o estorno que a regra do gateway já garante que seria negado, e o faz com +`RefundNotSupportedException` nos dois drivers, para a aplicação não precisar interpretar a +mensagem da Iugu ou da Stripe. + +```php +use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; + +$payment = new \Potelo\MultiPayment\MultiPayment('iugu'); + +try { + $invoice = $payment->refundInvoice($invoiceId); // integral + $invoice = $payment->refundInvoice($invoiceId, 5000); // parcial + + $invoice->status; // refunded ou partially_refunded + $invoice->lastRefundId; // id do estorno no gateway (Stripe: re_...; a Iugu não devolve id) +} catch (RefundNotSupportedException $e) { + // a lib recusou sem chamar o gateway; $e->reason diz por quê + if ($e->manualRefundRequired) { + // boleto ou prazo vencido: o dinheiro só volta por fora do gateway + ManualRefund::dispatch($invoiceId, $e->paymentMethod, $e->reason); + } +} +``` + +| `$e->reason` | Quando | `$e->manualRefundRequired` | +|---|---|---| +| `boleto_no_refund` | Fatura paga com boleto, nos dois gateways | `true` | +| `pix_partial_not_supported` | Iugu: valor pedido diferente do valor pago numa fatura Pix. Repita sem valor para estornar o total | `false` | +| `already_refunded` | Fatura já lida como `refunded` | `false` | +| `refund_window_expired` | Iugu: depois do fim do 90º dia após `paidAt` | `true` | + +Na Iugu, as guardas precisam do método de pagamento, do status, da data de pagamento e, no +estorno por valor, do valor pago. Chamar `refundInvoice($id)` só com o id custa **um GET a mais** +para ler a fatura antes do estorno; chamar `$invoice->refund()` num model já lido do gateway e já +pago não paga esse GET. Essa leitura não altera o model do chamador: ele só muda quando o +estorno acontece. No Stripe não há leitura prévia: as guardas usam o que já está no model, e o +estorno parcial de Pix é aceito. + +`lastRefundId` é preenchido só pela operação de estorno (a leitura da fatura o deixa `null`) e é +provisório: dá lugar a um objeto `Refund` numa versão futura. + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, estorno de boleto, Pix parcial, +> fatura já estornada e fora do prazo de 90 dias na Iugu iam até a API e voltavam como +> `GatewayException` com a mensagem do gateway. Agora lançam `RefundNotSupportedException`, que herda de `MultiPaymentException` e **não** de +> `GatewayException`: um `catch (GatewayException $e)` sozinho deixa de capturar esses casos. + #### charge ```php diff --git a/src/Contracts/InvoiceContract.php b/src/Contracts/InvoiceContract.php index f03ca2e..0d0d84d 100644 --- a/src/Contracts/InvoiceContract.php +++ b/src/Contracts/InvoiceContract.php @@ -35,10 +35,15 @@ public function getInvoice(Invoice $invoice): Invoice; /** * Refund an invoice * + * Full refund when `refundedAmount` is empty; partial when set. The gateway throws + * `RefundNotSupportedException` before any request when its own rules already guarantee + * the refusal (bank slip, partial Pix on Iugu, invoice already refunded, window expired). + * * @param Invoice $invoice * * @return Invoice * @throws GatewayException + * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException */ public function refundInvoice(Invoice $invoice): Invoice; diff --git a/src/Exceptions/RefundNotSupportedException.php b/src/Exceptions/RefundNotSupportedException.php new file mode 100644 index 0000000..3864095 --- /dev/null +++ b/src/Exceptions/RefundNotSupportedException.php @@ -0,0 +1,139 @@ +paymentMethod = $paymentMethod; + $this->reason = $reason; + $this->manualRefundRequired = $manualRefundRequired; + + parent::__construct($message); + } + + /** + * Boleto não tem estorno via API no gateway informado. + * + * @param string $gateway + * @return static + */ + public static function boletoNoRefund(string $gateway): static + { + return new static( + "O gateway {$gateway} não estorna boleto pela API; faça a devolução manualmente ao cliente.", + 'bank_slip', + self::REASON_BOLETO_NO_REFUND, + true + ); + } + + /** + * O gateway só aceita estorno integral de Pix e o valor pedido difere do valor pago. + * + * @param string $gateway + * @param int $requestedAmount valor pedido, em centavos + * @param int|null $paidAmount valor pago, em centavos + * @return static + */ + public static function pixPartialNotSupported(string $gateway, int $requestedAmount, ?int $paidAmount): static + { + $paid = is_null($paidAmount) ? 'desconhecido' : (string) $paidAmount; + + return new static( + "O gateway {$gateway} só estorna Pix integralmente (pedido: {$requestedAmount} centavos, pago: {$paid}); repita sem valor parcial.", + 'pix', + self::REASON_PIX_PARTIAL_NOT_SUPPORTED + ); + } + + /** + * A fatura já está integralmente estornada. + * + * @param string $gateway + * @param string|null $paymentMethod + * @return static + */ + public static function alreadyRefunded(string $gateway, ?string $paymentMethod): static + { + return new static( + "A fatura já foi integralmente estornada no gateway {$gateway}.", + $paymentMethod, + self::REASON_ALREADY_REFUNDED + ); + } + + /** + * O prazo de estorno do gateway, contado a partir do pagamento, já passou. + * + * @param string $gateway + * @param string|null $paymentMethod + * @param \Carbon\Carbon $paidAt + * @param int $windowDays + * @return static + */ + public static function refundWindowExpired(string $gateway, ?string $paymentMethod, Carbon $paidAt, int $windowDays): static + { + return new static( + "O gateway {$gateway} só estorna até {$windowDays} dias após o pagamento (pago em {$paidAt->toDateString()}); faça a devolução manualmente ao cliente.", + $paymentMethod, + self::REASON_REFUND_WINDOW_EXPIRED, + true + ); + } +} diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index 7184f92..d7ecf08 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -31,6 +31,7 @@ use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract @@ -53,6 +54,9 @@ class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract private const PLAN_INTERVAL_MIN = 1; private const PLAN_INTERVAL_MAX = 599; + /** Prazo, em dias após o pagamento, em que a Iugu ainda aceita estorno pela API. */ + private const REFUND_WINDOW_DAYS = 90; + private Iugu_APIRequest $apiRequest; /** @@ -317,20 +321,8 @@ public function createCreditCard(CreditCard $creditCard): CreditCard */ public function getInvoice(Invoice $invoice): Invoice { - try { - $iuguInvoice = \Iugu_Invoice::fetch($invoice->id); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\Exception $e) { - throw new GatewayException("Error getting invoice: {$e->getMessage()}"); - } - if (!empty($iuguInvoice->errors)) { - throw new GatewayException('Error getting invoice', $iuguInvoice->errors); - } + $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode((string) $invoice->id); + $iuguInvoice = $this->iuguRequest('GET', $url, [], 'getting invoice'); return $this->parseInvoice($iuguInvoice, $invoice); } @@ -361,25 +353,91 @@ private function iuguToMultiPaymentPaymentMethod($iuguPaymentMethod): ?string /** * @inheritDoc + * + * As guardas de estorno precisam do método de pagamento, do status, da data de pagamento e, + * no estorno por valor, do valor pago. Um model que traz só o `id` custa um GET a mais para + * ler a fatura antes do estorno; um model lido do gateway e já pago não paga esse GET. A + * leitura prévia acontece numa cópia: o model do chamador só é alterado se o estorno + * acontecer. + * + * @throws ModelAttributeValidationException|RefundNotSupportedException */ public function refundInvoice(Invoice $invoice): Invoice { - $iuguInvoice = new \Iugu_Invoice(['id' => $invoice->id]); + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } - try { - $refunded = $iuguInvoice->refund($invoice->refundedAmount ?? null); - if (!$refunded) { - throw new GatewayException("Error refunding invoice", $iuguInvoice->errors ?? []); - } - } catch (GatewayException $e) { - throw $e; - } catch (\Exception $e) { - throw new GatewayException("Error refunding invoice: {$e->getMessage()}"); + // guardado antes da leitura: parseInvoice() sobrescreve refundedAmount com o já estornado + $requestedAmount = $invoice->refundedAmount ?: null; + + $current = $invoice; + if ( + empty($invoice->paymentMethod) + || empty($invoice->status) + || is_null($invoice->paidAt) + || (!is_null($requestedAmount) && is_null($invoice->paidAmount)) + ) { + $current = $this->getInvoice(clone $invoice); + } + + $this->assertInvoiceIsRefundable($current, $requestedAmount); + + $data = []; + // valor igual ao pago é estorno integral e vai sem partial_value_refund_cents; assim o + // Pix, que só aceita integral, não é recusado por um "parcial" do valor cheio + if (!is_null($requestedAmount) && $requestedAmount !== $current->paidAmount) { + $data['partial_value_refund_cents'] = $requestedAmount; } + $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/refund'; + $iuguInvoice = $this->iuguRequest('POST', $url, $data, 'refunding invoice'); + return $this->parseInvoice($iuguInvoice, $invoice); } + /** + * Lança antes da rede quando a Iugu certamente recusaria o estorno: boleto não tem estorno + * pela API, fatura em `refunded` é terminal, Pix só estorna o valor integral e o prazo de + * estorno termina no fim do 90º dia após o pagamento. + * + * @param Invoice $invoice + * @param int|null $requestedAmount valor pedido em centavos; nulo é estorno integral + * @return void + * @throws RefundNotSupportedException + */ + private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmount): void + { + if ($invoice->paymentMethod === Invoice::PAYMENT_METHOD_BANK_SLIP) { + throw RefundNotSupportedException::boletoNoRefund('iugu'); + } + + if ($invoice->status === Invoice::STATUS_REFUNDED) { + throw RefundNotSupportedException::alreadyRefunded('iugu', $invoice->paymentMethod); + } + + if ( + $invoice->paymentMethod === Invoice::PAYMENT_METHOD_PIX + && !is_null($requestedAmount) + && $requestedAmount !== $invoice->paidAmount + ) { + throw RefundNotSupportedException::pixPartialNotSupported('iugu', $requestedAmount, $invoice->paidAmount); + } + + // a Iugu conta o prazo em dias; até o fim do 90º dia a chamada segue e a API decide + if ( + !is_null($invoice->paidAt) + && $invoice->paidAt->copy()->addDays(self::REFUND_WINDOW_DAYS)->endOfDay()->isPast() + ) { + throw RefundNotSupportedException::refundWindowExpired( + 'iugu', + $invoice->paymentMethod, + $invoice->paidAt, + self::REFUND_WINDOW_DAYS + ); + } + } + /** * @inheritDoc */ diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 7930ea9..0967cd1 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -26,6 +26,7 @@ use Potelo\MultiPayment\Exceptions\ChargingException; use Potelo\MultiPayment\Exceptions\MultiPaymentException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; class StripeGateway implements GatewayContract @@ -708,13 +709,23 @@ public function getInvoice(Invoice $invoice): Invoice /** * @inheritDoc - * @throws ModelAttributeValidationException + * + * As guardas de estorno usam só o que já está no model, sem leitura prévia: um PaymentIntent + * deste driver não pode ser boleto. + * + * @throws ModelAttributeValidationException|RefundNotSupportedException */ public function refundInvoice(Invoice $invoice): Invoice { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); } + if ($invoice->paymentMethod === Invoice::PAYMENT_METHOD_BANK_SLIP) { + throw RefundNotSupportedException::boletoNoRefund('stripe'); + } + if ($invoice->status === Invoice::STATUS_REFUNDED) { + throw RefundNotSupportedException::alreadyRefunded('stripe', $invoice->paymentMethod); + } // mesma semântica da Iugu: refundedAmount preenchido = estorno parcial; vazio = total $stripeRefundData = ['payment_intent' => $invoice->id]; @@ -724,12 +735,15 @@ public function refundInvoice(Invoice $invoice): Invoice $stripeRefundData = $this->mergeGatewayAdicionalOptions($stripeRefundData, $invoice); $requestOptions = $this->extractIdempotencyKey($stripeRefundData); - $this->stripeRequest(function () use ($stripeRefundData, $requestOptions) { + $stripeRefund = $this->stripeRequest(function () use ($stripeRefundData, $requestOptions) { return $this->client->refunds->create($stripeRefundData, $requestOptions); }); // o refund não devolve o PaymentIntent — refetch para reparse com o charge atualizado - return $this->getInvoice($invoice); + $invoice = $this->getInvoice($invoice); + $invoice->lastRefundId = $stripeRefund->id; + + return $invoice; } /** diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 6eedd4a..8e12b1d 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -65,6 +65,16 @@ class Invoice extends Model */ public ?int $refundedAmount = null; + /** + * Id do estorno criado pelo gateway na última chamada de `refund()`, quando o gateway + * devolve um (Stripe: `re_...`; a Iugu não devolve id de estorno). Preenchido só pela + * operação de estorno, não pela leitura da fatura. Campo provisório: dá lugar a um objeto + * `Refund` numa versão futura. + * + * @var string|null + */ + public ?string $lastRefundId = null; + /** * @var Customer|null */ @@ -334,6 +344,7 @@ public static function isContested(string $status): bool * * @return \Potelo\MultiPayment\Models\Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException */ public function refund(): Invoice { diff --git a/src/MultiPayment.php b/src/MultiPayment.php index 43de9b2..bbf2394 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -234,6 +234,7 @@ public function getCustomer(string $id): Customer * * @return \Potelo\MultiPayment\Models\Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException */ public function refundInvoice(string $id, ?int $partialValueCents = null): Invoice { diff --git a/tests/Integration/MultiPaymentTest.php b/tests/Integration/MultiPaymentTest.php index e86239e..ad78cf6 100644 --- a/tests/Integration/MultiPaymentTest.php +++ b/tests/Integration/MultiPaymentTest.php @@ -9,6 +9,7 @@ use Potelo\MultiPayment\Facades\MultiPayment; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; class MultiPaymentTest extends TestCase { @@ -357,6 +358,17 @@ public function testShouldRefundInvoice(string $gateway, array $data, string $st $this->assertEquals($status, $refundedInvoice->status); $this->assertEquals($refundedAmount, $refundedInvoice->refundedAmount); $this->assertEquals($total - $refundedAmount, $refundedInvoice->paidAmount); + + // na Iugu a guarda lê a fatura real antes: já estornada é recusada sem novo POST + if ($gateway === 'iugu' && $status === Invoice::STATUS_REFUNDED) { + try { + $multiPayment->refundInvoice($invoice->id); + $this->fail('Esperava RefundNotSupportedException'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $e->reason); + $this->assertFalse($e->manualRefundRequired); + } + } } /** diff --git a/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php b/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php new file mode 100644 index 0000000..087c046 --- /dev/null +++ b/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php @@ -0,0 +1,64 @@ +assertInstanceOf(MultiPaymentException::class, RefundNotSupportedException::boletoNoRefund('iugu')); + } + + public function testBoletoNoRefundRequiresManualRefund(): void + { + $exception = RefundNotSupportedException::boletoNoRefund('stripe'); + + $this->assertSame('boleto_no_refund', $exception->reason); + $this->assertSame('bank_slip', $exception->paymentMethod); + $this->assertTrue($exception->manualRefundRequired); + $this->assertStringContainsString('stripe', $exception->getMessage()); + } + + public function testPixPartialNotSupportedIsFixableByTheCaller(): void + { + $exception = RefundNotSupportedException::pixPartialNotSupported('iugu', 500, 1000); + + $this->assertSame('pix_partial_not_supported', $exception->reason); + $this->assertSame('pix', $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertStringContainsString('500', $exception->getMessage()); + $this->assertStringContainsString('1000', $exception->getMessage()); + } + + public function testPixPartialNotSupportedWithUnknownPaidAmount(): void + { + $exception = RefundNotSupportedException::pixPartialNotSupported('iugu', 500, null); + + $this->assertStringContainsString('desconhecido', $exception->getMessage()); + } + + public function testAlreadyRefundedKeepsThePaymentMethod(): void + { + $exception = RefundNotSupportedException::alreadyRefunded('iugu', 'credit_card'); + + $this->assertSame('already_refunded', $exception->reason); + $this->assertSame('credit_card', $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + } + + public function testRefundWindowExpiredRequiresManualRefund(): void + { + $exception = RefundNotSupportedException::refundWindowExpired('iugu', 'pix', Carbon::parse('2026-05-01'), 90); + + $this->assertSame('refund_window_expired', $exception->reason); + $this->assertSame('pix', $exception->paymentMethod); + $this->assertTrue($exception->manualRefundRequired); + $this->assertStringContainsString('90 dias', $exception->getMessage()); + $this->assertStringContainsString('2026-05-01', $exception->getMessage()); + } +} diff --git a/tests/Unit/Gateways/IuguGatewayRefundTest.php b/tests/Unit/Gateways/IuguGatewayRefundTest.php new file mode 100644 index 0000000..81c4909 --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewayRefundTest.php @@ -0,0 +1,490 @@ +instance('config', new Repository([ + 'multi-payment.gateways.iugu.api_key' => 'test-api-key', + ])); + Facade::setFacadeApplication($app); + + Carbon::setTestNow('2026-09-02 12:00:00'); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testRefundInvoiceRequiresId(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new IuguGateway(new QueuedIuguApiRequest([])))->refundInvoice(new Invoice()); + } + + public function testBoletoRefundThrowsBeforeTheNetworkAfterReadingTheInvoice(): void + { + $api = new QueuedIuguApiRequest([$this->paidInvoiceResponse([ + 'payment_method' => 'iugu_bank_slip', + 'payable_with' => 'bank_slip', + ])]); + + $exception = $this->refundExpectingRefusal($api, $this->invoiceWithId()); + + $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $exception->reason); + $this->assertSame(Invoice::PAYMENT_METHOD_BANK_SLIP, $exception->paymentMethod); + $this->assertTrue($exception->manualRefundRequired); + $this->assertOnlyTheInvoiceWasRead($api); + } + + public function testBoletoRefundWithThePaymentMethodInHandMakesNoRequest(): void + { + $api = new QueuedIuguApiRequest([]); + $invoice = $this->invoiceWithId(); + $invoice->paymentMethod = Invoice::PAYMENT_METHOD_BANK_SLIP; + $invoice->status = Invoice::STATUS_PAID; + $invoice->paidAt = Carbon::parse('2026-08-20'); + + $exception = $this->refundExpectingRefusal($api, $invoice); + + $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $exception->reason); + $this->assertSame([], $api->calls); + } + + public function testPartialPixRefundThrowsBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([$this->paidPixInvoiceResponse()]); + $invoice = $this->invoiceWithId(); + $invoice->refundedAmount = 5000; + + $exception = $this->refundExpectingRefusal($api, $invoice); + + $this->assertSame(RefundNotSupportedException::REASON_PIX_PARTIAL_NOT_SUPPORTED, $exception->reason); + $this->assertSame(Invoice::PAYMENT_METHOD_PIX, $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertStringContainsString('5000', $exception->getMessage()); + $this->assertStringContainsString('10000', $exception->getMessage()); + $this->assertOnlyTheInvoiceWasRead($api); + } + + public function testFullPixRefundWithoutAmountGoesToTheGateway(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidPixInvoiceResponse(), + $this->paidPixInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + + $result = (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + + $this->assertCount(2, $api->calls); + $this->assertSame('POST', $api->calls[1]['method']); + $this->assertStringEndsWith('/invoices/inv_1/refund', $api->calls[1]['url']); + $this->assertSame([], $api->calls[1]['data']); + $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(10000, $result->refundedAmount); + $this->assertNull($result->lastRefundId); + } + + /** + * Pix só aceita estorno integral: pedir exatamente o valor pago não pode virar um + * `partial_value_refund_cents` que a Iugu recusaria. + */ + public function testPixRefundOfTheFullPaidAmountIsSentAsIntegral(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidPixInvoiceResponse(), + $this->paidPixInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + $invoice = $this->invoiceWithId(); + $invoice->refundedAmount = 10000; + + $result = (new IuguGateway($api))->refundInvoice($invoice); + + $this->assertSame([], $api->calls[1]['data']); + $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + } + + public function testPartialCardRefundSendsThePartialValue(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + ]); + $invoice = $this->invoiceWithId(); + $invoice->refundedAmount = 2500; + + $result = (new IuguGateway($api))->refundInvoice($invoice); + + $this->assertSame('POST', $api->calls[1]['method']); + $this->assertSame(['partial_value_refund_cents' => 2500], $api->calls[1]['data']); + $this->assertSame(Invoice::STATUS_PARTIALLY_REFUNDED, $result->status); + $this->assertSame(2500, $result->refundedAmount); + $this->assertSame(7500, $result->paidAmount); + } + + /** + * Fatura parcialmente estornada aceita novo estorno: a guarda `already_refunded` olha só + * `refunded`. Pedir exatamente o que resta vai como integral. + */ + public function testPartiallyRefundedInvoiceAcceptsAnotherRefund(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + $invoice = $this->invoiceWithId(); + $invoice->refundedAmount = 7500; + + $result = (new IuguGateway($api))->refundInvoice($invoice); + + $this->assertCount(2, $api->calls); + $this->assertSame([], $api->calls[1]['data']); + $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + } + + /** + * Regressão: a leitura prévia não pode vazar para o model do chamador. Se o estorno falha, + * `refundedAmount` continua sendo o valor pedido, senão um retry viraria estorno integral. + */ + public function testFailedRefundLeavesTheCallerModelUntouched(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(), + (object) ['errors' => 'Fatura não pode ser reembolsada'], + ]); + $invoice = $this->invoiceWithId(); + $invoice->refundedAmount = 2500; + + try { + (new IuguGateway($api))->refundInvoice($invoice); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + } + + $this->assertSame(2500, $invoice->refundedAmount); + $this->assertNull($invoice->status); + $this->assertNull($invoice->paymentMethod); + } + + public function testRefusedRefundLeavesTheCallerModelUntouched(): void + { + $api = new QueuedIuguApiRequest([$this->paidPixInvoiceResponse()]); + $invoice = $this->invoiceWithId(); + $invoice->refundedAmount = 5000; + + $this->refundExpectingRefusal($api, $invoice); + + $this->assertSame(5000, $invoice->refundedAmount); + $this->assertNull($invoice->status); + } + + /** + * Regressão: model montado pela aplicação com método, status e data, mas sem o valor pago. + * Estorno por valor precisa do `paidAmount` para decidir entre integral e parcial, então o + * driver lê a fatura mesmo assim, e o valor cheio de um Pix segue como integral. + */ + public function testPixRefundByAmountWithoutPaidAmountReadsTheInvoiceFirst(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidPixInvoiceResponse(), + $this->paidPixInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + $invoice = $this->invoiceWithId(); + $invoice->paymentMethod = Invoice::PAYMENT_METHOD_PIX; + $invoice->status = Invoice::STATUS_PAID; + $invoice->paidAt = Carbon::parse('2026-08-20'); + $invoice->refundedAmount = 10000; + + $result = (new IuguGateway($api))->refundInvoice($invoice); + + $this->assertCount(2, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertSame([], $api->calls[1]['data']); + $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + } + + /** + * Boleto pendente chega da Iugu sem `payment_method` (só preenchido após o pagamento), então + * a guarda de boleto não dispara e é a API que recusa. Documenta o comportamento atual. + */ + public function testPendingInvoiceWithoutPaymentMethodGoesToTheGateway(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse([ + 'status' => 'pending', + 'payment_method' => null, + 'payable_with' => 'bank_slip', + 'paid_at' => null, + 'paid_cents' => 0, + ]), + (object) ['errors' => 'Fatura não pode ser reembolsada'], + ]); + + try { + (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + } + + $this->assertCount(2, $api->calls); + $this->assertSame('POST', $api->calls[1]['method']); + } + + public function testAlreadyRefundedInvoiceThrowsBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + + $exception = $this->refundExpectingRefusal($api, $this->invoiceWithId()); + + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $exception->reason); + $this->assertSame(Invoice::PAYMENT_METHOD_CREDIT_CARD, $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertOnlyTheInvoiceWasRead($api); + } + + public function testRefundAfterTheNinetyDayWindowThrowsBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['paid_at' => '2026-06-03T12:00:00-03:00']), + ]); + + $exception = $this->refundExpectingRefusal($api, $this->invoiceWithId()); + + $this->assertSame(RefundNotSupportedException::REASON_REFUND_WINDOW_EXPIRED, $exception->reason); + $this->assertSame(Invoice::PAYMENT_METHOD_CREDIT_CARD, $exception->paymentMethod); + $this->assertTrue($exception->manualRefundRequired); + $this->assertStringContainsString('2026-06-03', $exception->getMessage()); + $this->assertOnlyTheInvoiceWasRead($api); + } + + public function testRefundInsideTheNinetyDayWindowGoesToTheGateway(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['paid_at' => '2026-06-05T12:00:00-03:00']), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + + $result = (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + + $this->assertCount(2, $api->calls); + $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + } + + /** + * O prazo conta em dias: no 90º dia após o pagamento a chamada ainda segue, mesmo que a + * hora do pagamento já tenha passado, e é a API que decide no limite. + */ + public function testRefundOnTheLastDayOfTheWindowGoesToTheGateway(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['paid_at' => '2026-06-04T08:00:00-03:00']), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + + $result = (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + + $this->assertCount(2, $api->calls); + $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + } + + public function testRefundOnTheDayAfterTheWindowThrowsBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['paid_at' => '2026-06-03T23:30:00-03:00']), + ]); + + $exception = $this->refundExpectingRefusal($api, $this->invoiceWithId()); + + $this->assertSame(RefundNotSupportedException::REASON_REFUND_WINDOW_EXPIRED, $exception->reason); + $this->assertOnlyTheInvoiceWasRead($api); + } + + /** + * Um model já lido do gateway (método, status e data de pagamento em mãos) não paga o GET + * extra antes do estorno. + */ + public function testInvoiceReadFromTheGatewayDoesNotPayTheExtraGet(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + $gateway = new IuguGateway($api); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $result = $gateway->refundInvoice($invoice); + + $this->assertCount(2, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertSame('POST', $api->calls[1]['method']); + $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + } + + public function testGatewayErrorOnRefundBecomesGatewayException(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(), + (object) ['errors' => 'Fatura não pode ser reembolsada'], + ]); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/^Error refunding invoice - .*Fatura não pode ser reembolsada/'); + + (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + } + + public function testGetInvoiceReadsTheInvoiceById(): void + { + $api = new QueuedIuguApiRequest([$this->paidInvoiceResponse()]); + + $result = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertCount(1, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertStringEndsWith('/invoices/inv_1', $api->calls[0]['url']); + $this->assertSame('inv_1', $result->id); + $this->assertSame(Invoice::STATUS_PAID, $result->status); + $this->assertSame(Invoice::PAYMENT_METHOD_CREDIT_CARD, $result->paymentMethod); + $this->assertSame(10000, $result->paidAmount); + $this->assertSame('2026-08-20', $result->paidAt->toDateString()); + $this->assertNull($result->lastRefundId); + } + + public function testGetInvoiceErrorBecomesGatewayException(): void + { + $api = new QueuedIuguApiRequest([(object) ['errors' => 'Not Found']]); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/^Error getting invoice - .*Not Found/'); + + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + } + + public function testGetInvoiceNotFoundBecomesGatewayException(): void + { + $api = new QueuedIuguApiRequest([new \IuguObjectNotFound('{"errors":"Not Found"}', 404)]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + } + + $this->assertCount(1, $api->calls); + } + + public function testGetInvoiceOn502BecomesGatewayNotAvailableException(): void + { + $api = new QueuedIuguApiRequest([new \IuguRequestException('502 Bad Gateway', 502)]); + + $this->expectException(GatewayNotAvailableException::class); + + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + } + + public function testRefundOfUnknownInvoiceBecomesGatewayExceptionWithoutPosting(): void + { + $api = new QueuedIuguApiRequest([new \IuguObjectNotFound('{"errors":"Not Found"}', 404)]); + + try { + (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + } + + $this->assertOnlyTheInvoiceWasRead($api); + } + + private function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + + return $invoice; + } + + private function refundExpectingRefusal(QueuedIuguApiRequest $api, Invoice $invoice): RefundNotSupportedException + { + try { + (new IuguGateway($api))->refundInvoice($invoice); + } catch (RefundNotSupportedException $e) { + return $e; + } + + $this->fail('Esperava RefundNotSupportedException'); + } + + private function assertOnlyTheInvoiceWasRead(QueuedIuguApiRequest $api): void + { + $this->assertCount(1, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertStringEndsWith('/invoices/inv_1', $api->calls[0]['url']); + } + + private function paidPixInvoiceResponse(array $overrides = []): object + { + return $this->paidInvoiceResponse(array_merge([ + 'payment_method' => 'iugu_pix', + 'payable_with' => 'pix', + 'pix' => (object) ['qrcode' => 'https://example.com/qr.png', 'qrcode_text' => '000201'], + ], $overrides)); + } + + private function paidInvoiceResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'inv_1', + 'status' => 'paid', + 'total_cents' => 10000, + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'taxes_paid_cents' => 250, + 'created_at_iso' => '2026-08-20T09:00:00-03:00', + 'paid_cents' => 10000, + 'refunded_cents' => 0, + 'due_date' => '2026-08-25', + 'payment_method' => 'iugu_credit_card', + 'payable_with' => 'credit_card', + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [ + (object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1], + ], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + ], $overrides); + } +} diff --git a/tests/Unit/Gateways/QueuedIuguApiRequest.php b/tests/Unit/Gateways/QueuedIuguApiRequest.php index e5f1754..9f8b15d 100644 --- a/tests/Unit/Gateways/QueuedIuguApiRequest.php +++ b/tests/Unit/Gateways/QueuedIuguApiRequest.php @@ -5,14 +5,15 @@ use Iugu_APIRequest; /** - * Devolve uma resposta por chamada, na ordem, e guarda todas as chamadas feitas. + * Devolve uma resposta por chamada, na ordem, e guarda todas as chamadas feitas. Uma entrada + * `\Throwable` na fila é lançada em vez de devolvida, para simular o SDK sinalizando 404 ou 5xx. */ class QueuedIuguApiRequest extends Iugu_APIRequest { public array $calls = []; /** - * @param array $responses + * @param array $responses */ public function __construct(private array $responses) { @@ -26,6 +27,11 @@ public function request($method, $url, $data = []) throw new \RuntimeException("Sem resposta enfileirada para {$method} {$url}"); } - return array_shift($this->responses); + $response = array_shift($this->responses); + if ($response instanceof \Throwable) { + throw $response; + } + + return $response; } } diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index d9136e4..cf26b7a 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -15,6 +15,7 @@ use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; +use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use PHPUnit\Framework\Attributes\DataProvider; @@ -790,6 +791,7 @@ public function testRefundsInvoiceTotally(): void $this->assertSame(['payment_intent' => 'pi_fake123'], $params); $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); $this->assertSame(12345, $result->refundedAmount); + $this->assertSame('re_fake123', $result->lastRefundId); } public function testRefundsInvoicePartially(): void @@ -812,6 +814,7 @@ public function testRefundsInvoicePartially(): void ); $this->assertSame(Invoice::STATUS_PARTIALLY_REFUNDED, $result->status); $this->assertSame(2345, $result->refundedAmount); + $this->assertSame('re_fake123', $result->lastRefundId); } public function testRefundInvoiceRequiresId(): void @@ -821,6 +824,114 @@ public function testRefundInvoiceRequiresId(): void (new StripeGateway())->refundInvoice(new Invoice()); } + /** + * Boleto ainda não existe neste driver; a guarda já nasce coberta para quando entrar. + */ + public function testBoletoRefundThrowsBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->paymentMethod = Invoice::PAYMENT_METHOD_BANK_SLIP; + + try { + (new StripeGateway())->refundInvoice($invoice); + $this->fail('Esperava RefundNotSupportedException'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $e->reason); + $this->assertSame(Invoice::PAYMENT_METHOD_BANK_SLIP, $e->paymentMethod); + $this->assertTrue($e->manualRefundRequired); + } + + $this->assertSame([], $httpClient->calls); + } + + public function testAlreadyRefundedInvoiceThrowsBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->paymentMethod = Invoice::PAYMENT_METHOD_CREDIT_CARD; + $invoice->status = Invoice::STATUS_REFUNDED; + + try { + (new StripeGateway())->refundInvoice($invoice); + $this->fail('Esperava RefundNotSupportedException'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $e->reason); + $this->assertSame(Invoice::PAYMENT_METHOD_CREDIT_CARD, $e->paymentMethod); + $this->assertFalse($e->manualRefundRequired); + } + + $this->assertSame([], $httpClient->calls); + } + + /** + * Pix parcial é permitido na Stripe: a guarda da Iugu não pode vazar para cá. + */ + public function testPartialPixRefundGoesToTheGateway(): void + { + $refunded = $this->paidPixPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 2345; + $httpClient = RecordingStripeHttpClient::withResponses([ + ['id' => 're_fake123', 'object' => 'refund', 'status' => 'succeeded', 'amount' => 2345], + $refunded, + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->paymentMethod = Invoice::PAYMENT_METHOD_PIX; + $invoice->refundedAmount = 2345; + $result = (new StripeGateway())->refundInvoice($invoice); + + $this->assertCount(2, $httpClient->calls); + $this->assertSame('post', $httpClient->calls[0][0]); + $this->assertSame('/v1/refunds', parse_url($httpClient->calls[0][1], PHP_URL_PATH)); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 2345], $httpClient->calls[0][2]); + $this->assertSame(Invoice::STATUS_PARTIALLY_REFUNDED, $result->status); + $this->assertSame(2345, $result->refundedAmount); + $this->assertSame('re_fake123', $result->lastRefundId); + } + + /** + * Fatura parcialmente estornada aceita novo estorno: a guarda `already_refunded` olha só + * `refunded`. + */ + public function testPartiallyRefundedInvoiceAcceptsAnotherRefund(): void + { + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([ + ['id' => 're_fake456', 'object' => 'refund', 'status' => 'succeeded', 'amount' => 10000], + $refunded, + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->status = Invoice::STATUS_PARTIALLY_REFUNDED; + $invoice->refundedAmount = 10000; + $result = (new StripeGateway())->refundInvoice($invoice); + + $this->assertSame('post', $httpClient->calls[0][0]); + $this->assertSame('/v1/refunds', parse_url($httpClient->calls[0][1], PHP_URL_PATH)); + $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame('re_fake456', $result->lastRefundId); + } + + public function testGetInvoiceDoesNotFillLastRefundId(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 12345; + $response['latest_charge']['refunded'] = true; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice(); + + $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertNull($result->lastRefundId); + } + public function testDuplicatesPendingPixInvoiceCancelingTheOriginal(): void { $newIntent = $this->pendingPixPaymentIntentResponse(); From af993cf4aa57b08a3633974182cd30b9266bec59 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 08:31:10 -0300 Subject: [PATCH 14/32] =?UTF-8?q?fix(exceptions):=20separa=20autentica?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20indisponibilidade,=20classifica=20erros=20?= =?UTF-8?q?por=20status=20HTTP=20e=20anexa=20previous?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credencial inválida (401, 403 ou chave ausente) passa a lançar a nova AuthenticationException nos dois drivers, em vez de GatewayNotAvailableException ou GatewayException genérica. A base MultiPaymentException ganha `httpStatus` e recebe a exceção do SDK como `previous`; todo throw dentro de catch a repassa. Iugu: classificação centralizada em translateIuguException() e iuguResponseException(), no lugar dos onze str_contains('502 Bad Gateway'). O status vem de getCode() da IuguRequestException (resposta não JSON) ou da global $iugu_last_api_response_code (resposta JSON de erro, que o SDK devolve sem lançar). 5xx e cURL sem resposta viram GatewayNotAvailableException; 404, 409, 422 e 429 viram GatewayException com o status exposto. Iugu_PaymentToken::create() e Iugu_Charge::invoice() entram no mesmo tratamento; o token passa a ser o `id` da resposta, com erro de tokenização detectado antes de salvar o cartão. duplicateInvoice(), updateCustomer() e deleteCreditCard() migram para request cru, porque duplicate(), save() e delete() do SDK engolem exceção e devolviam sucesso silencioso em 401 e 502. parseCustomer() lê `created_at` (o recurso de cliente não tem `created_at_iso`; antes createdAt vinha como "agora"). Stripe: translateStripeException() mapeia AuthenticationException e PermissionException do SDK para AuthenticationException, ApiConnectionException e 5xx (inclusive página HTML, que o SDK lança como UnexpectedValueException) para GatewayNotAvailableException, e preserva type/code/decline_code/param no restante. Testes: fakes ganham status HTTP (QueuedIuguResponse), instalação como requester estático do SDK da Iugu e Throwable na fila do fake HTTP da Stripe; ignoreIndirectDeprecations no phpunit.xml.dist filtra as deprecações de ArrayAccess do SDK da Iugu ao carregar Iugu_Object. --- README.md | 57 +- phpunit.xml.dist | 2 +- src/Exceptions/AuthenticationException.php | 36 + src/Exceptions/GatewayException.php | 11 +- .../GatewayNotAvailableException.php | 6 + src/Exceptions/MultiPaymentException.php | 22 +- .../RefundNotSupportedException.php | 12 +- src/Gateways/IuguGateway.php | 362 +++++---- src/Gateways/StripeGateway.php | 81 +- .../Exceptions/MultiPaymentExceptionTest.php | 97 +++ .../IuguGatewayExceptionTranslationTest.php | 740 ++++++++++++++++++ tests/Unit/Gateways/QueuedIuguApiRequest.php | 45 +- tests/Unit/Gateways/QueuedIuguResponse.php | 13 + .../Gateways/RecordingStripeHttpClient.php | 20 +- .../Gateways/StripeGatewayCustomerTest.php | 13 +- .../StripeGatewayExceptionTranslationTest.php | 280 +++++++ .../Gateways/StripeGatewayInvoiceTest.php | 15 +- 17 files changed, 1628 insertions(+), 184 deletions(-) create mode 100644 src/Exceptions/AuthenticationException.php create mode 100644 tests/Unit/Exceptions/MultiPaymentExceptionTest.php create mode 100644 tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php create mode 100644 tests/Unit/Gateways/QueuedIuguResponse.php create mode 100644 tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php diff --git a/README.md b/README.md index 153b505..d52f931 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,8 @@ Invoice::isContested($invoice->status); // tem briga aberta? disputed ou chargeb tokenizar. Para decidir o fallback programaticamente, use `ChargingException::$reason`, que traz a razão normalizada da recusa (`card_declined`, `brand_not_supported`, `authentication_required`, `expired_card`, `insufficient_funds`, `incorrect_cvc`...). - `GatewayNotAvailableException` também sinaliza "tente outro gateway". + `GatewayNotAvailableException` também sinaliza "tente outro gateway"; `AuthenticationException` + sinaliza credencial errada e não deve gerar fallback (ver [Tratamento de erros](#tratamento-de-erros)). - **Pix exige `tax_document` do cliente** (CPF/CNPJ vai nos billing details do pagamento). - **`expires_at` do pix é opcional** (default do Stripe: 4 horas) e, quando informado, deve ficar entre 10 segundos e 14 dias no futuro — diferente da Iugu, onde `expires_at` é a @@ -176,6 +177,60 @@ Invoice::isContested($invoice->status); // tem briga aberta? disputed ou chargeb - **Idempotência**: envie `gateway_adicional_options['idempotency_key']` na criação de faturas e estornos para repassar o cabeçalho `Idempotency-Key` da Stripe. +## Tratamento de erros + +Toda exceção do pacote herda de `MultiPaymentException`. Nenhuma exceção dos SDKs da Iugu ou da +Stripe sai do pacote: os drivers traduzem cada falha para uma das classes abaixo, anexam a +exceção original em `getPrevious()` (quando o SDK lançou uma; a Iugu devolve alguns erros como +corpo JSON sem exceção) e expõem o status HTTP da resposta em `httpStatus` (nulo quando não +houve resposta HTTP, como numa falha de rede ou numa validação local). + +| Exceção | Quando | O que fazer | +|---|---|---| +| `AuthenticationException` | Chave de API inválida, revogada, sem permissão (401 ou 403) ou não configurada | Registrar e alertar. Repetir a chamada ou trocar de gateway não resolve | +| `GatewayNotAvailableException` | Erro 5xx, falha de conexão ou timeout | Repetir mais tarde ou tentar outro gateway | +| `ChargingException` | Cobrança recusada pelo gateway (cartão negado etc.); `reason` traz a razão normalizada quando o gateway a informa | Tratar como recusa do pagador; `reason` decide o fallback | +| `RefundNotSupportedException` | Estorno recusado pela lib antes de chamar o gateway (boleto, Pix parcial, já estornada, prazo vencido) | Ver [Estorno](#estorno) | +| `ModelAttributeValidationException` | Atributo obrigatório ausente ou inválido, antes de qualquer requisição | Corrigir a chamada | +| `ConfigurationException` | Gateway não configurado ou classe inválida | Corrigir a configuração | +| `GatewayException` | Qualquer outra resposta de erro do gateway (validação, 404, 409, 429) e operação não suportada ou não implementada; `getErrors()` traz o corpo de erro | Depende do caso; `httpStatus` e `getErrors()` dizem o que aconteceu | + +```php +use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\ChargingException; +use Potelo\MultiPayment\Exceptions\AuthenticationException; +use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; + +try { + $invoice = $payment->newInvoice()->/* ... */->create(); +} catch (ChargingException $e) { + return back()->withErrors('Pagamento recusado.'); +} catch (AuthenticationException $e) { + report($e); // credencial errada: alerta, sem retry e sem fallback + abort(500); +} catch (GatewayNotAvailableException $e) { + return $this->queueForRetry(); +} catch (GatewayException $e) { + if ($e->httpStatus === 429) { + return $this->retryLater(); + } + report($e); // $e->getPrevious() é a exceção do SDK, com stack trace e corpo + throw $e; +} +``` + +Rate limit (429) e conflito de idempotência (409) ainda chegam como `GatewayException`; o status +está em `httpStatus` para a aplicação ramificar. Exceções próprias para esses casos estão +previstas para uma versão futura. + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, credencial inválida chegava como +> `GatewayNotAvailableException` (Stripe e chave Iugu não configurada) ou como `GatewayException` +> genérica (chave Iugu recusada com 401), e um cartão inválido no caminho de dados crus da Iugu +> podia deixar escapar uma `IuguRequestException` do SDK. Agora os três casos lançam +> `AuthenticationException` ou `GatewayException` do pacote. Quem repetia toda +> `GatewayNotAvailableException` deixa de repetir credencial errada; quem capturava +> `GatewayException` para chave recusada na Iugu precisa capturar `AuthenticationException`. + ## Utilizando ### MultiPayment: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6b1c0f9..157fb49 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -24,7 +24,7 @@ ./tests/Integration - + src/ diff --git a/src/Exceptions/AuthenticationException.php b/src/Exceptions/AuthenticationException.php new file mode 100644 index 0000000..ec879a8 --- /dev/null +++ b/src/Exceptions/AuthenticationException.php @@ -0,0 +1,36 @@ +errors = $errors; $appends = $this->parseErrorsToString($errors); @@ -22,7 +25,7 @@ public function __construct(string $message = "", $errors = null) $message .= ' - ' . $appends; } - parent::__construct($message); + parent::__construct($message, $previous, $httpStatus); } /** diff --git a/src/Exceptions/GatewayNotAvailableException.php b/src/Exceptions/GatewayNotAvailableException.php index bafedbd..6f755c4 100644 --- a/src/Exceptions/GatewayNotAvailableException.php +++ b/src/Exceptions/GatewayNotAvailableException.php @@ -2,6 +2,12 @@ namespace Potelo\MultiPayment\Exceptions; +/** + * Gateway fora do ar no momento da chamada: erro 5xx, falha de conexão ou timeout de rede. + * + * A aplicação pode repetir a operação mais tarde ou tentar outro gateway. Credencial recusada + * chega como `AuthenticationException`. + */ class GatewayNotAvailableException extends MultiPaymentException { diff --git a/src/Exceptions/MultiPaymentException.php b/src/Exceptions/MultiPaymentException.php index 7c23d1c..ca1498c 100644 --- a/src/Exceptions/MultiPaymentException.php +++ b/src/Exceptions/MultiPaymentException.php @@ -4,5 +4,25 @@ class MultiPaymentException extends \Exception { - // + /** + * Status HTTP da resposta do gateway que originou a exceção. Nulo quando não houve + * resposta HTTP: falha de rede, validação local, chave de API não configurada. + * + * @var int|null + */ + public ?int $httpStatus = null; + + /** + * Cria a exceção anexando a original do SDK (quando houver) e o status HTTP da resposta. + * + * @param string $message + * @param \Throwable|null $previous + * @param int|null $httpStatus + */ + public function __construct(string $message = '', ?\Throwable $previous = null, ?int $httpStatus = null) + { + $this->httpStatus = $httpStatus; + + parent::__construct($message, 0, $previous); + } } diff --git a/src/Exceptions/RefundNotSupportedException.php b/src/Exceptions/RefundNotSupportedException.php index 3864095..7b19337 100644 --- a/src/Exceptions/RefundNotSupportedException.php +++ b/src/Exceptions/RefundNotSupportedException.php @@ -57,14 +57,20 @@ class RefundNotSupportedException extends MultiPaymentException * @param string|null $paymentMethod * @param string $reason * @param bool $manualRefundRequired + * @param \Throwable|null $previous */ - public function __construct(string $message, ?string $paymentMethod, string $reason, bool $manualRefundRequired = false) - { + public function __construct( + string $message, + ?string $paymentMethod, + string $reason, + bool $manualRefundRequired = false, + ?\Throwable $previous = null + ) { $this->paymentMethod = $paymentMethod; $this->reason = $reason; $this->manualRefundRequired = $manualRefundRequired; - parent::__construct($message); + parent::__construct($message, $previous); } /** diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index d7ecf08..1b7eeb8 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -30,6 +30,8 @@ use Potelo\MultiPayment\Contracts\SubscriptionContract; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; +use Potelo\MultiPayment\Exceptions\MultiPaymentException; +use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -135,25 +137,163 @@ public function createInvoice(Invoice $invoice): Invoice } else { try { $iuguInvoice = \Iugu_Invoice::create($iuguInvoiceData); - } catch (\IuguRequestException|IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); + throw $this->translateIuguException($e, 'creating invoice'); } if ($iuguInvoice->errors) { - throw new GatewayException('Error creating invoice', $iuguInvoice->errors); + throw $this->iuguResponseException('Error creating invoice', $iuguInvoice->errors); } } return $this->parseInvoice($iuguInvoice, $invoice); } + /** + * Tokeniza os dados crus do cartão na Iugu e devolve o token gerado. + * + * @param CreditCard $creditCard + * @return string + * @throws GatewayException|GatewayNotAvailableException|AuthenticationException + */ + private function createIuguPaymentToken(CreditCard $creditCard): string + { + try { + $iuguToken = Iugu_PaymentToken::create([ + 'account_id' => Config::get('multi-payment.gateways.iugu.id'), + 'method' => 'credit_card', + 'test' => Config::get('multi-payment.environment') != 'production', + 'data' => [ + 'number' => $creditCard->number, + 'verification_value' => $creditCard->cvv, + 'first_name' => $creditCard->firstName, + 'last_name' => $creditCard->lastName, + 'month' => $creditCard->month, + 'year' => $creditCard->year, + ], + ]); + } catch (\Exception $e) { + throw $this->translateIuguException($e, 'creating payment token'); + } + + if (!empty($iuguToken->errors) || empty($iuguToken->id)) { + throw $this->iuguResponseException('Error creating payment token', $iuguToken->errors); + } + + return $iuguToken->id; + } + + /** + * Traduz uma exceção capturada numa chamada à Iugu para a hierarquia do pacote, anexando a + * original como `previous` e o status HTTP quando o SDK o informa. + * + * O SDK lança `IuguRequestException` com o status HTTP em `getCode()` quando a resposta não é + * JSON (páginas de erro 5xx de proxy, corpo vazio de timeout com código 0) e + * `IuguObjectNotFound` para 404; `IuguAuthenticationException` só quando a chave não foi + * configurada. Erro com corpo JSON passa por `iuguResponseException()`. + * + * Regras: 401 e 403 viram `AuthenticationException`; 5xx e falha de rede viram + * `GatewayNotAvailableException`; 404 e o restante viram `GatewayException`, com o status + * acessível em `httpStatus` (429 e 409 inclusive). Exceção do próprio pacote passa intacta. + * + * @param \Throwable $e + * @param string $operation descrição da operação, em inglês, para a mensagem + * @return MultiPaymentException + */ + private function translateIuguException(\Throwable $e, string $operation): MultiPaymentException + { + if ($e instanceof MultiPaymentException) { + return $e; + } + + if ($e instanceof \IuguAuthenticationException) { + return AuthenticationException::invalidCredentials('iugu', $e->getMessage(), $e); + } + + if ($e instanceof IuguObjectNotFound) { + // o SDK lança essa classe para 404 e fetchAPI() a relança sem o código HTTP + return new GatewayException("Error {$operation}: {$e->getMessage()}", null, $e, 404); + } + + if ($e instanceof \IuguRequestException) { + // corpo vazio e código 0: o cURL não obteve resposta (falha de conexão ou timeout) + if ($e->getCode() <= 0 && trim($e->getMessage()) === '') { + return new GatewayNotAvailableException( + "Error {$operation}: no response from the gateway (network failure or timeout)", + $e + ); + } + + // fetchAPI() do SDK também lança esta classe, sem código, para resposta com `error` + return $this->classifyIuguFailure( + "Error {$operation}: {$e->getMessage()}", + $e->getMessage(), + null, + $e, + $e->getCode() > 0 ? (int) $e->getCode() : null + ); + } + + return new GatewayException("Error {$operation}: {$e->getMessage()}", null, $e); + } + + /** + * Traduz um corpo de erro devolvido pela Iugu numa resposta HTTP válida (o SDK não lança + * nesse caso) para a hierarquia do pacote, usando o status HTTP da última resposta. + * + * @param string $message + * @param mixed $errors corpo de `errors` da resposta + * @return MultiPaymentException + */ + private function iuguResponseException(string $message, $errors): MultiPaymentException + { + $detail = is_string($errors) ? $errors : json_encode($errors); + + return $this->classifyIuguFailure($message, (string) $detail, $errors, null, $this->lastIuguHttpStatus()); + } + + /** + * Escolhe a exceção do pacote pelo status HTTP da falha. + * + * @param string $message + * @param string $detail texto da resposta, para a mensagem de autenticação + * @param mixed $errors + * @param \Throwable|null $previous + * @param int|null $httpStatus nulo quando o SDK não informou o status + * @return MultiPaymentException + */ + private function classifyIuguFailure( + string $message, + string $detail, + $errors, + ?\Throwable $previous, + ?int $httpStatus + ): MultiPaymentException { + if (in_array($httpStatus, [401, 403], true)) { + return AuthenticationException::invalidCredentials('iugu', $detail, $previous, $httpStatus); + } + + if ($httpStatus >= 500) { + return new GatewayNotAvailableException($message, $previous, $httpStatus); + } + + return new GatewayException($message, $errors, $previous, $httpStatus); + } + + /** + * Status HTTP da última resposta que o SDK da Iugu conseguiu decodificar. O SDK só o expõe + * na variável global `$iugu_last_api_response_code`, gravada em toda resposta JSON + * (inclusive as de erro); quando a resposta não é JSON o status vai em `getCode()` da + * exceção e esta leitura não é usada. + * + * @return int|null + */ + private function lastIuguHttpStatus(): ?int + { + $code = $GLOBALS['iugu_last_api_response_code'] ?? null; + + return is_int($code) && $code > 0 ? $code : null; + } + /** * @inheritDoc */ @@ -163,20 +303,12 @@ public function createCustomer(Customer $customer): Customer try { $iuguCustomer = Iugu_Customer::create($iuguCustomerData); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); + throw $this->translateIuguException($e, 'creating customer'); } if ($iuguCustomer->errors) { - throw new GatewayException('Error creating customer', $iuguCustomer->errors); + throw $this->iuguResponseException('Error creating customer', $iuguCustomer->errors); } $customer->id = $iuguCustomer->id; @@ -271,19 +403,7 @@ public function createCreditCard(CreditCard $creditCard): CreditCard throw ModelAttributeValidationException::required('CreditCard', 'customer'); } if (empty($creditCard->token)) { - $creditCard->token = Iugu_PaymentToken::create([ - 'account_id' => Config::get('multi-payment.gateways.iugu.id'), - 'method' => 'credit_card', - 'test' => Config::get('multi-payment.environment') != 'production', - 'data' => [ - 'number' => $creditCard->number, - 'verification_value' => $creditCard->cvv, - 'first_name' => $creditCard->firstName, - 'last_name' => $creditCard->lastName, - 'month' => $creditCard->month, - 'year' => $creditCard->year, - ], - ]); + $creditCard->token = $this->createIuguPaymentToken($creditCard); } $options = [ @@ -298,19 +418,11 @@ public function createCreditCard(CreditCard $creditCard): CreditCard try { $iuguCreditCard = Iugu_PaymentMethod::create($options); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); + throw $this->translateIuguException($e, 'creating credit card'); } if ($iuguCreditCard->errors) { - throw new GatewayException('Error creating creditCard: ', $iuguCreditCard->errors); + throw $this->iuguResponseException('Error creating creditCard: ', $iuguCreditCard->errors); } return $this->parseIuguCard($iuguCreditCard, $creditCard); @@ -447,20 +559,12 @@ public function cancelInvoice(Invoice $invoice): Invoice 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()}"); + throw $this->translateIuguException($e, 'cancelling invoice'); } if (!empty($response->errors)) { - throw new GatewayException('Error cancelling invoice', (array) $response->errors); + throw $this->iuguResponseException('Error cancelling invoice', (array) $response->errors); } return $this->parseInvoice($response, $invoice); @@ -471,25 +575,21 @@ public function cancelInvoice(Invoice $invoice): Invoice */ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gatewayOptions = []): Invoice { - $iuguInvoice = new \Iugu_Invoice(['id' => $invoice->id]); + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } $params = array_merge($gatewayOptions, [ 'due_date' => $expiresAt->format('Y-m-d'), ]); - try { - $iuguInvoice = $iuguInvoice->duplicate($params); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\Exception $e) { - throw new GatewayException("Error getting invoice: {$e->getMessage()}"); - } - if (!empty($iuguInvoice->errors)) { - throw new GatewayException('Error getting invoice', $iuguInvoice->errors); - } + + // request cru em vez de Iugu_Invoice::duplicate(): o SDK engole a exceção e devolve false + $iuguInvoice = $this->iuguRequest( + 'POST', + Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/duplicate', + $params, + 'duplicating invoice' + ); return $this->parseInvoice($iuguInvoice); } @@ -742,16 +842,8 @@ private function iuguRequest( ): 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()}"); + throw $this->translateIuguException($e, $operation); } $responseObject = is_array($response) ? (object) $response : $response; @@ -759,7 +851,7 @@ private function iuguRequest( !empty($responseObject->errors) || (isset($responseObject->success) && $responseObject->success !== true) ) { - throw new GatewayException("Error {$operation}", (array) ($responseObject->errors ?? [])); + throw $this->iuguResponseException("Error {$operation}", (array) ($responseObject->errors ?? [])); } return $response; @@ -1004,22 +1096,31 @@ public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice * @return mixed * @throws \Potelo\MultiPayment\Exceptions\ChargingException * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\AuthenticationException */ private function chargeIuguInvoice(array $iuguInvoiceData) { try { $iuguCharge = \Iugu_Charge::create($iuguInvoiceData); } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); + throw $this->translateIuguException($e, 'charging invoice'); } if ($iuguCharge->errors) { - throw new GatewayException('Error charging invoice', $iuguCharge->errors); + throw $this->iuguResponseException('Error charging invoice', $iuguCharge->errors); } elseif (!$iuguCharge->success) { $exception = new ChargingException('Error charging invoice: ' . $iuguCharge->info_message); $exception->chargeResponse = $iuguCharge; + $exception->httpStatus = $this->lastIuguHttpStatus(); throw $exception; } - return $iuguCharge->invoice(); + + // a cobrança devolve só o id; a leitura da fatura é outra requisição e falha como tal + try { + return $iuguCharge->invoice(); + } catch (\Exception $e) { + throw $this->translateIuguException($e, 'getting charged invoice'); + } } /** @@ -1029,18 +1130,12 @@ public function getCustomer(Customer $customer): Customer { try { $iuguCustomer = Iugu_Customer::fetch($customer->id); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } } catch (\Exception $e) { - throw new GatewayException("Error getting customer: {$e->getMessage()}"); + throw $this->translateIuguException($e, 'getting customer'); } if (!empty($iuguCustomer->errors)) { - throw new GatewayException('Error getting customer', $iuguCustomer->errors); + throw $this->iuguResponseException('Error getting customer', $iuguCustomer->errors); } return $this->parseCustomer($iuguCustomer, $customer); @@ -1052,29 +1147,13 @@ public function updateCustomer(Customer $customer): Customer throw ModelAttributeValidationException::required('Customer', 'id'); } - $iuguCustomerData = $this->customerToIuguData($customer); - - try { - $iuguCustomer = Iugu_Customer::fetch($customer->id); - foreach ($iuguCustomerData as $key => $value) { - $iuguCustomer->{$key} = $value; - } - $iuguCustomer->save(); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); - } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); - } - - if ($iuguCustomer->errors) { - throw new GatewayException('Error updating customer', $iuguCustomer->errors); - } + // request cru em vez de Iugu_Customer::save(): o SDK engole a exceção e devolve false + $iuguCustomer = $this->iuguRequest( + 'PUT', + Iugu::getBaseURI() . '/customers/' . rawurlencode($customer->id), + $this->customerToIuguData($customer), + 'updating customer' + ); return $this->parseCustomer($iuguCustomer, $customer); } @@ -1101,17 +1180,20 @@ private function parseCustomer($iuguCustomer, ?Customer $customer = null): Custo } } - $customer->id = $iuguCustomer->id; - $customer->name = $iuguCustomer->name; - $customer->email = $iuguCustomer->email; - $customer->taxDocument = $iuguCustomer->cpf_cnpj; - $customer->phoneNumber = $iuguCustomer->phone; - $customer->phoneArea = $iuguCustomer->phone_prefix; + // leituras com `??`: a resposta pode ser stdClass (request cru), que avisa em campo ausente + $customer->id = $iuguCustomer->id ?? null; + $customer->name = $iuguCustomer->name ?? null; + $customer->email = $iuguCustomer->email ?? null; + $customer->taxDocument = $iuguCustomer->cpf_cnpj ?? null; + $customer->phoneNumber = $iuguCustomer->phone ?? null; + $customer->phoneArea = $iuguCustomer->phone_prefix ?? null; $customer->birthDate = !empty($valuesInsideCustomVariables['birth_date']) ? Carbon::createFromFormat('Y-m-d', $valuesInsideCustomVariables['birth_date']) : null; $customer->gateway = 'iugu'; - $customer->createdAt = new Carbon($iuguCustomer->created_at_iso); + // o recurso de cliente da Iugu devolve `created_at` (a fatura é que tem `created_at_iso`) + $createdAt = $iuguCustomer->created_at ?? $iuguCustomer->created_at_iso ?? null; + $customer->createdAt = !empty($createdAt) ? new Carbon($createdAt) : null; $customer->original = $iuguCustomer; if (!empty($iuguCustomer->zip_code) || !empty($iuguCustomer->street) || !empty($iuguCustomer->number) || !empty($iuguCustomer->district) || !empty($iuguCustomer->city) || !empty($iuguCustomer->state) || !empty($iuguCustomer->complement) || !empty($iuguCustomer->country)) { @@ -1208,23 +1290,21 @@ public function setCustomerDefaultCard(Customer $customer, string $cardId): Cust */ public function deleteCreditCard(CreditCard $creditCard): void { - try { - $iuguCreditCard = new Iugu_PaymentMethod([ - 'id' => $creditCard->id, - 'customer_id' => $creditCard->customer->id - ]); - $iuguCreditCard->delete(); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); - } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); + if (empty($creditCard->id)) { + throw ModelAttributeValidationException::required('CreditCard', 'id'); + } + if (empty($creditCard->customer) || empty($creditCard->customer->id)) { + throw ModelAttributeValidationException::required('CreditCard', 'customer'); } + + // request cru em vez de Iugu_PaymentMethod::delete(): o SDK engole a exceção e devolve false + $this->iuguRequest( + 'DELETE', + Iugu::getBaseURI() . '/customers/' . rawurlencode($creditCard->customer->id) + . '/payment_methods/' . rawurlencode($creditCard->id), + [], + 'deleting credit card' + ); } /** @@ -1235,19 +1315,11 @@ public function getCreditCard(CreditCard $creditCard): CreditCard try { $iuguCustomer = new Iugu_Customer(['id' => $creditCard->customer->id]); $iuguCreditCard = $iuguCustomer->payment_methods()->fetch($creditCard->id); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); + throw $this->translateIuguException($e, 'getting credit card'); } if ($iuguCreditCard->errors) { - throw new GatewayException('Error getting creditCard: ', $iuguCreditCard->errors); + throw $this->iuguResponseException('Error getting creditCard: ', $iuguCreditCard->errors); } return $this->parseIuguCard($iuguCreditCard, $creditCard); diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 0967cd1..479eb7b 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -9,8 +9,10 @@ use Stripe\PaymentMethod as StripePaymentMethod; use Stripe\Exception\CardException; use Stripe\Exception\ApiErrorException; +use Stripe\Exception\PermissionException; +use Stripe\Exception\UnexpectedValueException as StripeUnexpectedValueException; use Stripe\Exception\ApiConnectionException; -use Stripe\Exception\AuthenticationException; +use Stripe\Exception\AuthenticationException as StripeAuthenticationException; use Illuminate\Support\Facades\Config; use Potelo\MultiPayment\Models\Pix; use Potelo\MultiPayment\Models\Invoice; @@ -25,6 +27,7 @@ use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; use Potelo\MultiPayment\Exceptions\MultiPaymentException; +use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -403,27 +406,74 @@ private function taxDocumentType(string $taxDocument): string * * @param callable $request * @return mixed - * @throws GatewayException|GatewayNotAvailableException + * @throws GatewayException|GatewayNotAvailableException|AuthenticationException */ private function stripeRequest(callable $request) { try { return $request(); - } catch (AuthenticationException | ApiConnectionException $e) { - throw new GatewayNotAvailableException($e->getMessage()); - } catch (ApiErrorException $e) { + } catch (\Exception $e) { + throw $this->translateStripeException($e); + } + } + + /** + * Traduz uma exceção do stripe-php para a hierarquia do pacote, anexando a original como + * `previous` e o status HTTP da resposta. + * + * Regras: 401 (`AuthenticationException` do SDK, inclusive chave não configurada) e 403 + * (`PermissionException`) viram `AuthenticationException`; falha de conexão + * (`ApiConnectionException`) e 5xx viram `GatewayNotAvailableException`, inclusive o 5xx + * com corpo não JSON, que o SDK lança como `UnexpectedValueException`; o restante + * (`invalid_request_error`, 429, conflito de idempotência) vira `GatewayException` com + * `type`, `code`, `decline_code` e `param` em `getErrors()` e o status em `httpStatus`. + * Recusa de cartão (`CardException`) é tratada antes, em `stripeChargeRequest()`. Exceção do + * próprio pacote passa intacta. + * + * @param \Throwable $e + * @return MultiPaymentException + */ + private function translateStripeException(\Throwable $e): MultiPaymentException + { + if ($e instanceof MultiPaymentException) { + return $e; + } + + if ($e instanceof StripeAuthenticationException || $e instanceof PermissionException) { + return AuthenticationException::invalidCredentials('stripe', $e->getMessage(), $e, $e->getHttpStatus()); + } + + if ($e instanceof ApiConnectionException) { + return new GatewayNotAvailableException($e->getMessage(), $e, $e->getHttpStatus()); + } + + // corpo que não é JSON (página HTML de proxy num 5xx): o SDK lança esta classe com o + // status HTTP em getCode(), ou sem código quando o JSON veio sem a chave `error` + if ($e instanceof StripeUnexpectedValueException) { + $httpStatus = $e->getCode() > 0 ? (int) $e->getCode() : null; + if ($httpStatus >= 500) { + return new GatewayNotAvailableException($e->getMessage(), $e, $httpStatus); + } + + return new GatewayException($e->getMessage(), null, $e, $httpStatus); + } + + if ($e instanceof ApiErrorException) { + if ($e->getHttpStatus() >= 500) { + return new GatewayNotAvailableException($e->getMessage(), $e, $e->getHttpStatus()); + } + $error = $e->getError(); - throw new GatewayException($e->getMessage(), array_filter([ + + return new GatewayException($e->getMessage(), array_filter([ 'type' => $error?->type, 'code' => $error?->code, 'decline_code' => $error?->decline_code ?? null, 'param' => $error?->param, - ])); - } catch (MultiPaymentException $e) { - throw $e; - } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); + ]), $e, $e->getHttpStatus()); } + + return new GatewayException($e->getMessage(), null, $e); } /** @@ -519,7 +569,7 @@ private function createCreditCardInvoice(Invoice $invoice): Invoice if (($errors['type'] ?? null) !== 'card_error') { throw $e; } - $exception = new ChargingException('Error charging invoice: ' . $e->getMessage()); + $exception = new ChargingException('Error charging invoice: ' . $e->getMessage(), $e, $e->httpStatus); $exception->chargeResponse = $errors; $exception->reason = self::chargeFailureReason( $errors['code'] ?? null, @@ -992,7 +1042,7 @@ private function stripeChargeRequest(callable $request) try { return $request(); } catch (CardException $e) { - $exception = new ChargingException('Error charging invoice: ' . $e->getMessage()); + $exception = new ChargingException('Error charging invoice: ' . $e->getMessage(), $e, $e->getHttpStatus()); // array em vez do ErrorObject para manter o mesmo formato da recusa no attach $exception->chargeResponse = $e->getError()?->toArray(); $exception->reason = self::chargeFailureReason( @@ -1097,7 +1147,10 @@ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gat // a duplicata já existe — propaga o id dela para o consumidor não a perder throw new GatewayException( "Invoice duplicated as [{$duplicated->id}] but the original [{$invoice->id}] could not be canceled: " - . $e->getMessage() + . $e->getMessage(), + null, + $e, + $e->httpStatus ); } diff --git a/tests/Unit/Exceptions/MultiPaymentExceptionTest.php b/tests/Unit/Exceptions/MultiPaymentExceptionTest.php new file mode 100644 index 0000000..f68b50a --- /dev/null +++ b/tests/Unit/Exceptions/MultiPaymentExceptionTest.php @@ -0,0 +1,97 @@ +assertSame('erro', $exception->getMessage()); + $this->assertSame($original, $exception->getPrevious()); + $this->assertSame(503, $exception->httpStatus); + } + + public function testBaseExceptionDefaultsToNoPreviousAndNoHttpStatus(): void + { + $exception = new MultiPaymentException('erro'); + + $this->assertNull($exception->getPrevious()); + $this->assertNull($exception->httpStatus); + } + + public function testGatewayExceptionKeepsErrorsWhileAcceptingPreviousAndHttpStatus(): void + { + $original = new \RuntimeException('sdk'); + + $exception = new GatewayException('erro', ['code' => 'invalid'], $original, 422); + + $this->assertSame('erro - code: invalid', $exception->getMessage()); + $this->assertSame(['code' => 'invalid'], $exception->getErrors()); + $this->assertSame($original, $exception->getPrevious()); + $this->assertSame(422, $exception->httpStatus); + } + + public function testSubclassesWithoutOwnConstructorInheritPreviousAndHttpStatus(): void + { + $original = new \RuntimeException('sdk'); + + $charging = new ChargingException('recusado', $original, 402); + $unavailable = new GatewayNotAvailableException('fora do ar', $original, 502); + + $this->assertSame($original, $charging->getPrevious()); + $this->assertSame(402, $charging->httpStatus); + $this->assertSame($original, $unavailable->getPrevious()); + $this->assertSame(502, $unavailable->httpStatus); + } + + public function testAuthenticationExceptionNamesTheGatewayAndKeepsTheDetail(): void + { + $original = new \RuntimeException('Unauthorized'); + + $exception = AuthenticationException::invalidCredentials('iugu', 'Unauthorized', $original, 401); + + $this->assertInstanceOf(MultiPaymentException::class, $exception); + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $exception); + $this->assertStringContainsString('iugu', $exception->getMessage()); + $this->assertStringContainsString('Unauthorized', $exception->getMessage()); + $this->assertSame($original, $exception->getPrevious()); + $this->assertSame(401, $exception->httpStatus); + } + + public function testAuthenticationExceptionWithoutDetailHasNoDanglingSuffix(): void + { + $exception = AuthenticationException::invalidCredentials('stripe', ''); + + $this->assertStringEndsWith('configurada.', $exception->getMessage()); + $this->assertNull($exception->httpStatus); + } + + public function testRefundNotSupportedExceptionAcceptsPrevious(): void + { + $original = new \RuntimeException('sdk'); + + $exception = new RefundNotSupportedException( + 'recusado', + 'pix', + RefundNotSupportedException::REASON_PIX_PARTIAL_NOT_SUPPORTED, + false, + $original + ); + + $this->assertSame($original, $exception->getPrevious()); + $this->assertSame('pix', $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + } +} diff --git a/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php b/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php new file mode 100644 index 0000000..94abd59 --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php @@ -0,0 +1,740 @@ +instance('config', new Repository([ + 'multi-payment.gateways.iugu.api_key' => 'test-api-key', + 'multi-payment.gateways.iugu.id' => 'account-id', + 'multi-payment.environment' => 'testing', + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + QueuedIuguApiRequest::restoreSdkRequester(); + unset($GLOBALS['iugu_last_api_response_code']); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testUnauthorizedJsonBodyBecomesAuthenticationException(): void + { + // a Iugu responde 401 com corpo JSON, então o SDK devolve a resposta em vez de lançar + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Unauthorized'], 401), + ]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame(401, $e->httpStatus); + $this->assertStringContainsString('Unauthorized', $e->getMessage()); + $this->assertNull($e->getPrevious()); + } + } + + public function testForbiddenJsonBodyBecomesAuthenticationException(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Forbidden'], 403), + ]); + + try { + (new IuguGateway($api))->cancelInvoice($this->invoiceWithId()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertSame(403, $e->httpStatus); + $this->assertNull($e->getPrevious()); + $this->assertStringContainsString('Forbidden', $e->getMessage()); + } + } + + public function testUnauthorizedNonJsonResponseBecomesAuthenticationExceptionWithPrevious(): void + { + $original = new \IuguRequestException('401 Unauthorized', 401); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertSame(401, $e->httpStatus); + } + } + + public function testMissingApiKeyBecomesAuthenticationExceptionNotGatewayNotAvailable(): void + { + $original = new \IuguAuthenticationException('Chave de API não configurada.'); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame($original, $e->getPrevious()); + $this->assertNull($e->httpStatus); + } + } + + #[DataProvider('serverErrorProvider')] + public function testServerErrorsAndTimeoutBecomeGatewayNotAvailableException(\Throwable $original, ?int $expectedStatus): void + { + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertSame($expectedStatus, $e->httpStatus); + } + } + + public static function serverErrorProvider(): array + { + return [ + '502 com página html do proxy' => [new \IuguRequestException('502 Bad Gateway', 502), 502], + '503 sem corpo json' => [new \IuguRequestException('Service Unavailable', 503), 503], + '500 sem corpo json' => [new \IuguRequestException('Internal Server Error', 500), 500], + // cURL sem resposta: corpo vazio e código 0 + 'timeout de rede' => [new \IuguRequestException('', 0), null], + ]; + } + + public function testRequestExceptionWithoutCodeButWithMessageIsNotReadAsNetworkFailure(): void + { + // fetchAPI() do SDK lança IuguRequestException('Iugu: ...') sem código para resposta com `error` + $original = new \IuguRequestException('Iugu: invoice unavailable'); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame($original, $e->getPrevious()); + $this->assertNull($e->httpStatus); + $this->assertStringContainsString('invoice unavailable', $e->getMessage()); + } + } + + public function testServerErrorWithJsonBodyBecomesGatewayNotAvailableException(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Service Unavailable'], 503), + ]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame(503, $e->httpStatus); + $this->assertNull($e->getPrevious()); + } + } + + public function testNotFoundKeepsBeingGatewayExceptionWithStatusAndPrevious(): void + { + // fetchAPI() do SDK relança IuguObjectNotFound sem o código HTTP + $original = new \IuguObjectNotFound('invoice: not found'); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame($original, $e->getPrevious()); + $this->assertSame(404, $e->httpStatus); + } + } + + #[DataProvider('clientErrorProvider')] + public function testOtherHttpErrorsBecomeGatewayExceptionWithStatusExposed(int $status): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => ['base' => ['erro']]], $status), + ]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertNotInstanceOf(AuthenticationException::class, $e); + $this->assertSame($status, $e->httpStatus); + $this->assertSame(['base' => ['erro']], $e->getErrors()); + } + } + + public static function clientErrorProvider(): array + { + return [ + 'validação' => [422], + 'requisição inválida' => [400], + 'conflito de idempotência' => [409], + 'rate limit' => [429], + ]; + } + + public function testJsonErrorBodyWithoutKnownStatusIsStillGatewayException(): void + { + // ramo defensivo: o SDK real grava o status em toda resposta decodificada, mas se ele + // faltar o gateway ainda respondeu, então a falha não pode ser lida como indisponibilidade + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Not Found'], 0), + ]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertNull($e->httpStatus); + } + } + + public function testUnexpectedExceptionBecomesGatewayExceptionWithPrevious(): void + { + $original = new \RuntimeException('json inesperado'); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertNull($e->httpStatus); + $this->assertStringContainsString('json inesperado', $e->getMessage()); + } + } + + #[DataProvider('injectedRequesterFlowProvider')] + public function testEveryInjectedRequesterFlowAttachesThePreviousException(\Closure $operation): void + { + $original = new \IuguRequestException('502 Bad Gateway', 502); + $api = new QueuedIuguApiRequest([$original]); + + try { + $operation(new IuguGateway($api)); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertSame(502, $e->httpStatus); + } + + $this->assertCount(1, $api->calls); + } + + public static function injectedRequesterFlowProvider(): array + { + $invoice = static function (): Invoice { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + + return $invoice; + }; + $subscription = static function (): Subscription { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + return $subscription; + }; + + return [ + 'getInvoice' => [fn (IuguGateway $g) => $g->getInvoice($invoice())], + 'cancelInvoice' => [fn (IuguGateway $g) => $g->cancelInvoice($invoice())], + 'refundInvoice' => [function (IuguGateway $g) use ($invoice) { + $paid = $invoice(); + $paid->paymentMethod = Invoice::PAYMENT_METHOD_CREDIT_CARD; + $paid->status = Invoice::STATUS_PAID; + $paid->paidAt = Carbon::now(); + + return $g->refundInvoice($paid); + }], + 'rescheduleAutomaticPixPayment' => [fn (IuguGateway $g) => $g->rescheduleAutomaticPixPayment($invoice())], + 'getSubscription' => [fn (IuguGateway $g) => $g->getSubscription($subscription())], + 'suspendSubscription' => [fn (IuguGateway $g) => $g->suspendSubscription($subscription())], + 'getPlan' => [function (IuguGateway $g) { + $plan = new Plan(); + $plan->identifier = 'plano_mensal'; + + return $g->getPlan($plan); + }], + 'duplicateInvoice' => [fn (IuguGateway $g) => $g->duplicateInvoice($invoice(), Carbon::parse('2026-10-01'))], + 'updateCustomer' => [function (IuguGateway $g) { + $customer = self::customerWithId(); + + return $g->updateCustomer($customer); + }], + 'deleteCreditCard' => [fn (IuguGateway $g) => $g->deleteCreditCard(self::savedCreditCard())], + ]; + } + + #[DataProvider('staticSdkFlowProvider')] + public function testEveryStaticSdkFlowAttachesThePreviousException(\Closure $operation, array $responsesBefore = []): void + { + $original = new \IuguRequestException('502 Bad Gateway', 502); + $api = (new QueuedIuguApiRequest([...$responsesBefore, $original]))->installAsSdkRequester(); + + try { + $operation(new IuguGateway($api)); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertSame(502, $e->httpStatus); + } + + $this->assertCount(count($responsesBefore) + 1, $api->calls); + } + + public static function staticSdkFlowProvider(): array + { + return [ + 'createInvoice (Iugu_Invoice::create)' => [function (IuguGateway $g) { + $invoice = new Invoice(); + $invoice->customer = self::customerWithId(); + $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_PIX]; + $item = new InvoiceItem(); + $item->description = 'Item'; + $item->price = 1000; + $item->quantity = 1; + $invoice->items = [$item]; + + return $g->createInvoice($invoice); + }], + 'chargeInvoiceWithCreditCard (Iugu_Charge::create)' => [function (IuguGateway $g) { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + $invoice->creditCard = self::savedCreditCard(); + + return $g->chargeInvoiceWithCreditCard($invoice); + }], + 'createCustomer (Iugu_Customer::create)' => [function (IuguGateway $g) { + $customer = self::customerWithId(); + $customer->id = null; + + return $g->createCustomer($customer); + }], + 'getCustomer (Iugu_Customer::fetch)' => [fn (IuguGateway $g) => $g->getCustomer(self::customerWithId())], + 'createCreditCard (token ok, Iugu_PaymentMethod::create falha)' => [ + function (IuguGateway $g) { + $creditCard = self::savedCreditCard(); + $creditCard->id = null; + $creditCard->token = 'tok_1'; + + return $g->createCreditCard($creditCard); + }, + ], + 'createCreditCard (Iugu_PaymentToken::create falha)' => [ + function (IuguGateway $g) { + $creditCard = self::savedCreditCard(); + $creditCard->id = null; + $creditCard->number = '4111111111111111'; + $creditCard->cvv = '123'; + $creditCard->firstName = 'Cliente'; + $creditCard->lastName = 'Teste'; + $creditCard->month = '12'; + $creditCard->year = '2030'; + + return $g->createCreditCard($creditCard); + }, + ], + 'getCreditCard (payment_methods()->fetch)' => [fn (IuguGateway $g) => $g->getCreditCard(self::savedCreditCard())], + ]; + } + + private static function customerWithId(): Customer + { + $customer = new Customer(); + $customer->id = 'cus_1'; + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + $customer->taxDocument = '20176996915'; + + return $customer; + } + + private static function savedCreditCard(): CreditCard + { + $creditCard = new CreditCard(); + $creditCard->id = 'pm_1'; + $creditCard->customer = self::customerWithId(); + + return $creditCard; + } + + public function testStaticSdkResourceFailureIsTranslatedWithPrevious(): void + { + $original = new \IuguRequestException('502 Bad Gateway', 502); + $api = (new QueuedIuguApiRequest([$original]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->createCustomer($this->customerModel()); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame($original, $e->getPrevious()); + } + + $this->assertCount(1, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + } + + public function testStaticSdkResourceUnauthorizedBodyBecomesAuthenticationException(): void + { + $api = (new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Unauthorized'], 401), + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->createCustomer($this->customerModel()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertSame(401, $e->httpStatus); + } + + $this->assertCount(1, $api->calls); + } + + public function testInvalidRawCardOnTokenizationBecomesGatewayExceptionWithoutSecondRequest(): void + { + $api = (new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => ['number' => ['não é válido']]], 422), + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->createCreditCard($this->rawCreditCardModel()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertSame(422, $e->httpStatus); + $this->assertSame(['number' => ['não é válido']], $e->getErrors()); + $this->assertStringContainsString('payment token', $e->getMessage()); + } + + // só a tokenização foi tentada; o cartão não chegou a ser salvo no cliente + $this->assertCount(1, $api->calls); + $this->assertStringEndsWith('/payment_token', $api->calls[0]['url']); + } + + public function testSdkExceptionDuringTokenizationDoesNotEscapeThePackage(): void + { + $original = new \IuguRequestException('502 Bad Gateway', 502); + $api = (new QueuedIuguApiRequest([$original]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->createCreditCard($this->rawCreditCardModel()); + $this->fail('Esperava exceção do pacote'); + } catch (MultiPaymentException $e) { + $this->assertInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame($original, $e->getPrevious()); + } + } + + public function testTokenizationResponseWithoutIdBecomesGatewayException(): void + { + $api = (new QueuedIuguApiRequest([(object) ['method' => 'credit_card']]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->createCreditCard($this->rawCreditCardModel()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + // resposta 200 sem token: o status vem da global gravada pelo requester + $this->assertSame(200, $e->httpStatus); + } + + // sem token não há tentativa de salvar o cartão no cliente + $this->assertCount(1, $api->calls); + } + + public function testSuccessfulTokenizationUsesTheReturnedIdAsToken(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['id' => 'tok_1', 'method' => 'credit_card'], + (object) [ + 'id' => 'pm_1', + 'description' => 'CREDIT CARD', + 'data' => (object) ['brand' => 'VISA', 'display_number' => 'XXXX-XXXX-XXXX-4242', 'month' => 12, 'year' => 2030], + ], + ]))->installAsSdkRequester(); + + $creditCard = (new IuguGateway($api))->createCreditCard($this->rawCreditCardModel()); + + $this->assertSame('tok_1', $creditCard->token); + $this->assertSame('pm_1', $creditCard->id); + $this->assertCount(2, $api->calls); + $this->assertSame('tok_1', $api->calls[1]['data']['token']); + } + + public function testFailureReadingTheInvoiceAfterAChargeDoesNotEscapeThePackage(): void + { + // Iugu_Charge::invoice() faz um GET separado; fetchAPI() relança IuguObjectNotFound + $api = (new QueuedIuguApiRequest([ + (object) ['success' => true, 'invoice_id' => 'inv_1'], + new \IuguObjectNotFound('{"errors":"Not Found"}', 404), + ]))->installAsSdkRequester(); + + $invoice = $this->invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_1'; + + try { + (new IuguGateway($api))->chargeInvoiceWithCreditCard($invoice); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertInstanceOf(\IuguObjectNotFound::class, $e->getPrevious()); + $this->assertSame(404, $e->httpStatus); + } + + $this->assertCount(2, $api->calls); + } + + public function testDeclinedChargeExposesTheHttpStatusOnChargingException(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => false, 'LR' => '51', 'info_message' => 'Saldo insuficiente'], + ]))->installAsSdkRequester(); + + $invoice = $this->invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_1'; + + try { + (new IuguGateway($api))->chargeInvoiceWithCreditCard($invoice); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(200, $e->httpStatus); + $this->assertNull($e->getPrevious()); + } + } + + public function testDuplicateInvoiceGoesThroughTheInjectedRequesterAndTranslatesUnauthorized(): void + { + // Iugu_Invoice::duplicate() do SDK engole a exceção e devolve false; o driver faz o POST direto + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Unauthorized'], 401), + ]); + + try { + (new IuguGateway($api))->duplicateInvoice($this->invoiceWithId(), Carbon::parse('2026-10-01')); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertSame(401, $e->httpStatus); + } + + $this->assertCount(1, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith('/invoices/inv_1/duplicate', $api->calls[0]['url']); + $this->assertSame(['due_date' => '2026-10-01'], $api->calls[0]['data']); + } + + public function testDuplicateInvoiceParsesTheDuplicatedInvoice(): void + { + $api = new QueuedIuguApiRequest([$this->pendingInvoiceResponse('inv_2')]); + + $duplicated = (new IuguGateway($api))->duplicateInvoice( + $this->invoiceWithId(), + Carbon::parse('2026-10-01'), + ['ignore_due_email' => true] + ); + + $this->assertSame('inv_2', $duplicated->id); + $this->assertSame(Invoice::STATUS_PENDING, $duplicated->status); + $this->assertSame(['ignore_due_email' => true, 'due_date' => '2026-10-01'], $api->calls[0]['data']); + } + + public function testUpdateCustomerGoesThroughTheInjectedRequesterAndTranslatesServerError(): void + { + // Iugu_Customer::save() do SDK engole a exceção e devolve false; o driver faz o PUT direto + $original = new \IuguRequestException('502 Bad Gateway', 502); + $api = new QueuedIuguApiRequest([$original]); + + $customer = $this->customerModel(); + $customer->id = 'cus_1'; + + try { + (new IuguGateway($api))->updateCustomer($customer); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame($original, $e->getPrevious()); + } + + $this->assertCount(1, $api->calls); + $this->assertSame('PUT', $api->calls[0]['method']); + $this->assertStringEndsWith('/customers/cus_1', $api->calls[0]['url']); + $this->assertSame('Cliente', $api->calls[0]['data']['name']); + $this->assertSame('20176996915', $api->calls[0]['data']['cpf_cnpj']); + } + + public function testUpdateCustomerParsesTheUpdatedCustomer(): void + { + $api = new QueuedIuguApiRequest([$this->iuguCustomerResponse(['name' => 'Cliente Novo'])]); + + $customer = $this->customerModel(); + $customer->id = 'cus_1'; + $customer->name = 'Cliente Novo'; + + $updated = (new IuguGateway($api))->updateCustomer($customer); + + $this->assertSame('cus_1', $updated->id); + $this->assertSame('Cliente Novo', $updated->name); + $this->assertSame('iugu', $updated->gateway); + $this->assertSame('2026-09-02T09:00:00-03:00', $updated->createdAt->toIso8601String()); + } + + public function testDeleteCreditCardGoesThroughTheInjectedRequesterAndTranslatesUnauthorized(): void + { + // Iugu_PaymentMethod::delete() do SDK engole a exceção e devolve false; o driver faz o DELETE direto + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Unauthorized'], 401), + ]); + + $creditCard = new CreditCard(); + $creditCard->id = 'pm_1'; + $creditCard->customer = new Customer(); + $creditCard->customer->id = 'cus_1'; + + $this->expectException(AuthenticationException::class); + + try { + (new IuguGateway($api))->deleteCreditCard($creditCard); + } finally { + $this->assertCount(1, $api->calls); + $this->assertSame('DELETE', $api->calls[0]['method']); + $this->assertStringEndsWith('/customers/cus_1/payment_methods/pm_1', $api->calls[0]['url']); + } + } + + public function testDeleteCreditCardSucceedsSilentlyOnAValidResponse(): void + { + $api = new QueuedIuguApiRequest([(object) ['id' => 'pm_1', 'description' => 'CREDIT CARD']]); + + $creditCard = new CreditCard(); + $creditCard->id = 'pm_1'; + $creditCard->customer = new Customer(); + $creditCard->customer->id = 'cus_1'; + + (new IuguGateway($api))->deleteCreditCard($creditCard); + + $this->assertCount(1, $api->calls); + } + + private function pendingInvoiceResponse(string $id): object + { + return (object) [ + 'id' => $id, + 'status' => 'pending', + 'total_cents' => 10000, + 'paid_at' => null, + 'secure_url' => "https://faturas.iugu.com/{$id}", + 'taxes_paid_cents' => null, + 'created_at_iso' => '2026-09-02T09:00:00-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 0, + 'due_date' => '2026-10-01', + 'payment_method' => null, + 'payable_with' => 'pix', + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [ + (object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1], + ], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + ]; + } + + private function iuguCustomerResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'cus_1', + 'name' => 'Cliente', + 'email' => 'cliente@example.com', + 'cpf_cnpj' => '20176996915', + 'phone' => null, + 'phone_prefix' => null, + 'created_at' => '2026-09-02T09:00:00-03:00', + 'custom_variables' => [], + 'default_payment_method_id' => null, + ], $overrides); + } + + private function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + + return $invoice; + } + + private function customerModel(): Customer + { + $customer = new Customer(); + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + $customer->taxDocument = '20176996915'; + + return $customer; + } + + private function rawCreditCardModel(): CreditCard + { + $creditCard = new CreditCard(); + $creditCard->customer = new Customer(); + $creditCard->customer->id = 'cus_1'; + $creditCard->number = '4111111111111111'; + $creditCard->cvv = '123'; + $creditCard->firstName = 'Cliente'; + $creditCard->lastName = 'Teste'; + $creditCard->month = '12'; + $creditCard->year = '2030'; + + return $creditCard; + } +} diff --git a/tests/Unit/Gateways/QueuedIuguApiRequest.php b/tests/Unit/Gateways/QueuedIuguApiRequest.php index 9f8b15d..90aecd4 100644 --- a/tests/Unit/Gateways/QueuedIuguApiRequest.php +++ b/tests/Unit/Gateways/QueuedIuguApiRequest.php @@ -6,14 +6,18 @@ /** * Devolve uma resposta por chamada, na ordem, e guarda todas as chamadas feitas. Uma entrada - * `\Throwable` na fila é lançada em vez de devolvida, para simular o SDK sinalizando 404 ou 5xx. + * `\Throwable` na fila é lançada em vez de devolvida, para simular o SDK sinalizando 404 ou 5xx; + * uma entrada `QueuedIuguResponse` devolve o corpo com o status HTTP informado, para simular + * erro com corpo JSON (401, 422), que o SDK devolve sem lançar. + * + * Como o SDK, grava o status HTTP de cada resposta devolvida em `$iugu_last_api_response_code`. */ class QueuedIuguApiRequest extends Iugu_APIRequest { public array $calls = []; /** - * @param array $responses + * @param array $responses */ public function __construct(private array $responses) { @@ -32,6 +36,43 @@ public function request($method, $url, $data = []) throw $response; } + if ($response instanceof QueuedIuguResponse) { + $GLOBALS['iugu_last_api_response_code'] = $response->status; + + return $response->body; + } + + $GLOBALS['iugu_last_api_response_code'] = 200; + return $response; } + + /** + * Instala este fake como requester dos recursos estáticos do SDK (`Iugu_Invoice::create()`, + * `Iugu_Customer::fetch()`, `Iugu_PaymentToken::create()`...), que não recebem o requester + * pelo construtor do gateway. Chame `restoreSdkRequester()` no tearDown. + * + * @return $this + */ + public function installAsSdkRequester(): static + { + self::sdkRequesterProperty()->setValue(null, $this); + + return $this; + } + + /** + * Devolve o SDK ao requester real, para o fake não vazar para outros testes. + * + * @return void + */ + public static function restoreSdkRequester(): void + { + self::sdkRequesterProperty()->setValue(null, null); + } + + private static function sdkRequesterProperty(): \ReflectionProperty + { + return new \ReflectionProperty(\APIResource::class, '_apiRequester'); + } } diff --git a/tests/Unit/Gateways/QueuedIuguResponse.php b/tests/Unit/Gateways/QueuedIuguResponse.php new file mode 100644 index 0000000..df3365e --- /dev/null +++ b/tests/Unit/Gateways/QueuedIuguResponse.php @@ -0,0 +1,13 @@ + */ public array $calls = []; - /** @var array */ + /** @var array */ private array $responses; private function __construct(array $responses) { $this->responses = array_map(static function ($response) { + if ($response instanceof \Throwable) { + return $response; + } + return isset($response[1]) && is_int($response[1]) ? $response : [$response, 200]; @@ -50,8 +56,12 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode if (empty($this->responses)) { throw new \RuntimeException("Unexpected Stripe request: {$method} {$absUrl}"); } - [$body, $code] = array_shift($this->responses); + $response = array_shift($this->responses); + if ($response instanceof \Throwable) { + throw $response; + } + [$body, $code] = $response; - return [json_encode($body), $code, []]; + return [is_string($body) ? $body : json_encode($body), $code, []]; } } diff --git a/tests/Unit/Gateways/StripeGatewayCustomerTest.php b/tests/Unit/Gateways/StripeGatewayCustomerTest.php index b2ee7b6..b79f88c 100644 --- a/tests/Unit/Gateways/StripeGatewayCustomerTest.php +++ b/tests/Unit/Gateways/StripeGatewayCustomerTest.php @@ -12,6 +12,7 @@ use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -283,7 +284,7 @@ public function testUnimplementedOperationThrowsClearGatewayException(): void (new StripeGateway())->rescheduleAutomaticPixPayment(new Invoice()); } - public function testAuthenticationErrorBecomesGatewayNotAvailable(): void + public function testAuthenticationErrorBecomesAuthenticationExceptionNotGatewayNotAvailable(): void { RecordingStripeHttpClient::withResponses([ [['error' => ['type' => 'invalid_request_error', 'message' => 'Invalid API Key provided']], 401], @@ -292,9 +293,13 @@ public function testAuthenticationErrorBecomesGatewayNotAvailable(): void $customer = new Customer(); $customer->id = 'cus_fake123'; - $this->expectException(GatewayNotAvailableException::class); - - (new StripeGateway())->getCustomer($customer); + try { + (new StripeGateway())->getCustomer($customer); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame(401, $e->httpStatus); + } } public function testApiErrorBecomesGatewayExceptionWithNormalizedErrors(): void diff --git a/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php b/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php new file mode 100644 index 0000000..d2645ea --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php @@ -0,0 +1,280 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testInvalidApiKeyBecomesAuthenticationExceptionWithPrevious(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'invalid_request_error', 'message' => 'Invalid API Key provided']], 401], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertInstanceOf(StripeAuthenticationException::class, $e->getPrevious()); + $this->assertSame(401, $e->httpStatus); + $this->assertStringContainsString('stripe', $e->getMessage()); + $this->assertStringContainsString('Invalid API Key provided', $e->getMessage()); + } + } + + public function testKeyWithoutPermissionBecomesAuthenticationException(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'invalid_request_error', 'message' => 'This API key does not have permission']], 403], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertInstanceOf(PermissionException::class, $e->getPrevious()); + $this->assertSame(403, $e->httpStatus); + } + } + + #[DataProvider('serverErrorStatusProvider')] + public function testServerErrorsBecomeGatewayNotAvailableExceptionWithStatus(int $status): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'api_error', 'message' => 'Something went wrong']], $status], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertInstanceOf(UnknownApiErrorException::class, $e->getPrevious()); + $this->assertSame($status, $e->httpStatus); + } + } + + public static function serverErrorStatusProvider(): array + { + return ['500' => [500], '502' => [502], '503' => [503]]; + } + + public function testServerErrorWithHtmlBodyBecomesGatewayNotAvailableExceptionWithStatus(): void + { + // o stripe-php não consegue decodificar a página do proxy e lança + // UnexpectedValueException com o status em getCode() + RecordingStripeHttpClient::withResponses([['502 Bad Gateway', 502]]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertInstanceOf(StripeUnexpectedValueException::class, $e->getPrevious()); + $this->assertSame(502, $e->httpStatus); + } + } + + public function testClientErrorWithHtmlBodyStaysGatewayExceptionWithStatus(): void + { + RecordingStripeHttpClient::withResponses([['404 Not Found', 404]]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame(404, $e->httpStatus); + } + } + + public function testConnectionFailureBecomesGatewayNotAvailableExceptionWithoutStatus(): void + { + $original = new ApiConnectionException('Could not connect to Stripe (https://api.stripe.com)'); + RecordingStripeHttpClient::withResponses([$original]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertNull($e->httpStatus); + } + } + + public function testRateLimitStaysGatewayExceptionWithStatusExposed(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'rate_limit_error', 'message' => 'Too many requests']], 429], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertNotInstanceOf(AuthenticationException::class, $e); + $this->assertInstanceOf(RateLimitException::class, $e->getPrevious()); + $this->assertSame(429, $e->httpStatus); + $this->assertSame('rate_limit_error', $e->getErrors()['type']); + } + } + + public function testInvalidRequestStaysGatewayExceptionWithNormalizedErrorsAndPrevious(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'invalid_request_error', + 'code' => 'resource_missing', + 'param' => 'customer', + 'message' => 'No such customer', + ]], 404], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertInstanceOf(InvalidRequestException::class, $e->getPrevious()); + $this->assertSame(404, $e->httpStatus); + $this->assertSame([ + 'type' => 'invalid_request_error', + 'code' => 'resource_missing', + 'param' => 'customer', + ], $e->getErrors()); + } + } + + public function testCardDeclineAttachesTheCardExceptionAndItsStatus(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'generic_decline', + 'message' => 'Your card was declined.', + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertInstanceOf(CardException::class, $e->getPrevious()); + $this->assertSame(402, $e->httpStatus); + $this->assertSame('card_declined', $e->reason); + } + } + + public function testCardDeclineDuringAttachKeepsTheWholeExceptionChain(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'generic_decline', + 'message' => 'Your card was declined.', + ]], 402], + ]); + + $invoice = $this->creditCardInvoiceModel(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'pm_fake123'; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(402, $e->httpStatus); + $this->assertInstanceOf(GatewayException::class, $e->getPrevious()); + $this->assertInstanceOf(CardException::class, $e->getPrevious()->getPrevious()); + } + } + + public function testUnexpectedExceptionInsideTheSdkBecomesGatewayExceptionWithPrevious(): void + { + $original = new \RuntimeException('falha inesperada'); + RecordingStripeHttpClient::withResponses([$original]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertNull($e->httpStatus); + } + } + + private function customerWithId(): Customer + { + $customer = new Customer(); + $customer->id = 'cus_fake123'; + + return $customer; + } + + private function creditCardInvoiceModel(): Invoice + { + $invoice = new Invoice(); + $invoice->customer = new Customer(); + $invoice->customer->id = 'cus_fake123'; + $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_CREDIT_CARD]; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + $item = new InvoiceItem(); + $item->description = 'Assinatura mensal'; + $item->price = 12345; + $item->quantity = 1; + $invoice->items = [$item]; + + return $invoice; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index cf26b7a..a1feb52 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Stripe\ApiRequestor; +use Stripe\Exception\InvalidRequestException; use PHPUnit\Framework\TestCase; use Illuminate\Config\Repository; use Illuminate\Container\Container; @@ -1076,10 +1077,16 @@ public function testDuplicateReportsTheNewInvoiceWhenCancelingTheOriginalFails() $invoice = new Invoice(); $invoice->id = 'pi_fake123'; - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('Invoice duplicated as [pi_fake456]'); - - (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + try { + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertStringContainsString('Invoice duplicated as [pi_fake456]', $e->getMessage()); + // a falha do cancelamento continua acessível, com a exceção do SDK abaixo dela + $this->assertInstanceOf(GatewayException::class, $e->getPrevious()); + $this->assertInstanceOf(InvalidRequestException::class, $e->getPrevious()->getPrevious()); + $this->assertSame(400, $e->httpStatus); + } } public function testDuplicateRejectsPaidInvoice(): void From 87728af6a9f17aa3516beca27634307b431f4c30 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 08:50:54 -0300 Subject: [PATCH 15/32] =?UTF-8?q?docs:=20corrige=20mensagens=20de=20suport?= =?UTF-8?q?e=20do=20Stripe,=20cria=20alias=20gatewayOptions=20e=20document?= =?UTF-8?q?a=20responsabilidades=20do=20Pix=20Autom=C3=A1tico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 140 ++++++++++++---- src/Builders/Builder.php | 25 ++- src/Contracts/AutomaticPixContract.php | 11 ++ src/Gateways/IuguGateway.php | 14 +- src/Gateways/StripeGateway.php | 61 ++++--- src/Models/Model.php | 81 +++++++++- src/Models/Subscription.php | 3 +- src/MultiPayment.php | 5 +- .../Builders/InvoiceBuilderTest.php | 24 +-- tests/Integration/StripeGatewayTest.php | 2 +- .../Gateways/IuguGatewaySubscriptionTest.php | 8 +- .../Gateways/StripeGatewayCustomerTest.php | 23 ++- .../Gateways/StripeGatewayInvoiceTest.php | 55 +++++-- tests/Unit/ModelTest.php | 152 ++++++++++++++++++ tests/Unit/SubscriptionTest.php | 4 +- 15 files changed, 498 insertions(+), 110 deletions(-) create mode 100644 tests/Unit/ModelTest.php diff --git a/README.md b/README.md index d52f931..db38a9e 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,12 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [Suporte por gateway](#suporte-por-gateway) - [Status da fatura](#status-da-fatura) - [Particularidades do Stripe](#particularidades-do-stripe) + - [Opções extras do gateway](#opções-extras-do-gateway) - [Utilizando](#utilizando) - [MultiPayment](#multipayment) - [InvoiceBuilder](#invoicebuilder) - [Pix Automático](#pix-automático) + - [Pix Automático: quem agenda a cobrança](#pix-automático-quem-agenda-a-cobrança) - [Assinaturas e planos](#assinaturas-e-planos) - [CustomerBuilder](#customerbuilder) - [getInvoice](#getinvoice) @@ -84,28 +86,43 @@ Também é possível utilizar o Facade: ### Suporte por gateway +Cada célula é uma de três coisas: + +- **sim**: a lib implementa a operação nesse gateway. +- **não implementado**: o gateway oferece o recurso, mas a lib ainda não o integrou. No Stripe, + boleto e Pix Automático lançam `GatewayException` dizendo que a operação ainda não está + implementada nesta lib; assinatura e plano caem em `GatewayException::methodNotFound` ou na + checagem de contract (ver [Assinaturas e planos](#assinaturas-e-planos)); fatura multi-método + lança `ModelAttributeValidationException`. Na Iugu, idempotência e parcelamento não têm + chamada própria: a opção simplesmente não é tratada pelo driver. Boleto, assinatura, plano e + Pix Automático no Stripe estão planejados para uma versão futura. +- **limitação do gateway**: o gateway não oferece o recurso. A lib falha antes de chamar a API, + com a exceção indicada. + | Operação | Iugu | Stripe | |---|---|---| -| Fatura com cartão de crédito | ✅ | ✅ (token-only) | -| Fatura com pix | ✅ | ✅ | -| Fatura com boleto | ✅ | ❌ lança `GatewayException` | -| Fatura multi-método (`available_payment_methods` com mais de um) | ✅ | ❌ exatamente 1 método por fatura | -| Estorno de cartão (total e parcial) | ✅ | ✅ | -| Estorno de Pix | ✅ somente integral; parcial lança `RefundNotSupportedException` | ✅ total e parcial | -| Estorno de boleto | ❌ lança `RefundNotSupportedException` (devolução manual) | ❌ lança `RefundNotSupportedException` (devolução manual) | -| Cancelamento | ✅ | ✅ | -| Duplicar fatura (`duplicateInvoice`) | ✅ | ✅ somente pix pendente | -| Cobrar fatura pendente com cartão | ✅ | ✅ (inclusive pix expirado) | -| Customer (criar/atualizar/buscar) e cartões salvos | ✅ | ✅ | -| Pix Automático | ✅ | 🚧 em desenvolvimento | -| Assinatura (criar, buscar, atualizar, suspender, retomar, cancelar, listar) | ✅ | 🚧 em desenvolvimento | -| Cancelar assinatura ao fim do período (`cancel(atPeriodEnd: true)`) | ❌ lança `GatewayException` | 🚧 em desenvolvimento | -| Troca de plano e simulação (`changePlan`, `previewPlanChange`) | ✅ | 🚧 em desenvolvimento | -| Desconto na assinatura | ✅ somente valor fixo (`amountOff`), com `cycles` 1 ou `null` | 🚧 em desenvolvimento | -| Plano (criar, buscar, listar) | ✅ (`year` é enviado como 12 meses) | 🚧 em desenvolvimento | -| Desativar plano (`deactivatePlan`) | ❌ lança `GatewayException` | 🚧 em desenvolvimento | - -🚧 = ainda não implementado no gateway; hoje a chamada lança `GatewayException`. +| Fatura com cartão de crédito | sim | sim (token-only) | +| Cartão com dados crus (`number`, `cvv`) | sim | limitação do gateway: exige liberação de raw card data e PCI SAQ D; lança `GatewayException` orientando a tokenizar | +| Fatura com pix | sim | sim | +| Fatura com boleto | sim | não implementado | +| Fatura multi-método (`available_payment_methods` com mais de um) | sim | não implementado: a fatura é um PaymentIntent com exatamente um método; depende de uma decisão pendente sobre o mapeamento de `Invoice` | +| Estorno de cartão (total e parcial) | sim | sim | +| Estorno de Pix | sim, somente integral; parcial é limitação do gateway e lança `RefundNotSupportedException` | sim, total e parcial | +| Estorno de boleto | limitação do gateway: lança `RefundNotSupportedException` (devolução manual) | limitação do gateway: a guarda já lança `RefundNotSupportedException`, embora boleto ainda não exista no driver | +| Cancelamento | sim | sim | +| Duplicar fatura (`duplicateInvoice`) | sim | sim, somente pix pendente | +| Cobrar fatura pendente com cartão | sim | sim (inclusive pix expirado) | +| Customer (criar/atualizar/buscar) e cartões salvos | sim | sim | +| Idempotência (`gateway_options['idempotency_key']`) | não implementado: a Iugu aceita o cabeçalho em criar fatura, assinatura, cliente e cobrança direta, mas o driver ainda não o envia | sim, na criação de fatura e no estorno | +| Parcelamento no cartão | não implementado: a Iugu parcela nativamente até 12x | limitação do gateway: o Stripe BR não parcela | +| Pix Automático | sim | não implementado | +| Assinatura (criar, buscar, atualizar, suspender, retomar, cancelar, listar) | sim | não implementado | +| Cancelar assinatura ao fim do período (`cancel(atPeriodEnd: true)`) | limitação do gateway: lança `GatewayException`; suspenda na data | não implementado | +| Troca de plano e simulação (`changePlan`, `previewPlanChange`) | sim | não implementado | +| Desconto na assinatura com valor fixo (`amountOff`) | sim, com `cycles` 1 ou `null` | não implementado | +| Desconto percentual e cupom de primeira classe | limitação do gateway: `percentOff` lança `GatewayException` | não implementado | +| Plano (criar, buscar, listar) | sim (`year` é enviado como 12 meses) | não implementado | +| Desativar plano (`deactivatePlan`) | limitação do gateway: lança `GatewayException` | não implementado | ### Status da fatura @@ -174,8 +191,36 @@ Invoice::isContested($invoice->status); // tem briga aberta? disputed ou chargeb quando ela é verdadeira, o pacote consulta `/v1/disputes` do charge para decidir entre `disputed` e `chargeback` (ver [Status da fatura](#status-da-fatura)). Fatura sem contestação não paga esse GET. -- **Idempotência**: envie `gateway_adicional_options['idempotency_key']` na criação de - faturas e estornos para repassar o cabeçalho `Idempotency-Key` da Stripe. +- **Idempotência**: envie `gateway_options['idempotency_key']` (ou `$invoice->gatewayOptions`) + na criação de faturas e estornos para repassar o cabeçalho `Idempotency-Key` da Stripe. + +### Opções extras do gateway + +Todo model tem o array público `gatewayOptions`: é a válvula de escape para enviar ao gateway +uma opção que a lib não modela. O driver mescla esse array ao payload que monta a partir do +model, e as chaves daqui sobrepõem as geradas. Nos arrays de entrada (`charge()`, `fill()`) a +chave é `gateway_options`; nos builders, `setGatewayOptions()`. + +```php +$invoice = $payment->newInvoice() + ->setPaymentMethod('pix') + ->addCustomer('Nome', 'email@example.com', '01234567891') + ->addItem('Produto', 1, 10000) + ->setGatewayOptions(['expires_in' => 3]) // opção da Iugu, sem equivalente genérico + ->create(); + +$customer->gatewayOptions = ['metadata' => ['crm_id' => '42']]; // opção da Stripe +``` + +Use com moderação: o conteúdo é específico de um gateway e não passa por validação da lib. Se +uma opção vira uso recorrente, ela deve ser modelada genericamente. + +> **Nome antigo.** Até a 4.1.0 o array se chamava `gatewayAdicionalOptions` (com +> `setGatewayAdicionalOptions()` no builder e `gateway_adicional_options` nos arrays). Os três +> continuam funcionando como alias do nome novo, lendo e escrevendo o mesmo array, e emitem um +> aviso `E_USER_DEPRECATED` a cada uso; estão marcados `@deprecated` desde 2026-09-02 e saem na +> próxima versão maior. A única diferença observável é `toArray()`, que passa a devolver a +> chave `gateway_options`. ## Tratamento de erros @@ -257,7 +302,11 @@ Confira `src/MultiPayment/Builders/InvoiceBuilder.php` para saber quais métodos #### Pix Automático -O Pix Automático está disponível no gateway Iugu. No Stripe o suporte está **pendente** (aguardando a habilitação do recurso na conta): todas as operações de Pix Automático — inclusive criar fatura com `automatic_pix` — lançam `GatewayException` com mensagem "not yet implemented" até que essa integração seja concluída. +O Pix Automático está disponível no gateway Iugu. No Stripe ele ainda **não está implementado +nesta lib** (planejado para uma versão futura; a conta Stripe da empresa também aguarda a +liberação do recurso). Até lá, todas as operações de Pix Automático no Stripe, inclusive criar +fatura com `automatic_pix`, lançam `GatewayException` dizendo que a operação ainda não está +implementada nesta lib e orientando a usar a Iugu. Na Iugu, ele é configurado como parte da fatura: @@ -293,6 +342,29 @@ $multiPayment->getAutomaticPixCancellation($recurrenceId, $cancellationId); $multiPayment->listAutomaticPixCancellations($recurrenceId, page: 1, limit: 100); ``` +#### Pix Automático: quem agenda a cobrança + +Os dois gateways dividem a responsabilidade pela recorrência de forma oposta, e a lib ainda não +expõe essa diferença em código (uma capability declarada pelo gateway está planejada para uma +versão futura). Até lá, a regra é esta: + +- **Na Iugu, a aplicação é o motor de recorrência.** A API cria a recorrência junto com a + fatura e devolve o identificador, mas não controla a periodicidade das cobranças. É a + aplicação que decide quando cobrar e usa os métodos de `AutomaticPixContract` para isso: + `rescheduleAutomaticPixPayment()` para solicitar a retentativa de um agendamento, + `cancelAutomaticPixScheduledPayment()` para cancelar uma cobrança agendada e + `cancelAutomaticPixRecurrence()` para encerrar a recorrência. Sem esse motor na aplicação, + nenhuma cobrança recorrente acontece. +- **No Stripe, o gateway agenda.** O mandato vive na Subscription e a Stripe controla o + calendário: envia ao pagador a notificação de pré-débito obrigatória três dias antes de cada + débito, cobra e faz as retentativas automáticas. A aplicação não agenda nada; a data de + início do mandato precisa respeitar esse prazo de três dias. + +**Migrar uma recorrência de um gateway para o outro exige desligar o motor da aplicação para +aquela recorrência** quando o destino é o Stripe. Se o motor continuar ativo, a aplicação e o +gateway cobram o mesmo ciclo e o cliente é debitado duas vezes. No sentido inverso, do Stripe +para a Iugu, o motor precisa ser ligado, senão a recorrência para de cobrar. + ##### Testes com as sandboxes dos gateways A suíte `Integration` reúne todos os testes que acessam as sandboxes reais (Iugu @@ -315,11 +387,13 @@ que possam ser reativados quando o ambiente passar a suportar o fluxo. #### Assinaturas e planos -Assinatura recorrente está disponível no gateway Iugu. No Stripe as operações ainda não existem -e o `StripeGateway` não implementa `SubscriptionContract` nem `PlanContract`: `save()` e `get()` -lançam `GatewayException::methodNotFound`, e os métodos de domínio (`suspend()`, `resume()`, -`cancel()`, `changePlan()`, `previewPlanChange()`) lançam `GatewayException` avisando que o -gateway não implementa o contract. +Assinatura recorrente está disponível no gateway Iugu. No Stripe ela ainda **não está +implementada nesta lib** (planejada para uma versão futura; o Stripe Billing oferece o +recurso). Hoje o `StripeGateway` não declara `SubscriptionContract` nem `PlanContract`: `save()` +e `get()` lançam `GatewayException::methodNotFound`, e os métodos de domínio (`suspend()`, +`resume()`, `cancel()`, `changePlan()`, `previewPlanChange()`) lançam `GatewayException` +avisando que o gateway não implementa o contract e que a lib ainda não implementou essas +operações para ele. ```php use Potelo\MultiPayment\Models\Plan; @@ -416,10 +490,11 @@ Particularidades da Iugu: - **O plano de uma assinatura existente não muda por `save()`**; use `changePlan()`. - **Plano não é atualizável.** `save()` num `Plan` que já tem `id` lança `GatewayException`; para mudar preço ou intervalo, crie outro plano e troque as assinaturas com `changePlan()`. -- **Fatura vencida lê como `canceled`.** A Iugu chama de `expired` a fatura que venceu sem - pagamento, e o pacote a mapeia para `Invoice::STATUS_CANCELED` — mas ela ainda conta como - dívida na derivação de `past_due`. Para decidir se há pendência, olhe o `status` da assinatura, - não o da fatura. +- **Fatura vencida lê como `canceled` (limitação conhecida).** A Iugu chama de `expired` a + fatura que venceu sem pagamento, e o pacote ainda não tem um estado próprio para isso: ela é + mapeada para `Invoice::STATUS_CANCELED`, embora continue contando como dívida na derivação de + `past_due` da assinatura. Um estado próprio `expired` está planejado para uma versão futura, + junto com o enum completo de status da fatura. - **A assinatura lida traz o cliente resumido.** `Subscription::get()` preenche `customer` com id, nome e e-mail — documento, endereço e telefone não vêm da Iugu. Eles sobrevivem se o `customer` local já tiver o mesmo id; se o id for outro, ou o local não tiver id, o pacote @@ -600,6 +675,7 @@ $payment->setGateway('iugu')->charge($options); | `credit_card.first_name` | | string | primeiro nome no cartão de crédito | `'João'` | | `credit_card.last_name` | | string | último nome no cartão de crédito | `'Maria'` | | `bank_slip` | | array | array com os dados do boleto | `['expires_at' => '2022-12-31',...` | +| `gateway_options` | | array | opções específicas do gateway mescladas ao payload (ver [Opções extras do gateway](#opções-extras-do-gateway)) | `['expires_in' => 3]` | ### Models #### Customer diff --git a/src/Builders/Builder.php b/src/Builders/Builder.php index 1848593..a3ea529 100644 --- a/src/Builders/Builder.php +++ b/src/Builders/Builder.php @@ -58,7 +58,23 @@ public function setGateway($gateway = null): self } /** - * Set the gateway adicional options. + * Define as opções extras enviadas direto ao gateway (ver Model::$gatewayOptions). + * + * @param array $gatewayOptions + * + * @return $this + */ + public function setGatewayOptions(array $gatewayOptions): self + { + $this->model->gatewayOptions = $gatewayOptions; + + return $this; + } + + /** + * Set the gateway options. Old name of setGatewayOptions(). + * + * @deprecated since 2026-09-02, use setGatewayOptions() * * @param array $gatewayAdicionalOptions * @@ -66,8 +82,11 @@ public function setGateway($gateway = null): self */ public function setGatewayAdicionalOptions(array $gatewayAdicionalOptions): self { - $this->model->gatewayAdicionalOptions = $gatewayAdicionalOptions; + trigger_error( + 'Builder::setGatewayAdicionalOptions() está obsoleto desde 2026-09-02; use setGatewayOptions()', + E_USER_DEPRECATED + ); - return $this; + return $this->setGatewayOptions($gatewayAdicionalOptions); } } \ No newline at end of file diff --git a/src/Contracts/AutomaticPixContract.php b/src/Contracts/AutomaticPixContract.php index 4ed791a..fe13e76 100644 --- a/src/Contracts/AutomaticPixContract.php +++ b/src/Contracts/AutomaticPixContract.php @@ -9,6 +9,17 @@ use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +/** + * Operações de gestão de uma recorrência de Pix Automático. + * + * Quem agenda cada cobrança depende do gateway. Na Iugu a API não gerencia a recorrência: a + * aplicação é o motor de recorrência e precisa chamar estas operações na periodicidade certa + * para que as cobranças aconteçam, sejam reagendadas ou canceladas. No Stripe o mandato vive + * na Subscription e o próprio gateway agenda, notifica o pagador com três dias de + * antecedência e faz as retentativas. Ao migrar uma recorrência de um gateway que não agenda + * para um que agenda, desligue o motor da aplicação para aquela recorrência, sob risco de + * cobrança dupla. + */ interface AutomaticPixContract { /** diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index 1b7eeb8..4ef3511 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -118,8 +118,8 @@ public function createInvoice(Invoice $invoice): Invoice ); } - if (!empty($invoice->gatewayAdicionalOptions)) { - foreach ($invoice->gatewayAdicionalOptions as $option => $value) { + if (!empty($invoice->gatewayOptions)) { + foreach ($invoice->gatewayOptions as $option => $value) { $iuguInvoiceData[$option] = $value; } } @@ -1261,8 +1261,8 @@ private function customerToIuguData(Customer $customer): array ]; } - if (!empty($customer->gatewayAdicionalOptions)) { - foreach ($customer->gatewayAdicionalOptions as $option => $value) { + if (!empty($customer->gatewayOptions)) { + foreach ($customer->gatewayOptions as $option => $value) { $iuguCustomerData[$option] = $value; } } @@ -1361,7 +1361,7 @@ public function createSubscription(Subscription $subscription): Subscription { $data = array_merge( $this->subscriptionToIuguData($subscription), - $subscription->gatewayAdicionalOptions + $subscription->gatewayOptions ); $response = $this->iuguRequest( @@ -1404,7 +1404,7 @@ public function updateSubscription(Subscription $subscription): Subscription $data = array_merge( $this->subscriptionToIuguData($subscription, false), - $subscription->gatewayAdicionalOptions + $subscription->gatewayOptions ); $subitems = $data['subitems'] ?? null; unset($data['subitems']); @@ -1602,7 +1602,7 @@ public function createPlan(Plan $plan): Plan $response = $this->iuguRequest( 'POST', Iugu::getBaseURI() . '/plans', - array_merge($this->planToIuguData($plan), $plan->gatewayAdicionalOptions), + array_merge($this->planToIuguData($plan), $plan->gatewayOptions), 'creating plan' ); diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 479eb7b..4d5c5b3 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -168,8 +168,8 @@ public function setCustomerDefaultCard(Customer $customer, string $cardId): Cust /** * Garante os expands exigidos pelo parse no payload sem descartar um expand vindo de - * gatewayAdicionalOptions — sem eles a Stripe omite dados (tax ids, charge) e o - * parse/sync corromperia silenciosamente. + * gatewayOptions. Sem eles a Stripe omite dados (tax ids, charge) e o parse/sync + * corromperia silenciosamente. * * @param array $stripeData * @param array $expand @@ -253,8 +253,8 @@ private function customerToStripeData(Customer $customer): array $stripeCustomerData['invoice_settings']['default_payment_method'] = $customer->defaultCard->id; } - if (!empty($customer->gatewayAdicionalOptions)) { - foreach ($customer->gatewayAdicionalOptions as $option => $value) { + if (!empty($customer->gatewayOptions)) { + foreach ($customer->gatewayOptions as $option => $value) { $stripeCustomerData[$option] = $value; } } @@ -477,15 +477,22 @@ private function translateStripeException(\Throwable $e): MultiPaymentException } /** - * Exceção padrão para operações do contrato ainda não implementadas neste gateway — + * Exceção padrão para operações que a Stripe oferece mas este driver ainda não construiu; * mais clara que o methodNotFound do despacho por convenção, que sugeriria erro de digitação. * * @param string $operation + * @param string $advice orientação enquanto a operação não existe (ex.: usar a Iugu) * @return GatewayException */ - private function operationNotImplemented(string $operation): GatewayException + private function operationNotImplemented(string $operation, string $advice = ''): GatewayException { - return new GatewayException("Operation [{$operation}] is not yet implemented by the stripe gateway"); + $message = "A operação [{$operation}] no Stripe ainda não está implementada nesta lib;" + . ' a Stripe suporta o recurso.'; + if ($advice !== '') { + $message .= ' ' . $advice; + } + + return new GatewayException($message); } /** @@ -495,9 +502,12 @@ private function operationNotImplemented(string $operation): GatewayException public function createInvoice(Invoice $invoice): Invoice { // sem esta guarda a fatura seria criada como pix comum, descartando a recorrência - // silenciosamente — o suporte a Pix Automático no Stripe ainda não foi construído + // silenciosamente, porque o Pix Automático no Stripe ainda não foi construído if (!empty($invoice->automaticPix)) { - throw $this->operationNotImplemented('createInvoice with automatic pix'); + throw $this->operationNotImplemented( + 'createInvoice com Pix Automático', + 'Use a Iugu para Pix Automático por enquanto.' + ); } $paymentMethod = $this->invoicePaymentMethod($invoice); @@ -507,9 +517,12 @@ public function createInvoice(Invoice $invoice): Invoice case Invoice::PAYMENT_METHOD_PIX: return $this->createPixInvoice($invoice); case Invoice::PAYMENT_METHOD_BANK_SLIP: - throw new GatewayException('The stripe gateway does not support bank slip invoices; use the iugu gateway instead'); + throw $this->operationNotImplemented( + 'createInvoice com boleto', + 'Use a Iugu para boleto por enquanto.' + ); default: - throw $this->operationNotImplemented("createInvoice with the [{$paymentMethod}] payment method"); + throw $this->operationNotImplemented("createInvoice com o método de pagamento [{$paymentMethod}]"); } } @@ -529,7 +542,7 @@ private function invoicePaymentMethod(Invoice $invoice): string throw ModelAttributeValidationException::invalid( 'Invoice', 'availablePaymentMethods', - 'the stripe gateway supports exactly one payment method per invoice' + 'this library maps the invoice to a single PaymentIntent, so exactly one payment method per invoice is accepted for now' ); } @@ -584,7 +597,7 @@ private function createCreditCardInvoice(Invoice $invoice): Invoice $stripePaymentIntentData['payment_method'] = $invoice->creditCard->id; $stripePaymentIntentData['confirm'] = true; $stripePaymentIntentData['off_session'] = true; - $stripePaymentIntentData = $this->mergeGatewayAdicionalOptions($stripePaymentIntentData, $invoice); + $stripePaymentIntentData = $this->mergeGatewayOptions($stripePaymentIntentData, $invoice); $requestOptions = $this->extractIdempotencyKey($stripePaymentIntentData); $stripePaymentIntent = $this->stripeChargeRequest(function () use ($stripePaymentIntentData, $requestOptions) { @@ -638,7 +651,7 @@ private function createPixInvoice(Invoice $invoice): Invoice } $stripePaymentIntentData['payment_method_options']['pix']['expires_at'] = $invoice->expiresAt->getTimestamp(); } - $stripePaymentIntentData = $this->mergeGatewayAdicionalOptions($stripePaymentIntentData, $invoice); + $stripePaymentIntentData = $this->mergeGatewayOptions($stripePaymentIntentData, $invoice); $requestOptions = $this->extractIdempotencyKey($stripePaymentIntentData); $stripePaymentIntent = $this->stripeRequest(function () use ($stripePaymentIntentData, $requestOptions) { @@ -733,9 +746,9 @@ private function invoiceToStripeData(Invoice $invoice): array * @param \Potelo\MultiPayment\Models\Invoice $invoice * @return array */ - private function mergeGatewayAdicionalOptions(array $stripeData, Invoice $invoice): array + private function mergeGatewayOptions(array $stripeData, Invoice $invoice): array { - foreach ($invoice->gatewayAdicionalOptions ?? [] as $option => $value) { + foreach ($invoice->gatewayOptions ?? [] as $option => $value) { $stripeData[$option] = $value; } @@ -782,7 +795,7 @@ public function refundInvoice(Invoice $invoice): Invoice if (!empty($invoice->refundedAmount)) { $stripeRefundData['amount'] = $invoice->refundedAmount; } - $stripeRefundData = $this->mergeGatewayAdicionalOptions($stripeRefundData, $invoice); + $stripeRefundData = $this->mergeGatewayOptions($stripeRefundData, $invoice); $requestOptions = $this->extractIdempotencyKey($stripeRefundData); $stripeRefund = $this->stripeRequest(function () use ($stripeRefundData, $requestOptions) { @@ -1134,10 +1147,10 @@ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gat // as gatewayOptions do chamador vêm por último e podem sobrescrever $originalMetadata = !empty($original->metadata) ? $original->metadata->toArray() : []; if (!empty($originalMetadata)) { - $duplicated->gatewayAdicionalOptions['metadata'] = $originalMetadata; + $duplicated->gatewayOptions['metadata'] = $originalMetadata; } if (!empty($gatewayOptions)) { - $duplicated->gatewayAdicionalOptions = array_merge($duplicated->gatewayAdicionalOptions, $gatewayOptions); + $duplicated->gatewayOptions = array_merge($duplicated->gatewayOptions, $gatewayOptions); } $duplicated = $this->createPixInvoice($duplicated); @@ -1335,7 +1348,7 @@ private function parseStripeCard(StripePaymentMethod $stripePaymentMethod, ?Cred */ public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice { - throw $this->operationNotImplemented('rescheduleAutomaticPixPayment'); + throw $this->operationNotImplemented('rescheduleAutomaticPixPayment', 'Use a Iugu para Pix Automático por enquanto.'); } /** @@ -1343,7 +1356,7 @@ public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice */ public function cancelAutomaticPixScheduledPayment(AutomaticPixCharge $charge): AutomaticPixCancellation { - throw $this->operationNotImplemented('cancelAutomaticPixScheduledPayment'); + throw $this->operationNotImplemented('cancelAutomaticPixScheduledPayment', 'Use a Iugu para Pix Automático por enquanto.'); } /** @@ -1351,7 +1364,7 @@ public function cancelAutomaticPixScheduledPayment(AutomaticPixCharge $charge): */ public function cancelAutomaticPixRecurrence(AutomaticPix $automaticPix): AutomaticPixCancellation { - throw $this->operationNotImplemented('cancelAutomaticPixRecurrence'); + throw $this->operationNotImplemented('cancelAutomaticPixRecurrence', 'Use a Iugu para Pix Automático por enquanto.'); } /** @@ -1359,7 +1372,7 @@ public function cancelAutomaticPixRecurrence(AutomaticPix $automaticPix): Automa */ public function getAutomaticPixCancellation(AutomaticPixCancellation $cancellation): AutomaticPixCancellation { - throw $this->operationNotImplemented('getAutomaticPixCancellation'); + throw $this->operationNotImplemented('getAutomaticPixCancellation', 'Use a Iugu para Pix Automático por enquanto.'); } /** @@ -1367,7 +1380,7 @@ public function getAutomaticPixCancellation(AutomaticPixCancellation $cancellati */ public function listAutomaticPixCancellations(AutomaticPix $automaticPix, int $page = 1, int $limit = 100): array { - throw $this->operationNotImplemented('listAutomaticPixCancellations'); + throw $this->operationNotImplemented('listAutomaticPixCancellations', 'Use a Iugu para Pix Automático por enquanto.'); } /** diff --git a/src/Models/Model.php b/src/Models/Model.php index 5b62564..a578e27 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -8,13 +8,86 @@ use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; +/** + * @property array $gatewayAdicionalOptions Obsoleto desde 2026-09-02, use $gatewayOptions. Alias + * que lê e escreve o mesmo array, com aviso de deprecação. + */ abstract class Model { + /** + * Opções extras enviadas direto ao gateway. Cada driver mescla este array ao payload que + * monta a partir do model, e as chaves daqui sobrepõem as geradas. + * + * @var array + */ + public array $gatewayOptions = []; /** - * @var array $gatewayAdicionalOptions Gateway adicional options Can be used to send adicional options to the gateway and override the default options + * Resolve a leitura do nome antigo `gatewayAdicionalOptions` para `gatewayOptions`. + * + * Devolve por referência para que `$model->gatewayAdicionalOptions['chave'] = 'valor'` + * continue alterando o array, como fazia quando a propriedade existia. + * + * @param string $name + * @return mixed */ - public array $gatewayAdicionalOptions = []; + public function &__get(string $name): mixed + { + if ($name === 'gatewayAdicionalOptions') { + self::warnGatewayAdicionalOptionsDeprecated(); + + return $this->gatewayOptions; + } + + trigger_error('Undefined property: ' . static::class . '::$' . $name, E_USER_WARNING); + $undefined = null; + + return $undefined; + } + + /** + * Resolve a escrita no nome antigo `gatewayAdicionalOptions` para `gatewayOptions`. + * Qualquer outro nome segue o comportamento padrão do PHP (propriedade dinâmica). + * + * @param string $name + * @param mixed $value + * @return void + */ + public function __set(string $name, mixed $value): void + { + if ($name === 'gatewayAdicionalOptions') { + self::warnGatewayAdicionalOptionsDeprecated(); + $this->gatewayOptions = $value; + + return; + } + + $this->{$name} = $value; + } + + /** + * Mantém `isset()` e `empty()` funcionando sobre o nome antigo `gatewayAdicionalOptions`. + * + * @param string $name + * @return bool + */ + public function __isset(string $name): bool + { + return $name === 'gatewayAdicionalOptions'; + } + + /** + * Emite o aviso de deprecação do nome antigo `gatewayAdicionalOptions`. + * + * @return void + */ + private static function warnGatewayAdicionalOptionsDeprecated(): void + { + trigger_error( + 'Model::$gatewayAdicionalOptions está obsoleto desde 2026-09-02; use $gatewayOptions', + E_USER_DEPRECATED + ); + } /** * Create a new instance of the model with an array of attributes. @@ -115,6 +188,10 @@ public function fill(array $data): void { foreach ($data as $key => $value) { $key = lcfirst(str_replace('_', '', ucwords($key, '_'))); + if ($key === 'gatewayAdicionalOptions') { + self::warnGatewayAdicionalOptionsDeprecated(); + $key = 'gatewayOptions'; + } if (property_exists($this, $key)) { $this->{$key} = $value; } diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index b35c6ae..fc67fa7 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -360,7 +360,8 @@ private function resolveSubscriptionGateway(GatewayContract|string|null $gateway if (!$resolved instanceof SubscriptionContract) { throw new GatewayException( - 'Gateway [' . get_class($resolved) . '] does not implement SubscriptionContract' + 'Gateway [' . get_class($resolved) . '] does not implement SubscriptionContract;' + . ' subscriptions are not yet implemented in this library for that gateway' ); } diff --git a/src/MultiPayment.php b/src/MultiPayment.php index bbf2394..113050b 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -158,9 +158,10 @@ public function listPlans(int $page = 1, int $limit = 100): array private function gatewayImplementing(string $contract): GatewayContract { if (!$this->gateway instanceof $contract) { + $contractName = substr(strrchr($contract, '\\'), 1); throw new GatewayException( - 'Gateway [' . get_class($this->gateway) . '] does not implement ' - . substr(strrchr($contract, '\\'), 1) + 'Gateway [' . get_class($this->gateway) . "] does not implement {$contractName};" + . ' the operations of that contract are not yet implemented in this library for that gateway' ); } diff --git a/tests/Integration/Builders/InvoiceBuilderTest.php b/tests/Integration/Builders/InvoiceBuilderTest.php index 422ef05..b131940 100644 --- a/tests/Integration/Builders/InvoiceBuilderTest.php +++ b/tests/Integration/Builders/InvoiceBuilderTest.php @@ -110,8 +110,8 @@ private function createInvoice(string $gateway, array $data): Invoice ); } - if (isset($data['gatewayAdicionalOptions'])) { - $invoiceBuilder->setGatewayAdicionalOptions($data['gatewayAdicionalOptions']); + if (isset($data['gatewayOptions'])) { + $invoiceBuilder->setGatewayOptions($data['gatewayOptions']); } return $invoiceBuilder->create(); @@ -183,7 +183,7 @@ public function testShouldCreateInvoice(string $gateway, array $data): void $this->assertNotEmpty($invoice->creditCard->id); } - if ((isset($data['availablePaymentMethods']) && in_array('bank_slip', $data['availablePaymentMethods'])) || (isset($data['paymentMethod']) && $data['paymentMethod'] === 'bank_slip') || (isset($data['gatewayAdicionalOptions']) && in_array('payable_with', $data['gatewayAdicionalOptions']) && in_array('bank_slip', $data['gatewayAdicionalOptions']['payable_with']))) { + if ((isset($data['availablePaymentMethods']) && in_array('bank_slip', $data['availablePaymentMethods'])) || (isset($data['paymentMethod']) && $data['paymentMethod'] === 'bank_slip') || (isset($data['gatewayOptions']) && in_array('payable_with', $data['gatewayOptions']) && in_array('bank_slip', $data['gatewayOptions']['payable_with']))) { $this->assertNotEmpty($invoice->bankSlip); $this->assertNotEmpty($invoice->bankSlip->url); $this->assertNotEmpty($invoice->bankSlip->number); @@ -191,23 +191,23 @@ public function testShouldCreateInvoice(string $gateway, array $data): void $this->assertNotEmpty($invoice->bankSlip->barcodeImage); } - if ((isset($data['availablePaymentMethods']) && in_array('pix', $data['availablePaymentMethods'])) || (isset($data['paymentMethod']) && $data['paymentMethod'] === 'pix') || (isset($data['gatewayAdicionalOptions']) && in_array('payable_with', $data['gatewayAdicionalOptions']) && in_array('pix', $data['gatewayAdicionalOptions']['payable_with']))) { + if ((isset($data['availablePaymentMethods']) && in_array('pix', $data['availablePaymentMethods'])) || (isset($data['paymentMethod']) && $data['paymentMethod'] === 'pix') || (isset($data['gatewayOptions']) && in_array('payable_with', $data['gatewayOptions']) && in_array('pix', $data['gatewayOptions']['payable_with']))) { $this->assertNotEmpty($invoice->pix); $this->assertNotEmpty($invoice->pix->qrCodeImageUrl); $this->assertNotEmpty($invoice->pix->qrCodeText); } - if (isset($data['gatewayAdicionalOptions'])) { - if (in_array('payable_with', $data['gatewayAdicionalOptions']) && $gateway == 'iugu') { + if (isset($data['gatewayOptions'])) { + if (in_array('payable_with', $data['gatewayOptions']) && $gateway == 'iugu') { foreach ($invoice->original->payable_with as $value) { - $this->assertContains($value, $data['gatewayAdicionalOptions']['payable_with']); + $this->assertContains($value, $data['gatewayOptions']['payable_with']); } } - if (in_array('expires_in', $data['gatewayAdicionalOptions'])) { - $this->assertEquals($data['gatewayAdicionalOptions'], $invoice->gatewayAdicionalOptions); + if (in_array('expires_in', $data['gatewayOptions'])) { + $this->assertEquals($data['gatewayOptions'], $invoice->gatewayOptions); if ($gateway == 'iugu') { - foreach ($invoice->gatewayAdicionalOptions as $key => $value) { + foreach ($invoice->gatewayOptions as $key => $value) { $this->assertNotEmpty(array_filter($invoice->original->variables, function ($variable) use ($key, $value) { return $variable->variable == $key && $variable->value == $value; })); @@ -276,7 +276,7 @@ public static function shouldCreateInvoiceDataProvider(): array 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithAddress(), - 'gatewayAdicionalOptions' => [ + 'gatewayOptions' => [ 'expires_in' => 5, ] ] @@ -287,7 +287,7 @@ public static function shouldCreateInvoiceDataProvider(): array 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithAddress(), - 'gatewayAdicionalOptions' => [ + 'gatewayOptions' => [ 'payable_with' => ['bank_slip', 'pix'], ] ] diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index 44aa1ce..ef07e44 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -355,7 +355,7 @@ public function testShouldRejectBankSlipInvoice($gateway) ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_BANK_SLIP]); $this->expectException(GatewayException::class); - $this->expectExceptionMessage('does not support bank slip'); + $this->expectExceptionMessage('[createInvoice com boleto] no Stripe ainda não está implementada nesta lib'); $invoiceBuilder->create(); } diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index 5574ea0..a6a5edb 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -89,13 +89,13 @@ public function testCreateSubscriptionMapsGenericFieldsToIuguPayload(): void ], $call['data']); } - public function testGatewayAdicionalOptionsOverrideTheGeneratedPayload(): void + public function testGatewayOptionsOverrideTheGeneratedPayload(): void { $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); $subscription = new Subscription(); $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1']]); - $subscription->gatewayAdicionalOptions = [ + $subscription->gatewayOptions = [ 'only_on_charge_success' => true, 'plan_identifier' => 'outro_plano', ]; @@ -999,13 +999,13 @@ public function testSubitemsFromTheGatewayAreAcceptedAsAssociativeArrays(): void $this->assertSame([['id' => 'si_a', '_destroy' => true]], $api->calls[1]['data']['subitems']); } - public function testGatewayAdicionalOptionsAlsoOverrideTheUpdatePayload(): void + public function testGatewayOptionsAlsoOverrideTheUpdatePayload(): void { $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); $subscription = new Subscription(); $subscription->fill(['id' => 'sub_1', 'next_billing_at' => '2026-11-01']); - $subscription->gatewayAdicionalOptions = ['expires_at' => '2026-12-25', 'ignore_due_email' => true]; + $subscription->gatewayOptions = ['expires_at' => '2026-12-25', 'ignore_due_email' => true]; (new IuguGateway($api))->updateSubscription($subscription); diff --git a/tests/Unit/Gateways/StripeGatewayCustomerTest.php b/tests/Unit/Gateways/StripeGatewayCustomerTest.php index b79f88c..327a4ee 100644 --- a/tests/Unit/Gateways/StripeGatewayCustomerTest.php +++ b/tests/Unit/Gateways/StripeGatewayCustomerTest.php @@ -201,14 +201,14 @@ public function testSetCustomerDefaultCardSendsInvoiceSettingsAndParsesDefaultCa $this->assertSame('pm_fake123', $result->defaultCard->id); } - public function testGatewayAdicionalOptionsReachThePayloadAndExpandIsMerged(): void + public function testGatewayOptionsReachThePayloadAndExpandIsMerged(): void { $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); $customer = new Customer(); $customer->name = 'Fake Customer'; $customer->taxDocument = '20176996915'; - $customer->gatewayAdicionalOptions = [ + $customer->gatewayOptions = [ 'preferred_locales' => ['pt-BR'], 'expand' => ['subscriptions'], ]; @@ -274,14 +274,21 @@ public function testParsesNumberOnlyLine1IntoAddressNumber(): void $this->assertSame('123', $result->address->number); } - public function testUnimplementedOperationThrowsClearGatewayException(): void + public function testUnimplementedOperationThrowsClearGatewayExceptionWithoutHittingTheApi(): void { - RecordingStripeHttpClient::withResponses([]); - - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('Operation [rescheduleAutomaticPixPayment] is not yet implemented by the stripe gateway'); + $httpClient = RecordingStripeHttpClient::withResponses([]); - (new StripeGateway())->rescheduleAutomaticPixPayment(new Invoice()); + try { + (new StripeGateway())->rescheduleAutomaticPixPayment(new Invoice()); + $this->fail('Pix Automático no Stripe deveria lançar GatewayException'); + } catch (GatewayException $e) { + $this->assertSame( + 'A operação [rescheduleAutomaticPixPayment] no Stripe ainda não está implementada nesta lib;' + . ' a Stripe suporta o recurso. Use a Iugu para Pix Automático por enquanto.', + $e->getMessage() + ); + } + $this->assertSame([], $httpClient->calls); } public function testAuthenticationErrorBecomesAuthenticationExceptionNotGatewayNotAvailable(): void diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index a1feb52..f720bc4 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -120,15 +120,41 @@ public function testRejectsInvoiceWithMultiplePaymentMethods(): void (new StripeGateway())->createInvoice($invoice); } - public function testRejectsBankSlipInvoiceWithClearMessage(): void + public function testRejectsBankSlipInvoiceAttributingTheLimitationToTheLibrary(): void { + $httpClient = RecordingStripeHttpClient::withResponses([]); $invoice = $this->creditCardInvoiceModel(); $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_BANK_SLIP]; - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('does not support bank slip'); + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Boleto no Stripe deveria lançar GatewayException'); + } catch (GatewayException $e) { + $this->assertStringContainsString('[createInvoice com boleto] no Stripe ainda não está implementada nesta lib', $e->getMessage()); + $this->assertStringContainsString('Use a Iugu para boleto por enquanto', $e->getMessage()); + $this->assertStringNotContainsStringIgnoringCase('não suporta', $e->getMessage()); + $this->assertStringNotContainsStringIgnoringCase('does not support', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } - (new StripeGateway())->createInvoice($invoice); + public function testRejectsUnknownPaymentMethodWithoutHittingTheApi(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->creditCardInvoiceModel(); + $invoice->availablePaymentMethods = ['foo']; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Método de pagamento desconhecido deveria lançar GatewayException'); + } catch (GatewayException $e) { + $this->assertSame( + 'A operação [createInvoice com o método de pagamento [foo]] no Stripe ainda não está implementada nesta lib;' + . ' a Stripe suporta o recurso.', + $e->getMessage() + ); + } + $this->assertSame([], $httpClient->calls); } public function testCreatesPixInvoiceFullyServerSideAndParsesQrCode(): void @@ -178,13 +204,18 @@ public function testCreatesPixInvoiceFullyServerSideAndParsesQrCode(): void public function testRejectsInvoiceWithAutomaticPixUntilSupported(): void { + $httpClient = RecordingStripeHttpClient::withResponses([]); $invoice = $this->pixInvoiceModel(); $invoice->automaticPix = new \Potelo\MultiPayment\Models\AutomaticPix(); - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('Operation [createInvoice with automatic pix] is not yet implemented'); - - (new StripeGateway())->createInvoice($invoice); + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Pix Automático no Stripe deveria lançar GatewayException'); + } catch (GatewayException $e) { + $this->assertStringContainsString('A operação [createInvoice com Pix Automático] no Stripe ainda não está implementada nesta lib', $e->getMessage()); + $this->assertStringContainsString('Use a Iugu para Pix Automático por enquanto', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); } public function testPixInvoiceRequiresCustomerTaxDocument(): void @@ -246,12 +277,12 @@ public function testPixInvoiceBillingDetailsOmitsMissingNameAndEmail(): void ); } - public function testIdempotencyKeyFromGatewayAdicionalOptionsBecomesRequestHeader(): void + public function testIdempotencyKeyFromGatewayOptionsBecomesRequestHeader(): void { $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); $invoice = $this->pixInvoiceModel(); - $invoice->gatewayAdicionalOptions = ['idempotency_key' => 'chave-unica-123']; + $invoice->gatewayOptions = ['idempotency_key' => 'chave-unica-123']; (new StripeGateway())->createInvoice($invoice); // a chave não pode vazar como parâmetro do payload (a API a rejeitaria) @@ -690,12 +721,12 @@ public function testChargeInvoiceWithLegacyTokenConvertsItIntoPaymentMethod(): v $this->assertSame('pm_fake123', $httpClient->calls[4][2]['payment_method']); } - public function testGatewayAdicionalOptionsOverrideAndExpandIsMerged(): void + public function testGatewayOptionsOverrideAndExpandIsMerged(): void { $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); $invoice = $this->creditCardInvoiceModel(); - $invoice->gatewayAdicionalOptions = [ + $invoice->gatewayOptions = [ 'statement_descriptor_suffix' => 'POTELO', 'off_session' => false, 'expand' => ['customer'], diff --git a/tests/Unit/ModelTest.php b/tests/Unit/ModelTest.php new file mode 100644 index 0000000..52013a1 --- /dev/null +++ b/tests/Unit/ModelTest.php @@ -0,0 +1,152 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testGatewayOptionsStartsEmptyAndIsAPublicArray(): void + { + $invoice = new Invoice(); + + $this->assertSame([], $invoice->gatewayOptions); + $this->assertTrue(property_exists($invoice, 'gatewayOptions')); + $this->assertFalse(property_exists($invoice, 'gatewayAdicionalOptions')); + } + + #[IgnoreDeprecations] + public function testWritingTheDeprecatedNameFillsGatewayOptionsWithADeprecationNotice(): void + { + $invoice = new Invoice(); + + $this->expectUserDeprecationMessage('Model::$gatewayAdicionalOptions está obsoleto desde 2026-09-02; use $gatewayOptions'); + + $invoice->gatewayAdicionalOptions = ['expires_in' => 3]; + + $this->assertSame(['expires_in' => 3], $invoice->gatewayOptions); + } + + #[IgnoreDeprecations] + public function testReadingTheDeprecatedNameReturnsGatewayOptionsWithADeprecationNotice(): void + { + $invoice = new Invoice(); + $invoice->gatewayOptions = ['idempotency_key' => 'abc']; + + $this->expectUserDeprecationMessage('Model::$gatewayAdicionalOptions está obsoleto desde 2026-09-02; use $gatewayOptions'); + + $this->assertSame(['idempotency_key' => 'abc'], $invoice->gatewayAdicionalOptions); + } + + #[IgnoreDeprecations] + public function testIndirectModificationThroughTheDeprecatedNameStillAltersTheArray(): void + { + $invoice = new Invoice(); + $invoice->gatewayOptions = ['a' => 1]; + + $this->expectUserDeprecationMessage('Model::$gatewayAdicionalOptions está obsoleto desde 2026-09-02; use $gatewayOptions'); + + $invoice->gatewayAdicionalOptions['b'] = 2; + + $this->assertSame(['a' => 1, 'b' => 2], $invoice->gatewayOptions); + } + + #[IgnoreDeprecations] + public function testIssetAndEmptyWorkOnTheDeprecatedName(): void + { + $invoice = new Invoice(); + + // isset() só passa pelo __isset, sem aviso; empty() também lê o valor pelo __get e avisa + $this->assertTrue(isset($invoice->gatewayAdicionalOptions)); + $this->assertFalse(isset($invoice->propriedadeInexistente)); + + $this->expectUserDeprecationMessage('Model::$gatewayAdicionalOptions está obsoleto desde 2026-09-02; use $gatewayOptions'); + + $this->assertTrue(empty($invoice->gatewayAdicionalOptions)); + + $invoice->gatewayOptions = ['a' => 1]; + + $this->assertFalse(empty($invoice->gatewayAdicionalOptions)); + } + + public function testFillAcceptsBothSnakeCaseKeys(): void + { + $novo = new Customer(); + $novo->fill(['name' => 'Ana', 'gateway_options' => ['x' => 1]]); + $this->assertSame(['x' => 1], $novo->gatewayOptions); + + $antigo = new Customer(); + @$antigo->fill(['name' => 'Ana', 'gateway_adicional_options' => ['y' => 2]]); + $this->assertSame(['y' => 2], $antigo->gatewayOptions); + } + + #[IgnoreDeprecations] + public function testFillWithTheDeprecatedKeyTriggersADeprecationNotice(): void + { + $this->expectUserDeprecationMessage('Model::$gatewayAdicionalOptions está obsoleto desde 2026-09-02; use $gatewayOptions'); + + (new Customer())->fill(['gateway_adicional_options' => ['y' => 2]]); + } + + public function testToArrayExposesGatewayOptionsUnderTheNewKeyOnly(): void + { + $customer = new Customer(); + $customer->name = 'Ana'; + $customer->gatewayOptions = ['x' => 1]; + + $array = $customer->toArray(); + + $this->assertSame(['x' => 1], $array['gateway_options']); + $this->assertArrayNotHasKey('gateway_adicional_options', $array); + } + + public function testBuilderSetGatewayOptionsFillsTheModel(): void + { + $builder = new InvoiceBuilder(new StripeGateway()); + + $this->assertSame($builder, $builder->setGatewayOptions(['expand' => ['customer']])); + $this->assertSame(['expand' => ['customer']], $builder->get()->gatewayOptions); + } + + #[IgnoreDeprecations] + public function testBuilderDeprecatedSetterStillWorksWithADeprecationNotice(): void + { + $builder = new InvoiceBuilder(new StripeGateway()); + + $this->expectUserDeprecationMessage('Builder::setGatewayAdicionalOptions() está obsoleto desde 2026-09-02; use setGatewayOptions()'); + + $builder->setGatewayAdicionalOptions(['expand' => ['customer']]); + + $this->assertSame(['expand' => ['customer']], $builder->get()->gatewayOptions); + } +} diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php index fcb7e8e..46342d8 100644 --- a/tests/Unit/SubscriptionTest.php +++ b/tests/Unit/SubscriptionTest.php @@ -541,7 +541,7 @@ public function testDomainMethodsRejectAGatewayWithoutTheSubscriptionContract(): $subscription->id = 'sub_1'; $this->expectException(GatewayException::class); - $this->expectExceptionMessageMatches('/does not implement SubscriptionContract/'); + $this->expectExceptionMessageMatches('/does not implement SubscriptionContract; subscriptions are not yet implemented in this library/'); $subscription->suspend($gateway); } @@ -601,7 +601,7 @@ public function testListOperationsRejectAGatewayWithoutTheContract(string $metod $multiPayment = new \Potelo\MultiPayment\MultiPayment(Mockery::mock(GatewayContract::class)); $this->expectException(GatewayException::class); - $this->expectExceptionMessageMatches("/does not implement {$contract}/"); + $this->expectExceptionMessageMatches("/does not implement {$contract}; the operations of that contract are not yet implemented in this library/"); $multiPayment->{$metodo}(...$args); } From 4fa58f65125b3a2a5a1d4a7a258b6ac8c08a38d8 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 09:33:35 -0300 Subject: [PATCH 16/32] =?UTF-8?q?test:=20fecha=20pend=C3=AAncias=20de=20sa?= =?UTF-8?q?ndbox=20da=20revis=C3=A3o=20de=20DX=20e=20ativa=20asser=C3=A7?= =?UTF-8?q?=C3=B5es=20inertes=20do=20InvoiceBuilderTest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ativa as quatro asserções de gatewayOptions que usavam in_array no lugar de array_key_exists e nunca rodavam; os dois casos afetados passam na sandbox. Grava a resposta real de change_plan_simulation da Iugu como fixture e aponta o teste unitário de preview para ela. Cobre em teste unitário o merge de gatewayOptions no payload de criação de fatura da Iugu. Documenta no README que cartão salvo no Stripe sem SetupIntent pode ser recusado na primeira cobrança com authentication_required. --- README.md | 6 + .../Builders/InvoiceBuilderTest.php | 15 +-- .../Unit/Gateways/IuguGatewayInvoiceTest.php | 112 ++++++++++++++++++ .../Gateways/IuguGatewaySubscriptionTest.php | 28 +++-- .../fixtures/iugu/change_plan_simulation.json | 8 ++ 5 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/Gateways/IuguGatewayInvoiceTest.php create mode 100644 tests/fixtures/iugu/change_plan_simulation.json diff --git a/README.md b/README.md index db38a9e..2b2b649 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,12 @@ Invoice::isContested($invoice->status); // tem briga aberta? disputed ou chargeb `authentication_required`, `expired_card`, `insufficient_funds`, `incorrect_cvc`...). `GatewayNotAvailableException` também sinaliza "tente outro gateway"; `AuthenticationException` sinaliza credencial errada e não deve gerar fallback (ver [Tratamento de erros](#tratamento-de-erros)). +- **Cartão salvo não garante cobrança futura.** Salvar o cartão (`newCreditCard()->create()`) + faz só o `attach` do PaymentMethod ao cliente, sem autenticar com o emissor. Um cartão que + exige autenticação (3DS) é salvo normalmente e recusado na primeira cobrança `off_session`, + com `ChargingException::$reason` igual a `authentication_required`. Essa razão pede ação do + pagador (autenticar o cartão ou informar outro); o gateway respondeu normalmente e não cabe + fallback. Autenticar no momento de salvar (SetupIntent) está planejado para uma versão futura. - **Pix exige `tax_document` do cliente** (CPF/CNPJ vai nos billing details do pagamento). - **`expires_at` do pix é opcional** (default do Stripe: 4 horas) e, quando informado, deve ficar entre 10 segundos e 14 dias no futuro — diferente da Iugu, onde `expires_at` é a diff --git a/tests/Integration/Builders/InvoiceBuilderTest.php b/tests/Integration/Builders/InvoiceBuilderTest.php index b131940..02ca602 100644 --- a/tests/Integration/Builders/InvoiceBuilderTest.php +++ b/tests/Integration/Builders/InvoiceBuilderTest.php @@ -183,7 +183,7 @@ public function testShouldCreateInvoice(string $gateway, array $data): void $this->assertNotEmpty($invoice->creditCard->id); } - if ((isset($data['availablePaymentMethods']) && in_array('bank_slip', $data['availablePaymentMethods'])) || (isset($data['paymentMethod']) && $data['paymentMethod'] === 'bank_slip') || (isset($data['gatewayOptions']) && in_array('payable_with', $data['gatewayOptions']) && in_array('bank_slip', $data['gatewayOptions']['payable_with']))) { + if ((isset($data['availablePaymentMethods']) && in_array('bank_slip', $data['availablePaymentMethods'])) || (isset($data['paymentMethod']) && $data['paymentMethod'] === 'bank_slip') || (isset($data['gatewayOptions']) && array_key_exists('payable_with', $data['gatewayOptions']) && in_array('bank_slip', $data['gatewayOptions']['payable_with']))) { $this->assertNotEmpty($invoice->bankSlip); $this->assertNotEmpty($invoice->bankSlip->url); $this->assertNotEmpty($invoice->bankSlip->number); @@ -191,7 +191,7 @@ public function testShouldCreateInvoice(string $gateway, array $data): void $this->assertNotEmpty($invoice->bankSlip->barcodeImage); } - if ((isset($data['availablePaymentMethods']) && in_array('pix', $data['availablePaymentMethods'])) || (isset($data['paymentMethod']) && $data['paymentMethod'] === 'pix') || (isset($data['gatewayOptions']) && in_array('payable_with', $data['gatewayOptions']) && in_array('pix', $data['gatewayOptions']['payable_with']))) { + if ((isset($data['availablePaymentMethods']) && in_array('pix', $data['availablePaymentMethods'])) || (isset($data['paymentMethod']) && $data['paymentMethod'] === 'pix') || (isset($data['gatewayOptions']) && array_key_exists('payable_with', $data['gatewayOptions']) && in_array('pix', $data['gatewayOptions']['payable_with']))) { $this->assertNotEmpty($invoice->pix); $this->assertNotEmpty($invoice->pix->qrCodeImageUrl); @@ -199,12 +199,13 @@ public function testShouldCreateInvoice(string $gateway, array $data): void } if (isset($data['gatewayOptions'])) { - if (in_array('payable_with', $data['gatewayOptions']) && $gateway == 'iugu') { - foreach ($invoice->original->payable_with as $value) { - $this->assertContains($value, $data['gatewayOptions']['payable_with']); - } + if (array_key_exists('payable_with', $data['gatewayOptions']) && $gateway == 'iugu') { + $this->assertEqualsCanonicalizing( + $data['gatewayOptions']['payable_with'], + (array) $invoice->original->payable_with + ); } - if (in_array('expires_in', $data['gatewayOptions'])) { + if (array_key_exists('expires_in', $data['gatewayOptions'])) { $this->assertEquals($data['gatewayOptions'], $invoice->gatewayOptions); if ($gateway == 'iugu') { foreach ($invoice->gatewayOptions as $key => $value) { diff --git a/tests/Unit/Gateways/IuguGatewayInvoiceTest.php b/tests/Unit/Gateways/IuguGatewayInvoiceTest.php new file mode 100644 index 0000000..0452977 --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewayInvoiceTest.php @@ -0,0 +1,112 @@ +instance('config', new Repository([ + 'multi-payment.gateways.iugu.api_key' => 'test-api-key', + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + QueuedIuguApiRequest::restoreSdkRequester(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + /** + * As `gatewayOptions` entram no payload de `POST /invoices` como campos de primeiro nível e + * sobrescrevem o que o driver preenche por padrão, como o `expires_in` zerado. + */ + public function testCreateInvoiceMergesGatewayOptionsIntoIuguPayload(): void + { + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'expires_at' => '2026-10-01', + 'gateway_options' => ['expires_in' => 5, 'payable_with' => ['bank_slip', 'pix']], + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertCount(1, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith('/invoices', $api->calls[0]['url']); + + $payload = $api->calls[0]['data']; + $this->assertSame(5, $payload['expires_in']); + $this->assertSame(['bank_slip', 'pix'], $payload['payable_with']); + $this->assertSame('2026-10-01', $payload['due_date']); + $this->assertSame(['expires_in' => 5, 'payable_with' => ['bank_slip', 'pix']], $invoice->gatewayOptions); + } + + public function testCreateInvoiceWithoutGatewayOptionsKeepsTheDefaultExpiresIn(): void + { + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'expires_at' => '2026-10-01', + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $payload = $api->calls[0]['data']; + $this->assertSame(0, $payload['expires_in']); + $this->assertArrayNotHasKey('payable_with', $payload); + } + + private function pendingInvoiceResponse(): object + { + return (object) [ + 'id' => 'inv_1', + 'status' => 'pending', + 'total_cents' => 10000, + 'paid_at' => null, + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'taxes_paid_cents' => null, + 'created_at_iso' => '2026-09-02T09:00:00-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 0, + 'due_date' => '2026-10-01', + 'payment_method' => null, + 'payable_with' => ['bank_slip', 'pix'], + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [ + (object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1], + ], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + ]; + } +} diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index a6a5edb..1b66f1c 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -386,34 +386,36 @@ public function testChangePlanKeepsTheRequestedPlanWhenTheReloadOmitsIt(): void } /** - * Simulação com `cost` em centavos e sem linhas: o parse lê o valor e deixa `items` nulo. + * Resposta real de `change_plan_simulation` gravada na sandbox, de uma assinatura com + * subitem e desconto ativos: só `cost`, `discount`, `cycles`, `expires_at`, `new_plan` e + * `old_plan`, com `discount` em 0 e sem linhas. O parse lê `cost` e deixa `items` nulo. */ public function testPreviewPlanChangeReadsTheSimulationResponse(): void { $api = new QueuedIuguApiRequest([ - (object) [ - 'cost' => 30000, - 'discount' => 0, - 'cycles' => 1, - 'expires_at' => '2026-12-01', - 'new_plan' => 'plano_anual', - 'old_plan' => 'plano_mensal', - ], + json_decode( + file_get_contents(__DIR__ . '/../../fixtures/iugu/change_plan_simulation.json'), + flags: JSON_THROW_ON_ERROR + ), ]); $subscription = new Subscription(); $subscription->id = 'sub_1'; - $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'plano_anual'); + $planChange = (new IuguGateway($api)) + ->previewSubscriptionPlanChange($subscription, 'multipayment-teste-destino'); $this->assertStringEndsWith( - '/subscriptions/sub_1/change_plan_simulation/plano_anual', + '/subscriptions/sub_1/change_plan_simulation/multipayment-teste-destino', $api->calls[0]['url'] ); $this->assertSame(30000, $planChange->amount); - $this->assertSame('2026-12-01', $planChange->effectiveAt->format('Y-m-d')); + $this->assertSame('2026-10-02', $planChange->effectiveAt->format('Y-m-d')); $this->assertNull($planChange->items); - $this->assertSame('plano_anual', $planChange->original->new_plan); + $this->assertSame(0, $planChange->original->discount); + $this->assertSame(1, $planChange->original->cycles); + $this->assertSame('multipayment-teste-destino', $planChange->original->new_plan); + $this->assertSame('multipayment-teste-origem', $planChange->original->old_plan); } public function testPreviewPlanChangeFallsBackToPriceCentsAndSubitems(): void diff --git a/tests/fixtures/iugu/change_plan_simulation.json b/tests/fixtures/iugu/change_plan_simulation.json new file mode 100644 index 0000000..7f35f6e --- /dev/null +++ b/tests/fixtures/iugu/change_plan_simulation.json @@ -0,0 +1,8 @@ +{ + "cost": 30000, + "discount": 0, + "cycles": 1, + "expires_at": "2026-10-02", + "new_plan": "multipayment-teste-destino", + "old_plan": "multipayment-teste-origem" +} From 104f24e4b02090211426c03949ceafe13e1d3449 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 10:12:35 -0300 Subject: [PATCH 17/32] =?UTF-8?q?feat(enums):=20substitui=20constantes=20d?= =?UTF-8?q?e=20status,=20m=C3=A9todo=20de=20pagamento=20e=20intervalo=20po?= =?UTF-8?q?r=20enums=20com=20o=20conjunto=20completo=20de=20estados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 170 ++++++++--- src/Builders/InvoiceBuilder.php | 7 +- src/Builders/SubscriptionBuilder.php | 2 +- src/Contracts/AcceptsUnknownValue.php | 21 ++ src/Enums/InvoiceStatus.php | 141 +++++++++ src/Enums/PaymentMethod.php | 75 +++++ src/Enums/PlanInterval.php | 21 ++ src/Gateways/IuguGateway.php | 197 ++++++------ src/Gateways/StripeGateway.php | 114 ++++--- src/Helpers/LogHelper.php | 34 +++ src/Models/Invoice.php | 127 +++++--- src/Models/Model.php | 166 +++++++++- src/Models/Plan.php | 34 +-- src/Models/Subscription.php | 51 ++-- .../Builders/InvoiceBuilderTest.php | 12 +- tests/Integration/MultiPaymentTest.php | 30 +- tests/Integration/StripeGatewayTest.php | 64 ++-- tests/Integration/SubscriptionTest.php | 17 +- tests/Unit/AutomaticPixTest.php | 3 +- tests/Unit/Enums/InvoiceStatusTest.php | 135 +++++++++ tests/Unit/Enums/PaymentMethodTest.php | 52 ++++ tests/Unit/Enums/PlanIntervalTest.php | 19 ++ .../Gateways/IuguGatewayAutomaticPixTest.php | 3 +- .../IuguGatewayExceptionTranslationTest.php | 10 +- .../Gateways/IuguGatewayInvoiceStatusTest.php | 195 ++++++++++-- .../Unit/Gateways/IuguGatewayInvoiceTest.php | 52 ++++ tests/Unit/Gateways/IuguGatewayRefundTest.php | 38 +-- .../Gateways/IuguGatewaySubscriptionTest.php | 92 +++--- .../StripeGatewayExceptionTranslationTest.php | 3 +- .../Gateways/StripeGatewayInvoiceTest.php | 208 +++++++++---- tests/Unit/InvoiceTest.php | 66 +++- tests/Unit/ModelEnumCastTest.php | 283 ++++++++++++++++++ tests/Unit/RecordingLogger.php | 20 ++ tests/Unit/SubscriptionTest.php | 41 ++- 34 files changed, 1951 insertions(+), 552 deletions(-) create mode 100644 src/Contracts/AcceptsUnknownValue.php create mode 100644 src/Enums/InvoiceStatus.php create mode 100644 src/Enums/PaymentMethod.php create mode 100644 src/Enums/PlanInterval.php create mode 100644 src/Helpers/LogHelper.php create mode 100644 tests/Unit/Enums/InvoiceStatusTest.php create mode 100644 tests/Unit/Enums/PaymentMethodTest.php create mode 100644 tests/Unit/Enums/PlanIntervalTest.php create mode 100644 tests/Unit/ModelEnumCastTest.php create mode 100644 tests/Unit/RecordingLogger.php diff --git a/README.md b/README.md index 2b2b649..fd8d472 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [Gateways](#gateways) - [Suporte por gateway](#suporte-por-gateway) - [Status da fatura](#status-da-fatura) + - [Migração das constantes para enum](#migração-das-constantes-para-enum) - [Particularidades do Stripe](#particularidades-do-stripe) - [Opções extras do gateway](#opções-extras-do-gateway) - [Utilizando](#utilizando) @@ -126,38 +127,120 @@ Cada célula é uma de três coisas: ### Status da fatura -`Invoice::$status` usa sempre o vocabulário do pacote; o status específico de cada gateway fica -em `original`. Mapa atual: - -| Status genérico | Significado | Iugu | Stripe | -|---|---|---|---| -| `pending` | Aguardando pagamento | `pending`, `in_analysis`, `draft`, `partially_paid` | PaymentIntent em `processing`, `requires_action`, `requires_confirmation`, `requires_payment_method`, `requires_capture` | -| `paid` | Valor recebido | `paid`, `externally_paid`, `authorized` | PaymentIntent `succeeded` sem estorno nem contestação | -| `canceled` | Cancelada ou vencida sem pagamento | `canceled`, `expired` | PaymentIntent `canceled` | -| `refunded` | Estorno voluntário, integral | `refunded` | charge com `refunded = true` | -| `partially_refunded` | Estorno voluntário, parcial | `partially_refunded` | charge com `amount_refunded` menor que o total | -| `disputed` | Contestação aberta sobre fatura paga, resolução pendente | `in_protest` | charge `disputed` com dispute em `warning_needs_response`, `warning_under_review`, `needs_response` ou `under_review` | -| `chargeback` | Contestação perdida: valor devolvido ao cliente pelo gateway. Terminal | `chargeback` | dispute em `lost` | +`Invoice::$status` é o enum `Potelo\MultiPayment\Enums\InvoiceStatus`, sempre no vocabulário do +pacote; o status específico de cada gateway fica em `original`. Os treze estados: + +| `InvoiceStatus` | Significado | Iugu | Stripe | Helper que responde | +|---|---|---|---|---| +| `PENDING` | Aguardando pagamento | `pending`, `draft` | PaymentIntent em `requires_payment_method`, `requires_action`, `requires_confirmation` | `isOpen()` | +| `AUTHORIZED` | Valor reservado no cartão, aguardando captura ou análise | `in_analysis`, `authorized` | PaymentIntent `requires_capture` | `isOpen()` | +| `PROCESSING` | Pagamento em processamento no gateway | (não emite) | PaymentIntent `processing` | `isOpen()` | +| `PAID` | Valor recebido | `paid` | PaymentIntent `succeeded` sem estorno nem contestação | `isSettled()` | +| `PARTIALLY_PAID` | Parte do valor recebida, restante em aberto | `partially_paid` | (não emite em venda avulsa) | `isSettled()` e `isOpen()` | +| `EXTERNALLY_PAID` | Quitada fora do gateway, por baixa manual | `externally_paid` | (não emite em venda avulsa) | `isSettled()` | +| `PARTIALLY_REFUNDED` | Estorno voluntário, parcial | `partially_refunded` | charge com `amount_refunded` menor que o total | `isSettled()` | +| `REFUNDED` | Estorno voluntário, integral | `refunded` | charge com `refunded = true` | `isTerminal()` | +| `DISPUTED` | Contestação aberta sobre fatura paga, resolução pendente | `in_protest` | charge `disputed` com dispute em `warning_needs_response`, `warning_under_review`, `needs_response` ou `under_review` | `isContested()` | +| `CHARGEBACK` | Contestação perdida: valor devolvido ao cliente pelo gateway | `chargeback` | dispute em `lost` | `isContested()` e `isTerminal()` | +| `CANCELED` | Cancelada antes do pagamento | `canceled` | PaymentIntent `canceled` | `isTerminal()` | +| `EXPIRED` | Venceu sem pagamento | `expired` | (não emite em venda avulsa) | `isTerminal()` | +| `UNKNOWN` | Status que a lib não reconhece | qualquer outro | qualquer outro | nenhum responde verdadeiro | Dispute ganha (`won`), encerrada sem virar chargeback (`warning_closed`) ou prevenida -(`prevented`) não altera o status: a fatura volta a ler como `paid` (ou como estornada, se -houve estorno). Um status fora do mapa lança `GatewayException`. Estados próprios para captura -tardia, vencimento e pagamento parcial estão planejados para uma versão futura. +(`prevented`) não altera o status: a fatura volta a ler como `PAID` (ou como estornada, se +houve estorno). Um status fora do mapa vira `UNKNOWN`, com um aviso no log da aplicação (nível +`warning`) contendo o valor original e o gateway, e o valor cru continua em `original`. -Para não comparar status um a um, a `Invoice` traz dois helpers estáticos: +Os helpers do enum respondem às perguntas de negócio sem comparar status um a um: ```php -Invoice::isSettled($invoice->status); // recebi o dinheiro? paid ou partially_refunded -Invoice::isContested($invoice->status); // tem briga aberta? disputed ou chargeback +use Potelo\MultiPayment\Enums\InvoiceStatus; + +$invoice->status->isSettled(); // recebi dinheiro? PAID, PARTIALLY_PAID, EXTERNALLY_PAID, PARTIALLY_REFUNDED +$invoice->status->isOpen(); // ainda pode receber pagamento? PENDING, AUTHORIZED, PROCESSING, PARTIALLY_PAID +$invoice->status->isContested(); // tem briga? DISPUTED, CHARGEBACK +$invoice->status->isTerminal(); // acabou? REFUNDED, CHARGEBACK, CANCELED, EXPIRED + +match ($invoice->status) { + InvoiceStatus::DISPUTED => $this->openDisputeTicket($invoice), + InvoiceStatus::CHARGEBACK => $this->writeOff($invoice), + InvoiceStatus::EXPIRED => $this->offerNewPix($invoice), + default => null, +}; ``` +`PARTIALLY_PAID` responde verdadeiro a `isSettled()` e a `isOpen()` ao mesmo tempo: parte do +dinheiro entrou e o restante segue cobrável. Os helpers estáticos `Invoice::isSettled()` e +`Invoice::isContested()` continuam existindo, delegam ao enum e estão obsoletos (emitem +`E_USER_DEPRECATED`). + > **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, fatura Iugu em `in_protest` lia como > `paid` e fatura em `chargeback` lia como `refunded`; no Stripe, charge contestado lia como -> `paid`. A partir desta versão elas leem como `disputed` e `chargeback`. Quem compara com -> `Invoice::STATUS_PAID` para decidir se recebeu **deixa de ver faturas em disputa como pagas**, -> e quem compara com `STATUS_REFUNDED` deixa de confundir chargeback com estorno voluntário. Se -> a aplicação precisava do comportamento antigo, use `Invoice::isSettled()` para "pago" e trate -> `disputed` e `chargeback` explicitamente. +> `paid`. A partir desta versão elas leem como `DISPUTED` e `CHARGEBACK`. Quem compara com +> `PAID` para decidir se recebeu **deixa de ver faturas em disputa como pagas**, e quem compara +> com `REFUNDED` deixa de confundir chargeback com estorno voluntário. Na mesma versão, a Iugu +> deixou de ser achatada: `in_analysis` lia como `pending` e agora é `AUTHORIZED`; +> `partially_paid` lia como `pending` e agora é `PARTIALLY_PAID`; `externally_paid` lia como +> `paid` e agora é `EXTERNALLY_PAID`; `expired` lia como `canceled` e agora é `EXPIRED`. No +> Stripe, `requires_capture` lia como `pending` e agora é `AUTHORIZED`; `processing` lia como +> `pending` e agora é `PROCESSING`. Status desconhecido lançava `GatewayException` e agora vira +> `UNKNOWN` com log. Se a aplicação precisava do comportamento antigo, use `isSettled()` para +> "pago", `isOpen()` para "ainda cobrável" e trate `DISPUTED` e `CHARGEBACK` explicitamente. + +### Migração das constantes para enum + +Status da fatura, método de pagamento e intervalo do plano são enums do namespace +`Potelo\MultiPayment\Enums`: `InvoiceStatus`, `PaymentMethod` (`CREDIT_CARD`, `BANK_SLIP`, +`PIX`, `AUTOMATIC_PIX`) e `PlanInterval` (`DAY`, `WEEK`, `MONTH`, `YEAR`). As propriedades +`Invoice::$status`, `Invoice::$paymentMethod`, `Invoice::$availablePaymentMethods`, +`Subscription::$paymentMethod`, `Subscription::$availablePaymentMethods` e `Plan::$interval` +devolvem o enum na leitura e aceitam, na escrita, tanto o caso do enum quanto a string do valor +(as constantes antigas). O mesmo vale para `fill()` e para os builders. + +As constantes antigas (`Invoice::STATUS_*`, `Invoice::PAYMENT_METHOD_*`, `Plan::INTERVAL_*`) +continuam existindo, com os mesmos valores de string dos enums, e estão marcadas como +`@deprecated`. O que muda é a **comparação**: a propriedade agora devolve um enum, então +compará-la diretamente com a string antiga é sempre falso. + +```php +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Enums\PlanInterval; + +// antes +if ($invoice->status === Invoice::STATUS_PAID) { ... } +if (in_array($invoice->paymentMethod, [Invoice::PAYMENT_METHOD_PIX, Invoice::PAYMENT_METHOD_BANK_SLIP])) { ... } +$invoice->paymentMethod = Invoice::PAYMENT_METHOD_CREDIT_CARD; +$plan->interval = Plan::INTERVAL_MONTH; +$model->status_pagamento = $invoice->status; // gravando no banco + +// depois (recomendado) +if ($invoice->status === InvoiceStatus::PAID) { ... } +if (in_array($invoice->paymentMethod, [PaymentMethod::PIX, PaymentMethod::BANK_SLIP], true)) { ... } +$invoice->paymentMethod = PaymentMethod::CREDIT_CARD; +$plan->interval = PlanInterval::MONTH; +$model->status_pagamento = $invoice->status->value; // 'paid' + +// transição: a constante antiga ainda vale como valor de string +if ($invoice->status->value === Invoice::STATUS_PAID) { ... } +$invoice->paymentMethod = Invoice::PAYMENT_METHOD_CREDIT_CARD; // convertido para PaymentMethod::CREDIT_CARD +``` + +Onde a string vai para fora do PHP (banco, JSON, log, comparação com valor vindo do gateway), +use `->value`. `toArray()` já emite o valor de string, e `json_encode($invoice)` também. + +Valor de string fora do enum tem dois tratamentos: em `status`, vira `InvoiceStatus::UNKNOWN` +com aviso no log; em `paymentMethod`, `availablePaymentMethods` e `interval`, lança +`ModelAttributeValidationException` na escrita, com a lista de valores aceitos. Em +`availablePaymentMethods` só entram `CREDIT_CARD`, `BANK_SLIP` e `PIX`; `AUTOMATIC_PIX` é +recusado na validação, porque a fatura com Pix Automático é criada com `PIX` e o objeto +`automaticPix` preenchido. Nenhum driver emite `AUTOMATIC_PIX` em `paymentMethod` hoje. + +> **Mudança de comportamento (versão 5.0.0).** `$invoice->status === Invoice::STATUS_PAID` e +> comparações equivalentes com `paymentMethod` e `interval` passam a ser **falsas**, porque a +> propriedade devolve um enum. Revise cada comparação com constante ou string literal e troque +> pelo caso do enum ou compare `->value`. Método de pagamento e intervalo inválidos lançam já +> na escrita da propriedade (até a 4.1.0, só `validate()` acusava). ### Particularidades do Stripe @@ -209,9 +292,9 @@ chave é `gateway_options`; nos builders, `setGatewayOptions()`. ```php $invoice = $payment->newInvoice() - ->setPaymentMethod('pix') + ->addAvailablePaymentMethod(PaymentMethod::PIX) ->addCustomer('Nome', 'email@example.com', '01234567891') - ->addItem('Produto', 1, 10000) + ->addItem('Produto', 10000, 1) ->setGatewayOptions(['expires_in' => 3]) // opção da Iugu, sem equivalente genérico ->create(); @@ -296,12 +379,14 @@ $payment->setGateway('iugu'); ``` #### InvoiceBuilder ```php +use Potelo\MultiPayment\Enums\PaymentMethod; + $multiPayment = new \Potelo\MultiPayment\MultiPayment('iugu'); $invoiceBuilder = $multiPayment->newInvoice(); -$invoice = $invoiceBuilder->setPaymentMethod('payment_method') +$invoice = $invoiceBuilder->addAvailablePaymentMethod(PaymentMethod::PIX) // ou a string 'pix' ->addCustomer('name', 'email', 'tax_document', 'phone_area', 'phone_number') ->addCustomerAddress('zip_code', 'street', 'number') - ->addItem('description', 'quantity', 'price') + ->addItem('description', 'price', 'quantity') ->create(); ``` Confira `src/MultiPayment/Builders/InvoiceBuilder.php` para saber quais métodos estão disponíveis. @@ -403,12 +488,13 @@ operações para ele. ```php use Potelo\MultiPayment\Models\Plan; +use Potelo\MultiPayment\Enums\PlanInterval; $plan = new Plan(); $plan->name = 'Mensal'; $plan->identifier = 'plano_mensal'; $plan->amount = 10000; // centavos -$plan->interval = Plan::INTERVAL_MONTH; // week, month ou year +$plan->interval = PlanInterval::MONTH; // DAY, WEEK, MONTH ou YEAR (a Iugu recusa DAY) $plan->intervalCount = 1; $plan->save('iugu'); @@ -452,9 +538,10 @@ Particularidades da Iugu: ao fim do período, suspenda na data. - **Desconto é sempre valor fixo.** `percentOff` lança `GatewayException`, e `cycles` só aceita `1` (uma fatura) ou `null` (até ser removido). -- **Plano anual é 12 meses.** A Iugu só tem intervalos em semanas e meses, então - `Plan::INTERVAL_YEAR` é enviado como `12 * intervalCount` meses. Na leitura vale a heurística - inversa: todo plano em meses cujo intervalo é múltiplo de 12 volta como `year` com +- **Plano anual é 12 meses, e plano diário não existe.** A Iugu só tem intervalos em semanas e + meses, então `PlanInterval::YEAR` é enviado como `12 * intervalCount` meses e + `PlanInterval::DAY` lança `GatewayException` antes de chamar a API. Na leitura vale a + heurística inversa: todo plano em meses cujo intervalo é múltiplo de 12 volta como `YEAR` com `intervalCount` dividido por 12 (um plano criado direto na Iugu com 24 meses lê como 2 anos). Quem precisar do valor cru lê `original`. A Iugu aceita intervalo de 1 a 599, então um plano anual vai até `intervalCount` 49; acima disso o driver lança `GatewayException` antes de @@ -496,11 +583,10 @@ Particularidades da Iugu: - **O plano de uma assinatura existente não muda por `save()`**; use `changePlan()`. - **Plano não é atualizável.** `save()` num `Plan` que já tem `id` lança `GatewayException`; para mudar preço ou intervalo, crie outro plano e troque as assinaturas com `changePlan()`. -- **Fatura vencida lê como `canceled` (limitação conhecida).** A Iugu chama de `expired` a - fatura que venceu sem pagamento, e o pacote ainda não tem um estado próprio para isso: ela é - mapeada para `Invoice::STATUS_CANCELED`, embora continue contando como dívida na derivação de - `past_due` da assinatura. Um estado próprio `expired` está planejado para uma versão futura, - junto com o enum completo de status da fatura. +- **Fatura vencida lê como `EXPIRED` e continua sendo dívida.** A Iugu chama de `expired` a + fatura que venceu sem pagamento; ela conta como fatura em aberto na derivação de `past_due` + da assinatura, embora `InvoiceStatus::EXPIRED->isOpen()` seja falso (na Iugu a fatura vencida + ainda pode ser paga; em outros gateways, vencida é terminal). - **A assinatura lida traz o cliente resumido.** `Subscription::get()` preenche `customer` com id, nome e e-mail — documento, endereço e telefone não vêm da Iugu. Eles sobrevivem se o `customer` local já tiver o mesmo id; se o id for outro, ou o local não tiver id, o pacote @@ -566,7 +652,7 @@ try { $invoice = $payment->refundInvoice($invoiceId); // integral $invoice = $payment->refundInvoice($invoiceId, 5000); // parcial - $invoice->status; // refunded ou partially_refunded + $invoice->status; // InvoiceStatus::REFUNDED ou InvoiceStatus::PARTIALLY_REFUNDED $invoice->lastRefundId; // id do estorno no gateway (Stripe: re_...; a Iugu não devolve id) } catch (RefundNotSupportedException $e) { // a lib recusou sem chamar o gateway; $e->reason diz por quê @@ -669,8 +755,8 @@ $payment->setGateway('iugu')->charge($options); | `items.description` | **obrigatório** | string | descrição do item | `'Produto 1'` | | `items.quantity` | **obrigatório** | int | quantidade do item | `1` | | `items.price` | **obrigatório** | int | valor do item | `10000` | -| `payment_method` | | `'credit_card'`,`'bank_slip'`,`'pix'` | método de pagamento | `'credit_card'` | -| `available_payment_methods` | **obrigatório** no Stripe (exatamente um método) quando não há `credit_card` | array de métodos | métodos aceitos pela fatura | `['pix']` | +| `payment_method` | | `PaymentMethod` ou a string `'credit_card'`, `'bank_slip'`, `'pix'` | método de pagamento | `'credit_card'` | +| `available_payment_methods` | **obrigatório** no Stripe (exatamente um método) quando não há `credit_card` | array de `PaymentMethod` ou de strings | métodos aceitos pela fatura | `['pix']` | | `expires_at` | **obrigatório** na Iugu caso `payment_method` seja `'bank_slip'` ou `'pix'`; opcional no Stripe (pix — a data precisa cair na janela de 10 segundos a 14 dias no futuro) | string no formato `yyyy-mm-dd` | data de expiração da fatura | `2021-10-10` | | `credit_card` | **obrigatório** caso `payment_method` seja `'credit_card'` | array | array com os dados do cartão de crédito | `['number' => '1234567890123456',...` | | `credit_card.token` | | string | token do cartão para o gateway escolhido | `'abc123...'` (Iugu) / `'pm_...'` (Stripe) | @@ -695,6 +781,8 @@ echo $customer->id; // 7D96C7C932F2427CAF54F042345A13C60CD7 ``` #### Invoice ```php +use Potelo\MultiPayment\Enums\PaymentMethod; + $invoice = new Invoice(); $invoice->customer = $customer; $item = new InvoiceItem(); @@ -702,7 +790,7 @@ $item->description = 'Teste'; $item->price = 10000; $item->quantity = 1; $invoice->items[] = $item; -$invoice->paymentMethod = Invoice::PAYMENT_METHOD_CREDIT_CARD; +$invoice->paymentMethod = PaymentMethod::CREDIT_CARD; // a string 'credit_card' também é aceita $invoice->creditCard = new CreditCard(); $invoice->creditCard->number = '4111111111111111'; $invoice->creditCard->firstName = 'João'; @@ -728,7 +816,7 @@ $plan = new Plan(); $plan->name = 'Mensal'; $plan->identifier = 'plano_mensal'; $plan->amount = 10000; -$plan->interval = Plan::INTERVAL_MONTH; +$plan->interval = PlanInterval::MONTH; $plan->save('iugu'); echo $plan->id; ``` diff --git a/src/Builders/InvoiceBuilder.php b/src/Builders/InvoiceBuilder.php index 14d710a..feed63f 100644 --- a/src/Builders/InvoiceBuilder.php +++ b/src/Builders/InvoiceBuilder.php @@ -10,6 +10,7 @@ use Potelo\MultiPayment\Models\InvoiceItem; use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Models\AutomaticPixCharge; +use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Contracts\GatewayContract; /** @@ -48,7 +49,7 @@ public function create(): Invoice /** * Set the invoice available payment methods * - * @param string[] $paymentMethods + * @param PaymentMethod[]|string[] $paymentMethods * * @return InvoiceBuilder */ @@ -61,11 +62,11 @@ public function setAvailablePaymentMethods(array $paymentMethods): InvoiceBuilde /** * Add the invoice available payment methods * - * @param string $paymentMethod + * @param PaymentMethod|string $paymentMethod * * @return InvoiceBuilder */ - public function addAvailablePaymentMethod(string $paymentMethod): InvoiceBuilder + public function addAvailablePaymentMethod(PaymentMethod|string $paymentMethod): InvoiceBuilder { $paymentMethods = is_array($this->model->availablePaymentMethods) ? $this->model->availablePaymentMethods : []; $paymentMethods[] = $paymentMethod; diff --git a/src/Builders/SubscriptionBuilder.php b/src/Builders/SubscriptionBuilder.php index 1916e29..ef105c8 100644 --- a/src/Builders/SubscriptionBuilder.php +++ b/src/Builders/SubscriptionBuilder.php @@ -116,7 +116,7 @@ public function setTrialEndsAt(Carbon|string $trialEndsAt): SubscriptionBuilder /** * Define os métodos de pagamento aceitos pela assinatura. * - * @param string[] $paymentMethods + * @param \Potelo\MultiPayment\Enums\PaymentMethod[]|string[] $paymentMethods * * @return $this */ diff --git a/src/Contracts/AcceptsUnknownValue.php b/src/Contracts/AcceptsUnknownValue.php new file mode 100644 index 0000000..9494154 --- /dev/null +++ b/src/Contracts/AcceptsUnknownValue.php @@ -0,0 +1,21 @@ + true, + default => false, + }; + } + + /** + * Diz se existe contestação sobre a fatura: `DISPUTED` (aberta) ou `CHARGEBACK` (perdida). + * + * @return bool + */ + public function isContested(): bool + { + return match ($this) { + self::DISPUTED, self::CHARGEBACK => true, + default => false, + }; + } + + /** + * Diz se a fatura chegou a um estado final, do qual o gateway não a tira: `REFUNDED`, + * `CHARGEBACK`, `CANCELED` e `EXPIRED`. + * + * @return bool + */ + public function isTerminal(): bool + { + return match ($this) { + self::REFUNDED, self::CHARGEBACK, self::CANCELED, self::EXPIRED => true, + default => false, + }; + } + + /** + * Diz se a fatura ainda pode receber pagamento: `PENDING`, `AUTHORIZED`, `PROCESSING` e + * `PARTIALLY_PAID`. + * + * @return bool + */ + public function isOpen(): bool + { + return match ($this) { + self::PENDING, self::AUTHORIZED, self::PROCESSING, self::PARTIALLY_PAID => true, + default => false, + }; + } + + /** + * Converte o valor de string no caso correspondente. Valor fora do enum devolve `UNKNOWN` + * e registra um aviso no log com o valor original e o gateway. + * + * @param string $value + * @param string|null $gateway + * @return static + */ + public static function fromValue(string $value, ?string $gateway = null): static + { + return self::tryFrom($value) ?? self::unknown($value, $gateway); + } + + /** + * Devolve `UNKNOWN` e registra um aviso no log com o valor original e o gateway. + * + * @param string $value + * @param string|null $gateway + * @return static + */ + public static function unknown(string $value, ?string $gateway = null): static + { + LogHelper::warning( + "Status de fatura desconhecido [{$value}] no gateway [" . ($gateway ?? 'desconhecido') . '], lido como unknown', + ['status' => $value, 'gateway' => $gateway] + ); + + return self::UNKNOWN; + } +} diff --git a/src/Enums/PaymentMethod.php b/src/Enums/PaymentMethod.php new file mode 100644 index 0000000..260c847 --- /dev/null +++ b/src/Enums/PaymentMethod.php @@ -0,0 +1,75 @@ +availablePaymentMethods)) { - $iuguInvoiceData['payable_with'] = $invoice->availablePaymentMethods; + // normaliza antes de ler: uma string apensada por `[]=` entra no array sem conversão + $payableWith = !empty($invoice->availablePaymentMethods) + ? PaymentMethod::normalizeSelectable($invoice->availablePaymentMethods, 'Invoice') + : []; + + if (!empty($payableWith)) { + $iuguInvoiceData['payable_with'] = self::paymentMethodsToIuguPayableWith($payableWith); } if (!empty($invoice->automaticPix)) { @@ -124,11 +132,7 @@ public function createInvoice(Invoice $invoice): Invoice } } - if ( - !empty($invoice->availablePaymentMethods) && - in_array(Invoice::PAYMENT_METHOD_CREDIT_CARD, $invoice->availablePaymentMethods) && - !empty($invoice->creditCard) - ) { + if (in_array(PaymentMethod::CREDIT_CARD, $payableWith, true) && !empty($invoice->creditCard)) { if (empty($invoice->creditCard->id)) { $invoice->creditCard = $this->createCreditCard($invoice->creditCard); } @@ -320,39 +324,31 @@ public function createCustomer(Customer $customer): Customer } /** - * Convert Iugu status to MultiPayment status. + * Converte o status da fatura Iugu no status genérico. Cada status lê como o caso + * homônimo, com duas exceções: `draft` lê como `PENDING` e `in_analysis` (primeira etapa + * da cobrança em duas etapas) como `AUTHORIZED`. Status fora do mapa devolve `UNKNOWN` e + * registra um aviso no log. * - * @param $iuguStatus + * @param string|null $iuguStatus * - * @return string - * @throws GatewayException + * @return InvoiceStatus */ - private static function iuguStatusToMultiPayment($iuguStatus): string - { - switch ($iuguStatus) { - case self::STATUS_PENDING: - case self::STATUS_IN_ANALYSIS: - case self::STATUS_DRAFT: - case self::STATUS_PARTIALLY_PAID: - return Invoice::STATUS_PENDING; - case self::STATUS_PAID: - case self::STATUS_EXTERNALLY_PAID: - case self::STATUS_AUTHORIZED: - return Invoice::STATUS_PAID; - case self::STATUS_IN_PROTEST: - return Invoice::STATUS_DISPUTED; - case self::STATUS_CANCELED: - case self::STATUS_EXPIRED: - return Invoice::STATUS_CANCELED; - case self::STATUS_REFUNDED: - return Invoice::STATUS_REFUNDED; - case self::STATUS_CHARGEBACK: - return Invoice::STATUS_CHARGEBACK; - case self::STATUS_PARTIALLY_REFUNDED: - return Invoice::STATUS_PARTIALLY_REFUNDED; - default: - throw new GatewayException('Unexpected Iugu status: ' . $iuguStatus); - } + private static function iuguStatusToMultiPayment(?string $iuguStatus): InvoiceStatus + { + return match ($iuguStatus) { + self::STATUS_PENDING, self::STATUS_DRAFT => InvoiceStatus::PENDING, + self::STATUS_IN_ANALYSIS, self::STATUS_AUTHORIZED => InvoiceStatus::AUTHORIZED, + self::STATUS_PAID => InvoiceStatus::PAID, + self::STATUS_PARTIALLY_PAID => InvoiceStatus::PARTIALLY_PAID, + self::STATUS_EXTERNALLY_PAID => InvoiceStatus::EXTERNALLY_PAID, + self::STATUS_PARTIALLY_REFUNDED => InvoiceStatus::PARTIALLY_REFUNDED, + self::STATUS_REFUNDED => InvoiceStatus::REFUNDED, + self::STATUS_IN_PROTEST => InvoiceStatus::DISPUTED, + self::STATUS_CHARGEBACK => InvoiceStatus::CHARGEBACK, + self::STATUS_CANCELED => InvoiceStatus::CANCELED, + self::STATUS_EXPIRED => InvoiceStatus::EXPIRED, + default => InvoiceStatus::unknown((string) $iuguStatus, 'iugu'), + }; } /** @@ -442,27 +438,41 @@ public function getInvoice(Invoice $invoice): Invoice /** * Convert the iugu payment method to the MultiPayment payment method * - * @param $iuguPaymentMethod + * @param mixed $iuguPaymentMethod * - * @return string|null + * @return PaymentMethod|null */ - private function iuguToMultiPaymentPaymentMethod($iuguPaymentMethod): ?string + private function iuguToMultiPaymentPaymentMethod($iuguPaymentMethod): ?PaymentMethod { - $multiPaymentPaymentMethod = [ - Invoice::PAYMENT_METHOD_PIX, - Invoice::PAYMENT_METHOD_BANK_SLIP, - Invoice::PAYMENT_METHOD_CREDIT_CARD - ]; - if (!empty($iuguPaymentMethod)) { - foreach ($multiPaymentPaymentMethod as $paymentMethod) { - if (str_contains($iuguPaymentMethod, $paymentMethod)) { - return $paymentMethod; - } + if (empty($iuguPaymentMethod) || !is_string($iuguPaymentMethod)) { + return null; + } + + // o nome da Iugu carrega o genérico como sufixo (`iugu_credit_card`, `iugu_pix`) + foreach ([PaymentMethod::PIX, PaymentMethod::BANK_SLIP, PaymentMethod::CREDIT_CARD] as $paymentMethod) { + if (str_contains($iuguPaymentMethod, $paymentMethod->value)) { + return $paymentMethod; } } + return null; } + /** + * Converte a lista genérica de métodos de pagamento nos valores de `payable_with` da Iugu. + * + * @param PaymentMethod[] $paymentMethods + * + * @return string[] + */ + private static function paymentMethodsToIuguPayableWith(array $paymentMethods): array + { + return array_values(array_map( + static fn (PaymentMethod $paymentMethod) => $paymentMethod->value, + $paymentMethods + )); + } + /** * @inheritDoc * @@ -520,16 +530,16 @@ public function refundInvoice(Invoice $invoice): Invoice */ private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmount): void { - if ($invoice->paymentMethod === Invoice::PAYMENT_METHOD_BANK_SLIP) { + if ($invoice->paymentMethod === PaymentMethod::BANK_SLIP) { throw RefundNotSupportedException::boletoNoRefund('iugu'); } - if ($invoice->status === Invoice::STATUS_REFUNDED) { - throw RefundNotSupportedException::alreadyRefunded('iugu', $invoice->paymentMethod); + if ($invoice->status === InvoiceStatus::REFUNDED) { + throw RefundNotSupportedException::alreadyRefunded('iugu', $invoice->paymentMethod?->value); } if ( - $invoice->paymentMethod === Invoice::PAYMENT_METHOD_PIX + $invoice->paymentMethod === PaymentMethod::PIX && !is_null($requestedAmount) && $requestedAmount !== $invoice->paidAmount ) { @@ -543,7 +553,7 @@ private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmou ) { throw RefundNotSupportedException::refundWindowExpired( 'iugu', - $invoice->paymentMethod, + $invoice->paymentMethod?->value, $invoice->paidAt, self::REFUND_WINDOW_DAYS ); @@ -938,23 +948,8 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice $invoice->paymentMethod = $this->iuguToMultiPaymentPaymentMethod($iuguInvoice->payment_method); } - if (!empty(($iuguInvoice->payable_with))) { - - $payableWith = $iuguInvoice->payable_with; - if (is_string($payableWith)) { - $payableWith = [$payableWith]; - } - - foreach ($payableWith as $pm) { - $method = $this->iuguToMultiPaymentPaymentMethod($pm); - if (is_null($method) && $pm === 'all') { - $invoice->availablePaymentMethods = [ - Invoice::PAYMENT_METHOD_CREDIT_CARD, - Invoice::PAYMENT_METHOD_BANK_SLIP, - Invoice::PAYMENT_METHOD_PIX, - ]; - } - } + if (!empty($iuguInvoice->payable_with)) { + $invoice->availablePaymentMethods = $this->iuguPayableWithToPaymentMethods($iuguInvoice->payable_with); } if (empty($invoice->customer)) { @@ -1717,7 +1712,9 @@ private function subscriptionToIuguData(Subscription $subscription, bool $creati !empty($subscription->availablePaymentMethods) && ($creating || !$this->isOriginalPayableWith($subscription)) ) { - $data['payable_with'] = $subscription->availablePaymentMethods; + $data['payable_with'] = self::paymentMethodsToIuguPayableWith( + PaymentMethod::normalizeSelectable($subscription->availablePaymentMethods, 'Subscription') + ); } if (!empty($subscription->metadata)) { @@ -2065,10 +2062,8 @@ private function iuguToMultiPaymentSubscriptionStatus(object $iuguSubscription): } /** - * Diz se o resumo de fatura ainda tem valor a receber. - * - * `expired` conta: na Iugu a fatura vencida não foi paga nem cancelada, embora o pacote - * mapeie esse status para `Invoice::STATUS_CANCELED`. + * Diz se o resumo de fatura ainda tem valor a receber: `pending`, `partially_paid` e + * `expired` (na Iugu a fatura vencida segue devida até ser paga ou cancelada). * * @param object $iuguInvoice * @@ -2198,16 +2193,9 @@ private function parseIuguRecentInvoice(object $iuguSubscription): ?Invoice $invoice = new Invoice(); $invoice->id = $iuguInvoice->id; - - try { - $invoice->status = isset($iuguInvoice->status) - ? self::iuguStatusToMultiPayment($iuguInvoice->status) - : null; - } catch (GatewayException $e) { - // status fora do mapa não derruba a leitura da assinatura; o status cru continua - // em `original` - $invoice->status = null; - } + $invoice->status = isset($iuguInvoice->status) + ? self::iuguStatusToMultiPayment($iuguInvoice->status) + : null; $invoice->expiresAt = !empty($iuguInvoice->due_date) ? new Carbon($iuguInvoice->due_date) @@ -2294,22 +2282,23 @@ private function planToIuguData(Plan $plan): array * Converte o intervalo genérico no par `interval` e `interval_type` da Iugu. * * A Iugu só tem `weeks` e `months`, então o intervalo anual é enviado como múltiplo de 12 - * meses. A leitura inversa fica em `iuguIntervalToMultiPayment()`. + * meses e o diário lança `GatewayException`. A leitura inversa fica em + * `iuguIntervalToMultiPayment()`. * - * @param string|null $interval + * @param PlanInterval|null $interval * @param int $intervalCount * * @return array{interval: int, interval_type: string} * @throws GatewayException */ - private function intervalToIuguData(?string $interval, int $intervalCount): array + private function intervalToIuguData(?PlanInterval $interval, int $intervalCount): array { $data = match ($interval) { - Plan::INTERVAL_WEEK => ['interval' => $intervalCount, 'interval_type' => 'weeks'], - Plan::INTERVAL_MONTH => ['interval' => $intervalCount, 'interval_type' => 'months'], - Plan::INTERVAL_YEAR => ['interval' => 12 * $intervalCount, 'interval_type' => 'months'], + PlanInterval::WEEK => ['interval' => $intervalCount, 'interval_type' => 'weeks'], + PlanInterval::MONTH => ['interval' => $intervalCount, 'interval_type' => 'months'], + PlanInterval::YEAR => ['interval' => 12 * $intervalCount, 'interval_type' => 'months'], default => throw new GatewayException( - "Iugu driver does not support the `{$interval}` plan interval; " + 'Iugu driver does not support the `' . ($interval?->value ?? 'null') . '` plan interval; ' . 'use week, month or year (sent as 12 months).' ), }; @@ -2336,17 +2325,17 @@ private function intervalToIuguData(?string $interval, int $intervalCount): arra * @param string|null $intervalType * @param int|null $interval * - * @return array{0: string|null, 1: int|null} + * @return array{0: PlanInterval|null, 1: int|null} */ private function iuguIntervalToMultiPayment(?string $intervalType, ?int $interval): array { if ($intervalType === 'months' && $interval > 0 && $interval % 12 === 0) { - return [Plan::INTERVAL_YEAR, intdiv($interval, 12)]; + return [PlanInterval::YEAR, intdiv($interval, 12)]; } $genericInterval = match ($intervalType) { - 'weeks' => Plan::INTERVAL_WEEK, - 'months' => Plan::INTERVAL_MONTH, + 'weeks' => PlanInterval::WEEK, + 'months' => PlanInterval::MONTH, default => null, }; @@ -2394,24 +2383,18 @@ private function parseIuguPlan($iuguPlan, ?Plan $plan = null): Plan /** * Converte o `payable_with` da Iugu na lista de métodos de pagamento do MultiPayment. * - * O valor `all` expande para os três métodos. + * O valor `all` expande para os três métodos selecionáveis; valor desconhecido é ignorado. * * @param mixed $payableWith * - * @return string[] + * @return PaymentMethod[] */ private function iuguPayableWithToPaymentMethods($payableWith): array { - $todos = [ - Invoice::PAYMENT_METHOD_CREDIT_CARD, - Invoice::PAYMENT_METHOD_BANK_SLIP, - Invoice::PAYMENT_METHOD_PIX, - ]; - $methods = []; foreach ((array) $payableWith as $iuguMethod) { if ($iuguMethod === 'all') { - return $todos; + return PaymentMethod::selectable(); } $method = $this->iuguToMultiPaymentPaymentMethod($iuguMethod); diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 4d5c5b3..59cff11 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -23,6 +23,8 @@ use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Models\AutomaticPixCancellation; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; @@ -70,9 +72,9 @@ class StripeGateway implements GatewayContract /** Mapa de tipos de PaymentMethod da Stripe para os métodos genéricos do pacote. */ private const PAYMENT_METHOD_TYPES = [ - 'card' => Invoice::PAYMENT_METHOD_CREDIT_CARD, - 'pix' => Invoice::PAYMENT_METHOD_PIX, - 'boleto' => Invoice::PAYMENT_METHOD_BANK_SLIP, + 'card' => PaymentMethod::CREDIT_CARD, + 'pix' => PaymentMethod::PIX, + 'boleto' => PaymentMethod::BANK_SLIP, ]; private StripeClient $client; @@ -511,19 +513,18 @@ public function createInvoice(Invoice $invoice): Invoice } $paymentMethod = $this->invoicePaymentMethod($invoice); - switch ($paymentMethod) { - case Invoice::PAYMENT_METHOD_CREDIT_CARD: - return $this->createCreditCardInvoice($invoice); - case Invoice::PAYMENT_METHOD_PIX: - return $this->createPixInvoice($invoice); - case Invoice::PAYMENT_METHOD_BANK_SLIP: - throw $this->operationNotImplemented( - 'createInvoice com boleto', - 'Use a Iugu para boleto por enquanto.' - ); - default: - throw $this->operationNotImplemented("createInvoice com o método de pagamento [{$paymentMethod}]"); - } + + return match ($paymentMethod) { + PaymentMethod::CREDIT_CARD => $this->createCreditCardInvoice($invoice), + PaymentMethod::PIX => $this->createPixInvoice($invoice), + PaymentMethod::BANK_SLIP => throw $this->operationNotImplemented( + 'createInvoice com boleto', + 'Use a Iugu para boleto por enquanto.' + ), + default => throw $this->operationNotImplemented( + "createInvoice com o método de pagamento [{$paymentMethod->value}]" + ), + }; } /** @@ -532,10 +533,10 @@ public function createInvoice(Invoice $invoice): Invoice * Iugu não tem equivalente aqui e este gateway é restrito a um método por fatura. * * @param \Potelo\MultiPayment\Models\Invoice $invoice - * @return string + * @return PaymentMethod * @throws ModelAttributeValidationException */ - private function invoicePaymentMethod(Invoice $invoice): string + private function invoicePaymentMethod(Invoice $invoice): PaymentMethod { if (!empty($invoice->availablePaymentMethods)) { if (count($invoice->availablePaymentMethods) > 1) { @@ -546,11 +547,14 @@ private function invoicePaymentMethod(Invoice $invoice): string ); } - return reset($invoice->availablePaymentMethods); + // normaliza antes de ler: uma string apensada por `[]=` entra no array sem conversão + $methods = PaymentMethod::normalizeSelectable($invoice->availablePaymentMethods, 'Invoice'); + + return reset($methods); } if (!empty($invoice->creditCard)) { - return Invoice::PAYMENT_METHOD_CREDIT_CARD; + return PaymentMethod::CREDIT_CARD; } throw ModelAttributeValidationException::required('Invoice', 'availablePaymentMethods'); @@ -783,11 +787,11 @@ public function refundInvoice(Invoice $invoice): Invoice if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); } - if ($invoice->paymentMethod === Invoice::PAYMENT_METHOD_BANK_SLIP) { + if ($invoice->paymentMethod === PaymentMethod::BANK_SLIP) { throw RefundNotSupportedException::boletoNoRefund('stripe'); } - if ($invoice->status === Invoice::STATUS_REFUNDED) { - throw RefundNotSupportedException::alreadyRefunded('stripe', $invoice->paymentMethod); + if ($invoice->status === InvoiceStatus::REFUNDED) { + throw RefundNotSupportedException::alreadyRefunded('stripe', $invoice->paymentMethod?->value); } // mesma semântica da Iugu: refundedAmount preenchido = estorno parcial; vazio = total @@ -967,17 +971,16 @@ private function parseInvoice(StripePaymentIntent $stripePaymentIntent, ?Invoice } /** - * Deriva o status genérico de contestação de um charge pago. O Charge da Stripe só traz a - * flag `disputed`; a dispute não é expansível a partir dele, então um charge disputado - * custa um GET a mais em /v1/disputes. Contestação em aberto tem precedência sobre - * perdida; dispute ganha, encerrada sem virar chargeback (`warning_closed`) ou prevenida - * não altera o status da fatura. + * Deriva o status genérico de contestação de um charge pago. Quando a flag `disputed` do + * charge é verdadeira, lista as disputes dele em /v1/disputes (um GET a mais). + * Contestação em aberto tem precedência sobre perdida; dispute ganha, encerrada sem virar + * chargeback (`warning_closed`) ou prevenida não altera o status da fatura. * * @param object $stripeCharge - * @return string|null `Invoice::STATUS_DISPUTED`, `Invoice::STATUS_CHARGEBACK` ou null + * @return InvoiceStatus|null `DISPUTED`, `CHARGEBACK` ou null * @throws GatewayException|GatewayNotAvailableException */ - private function disputeStatus(object $stripeCharge): ?string + private function disputeStatus(object $stripeCharge): ?InvoiceStatus { // isset() passa pelo __isset e não loga "Undefined property" quando a chave falta if (!isset($stripeCharge->disputed) || !$stripeCharge->disputed) { @@ -991,10 +994,10 @@ private function disputeStatus(object $stripeCharge): ?string $statuses = array_map(static fn ($dispute) => $dispute->status, $disputes->data ?? []); if (!empty(array_intersect($statuses, self::OPEN_DISPUTE_STATUSES))) { - return Invoice::STATUS_DISPUTED; + return InvoiceStatus::DISPUTED; } if (in_array(self::LOST_DISPUTE_STATUS, $statuses, true)) { - return Invoice::STATUS_CHARGEBACK; + return InvoiceStatus::CHARGEBACK; } return null; @@ -1003,15 +1006,16 @@ private function disputeStatus(object $stripeCharge): ?string /** * Deriva o status genérico do par PaymentIntent + charge. Estorno não muda o status do * PaymentIntent na Stripe, então ele vem do charge. Contestação, quando existe, vence os - * dois: uma fatura disputada não lê como paga nem como estornada. + * dois: uma fatura disputada não lê como paga nem como estornada. `requires_capture` lê + * como `AUTHORIZED` e `processing` como `PROCESSING`; status de PaymentIntent fora do + * mapa devolve `UNKNOWN` com aviso no log. * * @param \Stripe\PaymentIntent $stripePaymentIntent * @param object|null $paidCharge - * @param string|null $disputeStatus resultado de disputeStatus() para o charge pago - * @return string - * @throws GatewayException + * @param InvoiceStatus|null $disputeStatus resultado de disputeStatus() para o charge pago + * @return InvoiceStatus */ - private static function stripeStatusToMultiPayment(StripePaymentIntent $stripePaymentIntent, ?object $paidCharge, ?string $disputeStatus = null): string + private static function stripeStatusToMultiPayment(StripePaymentIntent $stripePaymentIntent, ?object $paidCharge, ?InvoiceStatus $disputeStatus = null): InvoiceStatus { if ($disputeStatus !== null) { return $disputeStatus; @@ -1019,26 +1023,20 @@ private static function stripeStatusToMultiPayment(StripePaymentIntent $stripePa if ($paidCharge && $paidCharge->amount_refunded > 0) { return $paidCharge->refunded - ? Invoice::STATUS_REFUNDED - : Invoice::STATUS_PARTIALLY_REFUNDED; + ? InvoiceStatus::REFUNDED + : InvoiceStatus::PARTIALLY_REFUNDED; } - switch ($stripePaymentIntent->status) { - case 'succeeded': - return Invoice::STATUS_PAID; - case 'canceled': - return Invoice::STATUS_CANCELED; + return match ($stripePaymentIntent->status) { + 'succeeded' => InvoiceStatus::PAID, + 'canceled' => InvoiceStatus::CANCELED, + 'requires_capture' => InvoiceStatus::AUTHORIZED, + 'processing' => InvoiceStatus::PROCESSING, // pix expirado volta a requires_payment_method (não vira canceled) e segue - // re-cobrável — reportar PENDING preserva essa funcionalidade - case 'processing': - case 'requires_action': - case 'requires_confirmation': - case 'requires_payment_method': - case 'requires_capture': - return Invoice::STATUS_PENDING; - default: - throw new GatewayException('Unexpected Stripe payment intent status: ' . $stripePaymentIntent->status); - } + // re-cobrável; reportar PENDING preserva essa funcionalidade + 'requires_action', 'requires_confirmation', 'requires_payment_method' => InvoiceStatus::PENDING, + default => InvoiceStatus::unknown((string) $stripePaymentIntent->status, 'stripe'), + }; } /** @@ -1113,12 +1111,12 @@ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gat }); $parsedOriginal = $this->parseInvoice($original, new Invoice()); - if ($parsedOriginal->status !== Invoice::STATUS_PENDING) { + if ($parsedOriginal->status !== InvoiceStatus::PENDING) { throw new GatewayException( - "Only pending invoices can be duplicated on the stripe gateway; invoice [{$invoice->id}] is [{$parsedOriginal->status}]" + "Only pending invoices can be duplicated on the stripe gateway; invoice [{$invoice->id}] is [{$parsedOriginal->status->value}]" ); } - if ($parsedOriginal->paymentMethod !== Invoice::PAYMENT_METHOD_PIX) { + if ($parsedOriginal->paymentMethod !== PaymentMethod::PIX) { throw new GatewayException('Only pix invoices can be duplicated on the stripe gateway'); } if (empty($parsedOriginal->customer) || empty($parsedOriginal->customer->id)) { @@ -1141,7 +1139,7 @@ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gat $duplicated->customer = $customer; $duplicated->amount = $parsedOriginal->amount; $duplicated->items = $parsedOriginal->items; - $duplicated->availablePaymentMethods = [Invoice::PAYMENT_METHOD_PIX]; + $duplicated->availablePaymentMethods = [PaymentMethod::PIX]; $duplicated->expiresAt = $expiresAt; // preserva o metadata da original (inclusive chaves custom do consumidor); // as gatewayOptions do chamador vêm por último e podem sobrescrever diff --git a/src/Helpers/LogHelper.php b/src/Helpers/LogHelper.php new file mode 100644 index 0000000..b0fb97e --- /dev/null +++ b/src/Helpers/LogHelper.php @@ -0,0 +1,34 @@ +bound('log')) { + $app->make('log')->warning($message, $context); + + return; + } + + error_log(trim($message . ' ' . json_encode($context))); + } +} diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 8e12b1d..5959cbc 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -3,47 +3,69 @@ namespace Potelo\MultiPayment\Models; use Carbon\Carbon; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; /** - * Invoice class + * Fatura. + * + * As três propriedades abaixo são enums: aceitam na escrita a string do valor ou o caso do + * enum e devolvem sempre o enum (ver `Model::ENUM_CASTS`). + * + * @property InvoiceStatus|null $status Status genérico; `UNKNOWN` para status que a lib não reconhece. + * @property PaymentMethod|null $paymentMethod Método com que a fatura foi (ou será) paga. + * @property PaymentMethod[]|null $availablePaymentMethods Métodos aceitos pela fatura. */ class Invoice extends Model { + /** @deprecated desde 2026-09-02, use `InvoiceStatus::PENDING`. */ public const STATUS_PENDING = 'pending'; + + /** @deprecated desde 2026-09-02, use `InvoiceStatus::PAID`. */ public const STATUS_PAID = 'paid'; + + /** @deprecated desde 2026-09-02, use `InvoiceStatus::CANCELED`. */ public const STATUS_CANCELED = 'canceled'; + + /** @deprecated desde 2026-09-02, use `InvoiceStatus::REFUNDED`. */ public const STATUS_REFUNDED = 'refunded'; + + /** @deprecated desde 2026-09-02, use `InvoiceStatus::PARTIALLY_REFUNDED`. */ public const STATUS_PARTIALLY_REFUNDED = 'partially_refunded'; - /** - * Contestação aberta sobre uma fatura paga, com resolução pendente. Enquanto a disputa - * corre, o gateway pode ou não reter o valor (a Stripe retém no chargeback formal, não - * na inquiry). Se ganha, a fatura volta a `paid`; se perdida, vira `chargeback`. - */ + /** @deprecated desde 2026-09-02, use `InvoiceStatus::DISPUTED`. */ public const STATUS_DISPUTED = 'disputed'; - /** - * Contestação perdida: o valor foi devolvido ao cliente pelo gateway. Estado terminal, - * distinto de `refunded`, que é o estorno voluntário feito pela aplicação. - */ + /** @deprecated desde 2026-09-02, use `InvoiceStatus::CHARGEBACK`. */ public const STATUS_CHARGEBACK = 'chargeback'; + /** @deprecated desde 2026-09-02, use `PaymentMethod::CREDIT_CARD`. */ public const PAYMENT_METHOD_CREDIT_CARD = 'credit_card'; + + /** @deprecated desde 2026-09-02, use `PaymentMethod::BANK_SLIP`. */ public const PAYMENT_METHOD_BANK_SLIP = 'bank_slip'; + + /** @deprecated desde 2026-09-02, use `PaymentMethod::PIX`. */ public const PAYMENT_METHOD_PIX = 'pix'; + protected const ENUM_CASTS = [ + 'status' => InvoiceStatus::class, + 'paymentMethod' => PaymentMethod::class, + 'availablePaymentMethods' => [PaymentMethod::class], + ]; + /** * @var string|null */ public ?string $id = null; /** - * @var string|null + * @var InvoiceStatus|null */ - public ?string $status = null; + protected ?InvoiceStatus $status = null; /** * @var Carbon|null @@ -86,14 +108,14 @@ class Invoice extends Model public ?array $items = null; /** - * @var string|null + * @var PaymentMethod|null */ - public ?string $paymentMethod = null; + protected ?PaymentMethod $paymentMethod = null; /** - * @var string[]|null + * @var PaymentMethod[]|null */ - public ?array $availablePaymentMethods = null; + protected ?array $availablePaymentMethods = null; /** * @var CreditCard|null @@ -253,25 +275,19 @@ public function validateItemsAttribute() } /** + * Garante que `availablePaymentMethods` é uma lista de métodos selecionáveis + * (`PaymentMethod::selectable()`), convertendo string que tenha entrado por escrita + * indireta no array. + * * @return void * @throws ModelAttributeValidationException */ public function validateAvailablePaymentMethodsAttribute() { - $meethods = [ - self::PAYMENT_METHOD_CREDIT_CARD, - self::PAYMENT_METHOD_BANK_SLIP, - self::PAYMENT_METHOD_PIX, - ]; - - if (!is_array($this->availablePaymentMethods)) { - throw ModelAttributeValidationException::invalid('Invoice', 'availablePaymentMethods', 'availablePaymentMethods must be an array of payment methods'); - } - foreach ($this->availablePaymentMethods as $method) { - if (!in_array($method, $meethods)) { - throw ModelAttributeValidationException::invalid('Invoice', 'availablePaymentMethods', 'availablePaymentMethods must be one of: ' . implode(', ', $meethods)); - } - } + $this->availablePaymentMethods = PaymentMethod::normalizeSelectable( + $this->availablePaymentMethods, + $this->getClassName() + ); } /** @@ -309,34 +325,51 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate } /** - * Responde "o dinheiro desta fatura foi recebido?" sem que o consumidor precise conhecer - * cada status: verdadeiro para `paid` e `partially_refunded`. Fatura em disputa não conta - * como recebida enquanto a contestação estiver aberta. + * Diz se o dinheiro da fatura foi recebido; delega a `InvoiceStatus::isSettled()`. String + * fora do enum devolve falso. * - * @param string $status + * @deprecated desde 2026-09-02, use `$invoice->status->isSettled()`. + * @param InvoiceStatus|string $status * @return bool */ - public static function isSettled(string $status): bool + public static function isSettled(InvoiceStatus|string $status): bool { - return in_array($status, [ - self::STATUS_PAID, - self::STATUS_PARTIALLY_REFUNDED, - ], true); + trigger_error( + 'Invoice::isSettled() está obsoleto desde 2026-09-02; use $invoice->status->isSettled()', + E_USER_DEPRECATED + ); + + return self::statusFromHelperArgument($status)?->isSettled() ?? false; } /** - * Responde "existe contestação sobre esta fatura?": verdadeiro para `disputed` (aberta) e - * `chargeback` (perdida). + * Diz se existe contestação sobre a fatura; delega a `InvoiceStatus::isContested()`. + * String fora do enum devolve falso. * - * @param string $status + * @deprecated desde 2026-09-02, use `$invoice->status->isContested()`. + * @param InvoiceStatus|string $status * @return bool */ - public static function isContested(string $status): bool + public static function isContested(InvoiceStatus|string $status): bool + { + trigger_error( + 'Invoice::isContested() está obsoleto desde 2026-09-02; use $invoice->status->isContested()', + E_USER_DEPRECATED + ); + + return self::statusFromHelperArgument($status)?->isContested() ?? false; + } + + /** + * Converte o argumento dos helpers estáticos obsoletos em `InvoiceStatus`, sem log para + * string fora do enum. + * + * @param InvoiceStatus|string $status + * @return InvoiceStatus|null + */ + private static function statusFromHelperArgument(InvoiceStatus|string $status): ?InvoiceStatus { - return in_array($status, [ - self::STATUS_DISPUTED, - self::STATUS_CHARGEBACK, - ], true); + return $status instanceof InvoiceStatus ? $status : InvoiceStatus::tryFrom($status); } /** diff --git a/src/Models/Model.php b/src/Models/Model.php index a578e27..c890681 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -3,6 +3,7 @@ namespace Potelo\MultiPayment\Models; use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Contracts\AcceptsUnknownValue; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; @@ -12,8 +13,18 @@ * @property array $gatewayAdicionalOptions Obsoleto desde 2026-09-02, use $gatewayOptions. Alias * que lê e escreve o mesmo array, com aviso de deprecação. */ -abstract class Model +abstract class Model implements \JsonSerializable { + /** + * Propriedades convertidas para enum ao serem escritas, por nome. O valor é a classe do + * enum ou, para uma lista de enums, a classe dentro de um array (`[PaymentMethod::class]`). + * Essas propriedades são declaradas `protected` no model e passam pelos métodos mágicos, + * que aceitam a string do valor ou o próprio caso do enum e devolvem sempre o enum. + * + * @var array|array{0: class-string<\BackedEnum>}> + */ + protected const ENUM_CASTS = []; + /** * Opções extras enviadas direto ao gateway. Cada driver mescla este array ao payload que * monta a partir do model, e as chaves daqui sobrepõem as geradas. @@ -23,7 +34,8 @@ abstract class Model public array $gatewayOptions = []; /** - * Resolve a leitura do nome antigo `gatewayAdicionalOptions` para `gatewayOptions`. + * Devolve uma propriedade de enum (ver `ENUM_CASTS`) ou resolve a leitura do nome antigo + * `gatewayAdicionalOptions` para `gatewayOptions`. * * Devolve por referência para que `$model->gatewayAdicionalOptions['chave'] = 'valor'` * continue alterando o array, como fazia quando a propriedade existia. @@ -33,6 +45,10 @@ abstract class Model */ public function &__get(string $name): mixed { + if (isset(static::ENUM_CASTS[$name])) { + return $this->{$name}; + } + if ($name === 'gatewayAdicionalOptions') { self::warnGatewayAdicionalOptionsDeprecated(); @@ -46,15 +62,23 @@ public function &__get(string $name): mixed } /** - * Resolve a escrita no nome antigo `gatewayAdicionalOptions` para `gatewayOptions`. + * Escreve numa propriedade de enum (ver `ENUM_CASTS`), convertendo string no caso do enum, + * ou resolve a escrita no nome antigo `gatewayAdicionalOptions` para `gatewayOptions`. * Qualquer outro nome segue o comportamento padrão do PHP (propriedade dinâmica). * * @param string $name * @param mixed $value * @return void + * @throws ModelAttributeValidationException */ public function __set(string $name, mixed $value): void { + if (isset(static::ENUM_CASTS[$name])) { + $this->{$name} = $this->castToEnum($name, $value); + + return; + } + if ($name === 'gatewayAdicionalOptions') { self::warnGatewayAdicionalOptionsDeprecated(); $this->gatewayOptions = $value; @@ -66,13 +90,18 @@ public function __set(string $name, mixed $value): void } /** - * Mantém `isset()` e `empty()` funcionando sobre o nome antigo `gatewayAdicionalOptions`. + * Mantém `isset()` e `empty()` funcionando sobre as propriedades de enum e sobre o nome + * antigo `gatewayAdicionalOptions`. * * @param string $name * @return bool */ public function __isset(string $name): bool { + if (isset(static::ENUM_CASTS[$name])) { + return isset($this->{$name}); + } + return $name === 'gatewayAdicionalOptions'; } @@ -89,6 +118,103 @@ private static function warnGatewayAdicionalOptionsDeprecated(): void ); } + /** + * Converte o valor escrito numa propriedade de `ENUM_CASTS` para o enum declarado. + * + * Aceita o caso do enum, a string do valor ou nulo; numa lista, um array desses. Enum que + * implementa `AcceptsUnknownValue` recebe a string desconhecida e decide o que fazer; nos + * demais, string fora do enum lança `ModelAttributeValidationException` com os valores + * aceitos. + * + * @param string $property + * @param mixed $value + * @return \BackedEnum|\BackedEnum[]|null + * @throws ModelAttributeValidationException + */ + private function castToEnum(string $property, mixed $value): mixed + { + $cast = static::ENUM_CASTS[$property]; + + if (!is_array($cast)) { + return $this->castScalarToEnum($property, $cast, $value); + } + + if (is_null($value)) { + return null; + } + + if (!is_array($value)) { + throw ModelAttributeValidationException::invalid( + static::getClassName(), + $property, + "{$property} must be an array" + ); + } + + return array_map(fn ($item) => $this->castScalarToEnum($property, $cast[0], $item), $value); + } + + /** + * Converte um único valor no caso do enum informado. + * + * @param string $property + * @param class-string<\BackedEnum> $enumClass + * @param mixed $value + * @return \BackedEnum|null + * @throws ModelAttributeValidationException + */ + private function castScalarToEnum(string $property, string $enumClass, mixed $value): ?\BackedEnum + { + if (is_null($value) || $value instanceof $enumClass) { + return $value; + } + + $accepted = implode(', ', array_column($enumClass::cases(), 'value')); + + if (!is_string($value)) { + throw ModelAttributeValidationException::invalid( + static::getClassName(), + $property, + "{$property} must be one of: {$accepted}" + ); + } + + if (is_subclass_of($enumClass, AcceptsUnknownValue::class)) { + $gateway = property_exists($this, 'gateway') ? $this->gateway : null; + + return $enumClass::fromValue($value, $gateway); + } + + return $enumClass::tryFrom($value) ?? throw ModelAttributeValidationException::invalid( + static::getClassName(), + $property, + "{$property} must be one of: {$accepted}" + ); + } + + /** + * Converte enum em valor de string, inclusive dentro de uma lista; qualquer outro valor + * passa intacto. + * + * @param mixed $value + * @return mixed + */ + private static function enumToValue(mixed $value): mixed + { + if ($value instanceof \BackedEnum) { + return $value->value; + } + + if (is_array($value)) { + return array_map( + static fn ($item) => $item instanceof \BackedEnum ? $item->value : $item, + $value + ); + } + + return $value; + } + /** * Create a new instance of the model with an array of attributes. * @@ -178,11 +304,13 @@ protected function attributesExtraValidation(array $attributes): void } /** - * Fill the model with an array of attributes. + * Fill the model with an array of attributes. Chave em `snake_case` vira a propriedade em + * `camelCase`; valor de propriedade de enum (ver `ENUM_CASTS`) pode vir como string. * * @param array $data * * @return void + * @throws ModelAttributeValidationException */ public function fill(array $data): void { @@ -193,13 +321,14 @@ public function fill(array $data): void $key = 'gatewayOptions'; } if (property_exists($this, $key)) { - $this->{$key} = $value; + $this->{$key} = isset(static::ENUM_CASTS[$key]) ? $this->castToEnum($key, $value) : $value; } } } /** - * Convert the model instance to an array. + * Convert the model instance to an array. Chave em `snake_case`; propriedade de enum sai + * como o valor de string do enum. * * @return array */ @@ -207,17 +336,32 @@ public function toArray(): array { $array = []; $reflect = new \ReflectionClass($this); - $props = $reflect->getProperties(\ReflectionProperty::IS_PUBLIC); + $props = $reflect->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED); foreach ($props as $prop) { - if (!empty($this->{$prop->getName()})) { - $key = strtolower(preg_replace('/(?getName())); - $array[$key] = $this->{$prop->getName()}; + $name = $prop->getName(); + if ($prop->isProtected() && !isset(static::ENUM_CASTS[$name])) { + continue; + } + if (!empty($this->{$name})) { + $key = strtolower(preg_replace('/(?{$name}); } } return $array; } + /** + * Serializa o model para `json_encode()` com o nome da propriedade em `camelCase` como + * chave, incluindo as propriedades de enum, que saem como valor de string. + * + * @return array + */ + public function jsonSerialize(): array + { + return get_object_vars($this); + } + /** * Return the class name of the model without namespace. * diff --git a/src/Models/Plan.php b/src/Models/Plan.php index 236a830..ccc22d8 100644 --- a/src/Models/Plan.php +++ b/src/Models/Plan.php @@ -2,19 +2,32 @@ namespace Potelo\MultiPayment\Models; +use Potelo\MultiPayment\Enums\PlanInterval; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; /** * Plano recorrente ao qual uma assinatura se vincula. + * + * @property PlanInterval|null $interval Unidade do intervalo de cobrança; aceita a string do + * valor ou o caso do enum na escrita (ver `Model::ENUM_CASTS`). */ class Plan extends Model { + /** @deprecated desde 2026-09-02, use `PlanInterval::WEEK`. */ public const INTERVAL_WEEK = 'week'; + + /** @deprecated desde 2026-09-02, use `PlanInterval::MONTH`. */ public const INTERVAL_MONTH = 'month'; + + /** @deprecated desde 2026-09-02, use `PlanInterval::YEAR`. */ public const INTERVAL_YEAR = 'year'; + protected const ENUM_CASTS = [ + 'interval' => PlanInterval::class, + ]; + /** * @var string|null */ @@ -38,9 +51,9 @@ class Plan extends Model public ?int $amount = null; /** - * @var string|null + * @var PlanInterval|null */ - public ?string $interval = null; + protected ?PlanInterval $interval = null; /** * @var int|null @@ -91,23 +104,6 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate parent::save($gateway, $validate); } - /** - * @return void - * @throws ModelAttributeValidationException - */ - protected function validateIntervalAttribute(): void - { - $intervals = [self::INTERVAL_WEEK, self::INTERVAL_MONTH, self::INTERVAL_YEAR]; - - if (!in_array($this->interval, $intervals, true)) { - throw ModelAttributeValidationException::invalid( - $this->getClassName(), - 'interval', - 'interval must be one of: ' . implode(', ', $intervals) - ); - } - } - /** * @return void * @throws ModelAttributeValidationException diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index fc67fa7..06cf022 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -3,6 +3,7 @@ namespace Potelo\MultiPayment\Models; use Carbon\Carbon; +use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Contracts\SubscriptionContract; @@ -11,6 +12,12 @@ /** * Assinatura recorrente de um cliente a um plano. + * + * As duas propriedades abaixo são enums: aceitam na escrita a string do valor ou o caso do + * enum e devolvem sempre o enum (ver `Model::ENUM_CASTS`). + * + * @property PaymentMethod|null $paymentMethod Método de pagamento da assinatura. + * @property PaymentMethod[]|null $availablePaymentMethods Métodos aceitos pela assinatura. */ class Subscription extends Model { @@ -22,6 +29,11 @@ class Subscription extends Model public const STATUS_EXPIRED = 'expired'; public const STATUS_CANCELED = 'canceled'; + protected const ENUM_CASTS = [ + 'paymentMethod' => PaymentMethod::class, + 'availablePaymentMethods' => [PaymentMethod::class], + ]; + /** * @var string|null */ @@ -61,14 +73,14 @@ class Subscription extends Model public ?int $amount = null; /** - * @var string|null + * @var PaymentMethod|null */ - public ?string $paymentMethod = null; + protected ?PaymentMethod $paymentMethod = null; /** - * @var string[]|null + * @var PaymentMethod[]|null */ - public ?array $availablePaymentMethods = null; + protected ?array $availablePaymentMethods = null; /** * @var Carbon|null @@ -267,34 +279,19 @@ protected function validateDiscountsAttribute(): void } /** + * Garante que `availablePaymentMethods` é uma lista de métodos selecionáveis + * (`PaymentMethod::selectable()`), convertendo string que tenha entrado por escrita + * indireta no array. + * * @return void * @throws ModelAttributeValidationException */ protected function validateAvailablePaymentMethodsAttribute(): void { - $methods = [ - Invoice::PAYMENT_METHOD_CREDIT_CARD, - Invoice::PAYMENT_METHOD_BANK_SLIP, - Invoice::PAYMENT_METHOD_PIX, - ]; - - if (!is_array($this->availablePaymentMethods)) { - throw ModelAttributeValidationException::invalid( - $this->getClassName(), - 'availablePaymentMethods', - 'availablePaymentMethods must be an array of payment methods' - ); - } - - foreach ($this->availablePaymentMethods as $method) { - if (!in_array($method, $methods, true)) { - throw ModelAttributeValidationException::invalid( - $this->getClassName(), - 'availablePaymentMethods', - 'availablePaymentMethods must be one of: ' . implode(', ', $methods) - ); - } - } + $this->availablePaymentMethods = PaymentMethod::normalizeSelectable( + $this->availablePaymentMethods, + $this->getClassName() + ); } /** diff --git a/tests/Integration/Builders/InvoiceBuilderTest.php b/tests/Integration/Builders/InvoiceBuilderTest.php index 02ca602..3831e63 100644 --- a/tests/Integration/Builders/InvoiceBuilderTest.php +++ b/tests/Integration/Builders/InvoiceBuilderTest.php @@ -9,6 +9,8 @@ use Potelo\MultiPayment\Exceptions\ChargingException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; class InvoiceBuilderTest extends TestCase { @@ -27,7 +29,7 @@ public function testShouldCreateAutomaticPixInvoice(): void $reference = 'multipayment-' . Carbon::now()->format('YmdHis'); $invoice = (new \Potelo\MultiPayment\MultiPayment('iugu'))->newInvoice() - ->addAvailablePaymentMethod(Invoice::PAYMENT_METHOD_PIX) + ->addAvailablePaymentMethod(PaymentMethod::PIX) ->addCustomer( 'Automatic Pix Sandbox', "{$reference}@example.com", @@ -168,7 +170,7 @@ public function testShouldCreateInvoice(string $gateway, array $data): void } if (isset($data['paymentMethod'])) { - $this->assertEquals($data['paymentMethod'], $invoice->paymentMethod); + $this->assertEquals($data['paymentMethod'], $invoice->paymentMethod?->value); } if (isset($data['creditCard'])) { @@ -202,7 +204,7 @@ public function testShouldCreateInvoice(string $gateway, array $data): void if (array_key_exists('payable_with', $data['gatewayOptions']) && $gateway == 'iugu') { $this->assertEqualsCanonicalizing( $data['gatewayOptions']['payable_with'], - (array) $invoice->original->payable_with + array_map(fn (PaymentMethod $method) => $method->value, $invoice->availablePaymentMethods) ); } if (array_key_exists('expires_in', $data['gatewayOptions'])) { @@ -237,8 +239,8 @@ public function testShouldCreateInvoice(string $gateway, array $data): void $this->assertEquals($data['expiresAt'], $invoice->expiresAt->format('Y-m-d')); } - if (isset($data['paymentMethod']) && $invoice->status === $invoice::STATUS_PAID) { - $this->assertEquals($data['paymentMethod'], $invoice->paymentMethod); + if (isset($data['paymentMethod']) && $invoice->status === InvoiceStatus::PAID) { + $this->assertEquals($data['paymentMethod'], $invoice->paymentMethod?->value); } if (isset($data['customer']['address'])) { diff --git a/tests/Integration/MultiPaymentTest.php b/tests/Integration/MultiPaymentTest.php index ad78cf6..43ea6e6 100644 --- a/tests/Integration/MultiPaymentTest.php +++ b/tests/Integration/MultiPaymentTest.php @@ -10,6 +10,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; class MultiPaymentTest extends TestCase { @@ -27,7 +29,7 @@ public function testShouldGetAutomaticPixInvoice(): void $reference = 'multipayment-' . now()->format('YmdHis'); $invoice = MultiPayment::setGateway('iugu')->newInvoice() - ->addAvailablePaymentMethod(Invoice::PAYMENT_METHOD_PIX) + ->addAvailablePaymentMethod(PaymentMethod::PIX) ->addCustomer( 'Automatic Pix Sandbox', "{$reference}@example.com", @@ -129,7 +131,7 @@ public function testShouldGetInvoice() { $gateway = 'iugu'; $invoice = MultiPayment::setGateway($gateway)->newInvoice() - ->addAvailablePaymentMethod(Invoice::PAYMENT_METHOD_CREDIT_CARD) + ->addAvailablePaymentMethod(PaymentMethod::CREDIT_CARD) ->addCustomer('Fake Customer', 'email@exemplo.com', '20176996915') ->addItem('teste', 1000, 1) ->addCreditCardToken(self::iuguCreditCardToken()) @@ -267,7 +269,7 @@ public function testShouldDuplicateInvoice() { $gateway = 'iugu'; $invoice = MultiPayment::setGateway($gateway)->newInvoice() - ->addAvailablePaymentMethod(Invoice::PAYMENT_METHOD_PIX) + ->addAvailablePaymentMethod(PaymentMethod::PIX) ->addCustomer('Fake Customer', 'email@exemplo.com', '20176996915') ->addItem('teste', 1000, 1) ->create(); @@ -275,7 +277,7 @@ public function testShouldDuplicateInvoice() $multiPayment = new \Potelo\MultiPayment\MultiPayment($gateway); $new = $multiPayment->duplicateInvoice($invoice->id, now()->addDays(7)); $this->assertNotEquals($new->id, $invoice->id); - $this->assertEquals($new->status, Invoice::STATUS_PENDING); + $this->assertEquals($new->status, InvoiceStatus::PENDING); $this->assertTrue($new->expiresAt->isSameDay((now()->addDays(7)))); } @@ -319,7 +321,7 @@ public static function shouldNotGetInvoiceDataProvider(): array * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException */ #[DataProvider('shouldRefundInvoiceDataProvider')] - public function testShouldRefundInvoice(string $gateway, array $data, string $status, ?int $refundedAmount) + public function testShouldRefundInvoice(string $gateway, array $data, InvoiceStatus $status, ?int $refundedAmount) { $multiPayment = new \Potelo\MultiPayment\MultiPayment($gateway); @@ -355,12 +357,12 @@ public function testShouldRefundInvoice(string $gateway, array $data, string $st if (is_null($refundedAmount)) { $refundedAmount = $total; } - $this->assertEquals($status, $refundedInvoice->status); + $this->assertSame($status, $refundedInvoice->status); $this->assertEquals($refundedAmount, $refundedInvoice->refundedAmount); $this->assertEquals($total - $refundedAmount, $refundedInvoice->paidAmount); // na Iugu a guarda lê a fatura real antes: já estornada é recusada sem novo POST - if ($gateway === 'iugu' && $status === Invoice::STATUS_REFUNDED) { + if ($gateway === 'iugu' && $status === InvoiceStatus::REFUNDED) { try { $multiPayment->refundInvoice($invoice->id); $this->fail('Esperava RefundNotSupportedException'); @@ -385,7 +387,7 @@ public static function shouldRefundInvoiceDataProvider(): array 'paymentMethod' => 'credit_card', 'creditCard' => self::creditCard(), ], - 'status' => Invoice::STATUS_REFUNDED, + 'status' => InvoiceStatus::REFUNDED, 'refundedAmount' => null, ], ]; @@ -397,7 +399,7 @@ public static function shouldRefundInvoiceDataProvider(): array * * @param string $gateway * @param array $data - * @param string $status + * @param InvoiceStatus $status * @param string $creditCardDataMethod * @return void * @throws \Potelo\MultiPayment\Exceptions\ChargingException @@ -408,7 +410,7 @@ public static function shouldRefundInvoiceDataProvider(): array * @throws \Potelo\MultiPayment\Exceptions\MultiPaymentException */ #[DataProvider('shouldChargeInvoiceWithCreditCard')] - public function testShouldChargeInvoiceWithCreditCard(string $gateway, array $data, string $status, string $creditCardDataMethod) + public function testShouldChargeInvoiceWithCreditCard(string $gateway, array $data, InvoiceStatus $status, string $creditCardDataMethod) { $multiPayment = new \Potelo\MultiPayment\MultiPayment($gateway); @@ -445,7 +447,7 @@ public function testShouldChargeInvoiceWithCreditCard(string $gateway, array $da $invoice = $multiPayment->chargeInvoiceWithCreditCard($invoice->id, null, $creditCard->id); } - $this->assertEquals($status, $invoice->status); + $this->assertSame($status, $invoice->status); } /** @@ -461,7 +463,7 @@ public static function shouldChargeInvoiceWithCreditCard(): array 'customer' => self::customerWithoutAddress(), 'paymentMethod' => 'credit_card', ], - 'status' => Invoice::STATUS_PAID, + 'status' => InvoiceStatus::PAID, 'creditCardDataMethod' => 'creditCard', ], 'iugu - credit card token' => [ @@ -471,7 +473,7 @@ public static function shouldChargeInvoiceWithCreditCard(): array 'customer' => self::customerWithoutAddress(), 'paymentMethod' => 'credit_card', ], - 'status' => Invoice::STATUS_PAID, + 'status' => InvoiceStatus::PAID, 'creditCardDataMethod' => 'token', ], 'iugu - credit card id' => [ @@ -481,7 +483,7 @@ public static function shouldChargeInvoiceWithCreditCard(): array 'customer' => self::customerWithoutAddress(), 'paymentMethod' => 'credit_card', ], - 'status' => Invoice::STATUS_PAID, + 'status' => InvoiceStatus::PAID, 'creditCardDataMethod' => 'id', ], ]; diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index ef07e44..5393cb1 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -8,6 +8,8 @@ use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; use PHPUnit\Framework\Attributes\DataProvider; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; /** * Cenários específicos do gateway Stripe na sandbox real. O fluxo de cartão é token-only: @@ -47,15 +49,15 @@ public function testShouldChargeCreditCardInvoice($gateway) $customerData['phoneNumber'] ) ->addItem('Assinatura mensal', 12345, 1) - ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_CREDIT_CARD]) + ->setAvailablePaymentMethods([PaymentMethod::CREDIT_CARD]) ->addCreditCardToken('pm_card_visa') ->create(); $this->assertNotNull($invoice->id); - $this->assertEquals(Invoice::STATUS_PAID, $invoice->status); + $this->assertEquals(InvoiceStatus::PAID, $invoice->status); $this->assertEquals(12345, $invoice->amount); $this->assertEquals(12345, $invoice->paidAmount); - $this->assertEquals(Invoice::PAYMENT_METHOD_CREDIT_CARD, $invoice->paymentMethod); + $this->assertEquals(PaymentMethod::CREDIT_CARD, $invoice->paymentMethod); $this->assertEquals('4242', $invoice->creditCard->lastDigits); $this->assertNotNull($invoice->paidAt); $this->assertCount(1, $invoice->items); @@ -64,7 +66,7 @@ public function testShouldChargeCreditCardInvoice($gateway) sleep(3); // a balance transaction (fee) do cartão é assíncrona logo após o confirm $invoiceFetched = MultiPayment::setGateway($gateway)->getInvoice($invoice->id); - $this->assertEquals(Invoice::STATUS_PAID, $invoiceFetched->status); + $this->assertEquals(InvoiceStatus::PAID, $invoiceFetched->status); $this->assertEquals(12345, $invoiceFetched->paidAmount); $this->assertNotNull($invoiceFetched->fee); $this->assertEquals($invoice->id, $invoiceFetched->id); @@ -89,7 +91,7 @@ public function testShouldRaiseChargingExceptionOnDeclinedCard($gateway) $customerData['phoneNumber'] ) ->addItem('Assinatura mensal', 9900, 1) - ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_CREDIT_CARD]) + ->setAvailablePaymentMethods([PaymentMethod::CREDIT_CARD]) ->addCreditCardToken('pm_card_chargeDeclined'); try { @@ -153,13 +155,13 @@ public function testShouldCreatePixInvoiceAndReceiveMagicPayment($gateway) $customerData['taxDocument'] ) ->addItem('Assinatura mensal', 12345, 1) - ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) ->setExpiresAt(\Carbon\Carbon::now()->addHour()) ->create(); $this->assertNotNull($invoice->id); - $this->assertEquals(Invoice::STATUS_PENDING, $invoice->status); - $this->assertEquals(Invoice::PAYMENT_METHOD_PIX, $invoice->paymentMethod); + $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); + $this->assertEquals(PaymentMethod::PIX, $invoice->paymentMethod); $this->assertNotNull($invoice->pix); $this->assertNotNull($invoice->pix->qrCodeText); $this->assertNotNull($invoice->pix->qrCodeImageUrl); @@ -168,11 +170,11 @@ public function testShouldCreatePixInvoiceAndReceiveMagicPayment($gateway) // além do pagamento mágico, espera a balance transaction (fee) materializar $invoiceFetched = $this->waitForInvoiceCondition($gateway, $invoice->id, function (Invoice $fetched) { - return $fetched->status === Invoice::STATUS_PAID && !is_null($fetched->fee); + return $fetched->status === InvoiceStatus::PAID && !is_null($fetched->fee); }); - $this->assertEquals(Invoice::STATUS_PAID, $invoiceFetched->status); + $this->assertEquals(InvoiceStatus::PAID, $invoiceFetched->status); $this->assertEquals(12345, $invoiceFetched->paidAmount); - $this->assertEquals(Invoice::PAYMENT_METHOD_PIX, $invoiceFetched->paymentMethod); + $this->assertEquals(PaymentMethod::PIX, $invoiceFetched->paymentMethod); $this->assertNotNull($invoiceFetched->fee); } @@ -188,13 +190,13 @@ public function testShouldCancelPendingPixInvoice($gateway) $invoice = MultiPayment::setGateway($gateway)->newInvoice() ->addCustomer($customerData['name'], $customerData['email'], $customerData['taxDocument']) ->addItem('Assinatura mensal', 5000, 1) - ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) ->create(); - $this->assertEquals(Invoice::STATUS_PENDING, $invoice->status); + $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); $invoiceCanceled = MultiPayment::setGateway($gateway)->cancelInvoice($invoice->id); - $this->assertEquals(Invoice::STATUS_CANCELED, $invoiceCanceled->status); + $this->assertEquals(InvoiceStatus::CANCELED, $invoiceCanceled->status); } /** @@ -209,21 +211,21 @@ public function testShouldChargeExpiredPixInvoiceWithCreditCard($gateway) $invoice = MultiPayment::setGateway($gateway)->newInvoice() ->addCustomer($customerData['name'], 'expire_immediately@example.com', $customerData['taxDocument']) ->addItem('Assinatura mensal', 9900, 1) - ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) ->create(); - $this->assertEquals(Invoice::STATUS_PENDING, $invoice->status); + $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); // aguarda a sandbox processar a expiração mágica (o PI segue pendente e re-cobrável) $invoiceExpired = $this->waitForInvoiceCondition($gateway, $invoice->id, function (Invoice $fetched) { return empty($fetched->pix); }); - $this->assertEquals(Invoice::STATUS_PENDING, $invoiceExpired->status); + $this->assertEquals(InvoiceStatus::PENDING, $invoiceExpired->status); $invoicePaid = MultiPayment::setGateway($gateway) ->chargeInvoiceWithCreditCard($invoice->id, 'pm_card_visa'); - $this->assertEquals(Invoice::STATUS_PAID, $invoicePaid->status); - $this->assertEquals(Invoice::PAYMENT_METHOD_CREDIT_CARD, $invoicePaid->paymentMethod); + $this->assertEquals(InvoiceStatus::PAID, $invoicePaid->status); + $this->assertEquals(PaymentMethod::CREDIT_CARD, $invoicePaid->paymentMethod); $this->assertEquals('4242', $invoicePaid->creditCard->lastDigits); } @@ -262,14 +264,14 @@ public function testShouldRefundCreditCardInvoiceTotally($gateway) $invoice = MultiPayment::setGateway($gateway)->newInvoice() ->addCustomer($customerData['name'], $customerData['email'], $customerData['taxDocument']) ->addItem('Assinatura mensal', 9900, 1) - ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_CREDIT_CARD]) + ->setAvailablePaymentMethods([PaymentMethod::CREDIT_CARD]) ->addCreditCardToken('pm_card_visa') ->create(); - $this->assertEquals(Invoice::STATUS_PAID, $invoice->status); + $this->assertEquals(InvoiceStatus::PAID, $invoice->status); $invoiceRefunded = MultiPayment::setGateway($gateway)->refundInvoice($invoice->id); - $this->assertEquals(Invoice::STATUS_REFUNDED, $invoiceRefunded->status); + $this->assertEquals(InvoiceStatus::REFUNDED, $invoiceRefunded->status); $this->assertEquals(9900, $invoiceRefunded->refundedAmount); } @@ -285,16 +287,16 @@ public function testShouldRefundPixInvoicePartially($gateway) $invoice = MultiPayment::setGateway($gateway)->newInvoice() ->addCustomer($customerData['name'], 'succeed_immediately@example.com', $customerData['taxDocument']) ->addItem('Assinatura mensal', 12345, 1) - ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) ->create(); $invoicePaid = $this->waitForInvoiceCondition($gateway, $invoice->id, function (Invoice $fetched) { - return $fetched->status === Invoice::STATUS_PAID; + return $fetched->status === InvoiceStatus::PAID; }); - $this->assertEquals(Invoice::STATUS_PAID, $invoicePaid->status); + $this->assertEquals(InvoiceStatus::PAID, $invoicePaid->status); $invoiceRefunded = MultiPayment::setGateway($gateway)->refundInvoice($invoice->id, 2345); - $this->assertEquals(Invoice::STATUS_PARTIALLY_REFUNDED, $invoiceRefunded->status); + $this->assertEquals(InvoiceStatus::PARTIALLY_REFUNDED, $invoiceRefunded->status); $this->assertEquals(2345, $invoiceRefunded->refundedAmount); $this->assertEquals(12345, $invoiceRefunded->paidAmount); } @@ -311,18 +313,18 @@ public function testShouldDuplicatePendingPixInvoice($gateway) $invoice = MultiPayment::setGateway($gateway)->newInvoice() ->addCustomer($customerData['name'], $customerData['email'], $customerData['taxDocument']) ->addItem('Assinatura mensal', 5000, 1) - ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) ->setExpiresAt(\Carbon\Carbon::now()->addHour()) ->create(); - $this->assertEquals(Invoice::STATUS_PENDING, $invoice->status); + $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); $newExpiresAt = \Carbon\Carbon::now()->addDays(2); $invoiceDuplicated = MultiPayment::setGateway($gateway) ->duplicateInvoice($invoice->id, $newExpiresAt); $this->assertNotEquals($invoice->id, $invoiceDuplicated->id); - $this->assertEquals(Invoice::STATUS_PENDING, $invoiceDuplicated->status); + $this->assertEquals(InvoiceStatus::PENDING, $invoiceDuplicated->status); $this->assertEquals(5000, $invoiceDuplicated->amount); $this->assertNotNull($invoiceDuplicated->pix->qrCodeText); $this->assertEqualsWithDelta( @@ -333,7 +335,7 @@ public function testShouldDuplicatePendingPixInvoice($gateway) $this->assertEquals($invoice->customer->id, $invoiceDuplicated->customer->id); $originalFetched = MultiPayment::setGateway($gateway)->getInvoice($invoice->id); - $this->assertEquals(Invoice::STATUS_CANCELED, $originalFetched->status); + $this->assertEquals(InvoiceStatus::CANCELED, $originalFetched->status); } /** @@ -352,7 +354,7 @@ public function testShouldRejectBankSlipInvoice($gateway) $customerData['taxDocument'] ) ->addItem('Assinatura mensal', 9900, 1) - ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_BANK_SLIP]); + ->setAvailablePaymentMethods([PaymentMethod::BANK_SLIP]); $this->expectException(GatewayException::class); $this->expectExceptionMessage('[createInvoice com boleto] no Stripe ainda não está implementada nesta lib'); diff --git a/tests/Integration/SubscriptionTest.php b/tests/Integration/SubscriptionTest.php index 469816d..64cec75 100644 --- a/tests/Integration/SubscriptionTest.php +++ b/tests/Integration/SubscriptionTest.php @@ -12,6 +12,9 @@ use Potelo\MultiPayment\Models\SubscriptionItem; use Potelo\MultiPayment\Facades\MultiPayment; use Potelo\MultiPayment\Models\SubscriptionDiscount; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Enums\PlanInterval; /** * Cobre o que só a sandbox prova: a serialização do SDK, os endpoints de plano e assinatura e @@ -65,7 +68,7 @@ protected function tearDown(): void private function createPlan( int $amount, string $sufixo, - string $interval = Plan::INTERVAL_MONTH, + PlanInterval $interval = PlanInterval::MONTH, int $intervalCount = 1 ): Plan { $plan = new Plan(); @@ -87,7 +90,7 @@ private function createSubscription(Plan $plan, ?Carbon $nextBillingAt = null): $builder = MultiPayment::setGateway(self::GATEWAY)->newSubscription() ->setPlanId($plan->identifier) ->setCustomerId($customer->id) - ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]); + ->setAvailablePaymentMethods([PaymentMethod::PIX]); if ($nextBillingAt) { $builder->setNextBillingAt($nextBillingAt); @@ -110,7 +113,7 @@ public function testShouldCreateGetAndListPlans(): void $this->assertNotEmpty($plan->id); $this->assertSame(12345, $plan->amount); - $this->assertSame(Plan::INTERVAL_MONTH, $plan->interval); + $this->assertSame(PlanInterval::MONTH, $plan->interval); $this->assertSame('iugu', $plan->gateway); $porIdentifier = new Plan(); @@ -140,10 +143,10 @@ public function testShouldCreateGetAndListPlans(): void */ public function testShouldCreateAYearlyPlanAsTwelveMonths(): void { - $plan = $this->createPlan(120000, 'anual', Plan::INTERVAL_YEAR); + $plan = $this->createPlan(120000, 'anual', PlanInterval::YEAR); $this->assertNotEmpty($plan->id); - $this->assertSame(Plan::INTERVAL_YEAR, $plan->interval); + $this->assertSame(PlanInterval::YEAR, $plan->interval); $this->assertSame(1, $plan->intervalCount); $this->assertSame(12, $plan->original->interval); $this->assertSame('months', $plan->original->interval_type); @@ -152,7 +155,7 @@ public function testShouldCreateAYearlyPlanAsTwelveMonths(): void $lido->id = $plan->id; $lido = $lido->get(self::GATEWAY); - $this->assertSame(Plan::INTERVAL_YEAR, $lido->interval); + $this->assertSame(PlanInterval::YEAR, $lido->interval); $this->assertSame(1, $lido->intervalCount); } @@ -317,7 +320,7 @@ public function testShouldChangePlanGeneratingTheCharge(): void $this->assertNotNull($trocada->latestInvoice); $this->faturasCriadas[] = $trocada->latestInvoice->id; - $this->assertSame(Invoice::STATUS_PENDING, $trocada->latestInvoice->status); + $this->assertSame(InvoiceStatus::PENDING, $trocada->latestInvoice->status); // a cobrança é imediata, e não a do próximo ciclo; comparar contra now() traria o fuso // do gateway para dentro do teste $this->assertTrue($trocada->latestInvoice->expiresAt->lessThan($proximaCobranca)); diff --git a/tests/Unit/AutomaticPixTest.php b/tests/Unit/AutomaticPixTest.php index a199adc..b857053 100644 --- a/tests/Unit/AutomaticPixTest.php +++ b/tests/Unit/AutomaticPixTest.php @@ -11,6 +11,7 @@ use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Enums\InvoiceStatus; class AutomaticPixTest extends TestCase { @@ -193,7 +194,7 @@ public function testCancelsInvoiceThroughGateway(): void { $cancelledInvoice = new Invoice(); $cancelledInvoice->id = 'invoice-id'; - $cancelledInvoice->status = Invoice::STATUS_CANCELED; + $cancelledInvoice->status = InvoiceStatus::CANCELED; $gateway = Mockery::mock(GatewayContract::class); $gateway->shouldReceive('cancelInvoice') diff --git a/tests/Unit/Enums/InvoiceStatusTest.php b/tests/Unit/Enums/InvoiceStatusTest.php new file mode 100644 index 0000000..275a8f5 --- /dev/null +++ b/tests/Unit/Enums/InvoiceStatusTest.php @@ -0,0 +1,135 @@ +assertSame([ + 'pending', + 'authorized', + 'processing', + 'paid', + 'partially_paid', + 'externally_paid', + 'partially_refunded', + 'refunded', + 'disputed', + 'chargeback', + 'canceled', + 'expired', + 'unknown', + ], array_column(InvoiceStatus::cases(), 'value')); + } + + /** + * Tabela verdade completa dos quatro helpers, um caso por linha. + * + * @return array + */ + public static function helperTruthTableProvider(): array + { + // [status, isSettled, isContested, isTerminal, isOpen] + return [ + 'pending' => [InvoiceStatus::PENDING, false, false, false, true], + 'authorized' => [InvoiceStatus::AUTHORIZED, false, false, false, true], + 'processing' => [InvoiceStatus::PROCESSING, false, false, false, true], + 'paid' => [InvoiceStatus::PAID, true, false, false, false], + 'partially_paid' => [InvoiceStatus::PARTIALLY_PAID, true, false, false, true], + 'externally_paid' => [InvoiceStatus::EXTERNALLY_PAID, true, false, false, false], + 'partially_refunded' => [InvoiceStatus::PARTIALLY_REFUNDED, true, false, false, false], + 'refunded' => [InvoiceStatus::REFUNDED, false, false, true, false], + 'disputed' => [InvoiceStatus::DISPUTED, false, true, false, false], + 'chargeback' => [InvoiceStatus::CHARGEBACK, false, true, true, false], + 'canceled' => [InvoiceStatus::CANCELED, false, false, true, false], + 'expired' => [InvoiceStatus::EXPIRED, false, false, true, false], + 'unknown' => [InvoiceStatus::UNKNOWN, false, false, false, false], + ]; + } + + #[DataProvider('helperTruthTableProvider')] + public function testHelpersAnswerEachBusinessQuestion( + InvoiceStatus $status, + bool $settled, + bool $contested, + bool $terminal, + bool $open + ): void { + $this->assertSame($settled, $status->isSettled(), 'isSettled'); + $this->assertSame($contested, $status->isContested(), 'isContested'); + $this->assertSame($terminal, $status->isTerminal(), 'isTerminal'); + $this->assertSame($open, $status->isOpen(), 'isOpen'); + } + + public function testFromValueReturnsTheMatchingCaseWithoutLogging(): void + { + $logger = $this->bindLogger(); + + $this->assertSame(InvoiceStatus::PAID, InvoiceStatus::fromValue('paid', 'iugu')); + $this->assertSame(InvoiceStatus::PARTIALLY_PAID, InvoiceStatus::fromValue('partially_paid')); + $this->assertSame([], $logger->records); + } + + public function testFromValueTurnsAnUnknownStringIntoUnknownWithAWarning(): void + { + $logger = $this->bindLogger(); + + $status = InvoiceStatus::fromValue('status_novo', 'iugu'); + + $this->assertSame(InvoiceStatus::UNKNOWN, $status); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertStringContainsString('status_novo', $logger->records[0]['message']); + $this->assertStringContainsString('iugu', $logger->records[0]['message']); + $this->assertSame(['status' => 'status_novo', 'gateway' => 'iugu'], $logger->records[0]['context']); + } + + public function testUnknownLogsAGatewaylessValue(): void + { + $logger = $this->bindLogger(); + + $this->assertSame(InvoiceStatus::UNKNOWN, InvoiceStatus::unknown('x')); + $this->assertSame(['status' => 'x', 'gateway' => null], $logger->records[0]['context']); + $this->assertStringContainsString('desconhecido', $logger->records[0]['message']); + } + + public function testUnknownFallsBackToErrorLogWithoutALoggerInTheContainer(): void + { + Facade::setFacadeApplication(new Container()); + $previous = ini_set('error_log', $file = tempnam(sys_get_temp_dir(), 'multipayment-log')); + + try { + $this->assertSame(InvoiceStatus::UNKNOWN, InvoiceStatus::unknown('sem_logger', 'stripe')); + } finally { + ini_set('error_log', $previous); + } + + $this->assertStringContainsString('sem_logger', file_get_contents($file)); + unlink($file); + } + + private function bindLogger(): RecordingLogger + { + $app = new Container(); + $app->instance('log', $logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + + return $logger; + } +} diff --git a/tests/Unit/Enums/PaymentMethodTest.php b/tests/Unit/Enums/PaymentMethodTest.php new file mode 100644 index 0000000..adad489 --- /dev/null +++ b/tests/Unit/Enums/PaymentMethodTest.php @@ -0,0 +1,52 @@ +assertSame(Invoice::PAYMENT_METHOD_CREDIT_CARD, PaymentMethod::CREDIT_CARD->value); + $this->assertSame(Invoice::PAYMENT_METHOD_BANK_SLIP, PaymentMethod::BANK_SLIP->value); + $this->assertSame(Invoice::PAYMENT_METHOD_PIX, PaymentMethod::PIX->value); + $this->assertSame('automatic_pix', PaymentMethod::AUTOMATIC_PIX->value); + $this->assertCount(4, PaymentMethod::cases()); + } + + public function testSelectableExcludesAutomaticPix(): void + { + $this->assertSame( + [PaymentMethod::CREDIT_CARD, PaymentMethod::BANK_SLIP, PaymentMethod::PIX], + PaymentMethod::selectable() + ); + } + + public function testNormalizeSelectableConvertsStringsAndKeepsCases(): void + { + $this->assertSame( + [PaymentMethod::PIX, PaymentMethod::CREDIT_CARD], + PaymentMethod::normalizeSelectable(['pix', PaymentMethod::CREDIT_CARD], 'Invoice') + ); + } + + public function testNormalizeSelectableRejectsAutomaticPixAndUnknownValues(): void + { + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('availablePaymentMethods must be one of: credit_card, bank_slip, pix'); + + PaymentMethod::normalizeSelectable([PaymentMethod::AUTOMATIC_PIX], 'Invoice'); + } + + public function testNormalizeSelectableRejectsANonArray(): void + { + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('availablePaymentMethods must be an array of payment methods'); + + PaymentMethod::normalizeSelectable('pix', 'Subscription'); + } +} diff --git a/tests/Unit/Enums/PlanIntervalTest.php b/tests/Unit/Enums/PlanIntervalTest.php new file mode 100644 index 0000000..728ffc8 --- /dev/null +++ b/tests/Unit/Enums/PlanIntervalTest.php @@ -0,0 +1,19 @@ +assertSame(Plan::INTERVAL_WEEK, PlanInterval::WEEK->value); + $this->assertSame(Plan::INTERVAL_MONTH, PlanInterval::MONTH->value); + $this->assertSame(Plan::INTERVAL_YEAR, PlanInterval::YEAR->value); + $this->assertSame('day', PlanInterval::DAY->value); + $this->assertSame(['day', 'week', 'month', 'year'], array_column(PlanInterval::cases(), 'value')); + } +} diff --git a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php index 6ac3a2c..3d2cfd4 100644 --- a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php +++ b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php @@ -15,6 +15,7 @@ use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; +use Potelo\MultiPayment\Enums\InvoiceStatus; class IuguGatewayAutomaticPixTest extends TestCase { @@ -236,7 +237,7 @@ public function testCancelsInvoiceAndReturnsParsedInvoice(): void $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(InvoiceStatus::CANCELED, $result->status); $this->assertSame('invoice-id', $result->id); } diff --git a/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php b/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php index 94abd59..16b44f8 100644 --- a/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php +++ b/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php @@ -20,6 +20,8 @@ use Potelo\MultiPayment\Exceptions\MultiPaymentException; use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; /** * Cobre a tradução de falhas do SDK da Iugu para as exceções do pacote: classe escolhida pelo @@ -286,8 +288,8 @@ public static function injectedRequesterFlowProvider(): array 'cancelInvoice' => [fn (IuguGateway $g) => $g->cancelInvoice($invoice())], 'refundInvoice' => [function (IuguGateway $g) use ($invoice) { $paid = $invoice(); - $paid->paymentMethod = Invoice::PAYMENT_METHOD_CREDIT_CARD; - $paid->status = Invoice::STATUS_PAID; + $paid->paymentMethod = PaymentMethod::CREDIT_CARD; + $paid->status = InvoiceStatus::PAID; $paid->paidAt = Carbon::now(); return $g->refundInvoice($paid); @@ -334,7 +336,7 @@ public static function staticSdkFlowProvider(): array 'createInvoice (Iugu_Invoice::create)' => [function (IuguGateway $g) { $invoice = new Invoice(); $invoice->customer = self::customerWithId(); - $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_PIX]; + $invoice->availablePaymentMethods = [PaymentMethod::PIX]; $item = new InvoiceItem(); $item->description = 'Item'; $item->price = 1000; @@ -578,7 +580,7 @@ public function testDuplicateInvoiceParsesTheDuplicatedInvoice(): void ); $this->assertSame('inv_2', $duplicated->id); - $this->assertSame(Invoice::STATUS_PENDING, $duplicated->status); + $this->assertSame(InvoiceStatus::PENDING, $duplicated->status); $this->assertSame(['ignore_due_email' => true, 'due_date' => '2026-10-01'], $api->calls[0]['data']); } diff --git a/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php b/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php index cd2b338..a93f7e3 100644 --- a/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php +++ b/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php @@ -9,11 +9,15 @@ use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Gateways\IuguGateway; -use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Tests\Unit\RecordingLogger; use PHPUnit\Framework\Attributes\DataProvider; class IuguGatewayInvoiceStatusTest extends TestCase { + private RecordingLogger $logger; + protected function setUp(): void { parent::setUp(); @@ -22,6 +26,7 @@ protected function setUp(): void $app->instance('config', new Repository([ 'multi-payment.gateways.iugu.api_key' => 'test-api-key', ])); + $app->instance('log', $this->logger = new RecordingLogger()); Facade::setFacadeApplication($app); } @@ -35,59 +40,144 @@ protected function tearDown(): void /** * Mapa completo dos onze status oficiais da fatura Iugu, mais `partially_refunded` e - * `authorized`, que o gateway já conhecia, para o status genérico. + * `authorized`, que o gateway já conhecia, para o status genérico. Cada status lê como o + * caso homônimo, exceto `draft` (lê como `PENDING`, junto com `pending`) e `in_analysis` + * (lê como `AUTHORIZED`, junto com `authorized`). */ public static function statusProvider(): array { return [ - 'pending' => ['pending', Invoice::STATUS_PENDING], - 'in_analysis' => ['in_analysis', Invoice::STATUS_PENDING], - 'draft' => ['draft', Invoice::STATUS_PENDING], - 'partially_paid' => ['partially_paid', Invoice::STATUS_PENDING], - 'paid' => ['paid', Invoice::STATUS_PAID], - 'externally_paid' => ['externally_paid', Invoice::STATUS_PAID], - 'authorized' => ['authorized', Invoice::STATUS_PAID], - 'in_protest' => ['in_protest', Invoice::STATUS_DISPUTED], - 'canceled' => ['canceled', Invoice::STATUS_CANCELED], - 'expired' => ['expired', Invoice::STATUS_CANCELED], - 'refunded' => ['refunded', Invoice::STATUS_REFUNDED], - 'partially_refunded' => ['partially_refunded', Invoice::STATUS_PARTIALLY_REFUNDED], - 'chargeback' => ['chargeback', Invoice::STATUS_CHARGEBACK], + 'pending' => ['pending', InvoiceStatus::PENDING], + 'draft' => ['draft', InvoiceStatus::PENDING], + 'in_analysis' => ['in_analysis', InvoiceStatus::AUTHORIZED], + 'authorized' => ['authorized', InvoiceStatus::AUTHORIZED], + 'paid' => ['paid', InvoiceStatus::PAID], + 'partially_paid' => ['partially_paid', InvoiceStatus::PARTIALLY_PAID], + 'externally_paid' => ['externally_paid', InvoiceStatus::EXTERNALLY_PAID], + 'partially_refunded' => ['partially_refunded', InvoiceStatus::PARTIALLY_REFUNDED], + 'refunded' => ['refunded', InvoiceStatus::REFUNDED], + 'in_protest' => ['in_protest', InvoiceStatus::DISPUTED], + 'chargeback' => ['chargeback', InvoiceStatus::CHARGEBACK], + 'canceled' => ['canceled', InvoiceStatus::CANCELED], + 'expired' => ['expired', InvoiceStatus::EXPIRED], ]; } #[DataProvider('statusProvider')] - public function testMapsEveryIuguInvoiceStatusToTheGenericOne(string $iuguStatus, string $expected): void + public function testMapsEveryIuguInvoiceStatusToTheGenericOne(string $iuguStatus, InvoiceStatus $expected): void { $this->assertSame($expected, $this->mapStatus($iuguStatus)); + $this->assertSame([], $this->logger->records); + } + + /** + * Caminho público: cada status da Iugu passa por `getInvoice()` e chega no model como o + * caso do enum, com o valor cru preservado em `original`. + */ + #[DataProvider('statusProvider')] + public function testGetInvoiceParsesEveryIuguStatusFromTheGatewayResponse(string $iuguStatus, InvoiceStatus $expected): void + { + $api = new QueuedIuguApiRequest([$this->invoiceResponse(['status' => $iuguStatus])]); + + $invoice = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertSame($expected, $invoice->status); + $this->assertSame($iuguStatus, $invoice->original->status); + $this->assertSame([], $this->logger->records); + } + + public function testNoIuguStatusIsFlattenedAnymore(): void + { + $this->assertSame(InvoiceStatus::PARTIALLY_PAID, $this->mapStatus('partially_paid')); + $this->assertSame(InvoiceStatus::EXTERNALLY_PAID, $this->mapStatus('externally_paid')); + $this->assertSame(InvoiceStatus::AUTHORIZED, $this->mapStatus('in_analysis')); + $this->assertSame(InvoiceStatus::EXPIRED, $this->mapStatus('expired')); + $this->assertNotSame(InvoiceStatus::PENDING, $this->mapStatus('partially_paid')); + $this->assertNotSame(InvoiceStatus::CANCELED, $this->mapStatus('expired')); } public function testInProtestNoLongerReadsAsPaid(): void { $status = $this->mapStatus('in_protest'); - $this->assertNotSame(Invoice::STATUS_PAID, $status); - $this->assertSame(Invoice::STATUS_DISPUTED, $status); - $this->assertFalse(Invoice::isSettled($status)); - $this->assertTrue(Invoice::isContested($status)); + $this->assertNotSame(InvoiceStatus::PAID, $status); + $this->assertSame(InvoiceStatus::DISPUTED, $status); + $this->assertFalse($status->isSettled()); + $this->assertTrue($status->isContested()); } public function testChargebackNoLongerReadsAsRefunded(): void { $status = $this->mapStatus('chargeback'); - $this->assertNotSame(Invoice::STATUS_REFUNDED, $status); - $this->assertSame(Invoice::STATUS_CHARGEBACK, $status); - $this->assertFalse(Invoice::isSettled($status)); - $this->assertTrue(Invoice::isContested($status)); + $this->assertNotSame(InvoiceStatus::REFUNDED, $status); + $this->assertSame(InvoiceStatus::CHARGEBACK, $status); + $this->assertFalse($status->isSettled()); + $this->assertTrue($status->isContested()); } - public function testUnknownStatusStillThrows(): void + public function testUnknownStatusBecomesUnknownWithAWarningInsteadOfThrowing(): void { - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('Unexpected Iugu status: status_novo'); + $api = new QueuedIuguApiRequest([$this->invoiceResponse(['status' => 'status_novo'])]); - $this->mapStatus('status_novo'); + $invoice = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertSame(InvoiceStatus::UNKNOWN, $invoice->status); + $this->assertSame('status_novo', $invoice->original->status); + $this->assertCount(1, $this->logger->records); + $this->assertSame('warning', $this->logger->records[0]['level']); + $this->assertSame(['status' => 'status_novo', 'gateway' => 'iugu'], $this->logger->records[0]['context']); + $this->assertFalse($invoice->status->isSettled()); + $this->assertFalse($invoice->status->isOpen()); + $this->assertFalse($invoice->status->isTerminal()); + } + + /** + * Cada `payment_method` da Iugu chega como o caso do enum; valor desconhecido fica nulo. + */ + public static function paymentMethodProvider(): array + { + return [ + 'cartão' => ['iugu_credit_card', PaymentMethod::CREDIT_CARD], + 'boleto' => ['iugu_bank_slip', PaymentMethod::BANK_SLIP], + 'pix' => ['iugu_pix', PaymentMethod::PIX], + 'desconhecido' => ['iugu_novidade', null], + 'ausente' => [null, null], + ]; + } + + #[DataProvider('paymentMethodProvider')] + public function testGetInvoiceParsesTheIuguPaymentMethod(?string $iuguPaymentMethod, ?PaymentMethod $expected): void + { + $api = new QueuedIuguApiRequest([$this->invoiceResponse(['payment_method' => $iuguPaymentMethod])]); + + $invoice = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertSame($expected, $invoice->paymentMethod); + } + + /** + * `payable_with` explícito preenche `availablePaymentMethods` com os métodos pedidos; + * `all` expande para os três selecionáveis. + */ + public static function payableWithProvider(): array + { + return [ + 'lista explícita' => [['bank_slip', 'pix'], [PaymentMethod::BANK_SLIP, PaymentMethod::PIX]], + 'string única' => ['credit_card', [PaymentMethod::CREDIT_CARD]], + 'all' => ['all', [PaymentMethod::CREDIT_CARD, PaymentMethod::BANK_SLIP, PaymentMethod::PIX]], + 'valor desconhecido é ignorado' => [['pix', 'cripto'], [PaymentMethod::PIX]], + ]; + } + + #[DataProvider('payableWithProvider')] + public function testGetInvoiceParsesPayableWithIntoAvailablePaymentMethods(array|string $payableWith, array $expected): void + { + $api = new QueuedIuguApiRequest([$this->invoiceResponse(['payable_with' => $payableWith])]); + + $invoice = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertSame($expected, $invoice->availablePaymentMethods); } /** @@ -98,7 +188,7 @@ public function testDisputedInvoiceFromTheGatewayResponseIsParsedAsDisputed(): v { $subscription = $this->readSubscriptionWithLatestInvoiceStatus('in_protest'); - $this->assertSame(Invoice::STATUS_DISPUTED, $subscription->latestInvoice->status); + $this->assertSame(InvoiceStatus::DISPUTED, $subscription->latestInvoice->status); $this->assertSame('in_protest', $subscription->latestInvoice->original->status); } @@ -106,7 +196,7 @@ public function testChargebackInvoiceFromTheGatewayResponseIsParsedAsChargeback( { $subscription = $this->readSubscriptionWithLatestInvoiceStatus('chargeback'); - $this->assertSame(Invoice::STATUS_CHARGEBACK, $subscription->latestInvoice->status); + $this->assertSame(InvoiceStatus::CHARGEBACK, $subscription->latestInvoice->status); $this->assertSame('chargeback', $subscription->latestInvoice->original->status); } @@ -130,13 +220,56 @@ public function testContestedInvoicesDoNotMakeTheSubscriptionPastDue(string $iug $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); } - private function mapStatus(string $iuguStatus): string + private function mapStatus(string $iuguStatus): InvoiceStatus { $method = new \ReflectionMethod(IuguGateway::class, 'iuguStatusToMultiPayment'); return $method->invoke(null, $iuguStatus); } + private function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + + return $invoice; + } + + /** + * Fatura no formato de `GET /v1/invoices/{id}`, com os campos que `parseInvoice()` lê. + */ + private function invoiceResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'inv_1', + 'status' => 'paid', + 'total_cents' => 10000, + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'taxes_paid_cents' => 150, + 'created_at_iso' => '2026-08-20T09:00:00-03:00', + 'paid_cents' => 10000, + 'refunded_cents' => 0, + 'due_date' => '2026-08-25', + 'payment_method' => 'iugu_credit_card', + 'payable_with' => null, + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [ + (object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1], + ], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + ], $overrides); + } + private function readSubscriptionWithLatestInvoiceStatus(string $iuguStatus, string $expiresAt = '2026-10-01'): Subscription { $api = new QueuedIuguApiRequest([ diff --git a/tests/Unit/Gateways/IuguGatewayInvoiceTest.php b/tests/Unit/Gateways/IuguGatewayInvoiceTest.php index 0452977..bc33787 100644 --- a/tests/Unit/Gateways/IuguGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/IuguGatewayInvoiceTest.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Facade; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Gateways\IuguGateway; +use Potelo\MultiPayment\Enums\PaymentMethod; class IuguGatewayInvoiceTest extends TestCase { @@ -60,6 +61,57 @@ public function testCreateInvoiceMergesGatewayOptionsIntoIuguPayload(): void $this->assertSame(['expires_in' => 5, 'payable_with' => ['bank_slip', 'pix']], $invoice->gatewayOptions); } + /** + * `availablePaymentMethods` vai para a Iugu como `payable_with` de strings; uma string + * apensada por `[]=` (que entra no array sem conversão) é normalizada antes do envio. + */ + public function testCreateInvoiceSendsAvailablePaymentMethodsAsIuguStrings(): void + { + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'expires_at' => '2026-10-01', + ]); + $invoice->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $invoice->availablePaymentMethods[] = 'pix'; + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertStringEndsWith('/invoices', $api->calls[0]['url']); + $this->assertSame(['bank_slip', 'pix'], $api->calls[0]['data']['payable_with']); + } + + /** + * Cartão em `availablePaymentMethods` com um cartão salvo vai por `POST /charge`, com o + * id do cartão em `customer_payment_method_id`; a fatura cobrada é lida em seguida. + */ + public function testCreateInvoiceWithCreditCardChargesTheSavedCard(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => true, 'invoice_id' => 'inv_1'], + $this->pendingInvoiceResponse(), + ]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'available_payment_methods' => ['credit_card'], + 'credit_card' => ['id' => 'pm_1'], + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertCount(2, $api->calls); + $this->assertStringEndsWith('/charge', $api->calls[0]['url']); + $this->assertSame('pm_1', $api->calls[0]['data']['customer_payment_method_id']); + $this->assertSame(['credit_card'], $api->calls[0]['data']['payable_with']); + $this->assertStringEndsWith('/invoices/inv_1', $api->calls[1]['url']); + } + public function testCreateInvoiceWithoutGatewayOptionsKeepsTheDefaultExpiresIn(): void { $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); diff --git a/tests/Unit/Gateways/IuguGatewayRefundTest.php b/tests/Unit/Gateways/IuguGatewayRefundTest.php index 81c4909..2f7ae8c 100644 --- a/tests/Unit/Gateways/IuguGatewayRefundTest.php +++ b/tests/Unit/Gateways/IuguGatewayRefundTest.php @@ -13,6 +13,8 @@ use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; class IuguGatewayRefundTest extends TestCase { @@ -55,7 +57,7 @@ public function testBoletoRefundThrowsBeforeTheNetworkAfterReadingTheInvoice(): $exception = $this->refundExpectingRefusal($api, $this->invoiceWithId()); $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $exception->reason); - $this->assertSame(Invoice::PAYMENT_METHOD_BANK_SLIP, $exception->paymentMethod); + $this->assertSame(PaymentMethod::BANK_SLIP->value, $exception->paymentMethod); $this->assertTrue($exception->manualRefundRequired); $this->assertOnlyTheInvoiceWasRead($api); } @@ -64,8 +66,8 @@ public function testBoletoRefundWithThePaymentMethodInHandMakesNoRequest(): void { $api = new QueuedIuguApiRequest([]); $invoice = $this->invoiceWithId(); - $invoice->paymentMethod = Invoice::PAYMENT_METHOD_BANK_SLIP; - $invoice->status = Invoice::STATUS_PAID; + $invoice->paymentMethod = PaymentMethod::BANK_SLIP; + $invoice->status = InvoiceStatus::PAID; $invoice->paidAt = Carbon::parse('2026-08-20'); $exception = $this->refundExpectingRefusal($api, $invoice); @@ -83,7 +85,7 @@ public function testPartialPixRefundThrowsBeforeTheNetwork(): void $exception = $this->refundExpectingRefusal($api, $invoice); $this->assertSame(RefundNotSupportedException::REASON_PIX_PARTIAL_NOT_SUPPORTED, $exception->reason); - $this->assertSame(Invoice::PAYMENT_METHOD_PIX, $exception->paymentMethod); + $this->assertSame(PaymentMethod::PIX->value, $exception->paymentMethod); $this->assertFalse($exception->manualRefundRequired); $this->assertStringContainsString('5000', $exception->getMessage()); $this->assertStringContainsString('10000', $exception->getMessage()); @@ -103,7 +105,7 @@ public function testFullPixRefundWithoutAmountGoesToTheGateway(): void $this->assertSame('POST', $api->calls[1]['method']); $this->assertStringEndsWith('/invoices/inv_1/refund', $api->calls[1]['url']); $this->assertSame([], $api->calls[1]['data']); - $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->status); $this->assertSame(10000, $result->refundedAmount); $this->assertNull($result->lastRefundId); } @@ -124,7 +126,7 @@ public function testPixRefundOfTheFullPaidAmountIsSentAsIntegral(): void $result = (new IuguGateway($api))->refundInvoice($invoice); $this->assertSame([], $api->calls[1]['data']); - $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->status); } public function testPartialCardRefundSendsThePartialValue(): void @@ -140,7 +142,7 @@ public function testPartialCardRefundSendsThePartialValue(): void $this->assertSame('POST', $api->calls[1]['method']); $this->assertSame(['partial_value_refund_cents' => 2500], $api->calls[1]['data']); - $this->assertSame(Invoice::STATUS_PARTIALLY_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->status); $this->assertSame(2500, $result->refundedAmount); $this->assertSame(7500, $result->paidAmount); } @@ -162,7 +164,7 @@ public function testPartiallyRefundedInvoiceAcceptsAnotherRefund(): void $this->assertCount(2, $api->calls); $this->assertSame([], $api->calls[1]['data']); - $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->status); } /** @@ -213,8 +215,8 @@ public function testPixRefundByAmountWithoutPaidAmountReadsTheInvoiceFirst(): vo $this->paidPixInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), ]); $invoice = $this->invoiceWithId(); - $invoice->paymentMethod = Invoice::PAYMENT_METHOD_PIX; - $invoice->status = Invoice::STATUS_PAID; + $invoice->paymentMethod = PaymentMethod::PIX; + $invoice->status = InvoiceStatus::PAID; $invoice->paidAt = Carbon::parse('2026-08-20'); $invoice->refundedAmount = 10000; @@ -223,7 +225,7 @@ public function testPixRefundByAmountWithoutPaidAmountReadsTheInvoiceFirst(): vo $this->assertCount(2, $api->calls); $this->assertSame('GET', $api->calls[0]['method']); $this->assertSame([], $api->calls[1]['data']); - $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->status); } /** @@ -262,7 +264,7 @@ public function testAlreadyRefundedInvoiceThrowsBeforeTheNetwork(): void $exception = $this->refundExpectingRefusal($api, $this->invoiceWithId()); $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $exception->reason); - $this->assertSame(Invoice::PAYMENT_METHOD_CREDIT_CARD, $exception->paymentMethod); + $this->assertSame(PaymentMethod::CREDIT_CARD->value, $exception->paymentMethod); $this->assertFalse($exception->manualRefundRequired); $this->assertOnlyTheInvoiceWasRead($api); } @@ -276,7 +278,7 @@ public function testRefundAfterTheNinetyDayWindowThrowsBeforeTheNetwork(): void $exception = $this->refundExpectingRefusal($api, $this->invoiceWithId()); $this->assertSame(RefundNotSupportedException::REASON_REFUND_WINDOW_EXPIRED, $exception->reason); - $this->assertSame(Invoice::PAYMENT_METHOD_CREDIT_CARD, $exception->paymentMethod); + $this->assertSame(PaymentMethod::CREDIT_CARD->value, $exception->paymentMethod); $this->assertTrue($exception->manualRefundRequired); $this->assertStringContainsString('2026-06-03', $exception->getMessage()); $this->assertOnlyTheInvoiceWasRead($api); @@ -292,7 +294,7 @@ public function testRefundInsideTheNinetyDayWindowGoesToTheGateway(): void $result = (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); $this->assertCount(2, $api->calls); - $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->status); } /** @@ -309,7 +311,7 @@ public function testRefundOnTheLastDayOfTheWindowGoesToTheGateway(): void $result = (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); $this->assertCount(2, $api->calls); - $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->status); } public function testRefundOnTheDayAfterTheWindowThrowsBeforeTheNetwork(): void @@ -342,7 +344,7 @@ public function testInvoiceReadFromTheGatewayDoesNotPayTheExtraGet(): void $this->assertCount(2, $api->calls); $this->assertSame('GET', $api->calls[0]['method']); $this->assertSame('POST', $api->calls[1]['method']); - $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->status); } public function testGatewayErrorOnRefundBecomesGatewayException(): void @@ -368,8 +370,8 @@ public function testGetInvoiceReadsTheInvoiceById(): void $this->assertSame('GET', $api->calls[0]['method']); $this->assertStringEndsWith('/invoices/inv_1', $api->calls[0]['url']); $this->assertSame('inv_1', $result->id); - $this->assertSame(Invoice::STATUS_PAID, $result->status); - $this->assertSame(Invoice::PAYMENT_METHOD_CREDIT_CARD, $result->paymentMethod); + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertSame(PaymentMethod::CREDIT_CARD, $result->paymentMethod); $this->assertSame(10000, $result->paidAmount); $this->assertSame('2026-08-20', $result->paidAt->toDateString()); $this->assertNull($result->lastRefundId); diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index 1b66f1c..9b31c0c 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -17,6 +17,10 @@ use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use PHPUnit\Framework\Attributes\DataProvider; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Enums\PlanInterval; +use Potelo\MultiPayment\Tests\Unit\RecordingLogger; class IuguGatewaySubscriptionTest extends TestCase { @@ -64,7 +68,7 @@ public function testCreateSubscriptionMapsGenericFieldsToIuguPayload(): void 'plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1'], 'next_billing_at' => '2026-10-01', - 'available_payment_methods' => [Invoice::PAYMENT_METHOD_PIX], + 'available_payment_methods' => [PaymentMethod::PIX], 'metadata' => ['origem' => 'teste'], 'items' => [['description' => 'Consultas', 'amount' => 2500, 'quantity' => 2]], 'discounts' => [['description' => 'Promo', 'amount_off' => 500]], @@ -80,7 +84,7 @@ public function testCreateSubscriptionMapsGenericFieldsToIuguPayload(): void 'customer_id' => 'cus_1', 'plan_identifier' => 'plano_mensal', 'expires_at' => '2026-10-01', - 'payable_with' => [Invoice::PAYMENT_METHOD_PIX], + 'payable_with' => ['pix'], 'custom_variables' => [['name' => 'origem', 'value' => 'teste']], 'subitems' => [ ['description' => 'Consultas', 'price_cents' => 2500, 'quantity' => 2, 'recurrent' => 1], @@ -543,7 +547,7 @@ public function testCreatePlanMapsIntervalToIuguIntervalType(): void $plan->name = 'Mensal'; $plan->identifier = 'mensal'; $plan->amount = 10000; - $plan->interval = Plan::INTERVAL_MONTH; + $plan->interval = PlanInterval::MONTH; $plan->intervalCount = 1; $created = (new IuguGateway($api))->createPlan($plan); @@ -551,7 +555,7 @@ public function testCreatePlanMapsIntervalToIuguIntervalType(): void $this->assertSame('months', $api->calls[0]['data']['interval_type']); $this->assertSame(1, $api->calls[0]['data']['interval']); $this->assertSame(10000, $api->calls[0]['data']['value_cents']); - $this->assertSame(Plan::INTERVAL_MONTH, $created->interval); + $this->assertSame(PlanInterval::MONTH, $created->interval); $this->assertSame(10000, $created->amount); $this->assertSame('BRL', $created->currency); } @@ -561,7 +565,7 @@ public function testCreatePlanMapsIntervalToIuguIntervalType(): void */ #[DataProvider('intervalRoundTripProvider')] public function testCreatePlanTranslatesTheIntervalBothWays( - string $interval, + PlanInterval $interval, int $intervalCount, int $iuguInterval, string $iuguIntervalType @@ -600,11 +604,11 @@ public function testCreatePlanTranslatesTheIntervalBothWays( public static function intervalRoundTripProvider(): array { return [ - 'anual' => [Plan::INTERVAL_YEAR, 1, 12, 'months'], - 'bianual' => [Plan::INTERVAL_YEAR, 2, 24, 'months'], - 'mensal' => [Plan::INTERVAL_MONTH, 1, 1, 'months'], - 'semestral' => [Plan::INTERVAL_MONTH, 6, 6, 'months'], - 'quinzenal' => [Plan::INTERVAL_WEEK, 2, 2, 'weeks'], + 'anual' => [PlanInterval::YEAR, 1, 12, 'months'], + 'bianual' => [PlanInterval::YEAR, 2, 24, 'months'], + 'mensal' => [PlanInterval::MONTH, 1, 1, 'months'], + 'semestral' => [PlanInterval::MONTH, 6, 6, 'months'], + 'quinzenal' => [PlanInterval::WEEK, 2, 2, 'weeks'], ]; } @@ -618,13 +622,13 @@ public function testYearlyPlanWithoutIntervalCountIsSentAsTwelveMonths(): void $plan->name = 'Anual'; $plan->identifier = 'anual'; $plan->amount = 100000; - $plan->interval = Plan::INTERVAL_YEAR; + $plan->interval = PlanInterval::YEAR; $created = (new IuguGateway($api))->createPlan($plan); $this->assertSame(12, $api->calls[0]['data']['interval']); $this->assertSame('months', $api->calls[0]['data']['interval_type']); - $this->assertSame(Plan::INTERVAL_YEAR, $created->interval); + $this->assertSame(PlanInterval::YEAR, $created->interval); $this->assertSame(1, $created->intervalCount); } @@ -642,19 +646,19 @@ public function testTwelveMonthPlanIsReadBackAsYearly(): void $plan->name = 'Doze'; $plan->identifier = 'doze'; $plan->amount = 100000; - $plan->interval = Plan::INTERVAL_MONTH; + $plan->interval = PlanInterval::MONTH; $plan->intervalCount = 12; $created = (new IuguGateway($api))->createPlan($plan); $this->assertSame(12, $api->calls[0]['data']['interval']); - $this->assertSame(Plan::INTERVAL_YEAR, $created->interval); + $this->assertSame(PlanInterval::YEAR, $created->interval); $this->assertSame(1, $created->intervalCount); $this->assertSame(12, $created->original->interval); } #[DataProvider('invalidIntervalProvider')] - public function testInvalidIntervalIsRejectedBeforeTheRequest(string $interval, int $intervalCount, string $message): void + public function testInvalidIntervalIsRejectedBeforeTheRequest(PlanInterval|string|null $interval, int $intervalCount, string $message): void { $api = new QueuedIuguApiRequest([]); @@ -677,9 +681,11 @@ public function testInvalidIntervalIsRejectedBeforeTheRequest(string $interval, public static function invalidIntervalProvider(): array { return [ - 'intervalo desconhecido' => ['day', 1, '/does not support the `day` plan interval/'], - 'anual acima do teto da Iugu' => [Plan::INTERVAL_YEAR, 50, '/from 1 to 599 months, 600 given/'], - 'mensal acima do teto da Iugu' => [Plan::INTERVAL_MONTH, 600, '/from 1 to 599 months, 600 given/'], + 'diário como string' => ['day', 1, '/does not support the `day` plan interval/'], + 'diário como enum' => [PlanInterval::DAY, 1, '/does not support the `day` plan interval/'], + 'intervalo ausente' => [null, 1, '/does not support the `null` plan interval/'], + 'anual acima do teto da Iugu' => [PlanInterval::YEAR, 50, '/from 1 to 599 months, 600 given/'], + 'mensal acima do teto da Iugu' => [PlanInterval::MONTH, 600, '/from 1 to 599 months, 600 given/'], ]; } @@ -691,12 +697,12 @@ public function testGetPlanKeepsTheLocalIntervalWhenTheResponseOmitsIt(): void $plan = new Plan(); $plan->id = 'plan_1'; - $plan->interval = Plan::INTERVAL_YEAR; + $plan->interval = PlanInterval::YEAR; $plan->intervalCount = 1; $found = (new IuguGateway($api))->getPlan($plan); - $this->assertSame(Plan::INTERVAL_YEAR, $found->interval); + $this->assertSame(PlanInterval::YEAR, $found->interval); $this->assertSame(1, $found->intervalCount); } @@ -707,7 +713,7 @@ public function testGetPlanKeepsTheLocalIntervalWhenTheResponseOmitsIt(): void public function testGetPlanParsesTheIuguInterval( int|string $iuguInterval, string $iuguIntervalType, - string $interval, + PlanInterval $interval, int $intervalCount ): void { $api = new QueuedIuguApiRequest([ @@ -726,12 +732,12 @@ public function testGetPlanParsesTheIuguInterval( public static function iuguIntervalParseProvider(): array { return [ - '12 meses vira 1 ano' => [12, 'months', Plan::INTERVAL_YEAR, 1], - '24 meses vira 2 anos' => [24, 'months', Plan::INTERVAL_YEAR, 2], - '6 meses continua mensal' => [6, 'months', Plan::INTERVAL_MONTH, 6], - '1 mês continua mensal' => [1, 'months', Plan::INTERVAL_MONTH, 1], - '12 semanas continua semanal' => [12, 'weeks', Plan::INTERVAL_WEEK, 12], - '12 como string vira 1 ano' => ['12', 'months', Plan::INTERVAL_YEAR, 1], + '12 meses vira 1 ano' => [12, 'months', PlanInterval::YEAR, 1], + '24 meses vira 2 anos' => [24, 'months', PlanInterval::YEAR, 2], + '6 meses continua mensal' => [6, 'months', PlanInterval::MONTH, 6], + '1 mês continua mensal' => [1, 'months', PlanInterval::MONTH, 1], + '12 semanas continua semanal' => [12, 'weeks', PlanInterval::WEEK, 12], + '12 como string vira 1 ano' => ['12', 'months', PlanInterval::YEAR, 1], ]; } @@ -801,7 +807,7 @@ public function testExpiredInvoiceAlsoCountsAsPastDue(): void $subscription = (new IuguGateway($api))->getSubscription($subscription); $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); - $this->assertSame(Invoice::STATUS_CANCELED, $subscription->latestInvoice->status); + $this->assertSame(InvoiceStatus::EXPIRED, $subscription->latestInvoice->status); } public function testPaidInvoiceWithOverdueDateIsNotPastDue(): void @@ -860,10 +866,12 @@ public function testSuspendedTakesPrecedenceOverPastDue(): void } /** - * `recent_invoices` é um resumo. + * `recent_invoices` é um resumo: status fora do mapa vira `UNKNOWN` com aviso no log, e + * a leitura da assinatura segue. */ public function testUnknownInvoiceStatusDoesNotBreakTheSubscriptionRead(): void { + Facade::getFacadeApplication()->instance('log', $logger = new RecordingLogger()); $api = new QueuedIuguApiRequest([ $this->subscriptionResponse([ 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'status_novo_da_iugu']], @@ -876,8 +884,10 @@ public function testUnknownInvoiceStatusDoesNotBreakTheSubscriptionRead(): void $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); $this->assertSame('inv_1', $subscription->latestInvoice->id); - $this->assertNull($subscription->latestInvoice->status); + $this->assertSame(InvoiceStatus::UNKNOWN, $subscription->latestInvoice->status); $this->assertSame('status_novo_da_iugu', $subscription->latestInvoice->original->status); + $this->assertCount(1, $logger->records); + $this->assertSame(['status' => 'status_novo_da_iugu', 'gateway' => 'iugu'], $logger->records[0]['context']); } public function testSubscriptionWithoutRecentInvoicesHasNoLatestInvoice(): void @@ -1071,18 +1081,18 @@ public static function payableWithProvider(): array return [ 'lista' => [ ['credit_card', 'pix'], - [Invoice::PAYMENT_METHOD_CREDIT_CARD, Invoice::PAYMENT_METHOD_PIX], + [PaymentMethod::CREDIT_CARD, PaymentMethod::PIX], ], 'metodo desconhecido e ignorado' => [ ['pix', 'crypto'], - [Invoice::PAYMENT_METHOD_PIX], + [PaymentMethod::PIX], ], 'all expande nos tres' => [ 'all', [ - Invoice::PAYMENT_METHOD_CREDIT_CARD, - Invoice::PAYMENT_METHOD_BANK_SLIP, - Invoice::PAYMENT_METHOD_PIX, + PaymentMethod::CREDIT_CARD, + PaymentMethod::BANK_SLIP, + PaymentMethod::PIX, ], ], ]; @@ -1117,7 +1127,7 @@ public function testGetPlanUsesTheIdentifierEndpointWhenThereIsNoId(): void $this->assertStringEndsWith('/plans/identifier/mensal', $api->calls[0]['url']); $this->assertSame(10000, $found->amount); - $this->assertSame(Plan::INTERVAL_MONTH, $found->interval); + $this->assertSame(PlanInterval::MONTH, $found->interval); } public function testGetPlanRequiresIdOrIdentifier(): void @@ -1138,7 +1148,7 @@ public function testListPlansPaginates(): void $this->assertStringContainsString('limit=10', $api->calls[0]['url']); $this->assertStringContainsString('start=20', $api->calls[0]['url']); $this->assertCount(1, $plans); - $this->assertSame(Plan::INTERVAL_MONTH, $plans[0]->interval); + $this->assertSame(PlanInterval::MONTH, $plans[0]->interval); $this->assertNull($plans[0]->intervalCount); } @@ -1342,10 +1352,10 @@ public function testUpdateSendsPaymentMethodsWhenTheyActuallyChanged(): void $subscription->id = 'sub_1'; $subscription = $gateway->getSubscription($subscription); - $subscription->availablePaymentMethods = [Invoice::PAYMENT_METHOD_PIX]; + $subscription->availablePaymentMethods = [PaymentMethod::PIX]; $gateway->updateSubscription($subscription); - $this->assertSame([Invoice::PAYMENT_METHOD_PIX], $api->calls[1]['data']['payable_with']); + $this->assertSame(['pix'], $api->calls[1]['data']['payable_with']); } public function testDivergingNextBillingAndTrialEndAreStillRejected(): void @@ -1770,7 +1780,7 @@ public function testPastDueCanPointAtAnInvoiceThatIsAlreadyPaid(): void $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); $this->assertSame('inv_paga', $subscription->latestInvoice->id); - $this->assertSame(Invoice::STATUS_PAID, $subscription->latestInvoice->status); + $this->assertSame(InvoiceStatus::PAID, $subscription->latestInvoice->status); } /** @@ -1814,7 +1824,7 @@ public function testPartiallyPaidInvoiceCountsAsOpen(): void $subscription->id = 'sub_1'; $subscription = (new IuguGateway($api))->getSubscription($subscription); - $this->assertSame(Invoice::STATUS_PENDING, $subscription->latestInvoice->status); + $this->assertSame(InvoiceStatus::PARTIALLY_PAID, $subscription->latestInvoice->status); $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); } diff --git a/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php b/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php index d2645ea..f5d3ba5 100644 --- a/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php +++ b/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php @@ -25,6 +25,7 @@ use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Stripe\Exception\AuthenticationException as StripeAuthenticationException; +use Potelo\MultiPayment\Enums\PaymentMethod; /** * Cobre a tradução de falhas do stripe-php para as exceções do pacote: classe escolhida pelo @@ -266,7 +267,7 @@ private function creditCardInvoiceModel(): Invoice $invoice = new Invoice(); $invoice->customer = new Customer(); $invoice->customer->id = 'cus_fake123'; - $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_CREDIT_CARD]; + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD]; $invoice->creditCard = new CreditCard(); $invoice->creditCard->id = 'pm_fake123'; $item = new InvoiceItem(); diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index f720bc4..92a2caf 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -19,6 +19,9 @@ use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use PHPUnit\Framework\Attributes\DataProvider; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Tests\Unit\RecordingLogger; class StripeGatewayInvoiceTest extends TestCase { @@ -73,13 +76,13 @@ public function testCreatesCreditCardInvoiceChargingSavedCard(): void ], $params); $this->assertSame('pi_fake123', $result->id); - $this->assertSame(Invoice::STATUS_PAID, $result->status); + $this->assertSame(InvoiceStatus::PAID, $result->status); $this->assertSame(12345, $result->amount); $this->assertSame(12345, $result->paidAmount); $this->assertSame(0, $result->refundedAmount); $this->assertSame(425, $result->fee); $this->assertInstanceOf(Carbon::class, $result->paidAt); - $this->assertSame(Invoice::PAYMENT_METHOD_CREDIT_CARD, $result->paymentMethod); + $this->assertSame(PaymentMethod::CREDIT_CARD, $result->paymentMethod); $this->assertSame('visa', $result->creditCard->brand); $this->assertSame('4242', $result->creditCard->lastDigits); $this->assertCount(1, $result->items); @@ -112,7 +115,7 @@ public function testCreatesCreditCardInvoiceSavingTokenizedCardFirst(): void public function testRejectsInvoiceWithMultiplePaymentMethods(): void { $invoice = $this->creditCardInvoiceModel(); - $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_CREDIT_CARD, Invoice::PAYMENT_METHOD_PIX]; + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD, PaymentMethod::PIX]; $this->expectException(ModelAttributeValidationException::class); $this->expectExceptionMessage('exactly one payment method'); @@ -124,7 +127,7 @@ public function testRejectsBankSlipInvoiceAttributingTheLimitationToTheLibrary() { $httpClient = RecordingStripeHttpClient::withResponses([]); $invoice = $this->creditCardInvoiceModel(); - $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_BANK_SLIP]; + $invoice->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; try { (new StripeGateway())->createInvoice($invoice); @@ -138,25 +141,52 @@ public function testRejectsBankSlipInvoiceAttributingTheLimitationToTheLibrary() $this->assertSame([], $httpClient->calls); } - public function testRejectsUnknownPaymentMethodWithoutHittingTheApi(): void + public function testRejectsUnknownPaymentMethodStringOnWriteWithoutHittingTheApi(): void { $httpClient = RecordingStripeHttpClient::withResponses([]); $invoice = $this->creditCardInvoiceModel(); - $invoice->availablePaymentMethods = ['foo']; + + try { + $invoice->availablePaymentMethods = ['foo']; + $this->fail('Método de pagamento desconhecido deveria lançar ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('availablePaymentMethods must be one of', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + public function testRejectsNonSelectablePaymentMethodWithoutHittingTheApi(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->creditCardInvoiceModel(); + $invoice->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX]; try { (new StripeGateway())->createInvoice($invoice); - $this->fail('Método de pagamento desconhecido deveria lançar GatewayException'); - } catch (GatewayException $e) { - $this->assertSame( - 'A operação [createInvoice com o método de pagamento [foo]] no Stripe ainda não está implementada nesta lib;' - . ' a Stripe suporta o recurso.', - $e->getMessage() - ); + $this->fail('Método de pagamento não selecionável deveria lançar ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('availablePaymentMethods must be one of: credit_card, bank_slip, pix', $e->getMessage()); } $this->assertSame([], $httpClient->calls); } + /** + * `availablePaymentMethods[] = 'pix'` entra no array sem conversão; o driver normaliza + * antes de escolher o método. + */ + public function testStringAppendedToAvailablePaymentMethodsIsNormalizedBeforeUse(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + $invoice = $this->pixInvoiceModel(); + $invoice->availablePaymentMethods = []; + $invoice->availablePaymentMethods[] = 'pix'; + + $result = (new StripeGateway())->createInvoice($invoice); + + $this->assertSame(['pix'], $httpClient->calls[0][2]['payment_method_types']); + $this->assertSame(PaymentMethod::PIX, $result->paymentMethod); + } + public function testCreatesPixInvoiceFullyServerSideAndParsesQrCode(): void { $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); @@ -193,8 +223,8 @@ public function testCreatesPixInvoiceFullyServerSideAndParsesQrCode(): void 'expand' => ['latest_charge.balance_transaction'], ], $params); - $this->assertSame(Invoice::STATUS_PENDING, $result->status); - $this->assertSame(Invoice::PAYMENT_METHOD_PIX, $result->paymentMethod); + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertSame(PaymentMethod::PIX, $result->paymentMethod); $this->assertSame('00020126pixcopiaecola', $result->pix->qrCodeText); $this->assertSame('https://qr.stripe.com/test.png', $result->pix->qrCodeImageUrl); $this->assertSame('https://payments.stripe.com/qr/instructions/test', $result->url); @@ -303,7 +333,7 @@ public function testCancelsPendingInvoice(): void $this->assertSame('post', $method); $this->assertSame('/v1/payment_intents/pi_fake123/cancel', parse_url($url, PHP_URL_PATH)); $this->assertSame(['expand' => ['latest_charge.balance_transaction']], $params); - $this->assertSame(Invoice::STATUS_CANCELED, $result->status); + $this->assertSame(InvoiceStatus::CANCELED, $result->status); } public function testCancelPaidInvoiceBecomesGatewayException(): void @@ -429,7 +459,7 @@ public function testGetInvoiceParsesFullRefund(): void $result = $this->getInvoice(); - $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->status); $this->assertSame(12345, $result->refundedAmount); } @@ -441,7 +471,7 @@ public function testGetInvoiceParsesPartialRefund(): void $result = $this->getInvoice(); - $this->assertSame(Invoice::STATUS_PARTIALLY_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->status); $this->assertSame(2345, $result->refundedAmount); } @@ -460,23 +490,71 @@ public function testGetInvoiceReportsExpiredPixAsPendingIgnoringFailedCharge(): $result = $this->getInvoice(); - $this->assertSame(Invoice::STATUS_PENDING, $result->status); + $this->assertSame(InvoiceStatus::PENDING, $result->status); $this->assertNull($result->paidAmount); $this->assertNull($result->paidAt); - $this->assertSame(Invoice::PAYMENT_METHOD_PIX, $result->paymentMethod); + $this->assertSame(PaymentMethod::PIX, $result->paymentMethod); } - public function testGetInvoiceRejectsUnexpectedStatus(): void + public function testGetInvoiceReadsAnUnexpectedStatusAsUnknownWithAWarning(): void { + Facade::getFacadeApplication()->instance('log', $logger = new RecordingLogger()); $response = $this->paidCardPaymentIntentResponse(); $response['status'] = 'partially_funded'; $response['latest_charge'] = null; RecordingStripeHttpClient::withResponses([$response]); - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('Unexpected Stripe payment intent status: partially_funded'); + $result = $this->getInvoice(); + + $this->assertSame(InvoiceStatus::UNKNOWN, $result->status); + $this->assertSame('partially_funded', $result->original->status); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertSame(['status' => 'partially_funded', 'gateway' => 'stripe'], $logger->records[0]['context']); + } + + /** + * Cada tipo de PaymentMethod da Stripe em `payment_method_details.type` chega como o caso + * do enum; tipo fora do mapa fica nulo. + */ + public static function stripePaymentMethodTypeProvider(): array + { + return [ + 'card' => ['card', PaymentMethod::CREDIT_CARD], + 'pix' => ['pix', PaymentMethod::PIX], + 'boleto' => ['boleto', PaymentMethod::BANK_SLIP], + 'tipo fora do mapa' => ['card_present', null], + ]; + } + + #[DataProvider('stripePaymentMethodTypeProvider')] + public function testGetInvoiceParsesTheStripePaymentMethodType(string $type, ?PaymentMethod $expected): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['payment_method_types'] = [$type]; + $response['latest_charge']['payment_method_details'] = ['type' => $type]; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice(); + + $this->assertSame($expected, $result->paymentMethod); + $this->assertSame($expected ? [$expected] : null, $result->availablePaymentMethods); + } + + public function testRequiresCaptureReadsAsAuthorizedAndProcessingAsProcessing(): void + { + RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse(status: 'requires_capture')]); + $authorized = $this->getInvoice()->status; + + RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse(status: 'processing')]); + $processing = $this->getInvoice()->status; - $this->getInvoice(); + $this->assertSame(InvoiceStatus::AUTHORIZED, $authorized); + $this->assertTrue($authorized->isOpen()); + $this->assertFalse($authorized->isSettled()); + $this->assertSame(InvoiceStatus::PROCESSING, $processing); + $this->assertTrue($processing->isOpen()); + $this->assertFalse($processing->isSettled()); } /** @@ -502,8 +580,8 @@ public function testGetInvoiceReportsOpenDisputeAsDisputed(string $disputeStatus $result = $this->getInvoice(); - $this->assertSame(Invoice::STATUS_DISPUTED, $result->status); - $this->assertNotSame(Invoice::STATUS_PAID, $result->status); + $this->assertSame(InvoiceStatus::DISPUTED, $result->status); + $this->assertNotSame(InvoiceStatus::PAID, $result->status); // o dinheiro continua contabilizado no charge até a resolução $this->assertSame(12345, $result->paidAmount); @@ -523,10 +601,10 @@ public function testGetInvoiceReportsLostDisputeAsChargeback(): void $result = $this->getInvoice(); - $this->assertSame(Invoice::STATUS_CHARGEBACK, $result->status); - $this->assertNotSame(Invoice::STATUS_REFUNDED, $result->status); - $this->assertTrue(Invoice::isContested($result->status)); - $this->assertFalse(Invoice::isSettled($result->status)); + $this->assertSame(InvoiceStatus::CHARGEBACK, $result->status); + $this->assertNotSame(InvoiceStatus::REFUNDED, $result->status); + $this->assertTrue($result->status->isContested()); + $this->assertFalse($result->status->isSettled()); } /** @@ -549,19 +627,19 @@ public function testGetInvoiceKeepsDerivedStatusWhenDisputeWasWonOrClosed(string $this->disputeListResponse([$disputeStatus]), ]); - $this->assertSame(Invoice::STATUS_PAID, $this->getInvoice()->status); + $this->assertSame(InvoiceStatus::PAID, $this->getInvoice()->status); } public static function disputeOverRefundProvider(): array { return [ - 'aberta sobre estorno parcial' => ['needs_response', 2345, false, Invoice::STATUS_DISPUTED], - 'perdida sobre estorno total' => ['lost', 12345, true, Invoice::STATUS_CHARGEBACK], + 'aberta sobre estorno parcial' => ['needs_response', 2345, false, InvoiceStatus::DISPUTED], + 'perdida sobre estorno total' => ['lost', 12345, true, InvoiceStatus::CHARGEBACK], ]; } #[DataProvider('disputeOverRefundProvider')] - public function testGetInvoiceDisputeTakesPrecedenceOverRefund(string $disputeStatus, int $refunded, bool $fully, string $expected): void + public function testGetInvoiceDisputeTakesPrecedenceOverRefund(string $disputeStatus, int $refunded, bool $fully, InvoiceStatus $expected): void { $response = $this->disputedCardPaymentIntentResponse(); $response['latest_charge']['amount_refunded'] = $refunded; @@ -586,7 +664,7 @@ public function testGetInvoiceFallsBackToDerivedStatusWhenDisputedChargeHasNoDis $this->disputeListResponse([]), ]); - $this->assertSame(Invoice::STATUS_PAID, $this->getInvoice()->status); + $this->assertSame(InvoiceStatus::PAID, $this->getInvoice()->status); $this->assertCount(2, $httpClient->calls); } @@ -597,14 +675,14 @@ public function testGetInvoiceOpenDisputeWinsOverAnEarlierWonOne(): void $this->disputeListResponse(['won', 'needs_response']), ]); - $this->assertSame(Invoice::STATUS_DISPUTED, $this->getInvoice()->status); + $this->assertSame(InvoiceStatus::DISPUTED, $this->getInvoice()->status); } public function testGetInvoiceDoesNotListDisputesWhenChargeIsNotDisputed(): void { $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); - $this->assertSame(Invoice::STATUS_PAID, $this->getInvoice()->status); + $this->assertSame(InvoiceStatus::PAID, $this->getInvoice()->status); $this->assertCount(1, $httpClient->calls); } @@ -617,7 +695,7 @@ public function testGetInvoiceDoesNotListDisputesForFailedCharge(): void $response['latest_charge']['status'] = 'failed'; $httpClient = RecordingStripeHttpClient::withResponses([$response]); - $this->assertSame(Invoice::STATUS_PENDING, $this->getInvoice()->status); + $this->assertSame(InvoiceStatus::PENDING, $this->getInvoice()->status); $this->assertCount(1, $httpClient->calls); } @@ -652,7 +730,7 @@ public function testChargeInvoiceWithCreditCardUpdatesIntentBeforeConfirming(): ); $this->assertSame('pm_fake123', $httpClient->calls[3][2]['payment_method']); $this->assertSame('true', $httpClient->calls[3][2]['off_session']); - $this->assertSame(Invoice::STATUS_PAID, $result->status); + $this->assertSame(InvoiceStatus::PAID, $result->status); } public function testChargeInvoiceKeepsMatchingCustomerAndOmitsItFromUpdate(): void @@ -749,18 +827,18 @@ public function testGatewayOptionsOverrideAndExpandIsMerged(): void public static function paymentIntentStatusDataProvider(): array { return [ - ['succeeded', Invoice::STATUS_PAID], - ['canceled', Invoice::STATUS_CANCELED], - ['processing', Invoice::STATUS_PENDING], - ['requires_action', Invoice::STATUS_PENDING], - ['requires_confirmation', Invoice::STATUS_PENDING], - ['requires_capture', Invoice::STATUS_PENDING], - ['requires_payment_method', Invoice::STATUS_PENDING], + ['succeeded', InvoiceStatus::PAID], + ['canceled', InvoiceStatus::CANCELED], + ['processing', InvoiceStatus::PROCESSING], + ['requires_action', InvoiceStatus::PENDING], + ['requires_confirmation', InvoiceStatus::PENDING], + ['requires_capture', InvoiceStatus::AUTHORIZED], + ['requires_payment_method', InvoiceStatus::PENDING], ]; } #[DataProvider('paymentIntentStatusDataProvider')] - public function testStatusMapping(string $stripeStatus, string $expected): void + public function testStatusMapping(string $stripeStatus, InvoiceStatus $expected): void { $response = $this->paidCardPaymentIntentResponse(status: $stripeStatus); RecordingStripeHttpClient::withResponses([$response]); @@ -821,7 +899,7 @@ public function testRefundsInvoiceTotally(): void $this->assertSame('/v1/refunds', parse_url($url, PHP_URL_PATH)); // sem amount: estorno total $this->assertSame(['payment_intent' => 'pi_fake123'], $params); - $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->status); $this->assertSame(12345, $result->refundedAmount); $this->assertSame('re_fake123', $result->lastRefundId); } @@ -844,7 +922,7 @@ public function testRefundsInvoicePartially(): void ['payment_intent' => 'pi_fake123', 'amount' => 2345], $httpClient->calls[0][2] ); - $this->assertSame(Invoice::STATUS_PARTIALLY_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->status); $this->assertSame(2345, $result->refundedAmount); $this->assertSame('re_fake123', $result->lastRefundId); } @@ -864,14 +942,14 @@ public function testBoletoRefundThrowsBeforeTheNetwork(): void $httpClient = RecordingStripeHttpClient::withResponses([]); $invoice = new Invoice(); $invoice->id = 'pi_fake123'; - $invoice->paymentMethod = Invoice::PAYMENT_METHOD_BANK_SLIP; + $invoice->paymentMethod = PaymentMethod::BANK_SLIP; try { (new StripeGateway())->refundInvoice($invoice); $this->fail('Esperava RefundNotSupportedException'); } catch (RefundNotSupportedException $e) { $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $e->reason); - $this->assertSame(Invoice::PAYMENT_METHOD_BANK_SLIP, $e->paymentMethod); + $this->assertSame(PaymentMethod::BANK_SLIP->value, $e->paymentMethod); $this->assertTrue($e->manualRefundRequired); } @@ -883,15 +961,15 @@ public function testAlreadyRefundedInvoiceThrowsBeforeTheNetwork(): void $httpClient = RecordingStripeHttpClient::withResponses([]); $invoice = new Invoice(); $invoice->id = 'pi_fake123'; - $invoice->paymentMethod = Invoice::PAYMENT_METHOD_CREDIT_CARD; - $invoice->status = Invoice::STATUS_REFUNDED; + $invoice->paymentMethod = PaymentMethod::CREDIT_CARD; + $invoice->status = InvoiceStatus::REFUNDED; try { (new StripeGateway())->refundInvoice($invoice); $this->fail('Esperava RefundNotSupportedException'); } catch (RefundNotSupportedException $e) { $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $e->reason); - $this->assertSame(Invoice::PAYMENT_METHOD_CREDIT_CARD, $e->paymentMethod); + $this->assertSame(PaymentMethod::CREDIT_CARD->value, $e->paymentMethod); $this->assertFalse($e->manualRefundRequired); } @@ -912,7 +990,7 @@ public function testPartialPixRefundGoesToTheGateway(): void $invoice = new Invoice(); $invoice->id = 'pi_fake123'; - $invoice->paymentMethod = Invoice::PAYMENT_METHOD_PIX; + $invoice->paymentMethod = PaymentMethod::PIX; $invoice->refundedAmount = 2345; $result = (new StripeGateway())->refundInvoice($invoice); @@ -920,7 +998,7 @@ public function testPartialPixRefundGoesToTheGateway(): void $this->assertSame('post', $httpClient->calls[0][0]); $this->assertSame('/v1/refunds', parse_url($httpClient->calls[0][1], PHP_URL_PATH)); $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 2345], $httpClient->calls[0][2]); - $this->assertSame(Invoice::STATUS_PARTIALLY_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->status); $this->assertSame(2345, $result->refundedAmount); $this->assertSame('re_fake123', $result->lastRefundId); } @@ -941,13 +1019,13 @@ public function testPartiallyRefundedInvoiceAcceptsAnotherRefund(): void $invoice = new Invoice(); $invoice->id = 'pi_fake123'; - $invoice->status = Invoice::STATUS_PARTIALLY_REFUNDED; + $invoice->status = InvoiceStatus::PARTIALLY_REFUNDED; $invoice->refundedAmount = 10000; $result = (new StripeGateway())->refundInvoice($invoice); $this->assertSame('post', $httpClient->calls[0][0]); $this->assertSame('/v1/refunds', parse_url($httpClient->calls[0][1], PHP_URL_PATH)); - $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->status); $this->assertSame('re_fake456', $result->lastRefundId); } @@ -960,7 +1038,7 @@ public function testGetInvoiceDoesNotFillLastRefundId(): void $result = $this->getInvoice(); - $this->assertSame(Invoice::STATUS_REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->status); $this->assertNull($result->lastRefundId); } @@ -1017,7 +1095,7 @@ public function testDuplicatesPendingPixInvoiceCancelingTheOriginal(): void ], $httpClient->calls[2][2]); $this->assertSame('pi_fake456', $result->id); - $this->assertSame(Invoice::STATUS_PENDING, $result->status); + $this->assertSame(InvoiceStatus::PENDING, $result->status); } public function testDuplicateFallsBackToOriginalBillingTaxIdWhenCustomerHasNone(): void @@ -1128,7 +1206,7 @@ public function testDuplicateRejectsPaidInvoice(): void $invoice->id = 'pi_fake123'; $this->expectException(GatewayException::class); - $this->expectExceptionMessage('Only pending invoices can be duplicated'); + $this->expectExceptionMessage('Only pending invoices can be duplicated on the stripe gateway; invoice [pi_fake123] is [paid]'); (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); } @@ -1189,7 +1267,7 @@ private function creditCardInvoiceModel(): Invoice $invoice = new Invoice(); $invoice->customer = new Customer(); $invoice->customer->id = 'cus_fake123'; - $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_CREDIT_CARD]; + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD]; $invoice->creditCard = new CreditCard(); $invoice->creditCard->id = 'pm_fake123'; $item = new InvoiceItem(); @@ -1205,7 +1283,7 @@ private function pixInvoiceModel(): Invoice { $invoice = $this->creditCardInvoiceModel(); $invoice->creditCard = null; - $invoice->availablePaymentMethods = [Invoice::PAYMENT_METHOD_PIX]; + $invoice->availablePaymentMethods = [PaymentMethod::PIX]; $invoice->customer->name = 'Fake Customer'; $invoice->customer->email = 'email@exemplo.com'; $invoice->customer->taxDocument = '20176996915'; @@ -1236,8 +1314,8 @@ public function testParsingPaidPixInvoiceDoesNotLogUndefinedProperty(): void \Stripe\Stripe::setLogger($loggerAnterior); } - $this->assertSame(Invoice::STATUS_PAID, $result->status); - $this->assertSame(Invoice::PAYMENT_METHOD_PIX, $result->paymentMethod); + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertSame(PaymentMethod::PIX, $result->paymentMethod); $this->assertNull($result->creditCard); $this->assertSame([], $logger->messages); } diff --git a/tests/Unit/InvoiceTest.php b/tests/Unit/InvoiceTest.php index a4411d2..94f44de 100644 --- a/tests/Unit/InvoiceTest.php +++ b/tests/Unit/InvoiceTest.php @@ -3,16 +3,25 @@ namespace Potelo\MultiPayment\Tests\Unit; use PHPUnit\Framework\TestCase; -use Potelo\MultiPayment\Models\Invoice; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Enums\InvoiceStatus; +/** + * Cobre os helpers estáticos obsoletos de `Invoice`, que delegam ao enum e aceitam tanto o + * caso do enum quanto a string antiga. + */ class InvoiceTest extends TestCase { public static function settledProvider(): array { return [ - 'paga' => [Invoice::STATUS_PAID, true], + 'paga (enum)' => [InvoiceStatus::PAID, true], + 'paga (string antiga)' => [Invoice::STATUS_PAID, true], 'parcialmente estornada' => [Invoice::STATUS_PARTIALLY_REFUNDED, true], + 'parcialmente paga' => ['partially_paid', true], + 'paga por fora' => ['externally_paid', true], 'pendente' => [Invoice::STATUS_PENDING, false], 'cancelada' => [Invoice::STATUS_CANCELED, false], 'estornada' => [Invoice::STATUS_REFUNDED, false], @@ -23,7 +32,8 @@ public static function settledProvider(): array } #[DataProvider('settledProvider')] - public function testIsSettledOnlyForStatusesWhereTheMoneyWasReceived(string $status, bool $expected): void + #[IgnoreDeprecations] + public function testIsSettledDelegatesToTheEnumAndAcceptsTheOldString(InvoiceStatus|string $status, bool $expected): void { $this->assertSame($expected, Invoice::isSettled($status)); } @@ -31,7 +41,8 @@ public function testIsSettledOnlyForStatusesWhereTheMoneyWasReceived(string $sta public static function contestedProvider(): array { return [ - 'em disputa' => [Invoice::STATUS_DISPUTED, true], + 'em disputa (enum)' => [InvoiceStatus::DISPUTED, true], + 'em disputa (string antiga)' => [Invoice::STATUS_DISPUTED, true], 'chargeback' => [Invoice::STATUS_CHARGEBACK, true], 'paga' => [Invoice::STATUS_PAID, false], 'estornada' => [Invoice::STATUS_REFUNDED, false], @@ -43,8 +54,53 @@ public static function contestedProvider(): array } #[DataProvider('contestedProvider')] - public function testIsContestedOnlyForOpenOrLostDisputes(string $status, bool $expected): void + #[IgnoreDeprecations] + public function testIsContestedDelegatesToTheEnumAndAcceptsTheOldString(InvoiceStatus|string $status, bool $expected): void { $this->assertSame($expected, Invoice::isContested($status)); } + + #[IgnoreDeprecations] + public function testIsSettledTriggersADeprecationNotice(): void + { + $this->expectUserDeprecationMessage('Invoice::isSettled() está obsoleto desde 2026-09-02; use $invoice->status->isSettled()'); + + Invoice::isSettled(InvoiceStatus::PAID); + } + + #[IgnoreDeprecations] + public function testIsContestedTriggersADeprecationNotice(): void + { + $this->expectUserDeprecationMessage('Invoice::isContested() está obsoleto desde 2026-09-02; use $invoice->status->isContested()'); + + Invoice::isContested(InvoiceStatus::DISPUTED); + } + + /** + * As constantes antigas continuam existindo com o mesmo valor do enum, então quem compara + * `$invoice->status->value` com `Invoice::STATUS_PAID` continua obtendo verdadeiro. + */ + public static function oldConstantProvider(): array + { + return [ + [Invoice::STATUS_PENDING, InvoiceStatus::PENDING], + [Invoice::STATUS_PAID, InvoiceStatus::PAID], + [Invoice::STATUS_CANCELED, InvoiceStatus::CANCELED], + [Invoice::STATUS_REFUNDED, InvoiceStatus::REFUNDED], + [Invoice::STATUS_PARTIALLY_REFUNDED, InvoiceStatus::PARTIALLY_REFUNDED], + [Invoice::STATUS_DISPUTED, InvoiceStatus::DISPUTED], + [Invoice::STATUS_CHARGEBACK, InvoiceStatus::CHARGEBACK], + ]; + } + + #[DataProvider('oldConstantProvider')] + public function testOldStatusConstantsKeepTheEnumValue(string $constant, InvoiceStatus $status): void + { + $invoice = new Invoice(); + $invoice->status = $constant; + + $this->assertSame($status, $invoice->status); + $this->assertSame($constant, $invoice->status->value); + $this->assertTrue($invoice->status->value === $constant); + } } diff --git a/tests/Unit/ModelEnumCastTest.php b/tests/Unit/ModelEnumCastTest.php new file mode 100644 index 0000000..8b3b431 --- /dev/null +++ b/tests/Unit/ModelEnumCastTest.php @@ -0,0 +1,283 @@ +instance('config', new \Illuminate\Config\Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + $app->instance('log', $this->logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testStatusAcceptsTheOldStringAndReadsAsTheEnum(): void + { + $invoice = new Invoice(); + $invoice->status = Invoice::STATUS_PAID; + + $this->assertSame(InvoiceStatus::PAID, $invoice->status); + $this->assertTrue($invoice->status === InvoiceStatus::PAID); + $this->assertSame(Invoice::STATUS_PAID, $invoice->status->value); + } + + public function testStatusAcceptsTheEnumCaseAndNull(): void + { + $invoice = new Invoice(); + $invoice->status = InvoiceStatus::DISPUTED; + $this->assertSame(InvoiceStatus::DISPUTED, $invoice->status); + + $invoice->status = null; + $this->assertNull($invoice->status); + } + + public function testUnknownStatusStringBecomesUnknownWithAWarningNamingTheGateway(): void + { + $invoice = new Invoice(); + $invoice->gateway = 'iugu'; + $invoice->status = 'status_inventado'; + + $this->assertSame(InvoiceStatus::UNKNOWN, $invoice->status); + $this->assertCount(1, $this->logger->records); + $this->assertSame('warning', $this->logger->records[0]['level']); + $this->assertSame(['status' => 'status_inventado', 'gateway' => 'iugu'], $this->logger->records[0]['context']); + } + + public function testFillConvertsStatusPaymentMethodAndAvailablePaymentMethods(): void + { + $invoice = new Invoice(); + $invoice->fill([ + 'status' => 'partially_paid', + 'payment_method' => 'pix', + 'available_payment_methods' => ['pix', PaymentMethod::BANK_SLIP], + ]); + + $this->assertSame(InvoiceStatus::PARTIALLY_PAID, $invoice->status); + $this->assertSame(PaymentMethod::PIX, $invoice->paymentMethod); + $this->assertSame([PaymentMethod::PIX, PaymentMethod::BANK_SLIP], $invoice->availablePaymentMethods); + } + + public function testUnknownPaymentMethodStringIsRejectedOnWrite(): void + { + $invoice = new Invoice(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('paymentMethod must be one of: credit_card, bank_slip, pix, automatic_pix'); + + $invoice->paymentMethod = 'dinheiro'; + } + + public function testUnknownPaymentMethodInTheListIsRejectedOnWrite(): void + { + $invoice = new Invoice(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('availablePaymentMethods must be one of'); + + $invoice->availablePaymentMethods = ['pix', 'cripto']; + } + + public function testNonArrayAvailablePaymentMethodsIsRejectedOnWrite(): void + { + $invoice = new Invoice(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('availablePaymentMethods must be an array'); + + $invoice->availablePaymentMethods = 'pix'; + } + + public function testNonStringValueIsRejectedOnWrite(): void + { + $plan = new Plan(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('interval must be one of: day, week, month, year'); + + $plan->interval = 12; + } + + /** + * `__get` devolve o array por referência, então um `[]=` entra sem conversão; a validação + * normaliza e recusa o que não é selecionável. + */ + public function testValidationNormalizesStringsAppendedToAvailablePaymentMethods(): void + { + $invoice = new Invoice(); + $invoice->availablePaymentMethods = [PaymentMethod::PIX]; + $invoice->availablePaymentMethods[] = 'credit_card'; + + $invoice->validateAvailablePaymentMethodsAttribute(); + + $this->assertSame([PaymentMethod::PIX, PaymentMethod::CREDIT_CARD], $invoice->availablePaymentMethods); + } + + public function testValidationRejectsAutomaticPixInAvailablePaymentMethods(): void + { + $invoice = new Invoice(); + $invoice->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX]; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('availablePaymentMethods must be one of: credit_card, bank_slip, pix'); + + $invoice->validateAvailablePaymentMethodsAttribute(); + } + + public function testPlanIntervalAcceptsTheOldConstantAndReadsAsTheEnum(): void + { + $plan = new Plan(); + $plan->interval = Plan::INTERVAL_YEAR; + + $this->assertSame(PlanInterval::YEAR, $plan->interval); + + $plan->fill(['interval' => 'week']); + $this->assertSame(PlanInterval::WEEK, $plan->interval); + } + + public function testUnknownPlanIntervalIsRejectedOnWrite(): void + { + $plan = new Plan(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('interval must be one of: day, week, month, year'); + + $plan->interval = 'quinzena'; + } + + public function testSubscriptionPaymentMethodsAreConvertedToo(): void + { + $subscription = new Subscription(); + $subscription->fill(['payment_method' => 'credit_card', 'available_payment_methods' => ['pix']]); + + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->paymentMethod); + $this->assertSame([PaymentMethod::PIX], $subscription->availablePaymentMethods); + } + + public function testToArrayEmitsTheStringValues(): void + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + $invoice->status = InvoiceStatus::PAID; + $invoice->paymentMethod = PaymentMethod::CREDIT_CARD; + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD, PaymentMethod::PIX]; + + $this->assertSame([ + 'id' => 'inv_1', + 'status' => 'paid', + 'payment_method' => 'credit_card', + 'available_payment_methods' => ['credit_card', 'pix'], + ], $invoice->toArray()); + + $plan = new Plan(); + $plan->interval = PlanInterval::MONTH; + $this->assertSame('month', $plan->toArray()['interval']); + } + + public function testToArrayRoundTripsThroughFill(): void + { + $invoice = new Invoice(); + $invoice->status = 'paid'; + $invoice->availablePaymentMethods = ['pix']; + + $copy = new Invoice(); + $copy->fill($invoice->toArray()); + + $this->assertSame(InvoiceStatus::PAID, $copy->status); + $this->assertSame([PaymentMethod::PIX], $copy->availablePaymentMethods); + } + + public function testJsonEncodeKeepsTheCamelCaseKeysWithTheStringValues(): void + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + $invoice->status = InvoiceStatus::EXPIRED; + $invoice->paymentMethod = PaymentMethod::PIX; + $invoice->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::BANK_SLIP]; + + $json = json_decode(json_encode($invoice), true); + + $this->assertSame('inv_1', $json['id']); + $this->assertSame('expired', $json['status']); + $this->assertSame('pix', $json['paymentMethod']); + $this->assertSame(['pix', 'bank_slip'], $json['availablePaymentMethods']); + $this->assertArrayHasKey('gatewayOptions', $json); + + $plan = new Plan(); + $plan->interval = PlanInterval::YEAR; + $this->assertSame('year', json_decode(json_encode($plan), true)['interval']); + } + + public function testIssetAndEmptyWorkOnEnumProperties(): void + { + $invoice = new Invoice(); + + $this->assertFalse(isset($invoice->status)); + $this->assertTrue(empty($invoice->status)); + $this->assertFalse(isset($invoice->availablePaymentMethods)); + $this->assertTrue(empty($invoice->availablePaymentMethods)); + + $invoice->status = 'paid'; + $invoice->availablePaymentMethods = ['pix']; + + $this->assertTrue(isset($invoice->status)); + $this->assertFalse(empty($invoice->status)); + $this->assertTrue(isset($invoice->availablePaymentMethods)); + $this->assertFalse(empty($invoice->availablePaymentMethods)); + } + + public function testBuilderAcceptsStringsAndEnumCasesForAvailablePaymentMethods(): void + { + $builder = new InvoiceBuilder(new StripeGateway()); + $builder->addAvailablePaymentMethod('pix')->addAvailablePaymentMethod(PaymentMethod::CREDIT_CARD); + + $this->assertSame([PaymentMethod::PIX, PaymentMethod::CREDIT_CARD], $builder->get()->availablePaymentMethods); + + $builder->setAvailablePaymentMethods(['bank_slip']); + $this->assertSame([PaymentMethod::BANK_SLIP], $builder->get()->availablePaymentMethods); + } + + public function testValidateStillSeesEnumPropertiesAsAttributes(): void + { + $plan = new Plan(); + $plan->name = 'Plano'; + $plan->amount = 1000; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/interval/'); + + $plan->validate(); + } +} diff --git a/tests/Unit/RecordingLogger.php b/tests/Unit/RecordingLogger.php new file mode 100644 index 0000000..c46550a --- /dev/null +++ b/tests/Unit/RecordingLogger.php @@ -0,0 +1,20 @@ + */ + public array $records = []; + + public function log($level, string|\Stringable $message, array $context = []): void + { + $this->records[] = ['level' => (string) $level, 'message' => (string) $message, 'context' => $context]; + } +} diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php index 46342d8..e9efcbf 100644 --- a/tests/Unit/SubscriptionTest.php +++ b/tests/Unit/SubscriptionTest.php @@ -19,6 +19,9 @@ use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use PHPUnit\Framework\Attributes\DataProvider; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Enums\PlanInterval; class SubscriptionTest extends TestCase { @@ -117,11 +120,22 @@ public function testSubscriptionRequiresPlanId(): void $subscription->validate(); } - public function testSubscriptionRejectsUnknownPaymentMethod(): void + public function testSubscriptionRejectsUnknownPaymentMethodOnWrite(): void { $subscription = new Subscription(); $subscription->fill(['customer' => ['name' => 'Fulano'], 'plan_id' => 'plano']); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/availablePaymentMethods must be one of/'); + $subscription->availablePaymentMethods = ['bitcoin']; + } + + public function testSubscriptionValidationRejectsNonSelectablePaymentMethod(): void + { + $subscription = new Subscription(); + $subscription->fill(['customer' => ['name' => 'Fulano'], 'plan_id' => 'plano']); + $subscription->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX]; $this->expectException(ModelAttributeValidationException::class); $this->expectExceptionMessageMatches('/availablePaymentMethods must be one of/'); @@ -164,7 +178,7 @@ public function testValidSubscriptionPassesValidation(): void 'plan_id' => 'plano', 'items' => [['description' => 'Consultas', 'amount' => 1000, 'quantity' => 1]], 'discounts' => [['description' => 'Promo', 'amount_off' => 500]], - 'available_payment_methods' => [Invoice::PAYMENT_METHOD_CREDIT_CARD, Invoice::PAYMENT_METHOD_PIX], + 'available_payment_methods' => [PaymentMethod::CREDIT_CARD, PaymentMethod::PIX], ]); $subscription->validate(); @@ -285,24 +299,23 @@ public function testDiscountRejectsZeroCycles(): void $discount->validate(); } - public function testPlanRejectsUnknownInterval(): void + public function testPlanRejectsUnknownIntervalOnWrite(): void { $plan = new Plan(); $plan->name = 'Mensal'; $plan->amount = 10000; - $plan->interval = 'day'; $this->expectException(ModelAttributeValidationException::class); - $this->expectExceptionMessageMatches('/interval must be one of/'); + $this->expectExceptionMessageMatches('/interval must be one of: day, week, month, year/'); - $plan->validate(); + $plan->interval = 'quinzena'; } public function testPlanRequiresNameAmountAndInterval(): void { $plan = new Plan(); $plan->amount = 10000; - $plan->interval = Plan::INTERVAL_MONTH; + $plan->interval = PlanInterval::MONTH; $this->expectException(ModelAttributeValidationException::class); $this->expectExceptionMessageMatches('/`name` attribute is required/'); @@ -315,12 +328,12 @@ public function testValidPlanPassesValidation(): void $plan = new Plan(); $plan->name = 'Mensal'; $plan->amount = 10000; - $plan->interval = Plan::INTERVAL_MONTH; + $plan->interval = PlanInterval::MONTH; $plan->intervalCount = 1; $plan->validate(); - $this->assertSame(Plan::INTERVAL_MONTH, $plan->interval); + $this->assertSame(PlanInterval::MONTH, $plan->interval); } /** @@ -394,7 +407,7 @@ public function testFillAndToArrayHandleTheLatestInvoice(): void $subscription = new Subscription(); $subscription->fill([ 'id' => 'sub_1', - 'latest_invoice' => ['id' => 'inv_1', 'status' => Invoice::STATUS_PENDING], + 'latest_invoice' => ['id' => 'inv_1', 'status' => InvoiceStatus::PENDING], ]); $this->assertInstanceOf(Invoice::class, $subscription->latestInvoice); @@ -499,13 +512,13 @@ public function testSetItemsAndSetDiscountsReplaceInsteadOfAppending(): void ->setItems([$item]) ->addAmountDiscount('Descartado', 999) ->setDiscounts([]) - ->setAvailablePaymentMethods([Invoice::PAYMENT_METHOD_PIX]) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) ->setTrialEndsAt('2026-09-15') ->get(); $this->assertSame([$item], $subscription->items); $this->assertSame([], $subscription->discounts); - $this->assertSame([Invoice::PAYMENT_METHOD_PIX], $subscription->availablePaymentMethods); + $this->assertSame([PaymentMethod::PIX], $subscription->availablePaymentMethods); $this->assertSame('2026-09-15', $subscription->trialEndsAt->format('Y-m-d')); } @@ -574,7 +587,7 @@ public function testUpdateStillValidatesAvailablePaymentMethods(): void $subscription = new Subscription(); $subscription->id = 'sub_1'; - $subscription->availablePaymentMethods = ['bitcoin']; + $subscription->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX]; $this->expectException(ModelAttributeValidationException::class); @@ -587,7 +600,7 @@ public function testPlanWithIdCannotBeSavedAgain(): void $plan->id = 'plan_1'; $plan->name = 'Mensal'; $plan->amount = 10000; - $plan->interval = Plan::INTERVAL_MONTH; + $plan->interval = PlanInterval::MONTH; $this->expectException(GatewayException::class); $this->expectExceptionMessageMatches('/cannot be updated/'); From 8dc21a1045b9110d6097289d7e34399ca012a9d3 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 10:57:40 -0300 Subject: [PATCH 18/32] =?UTF-8?q?feat(capabilities):=20declara=20capabilit?= =?UTF-8?q?ies=20por=20driver=20e=20unifica=20opera=C3=A7=C3=A3o=20n=C3=A3?= =?UTF-8?q?o=20suportada=20em=20UnsupportedOperationException?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cada driver implementa DeclaresCapabilities (capabilities(), notYetImplemented(), supports()); o que fica fora das duas listas é limitação do gateway. Toda operação fora das capabilities lança UnsupportedOperationException (capability, gateway, reason) antes de qualquer requisição, inclusive antes de criar o cliente que acompanha fatura e assinatura. Substitui operationNotImplemented() do Stripe, o methodNotFound de despacho e as checagens de instanceof de contract; RefundNotSupportedException passa a herdar da nova exceção. MultiPayment e Facade expõem gateway(), supports(), capabilities() e notYetImplemented(). O README ganha a seção Capabilities com a matriz gerada por composer capabilities:table, com teste unitário que falha se o README ficar defasado. --- README.md | 156 ++++--- composer.json | 3 +- scripts/capabilities-table.php | 31 ++ src/Contracts/CreditCardContract.php | 1 + src/Contracts/DeclaresCapabilities.php | 35 ++ src/Contracts/GatewayContract.php | 2 +- src/Contracts/InvoiceContract.php | 2 + src/Contracts/PlanContract.php | 1 + src/Contracts/SubscriptionContract.php | 4 +- src/Enums/Capability.php | 129 ++++++ src/Exceptions/GatewayException.php | 2 +- .../RefundNotSupportedException.php | 36 +- .../UnsupportedOperationException.php | 154 +++++++ src/Facades/MultiPayment.php | 4 + src/Gateways/Concerns/ChecksCapabilities.php | 61 +++ src/Gateways/IuguGateway.php | 72 +++- src/Gateways/StripeGateway.php | 123 +++--- src/Helpers/CapabilitiesTable.php | 66 +++ src/Models/Invoice.php | 52 +++ src/Models/Model.php | 66 ++- src/Models/Plan.php | 3 + src/Models/Subscription.php | 47 ++- src/MultiPayment.php | 74 +++- tests/Integration/StripeGatewayTest.php | 13 +- tests/Unit/CapabilityGuardsTest.php | 394 ++++++++++++++++++ tests/Unit/Enums/CapabilityTest.php | 73 ++++ .../UnsupportedOperationExceptionTest.php | 141 +++++++ .../Unit/Gateways/GatewayCapabilitiesTest.php | 167 ++++++++ .../Gateways/IuguGatewaySubscriptionTest.php | 56 ++- .../Gateways/StripeGatewayCreditCardTest.php | 17 +- .../Gateways/StripeGatewayCustomerTest.php | 16 +- .../Gateways/StripeGatewayInvoiceTest.php | 60 ++- tests/Unit/SubscriptionTest.php | 87 +++- 33 files changed, 1932 insertions(+), 216 deletions(-) create mode 100644 scripts/capabilities-table.php create mode 100644 src/Contracts/DeclaresCapabilities.php create mode 100644 src/Enums/Capability.php create mode 100644 src/Exceptions/UnsupportedOperationException.php create mode 100644 src/Gateways/Concerns/ChecksCapabilities.php create mode 100644 src/Helpers/CapabilitiesTable.php create mode 100644 tests/Unit/CapabilityGuardsTest.php create mode 100644 tests/Unit/Enums/CapabilityTest.php create mode 100644 tests/Unit/Exceptions/UnsupportedOperationExceptionTest.php create mode 100644 tests/Unit/Gateways/GatewayCapabilitiesTest.php diff --git a/README.md b/README.md index fd8d472..24eebc4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [Instalação](#instalação) - [Configuração](#configuração) - [Gateways](#gateways) - - [Suporte por gateway](#suporte-por-gateway) + - [Capabilities](#capabilities) - [Status da fatura](#status-da-fatura) - [Migração das constantes para enum](#migração-das-constantes-para-enum) - [Particularidades do Stripe](#particularidades-do-stripe) @@ -85,45 +85,77 @@ Também é possível utilizar o Facade: ## Gateways -### Suporte por gateway +### Capabilities -Cada célula é uma de três coisas: +Cada driver declara o que suporta em dois níveis, pelo contract `DeclaresCapabilities`: +`capabilities()` lista o que o gateway oferece e a lib implementa; `notYetImplemented()` lista o +que o gateway oferece mas a lib ainda não construiu (planejado para uma versão futura). O que não +aparece em nenhuma das duas listas é limitação do gateway. `supports(Capability $c)` responde +sobre a primeira lista. Os valores são o enum `Potelo\MultiPayment\Enums\Capability`. -- **sim**: a lib implementa a operação nesse gateway. -- **não implementado**: o gateway oferece o recurso, mas a lib ainda não o integrou. No Stripe, - boleto e Pix Automático lançam `GatewayException` dizendo que a operação ainda não está - implementada nesta lib; assinatura e plano caem em `GatewayException::methodNotFound` ou na - checagem de contract (ver [Assinaturas e planos](#assinaturas-e-planos)); fatura multi-método - lança `ModelAttributeValidationException`. Na Iugu, idempotência e parcelamento não têm - chamada própria: a opção simplesmente não é tratada pelo driver. Boleto, assinatura, plano e - Pix Automático no Stripe estão planejados para uma versão futura. -- **limitação do gateway**: o gateway não oferece o recurso. A lib falha antes de chamar a API, - com a exceção indicada. +Consulte a capability **antes** de montar a interface de checkout ou de escolher o gateway, em +vez de capturar a exceção depois: -| Operação | Iugu | Stripe | -|---|---|---| -| Fatura com cartão de crédito | sim | sim (token-only) | -| Cartão com dados crus (`number`, `cvv`) | sim | limitação do gateway: exige liberação de raw card data e PCI SAQ D; lança `GatewayException` orientando a tokenizar | -| Fatura com pix | sim | sim | -| Fatura com boleto | sim | não implementado | -| Fatura multi-método (`available_payment_methods` com mais de um) | sim | não implementado: a fatura é um PaymentIntent com exatamente um método; depende de uma decisão pendente sobre o mapeamento de `Invoice` | -| Estorno de cartão (total e parcial) | sim | sim | -| Estorno de Pix | sim, somente integral; parcial é limitação do gateway e lança `RefundNotSupportedException` | sim, total e parcial | -| Estorno de boleto | limitação do gateway: lança `RefundNotSupportedException` (devolução manual) | limitação do gateway: a guarda já lança `RefundNotSupportedException`, embora boleto ainda não exista no driver | -| Cancelamento | sim | sim | -| Duplicar fatura (`duplicateInvoice`) | sim | sim, somente pix pendente | -| Cobrar fatura pendente com cartão | sim | sim (inclusive pix expirado) | -| Customer (criar/atualizar/buscar) e cartões salvos | sim | sim | -| Idempotência (`gateway_options['idempotency_key']`) | não implementado: a Iugu aceita o cabeçalho em criar fatura, assinatura, cliente e cobrança direta, mas o driver ainda não o envia | sim, na criação de fatura e no estorno | -| Parcelamento no cartão | não implementado: a Iugu parcela nativamente até 12x | limitação do gateway: o Stripe BR não parcela | -| Pix Automático | sim | não implementado | -| Assinatura (criar, buscar, atualizar, suspender, retomar, cancelar, listar) | sim | não implementado | -| Cancelar assinatura ao fim do período (`cancel(atPeriodEnd: true)`) | limitação do gateway: lança `GatewayException`; suspenda na data | não implementado | -| Troca de plano e simulação (`changePlan`, `previewPlanChange`) | sim | não implementado | -| Desconto na assinatura com valor fixo (`amountOff`) | sim, com `cycles` 1 ou `null` | não implementado | -| Desconto percentual e cupom de primeira classe | limitação do gateway: `percentOff` lança `GatewayException` | não implementado | -| Plano (criar, buscar, listar) | sim (`year` é enviado como 12 meses) | não implementado | -| Desativar plano (`deactivatePlan`) | limitação do gateway: lança `GatewayException` | não implementado | +```php +use Potelo\MultiPayment\Enums\Capability; +use Potelo\MultiPayment\Facades\MultiPayment; + +if (!MultiPayment::gateway('stripe')->supports(Capability::BANK_SLIP)) { + $gateway = 'iugu'; // roteia antes de exibir a opção +} + +MultiPayment::supports(Capability::INSTALLMENTS, 'iugu'); // true +MultiPayment::capabilities('stripe'); // Capability[] que a lib implementa +MultiPayment::notYetImplemented('stripe'); // Capability[] planejadas +``` + +Toda operação fora das capabilities do gateway lança `UnsupportedOperationException` **antes de +qualquer requisição**, inclusive antes de criar o cliente que acompanha a fatura ou a +assinatura. A exceção traz `capability`, `gateway` e `reason` (`not_implemented` quando o +gateway oferece e a lib ainda não implementou; `gateway_limitation` quando o gateway não +oferece). Ver [Tratamento de erros](#tratamento-de-erros). + +A matriz abaixo é gerada a partir das declarações dos drivers com `composer capabilities:table`; +o teste `GatewayCapabilitiesTest` falha quando o README fica defasado em relação ao código. + +| Capability | Significado | Iugu | Stripe | +|---|---|---|---| +| `CREDIT_CARD` | Fatura paga com cartão de crédito. | sim | sim | +| `PIX` | Fatura paga com Pix avulso, com QR Code de pagamento único. | sim | sim | +| `BANK_SLIP` | Fatura paga com boleto bancário. | sim | não implementado | +| `AUTOMATIC_PIX` | Recorrência de Pix Automático criada junto com a fatura, com reagendamento e cancelamento pela lib. | sim | não implementado | +| `MULTIPLE_PAYMENT_METHODS` | Fatura aberta a mais de um método de pagamento, escolhido pelo pagador na hora de pagar. | sim | não implementado | +| `RAW_CARD_DATA` | Cartão informado com número e CVV pela API; sem ela, o cartão é tokenizado no navegador e só o token chega à lib. | sim | limitação do gateway | +| `INSTALLMENTS` | Parcelamento da cobrança no cartão de crédito. | sim | limitação do gateway | +| `DELAYED_CAPTURE` | Cobrança em duas etapas no cartão: reserva do valor agora e captura depois. | não implementado | não implementado | +| `PARTIAL_REFUND_CARD` | Estorno de parte do valor numa fatura paga com cartão. | sim | sim | +| `PARTIAL_REFUND_PIX` | Estorno de parte do valor numa fatura paga com Pix. | limitação do gateway | sim | +| `REFUND_BANK_SLIP` | Estorno pela API de uma fatura paga com boleto. | limitação do gateway | limitação do gateway | +| `INVOICE_DUPLICATION` | Segunda via de uma fatura pendente com nova data de vencimento (`duplicateInvoice`). | sim | sim | +| `IDEMPOTENCY` | Chave de idempotência (`gateway_options['idempotency_key']`) honrada na criação de fatura e no estorno. | não implementado | sim | +| `IDEMPOTENCY_ALL_ENDPOINTS` | Chave de idempotência honrada em toda operação de escrita, inclusive cliente, cartão, cancelamento e troca de plano. | limitação do gateway | não implementado | +| `SUBSCRIPTIONS` | Assinatura recorrente: criar, buscar, atualizar, suspender, retomar, cancelar, trocar de plano e listar. | sim | não implementado | +| `PLANS` | Plano de assinatura: criar, buscar e listar. | sim | não implementado | +| `PLAN_DEACTIVATION` | Desativar um plano sem apagá-lo (`deactivatePlan`). | limitação do gateway | não implementado | +| `CANCEL_AT_PERIOD_END` | Cancelar a assinatura só no fim do período já pago (`cancel(atPeriodEnd: true)`). | limitação do gateway | não implementado | +| `NATIVE_COUPONS` | Cupom de primeira classe na assinatura: desconto percentual e desconto limitado a vários ciclos. | limitação do gateway | não implementado | +| `PLAN_CHANGE_PRORATION` | Crédito proporcional do período não usado, calculado pelo gateway, ao trocar de plano. | limitação do gateway | não implementado | +| `SUBSCRIPTION_CREDITS` | Assinatura com saldo de créditos consumíveis, abatidos a cada uso. | não implementado | limitação do gateway | +| `MANAGES_RECURRENCE` | O gateway agenda as cobranças do Pix Automático por conta própria; sem ela, a aplicação é o motor de recorrência e chama as operações de `AutomaticPixContract` na periodicidade certa. | limitação do gateway | não implementado | + +Restrições dentro de uma célula "sim": + +- **`INVOICE_DUPLICATION` no Stripe** vale só para fatura Pix pendente; cartão ou fatura em outro + estado lança `UnsupportedOperationException` com `gateway_limitation` (ver + [Particularidades do Stripe](#particularidades-do-stripe)). +- **`INSTALLMENTS` na Iugu** é informado em `gateway_options['months']`; a lib não modela parcelas + nem lê os campos da fatura parcelada. +- **`IDEMPOTENCY` no Stripe** cobre criação de fatura e estorno; nas demais operações de escrita a + chave não é repassada (`IDEMPOTENCY_ALL_ENDPOINTS`). +- **`PARTIAL_REFUND_PIX` e `REFUND_BANK_SLIP`** chegam como `RefundNotSupportedException`, que + herda de `UnsupportedOperationException` (ver [Estorno](#estorno)). +- **`MANAGES_RECURRENCE`** é informativa: diz quem agenda a cobrança do Pix Automático (ver + [Pix Automático: quem agenda a cobrança](#pix-automático-quem-agenda-a-cobrança)). ### Status da fatura @@ -248,7 +280,8 @@ recusado na validação, porque a fatura com Pix Automático é criada com `PIX` liberação de "raw card data" e escopo PCI SAQ D). Tokenize o cartão no navegador com Stripe.js e envie o id resultante (`pm_...`) em `credit_card.token` / `CreditCard::$token` (tokens legados `tok_...` também são aceitos). O caminho com `number`/`cvv` lança - `GatewayException` orientando o uso de token. + `UnsupportedOperationException` (`RAW_CARD_DATA`, `gateway_limitation`) orientando o uso de + token. - **Bandeiras aceitas no Brasil: somente Visa e Mastercard crédito.** Elo, Hipercard, Amex e débito nacional não são suportados pelo Stripe BR. Para essas bandeiras, roteie a cobrança para outro gateway (ex.: Iugu) — de preferência detectando a bandeira pelo BIN antes de @@ -270,7 +303,8 @@ recusado na validação, porque a fatura com Pix Automático é criada com `PIX` - **Pix expirado continua pendente e re-cobrável.** Na Iugu, fatura expirada vira `canceled`; no Stripe ela volta a aguardar pagamento (`pending`) e pode ser paga com cartão via `chargeInvoiceWithCreditCard` ou duplicada com `duplicateInvoice` (nova expiração; - a original é cancelada). + a original é cancelada). Só fatura Pix pendente é duplicável: cartão ou fatura em outro + estado lança `UnsupportedOperationException` (`INVOICE_DUPLICATION`, `gateway_limitation`). - **`url` da fatura**: no pix é a página hospedada com instruções de pagamento (`hosted_instructions_url`); em fatura de cartão é `null` — não assuma `url` preenchida como na Iugu (`secure_url`). @@ -324,21 +358,26 @@ houve resposta HTTP, como numa falha de rede ou numa validação local). | `AuthenticationException` | Chave de API inválida, revogada, sem permissão (401 ou 403) ou não configurada | Registrar e alertar. Repetir a chamada ou trocar de gateway não resolve | | `GatewayNotAvailableException` | Erro 5xx, falha de conexão ou timeout | Repetir mais tarde ou tentar outro gateway | | `ChargingException` | Cobrança recusada pelo gateway (cartão negado etc.); `reason` traz a razão normalizada quando o gateway a informa | Tratar como recusa do pagador; `reason` decide o fallback | -| `RefundNotSupportedException` | Estorno recusado pela lib antes de chamar o gateway (boleto, Pix parcial, já estornada, prazo vencido) | Ver [Estorno](#estorno) | +| `UnsupportedOperationException` | Operação fora das capabilities do gateway, antes de qualquer requisição; `capability`, `gateway` e `reason` (`not_implemented` ou `gateway_limitation`) dizem qual e por quê | Rotear para um gateway que declare a capability; melhor ainda, consultar `supports()` antes (ver [Capabilities](#capabilities)) | +| `RefundNotSupportedException` | Estorno recusado pela lib antes de chamar o gateway (boleto, Pix parcial, já estornada, prazo vencido); herda de `UnsupportedOperationException` e refina `reason` | Ver [Estorno](#estorno) | | `ModelAttributeValidationException` | Atributo obrigatório ausente ou inválido, antes de qualquer requisição | Corrigir a chamada | | `ConfigurationException` | Gateway não configurado ou classe inválida | Corrigir a configuração | -| `GatewayException` | Qualquer outra resposta de erro do gateway (validação, 404, 409, 429) e operação não suportada ou não implementada; `getErrors()` traz o corpo de erro | Depende do caso; `httpStatus` e `getErrors()` dizem o que aconteceu | +| `GatewayException` | Qualquer outra resposta de erro do gateway (validação, 404, 409, 429); `getErrors()` traz o corpo de erro | Depende do caso; `httpStatus` e `getErrors()` dizem o que aconteceu | ```php use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; try { $invoice = $payment->newInvoice()->/* ... */->create(); } catch (ChargingException $e) { return back()->withErrors('Pagamento recusado.'); +} catch (UnsupportedOperationException $e) { + report($e); // $e->capability e $e->gateway dizem o que faltou; nada foi enviado + return $this->chargeOn('iugu'); } catch (AuthenticationException $e) { report($e); // credencial errada: alerta, sem retry e sem fallback abort(500); @@ -364,6 +403,13 @@ previstas para uma versão futura. > `AuthenticationException` ou `GatewayException` do pacote. Quem repetia toda > `GatewayNotAvailableException` deixa de repetir credencial errada; quem capturava > `GatewayException` para chave recusada na Iugu precisa capturar `AuthenticationException`. +> +> Na mesma versão, operação não suportada ou ainda não implementada (boleto, Pix Automático, +> assinatura e plano no Stripe; cancelamento ao fim do período, desconto percentual e desativação +> de plano na Iugu; cartão com dados crus e duplicação fora de Pix pendente no Stripe) deixou de +> chegar como `GatewayException` (ou `GatewayException::methodNotFound`) e passou a lançar +> `UnsupportedOperationException`, que herda de `MultiPaymentException`. Um +> `catch (GatewayException $e)` sozinho deixa de capturar esses casos. ## Utilizando @@ -396,8 +442,8 @@ Confira `src/MultiPayment/Builders/InvoiceBuilder.php` para saber quais métodos O Pix Automático está disponível no gateway Iugu. No Stripe ele ainda **não está implementado nesta lib** (planejado para uma versão futura; a conta Stripe da empresa também aguarda a liberação do recurso). Até lá, todas as operações de Pix Automático no Stripe, inclusive criar -fatura com `automatic_pix`, lançam `GatewayException` dizendo que a operação ainda não está -implementada nesta lib e orientando a usar a Iugu. +fatura com `automatic_pix`, lançam `UnsupportedOperationException` com `capability` +`AUTOMATIC_PIX` e `reason` `not_implemented`, antes de qualquer requisição. Na Iugu, ele é configurado como parte da fatura: @@ -480,11 +526,10 @@ que possam ser reativados quando o ambiente passar a suportar o fluxo. Assinatura recorrente está disponível no gateway Iugu. No Stripe ela ainda **não está implementada nesta lib** (planejada para uma versão futura; o Stripe Billing oferece o -recurso). Hoje o `StripeGateway` não declara `SubscriptionContract` nem `PlanContract`: `save()` -e `get()` lançam `GatewayException::methodNotFound`, e os métodos de domínio (`suspend()`, -`resume()`, `cancel()`, `changePlan()`, `previewPlanChange()`) lançam `GatewayException` -avisando que o gateway não implementa o contract e que a lib ainda não implementou essas -operações para ele. +recurso). O `StripeGateway` lista `SUBSCRIPTIONS` e `PLANS` em `notYetImplemented()`, então +`save()`, `get()`, os métodos de domínio (`suspend()`, `resume()`, `cancel()`, `changePlan()`, +`previewPlanChange()`) e `listSubscriptions()`/`listPlans()` lançam +`UnsupportedOperationException` com `reason` `not_implemented`, antes de qualquer requisição. ```php use Potelo\MultiPayment\Models\Plan; @@ -534,10 +579,12 @@ $planos = (new \Potelo\MultiPayment\MultiPayment('iugu'))->listPlans(); Particularidades da Iugu: -- **Cancelar é suspender.** `cancel(atPeriodEnd: true)` lança `GatewayException`; para encerrar - ao fim do período, suspenda na data. -- **Desconto é sempre valor fixo.** `percentOff` lança `GatewayException`, e `cycles` só aceita - `1` (uma fatura) ou `null` (até ser removido). +- **Cancelar é suspender.** `cancel(atPeriodEnd: true)` lança `UnsupportedOperationException` + (`CANCEL_AT_PERIOD_END`, `gateway_limitation`); para encerrar ao fim do período, suspenda na + data. +- **Desconto é sempre valor fixo.** `percentOff` e `cycles` maior que `1` lançam + `UnsupportedOperationException` (`NATIVE_COUPONS`, `gateway_limitation`); `cycles` aceita `1` + (uma fatura) ou `null` (até ser removido). - **Plano anual é 12 meses, e plano diário não existe.** A Iugu só tem intervalos em semanas e meses, então `PlanInterval::YEAR` é enviado como `12 * intervalCount` meses e `PlanInterval::DAY` lança `GatewayException` antes de chamar a API. Na leitura vale a @@ -546,7 +593,8 @@ Particularidades da Iugu: Quem precisar do valor cru lê `original`. A Iugu aceita intervalo de 1 a 599, então um plano anual vai até `intervalCount` 49; acima disso o driver lança `GatewayException` antes de chamar a API. -- **Planos não são desativáveis.** `deactivatePlan` lança `GatewayException`. +- **Planos não são desativáveis.** `deactivatePlan` lança `UnsupportedOperationException` + (`PLAN_DEACTIVATION`, `gateway_limitation`). - **`nextBillingAt` e `trialEndsAt` são o mesmo campo** (`expires_at`); informar os dois com datas diferentes lança `GatewayException`. Ao prorrogar um trial lido do gateway, zere `nextBillingAt` antes, porque a leitura preenche os dois. @@ -682,7 +730,7 @@ provisório: dá lugar a um objeto `Refund` numa versão futura. > **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, estorno de boleto, Pix parcial, > fatura já estornada e fora do prazo de 90 dias na Iugu iam até a API e voltavam como -> `GatewayException` com a mensagem do gateway. Agora lançam `RefundNotSupportedException`, que herda de `MultiPaymentException` e **não** de +> `GatewayException` com a mensagem do gateway. Agora lançam `RefundNotSupportedException`, que herda de `UnsupportedOperationException` (e por ela de `MultiPaymentException`) e **não** de > `GatewayException`: um `catch (GatewayException $e)` sozinho deixa de capturar esses casos. #### charge diff --git a/composer.json b/composer.json index c09403a..6bf25b4 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ "test": [ "Composer\\Config::disableProcessTimeout", "phpunit" - ] + ], + "capabilities:table": "php scripts/capabilities-table.php" }, "repositories": [ { diff --git a/scripts/capabilities-table.php b/scripts/capabilities-table.php new file mode 100644 index 0000000..6f99ea5 --- /dev/null +++ b/scripts/capabilities-table.php @@ -0,0 +1,31 @@ + $gateway) { + $config['gateways'][$name]['api_key'] = $gateway['api_key'] ?? 'chave-nao-usada'; +} + +$app = new Container(); +$app->instance('config', new Repository(['multi-payment' => $config])); +Facade::setFacadeApplication($app); + +$gateways = []; +foreach ($config['gateways'] as $name => $gateway) { + $gateways[$name] = new $gateway['class'](); +} + +echo CapabilitiesTable::markdown($gateways); diff --git a/src/Contracts/CreditCardContract.php b/src/Contracts/CreditCardContract.php index 5b4e831..366b781 100644 --- a/src/Contracts/CreditCardContract.php +++ b/src/Contracts/CreditCardContract.php @@ -17,6 +17,7 @@ interface CreditCardContract * * @return CreditCard * @throws GatewayException|GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException */ public function createCreditCard(CreditCard $creditCard): CreditCard; diff --git a/src/Contracts/DeclaresCapabilities.php b/src/Contracts/DeclaresCapabilities.php new file mode 100644 index 0000000..1be9f06 --- /dev/null +++ b/src/Contracts/DeclaresCapabilities.php @@ -0,0 +1,35 @@ + self::CREDIT_CARD, + PaymentMethod::PIX => self::PIX, + PaymentMethod::BANK_SLIP => self::BANK_SLIP, + PaymentMethod::AUTOMATIC_PIX => self::AUTOMATIC_PIX, + }; + } + + /** + * Descrição do que a capability significa para quem consome, lida do docblock do caso; + * um docblock de várias linhas vira uma linha só. Vazia quando o interpretador não guarda + * comentários. + * + * @return string + */ + public function description(): string + { + $docComment = (new \ReflectionEnumBackedCase(self::class, $this->name))->getDocComment(); + if ($docComment === false) { + return ''; + } + + $body = preg_replace('#^/\*\*|\*/$#', '', trim($docComment)); + $lines = array_map( + static fn (string $line) => trim(preg_replace('#^\s*\*\s?#', '', trim($line))), + preg_split('/\R/', $body) + ); + + return trim(implode(' ', array_filter($lines, static fn (string $line) => $line !== ''))); + } +} diff --git a/src/Exceptions/GatewayException.php b/src/Exceptions/GatewayException.php index 3237f9f..5c40adf 100644 --- a/src/Exceptions/GatewayException.php +++ b/src/Exceptions/GatewayException.php @@ -44,7 +44,7 @@ public function getErrors(): array } /** - * Method not found in gateway. + * Dispatch method missing in a gateway that declares the capability (driver error). * * @param string $gatewayClass * @param string $method diff --git a/src/Exceptions/RefundNotSupportedException.php b/src/Exceptions/RefundNotSupportedException.php index 7b19337..42b39a6 100644 --- a/src/Exceptions/RefundNotSupportedException.php +++ b/src/Exceptions/RefundNotSupportedException.php @@ -3,6 +3,7 @@ namespace Potelo\MultiPayment\Exceptions; use Carbon\Carbon; +use Potelo\MultiPayment\Enums\Capability; /** * Estorno recusado pela abstração antes de qualquer requisição ao gateway. @@ -11,8 +12,10 @@ * estorno via API em nenhum gateway, Pix na Iugu só aceita estorno integral, fatura já estornada * não estorna de novo e a Iugu fecha a janela de estorno 90 dias após o pagamento. O motivo fica * em `$reason`, no vocabulário do pacote, para a aplicação ramificar sem ler a mensagem. + * `$capability` aponta a capability recusada quando existe uma (`REFUND_BANK_SLIP`, + * `PARTIAL_REFUND_PIX`) e fica nula para fatura já estornada e prazo vencido. */ -class RefundNotSupportedException extends MultiPaymentException +class RefundNotSupportedException extends UnsupportedOperationException { /** Boleto não tem estorno pela API do gateway; devolução manual. */ public const REASON_BOLETO_NO_REFUND = 'boleto_no_refund'; @@ -35,7 +38,7 @@ class RefundNotSupportedException extends MultiPaymentException public ?string $paymentMethod; /** - * Motivo da recusa, uma das constantes `REASON_*`. + * Motivo da recusa, uma das constantes `REASON_*` desta classe. * * @var string */ @@ -58,19 +61,22 @@ class RefundNotSupportedException extends MultiPaymentException * @param string $reason * @param bool $manualRefundRequired * @param \Throwable|null $previous + * @param string $gateway + * @param Capability|null $capability */ public function __construct( string $message, ?string $paymentMethod, string $reason, bool $manualRefundRequired = false, - ?\Throwable $previous = null + ?\Throwable $previous = null, + string $gateway = '', + ?Capability $capability = null ) { $this->paymentMethod = $paymentMethod; - $this->reason = $reason; $this->manualRefundRequired = $manualRefundRequired; - parent::__construct($message, $previous); + parent::__construct($message, $gateway, $capability, $reason, $previous); } /** @@ -85,7 +91,10 @@ public static function boletoNoRefund(string $gateway): static "O gateway {$gateway} não estorna boleto pela API; faça a devolução manualmente ao cliente.", 'bank_slip', self::REASON_BOLETO_NO_REFUND, - true + true, + null, + $gateway, + Capability::REFUND_BANK_SLIP ); } @@ -104,7 +113,11 @@ public static function pixPartialNotSupported(string $gateway, int $requestedAmo return new static( "O gateway {$gateway} só estorna Pix integralmente (pedido: {$requestedAmount} centavos, pago: {$paid}); repita sem valor parcial.", 'pix', - self::REASON_PIX_PARTIAL_NOT_SUPPORTED + self::REASON_PIX_PARTIAL_NOT_SUPPORTED, + false, + null, + $gateway, + Capability::PARTIAL_REFUND_PIX ); } @@ -120,7 +133,10 @@ public static function alreadyRefunded(string $gateway, ?string $paymentMethod): return new static( "A fatura já foi integralmente estornada no gateway {$gateway}.", $paymentMethod, - self::REASON_ALREADY_REFUNDED + self::REASON_ALREADY_REFUNDED, + false, + null, + $gateway ); } @@ -139,7 +155,9 @@ public static function refundWindowExpired(string $gateway, ?string $paymentMeth "O gateway {$gateway} só estorna até {$windowDays} dias após o pagamento (pago em {$paidAt->toDateString()}); faça a devolução manualmente ao cliente.", $paymentMethod, self::REASON_REFUND_WINDOW_EXPIRED, - true + true, + null, + $gateway ); } } diff --git a/src/Exceptions/UnsupportedOperationException.php b/src/Exceptions/UnsupportedOperationException.php new file mode 100644 index 0000000..b9a8476 --- /dev/null +++ b/src/Exceptions/UnsupportedOperationException.php @@ -0,0 +1,154 @@ +gateway = $gateway; + $this->capability = $capability; + $this->reason = $reason; + + parent::__construct($message, $previous, $httpStatus); + } + + /** + * O gateway oferece o recurso e a lib ainda não o implementou para ele. + * + * @param string $gateway + * @param Capability $capability + * @param string $detail orientação acrescentada ao fim da mensagem + * @return static + */ + public static function notImplemented(string $gateway, Capability $capability, string $detail = ''): static + { + $message = "A capability [{$capability->value}] ainda não está implementada nesta lib para o gateway" + . " {$gateway}; o gateway oferece o recurso."; + + return new static(self::appendDetail($message, $detail), $gateway, $capability, self::REASON_NOT_IMPLEMENTED); + } + + /** + * O gateway não oferece o recurso. + * + * @param string $gateway + * @param Capability $capability + * @param string $detail orientação acrescentada ao fim da mensagem + * @return static + */ + public static function gatewayLimitation(string $gateway, Capability $capability, string $detail = ''): static + { + $message = "O gateway {$gateway} não oferece a capability [{$capability->value}]."; + + return new static(self::appendDetail($message, $detail), $gateway, $capability, self::REASON_GATEWAY_LIMITATION); + } + + /** + * O gateway oferece a capability só numa parte dos casos e este pedido está fora dela; a + * mensagem descreve a restrição. O motivo é `gateway_limitation`. + * + * @param string $gateway + * @param Capability $capability + * @param string $message + * @return static + */ + public static function restricted(string $gateway, Capability $capability, string $message): static + { + return new static($message, $gateway, $capability, self::REASON_GATEWAY_LIMITATION); + } + + /** + * Decide o motivo pela declaração do gateway: capability em `notYetImplemented()` é + * `not_implemented`; fora das duas listas é `gateway_limitation`. + * + * @param GatewayContract $gateway + * @param Capability $capability + * @param string $detail orientação acrescentada ao fim da mensagem + * @return static + */ + public static function forGateway(GatewayContract $gateway, Capability $capability, string $detail = ''): static + { + if (in_array($capability, $gateway->notYetImplemented(), true)) { + return self::notImplemented((string) $gateway, $capability, $detail); + } + + return self::gatewayLimitation((string) $gateway, $capability, $detail); + } + + /** + * Diz se o motivo é `not_implemented`: o gateway oferece o recurso e a lib ainda não o + * implementou para ele. + * + * @return bool + */ + public function isNotImplemented(): bool + { + return $this->reason === self::REASON_NOT_IMPLEMENTED; + } + + /** + * Acrescenta a orientação à mensagem, separada por espaço. + * + * @param string $message + * @param string $detail + * @return string + */ + private static function appendDetail(string $message, string $detail): string + { + return $detail === '' ? $message : "{$message} {$detail}"; + } +} diff --git a/src/Facades/MultiPayment.php b/src/Facades/MultiPayment.php index a569b4c..2eeb623 100644 --- a/src/Facades/MultiPayment.php +++ b/src/Facades/MultiPayment.php @@ -25,6 +25,10 @@ * @method static CreditCard getCard(string $customerId, string $creditCardId) * @method static void deleteCard(string $customerId, string $creditCardId) * @method static \Potelo\MultiPayment\MultiPayment setGateway($gateway) + * @method static \Potelo\MultiPayment\Contracts\GatewayContract gateway($gateway = null) + * @method static bool supports(\Potelo\MultiPayment\Enums\Capability $capability, $gateway = null) + * @method static \Potelo\MultiPayment\Enums\Capability[] capabilities($gateway = null) + * @method static \Potelo\MultiPayment\Enums\Capability[] notYetImplemented($gateway = null) * @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) diff --git a/src/Gateways/Concerns/ChecksCapabilities.php b/src/Gateways/Concerns/ChecksCapabilities.php new file mode 100644 index 0000000..19ff863 --- /dev/null +++ b/src/Gateways/Concerns/ChecksCapabilities.php @@ -0,0 +1,61 @@ +capabilities(), true); + } + + /** + * Lança `UnsupportedOperationException` quando a capability não está em `capabilities()`. + * + * @param Capability $capability + * @param string $detail orientação acrescentada ao fim da mensagem + * @return void + * @throws UnsupportedOperationException + */ + protected function assertSupports(Capability $capability, string $detail = ''): void + { + if (!$this->supports($capability)) { + throw UnsupportedOperationException::forGateway($this, $capability, $detail); + } + } + + /** + * Lança `UnsupportedOperationException` na primeira capability da lista que não está em + * `capabilities()`. + * + * @param Capability[] $capabilities + * @return void + * @throws UnsupportedOperationException + */ + protected function assertSupportsAll(array $capabilities): void + { + foreach ($capabilities as $capability) { + $this->assertSupports($capability); + } + } +} diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index 0b03bcf..60f2026 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -25,22 +25,27 @@ use Potelo\MultiPayment\Models\SubscriptionDiscount; use Potelo\MultiPayment\Models\SubscriptionPlanChange; use Potelo\MultiPayment\Models\AutomaticPixCancellation; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\PlanInterval; use Potelo\MultiPayment\Contracts\PlanContract; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Contracts\SubscriptionContract; +use Potelo\MultiPayment\Gateways\Concerns\ChecksCapabilities; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; use Potelo\MultiPayment\Exceptions\MultiPaymentException; use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract { + use ChecksCapabilities; + private const STATUS_PENDING = 'pending'; private const STATUS_PAID = 'paid'; private const STATUS_EXTERNALLY_PAID = 'externally_paid'; @@ -75,10 +80,44 @@ public function __construct(?Iugu_APIRequest $apiRequest = null) /** * @inheritDoc - * @throws ModelAttributeValidationException|ChargingException + */ + public function capabilities(): array + { + return [ + Capability::CREDIT_CARD, + Capability::PIX, + Capability::BANK_SLIP, + Capability::AUTOMATIC_PIX, + Capability::MULTIPLE_PAYMENT_METHODS, + Capability::RAW_CARD_DATA, + Capability::INSTALLMENTS, + Capability::PARTIAL_REFUND_CARD, + Capability::INVOICE_DUPLICATION, + Capability::SUBSCRIPTIONS, + Capability::PLANS, + ]; + } + + /** + * @inheritDoc + */ + public function notYetImplemented(): array + { + return [ + Capability::DELAYED_CAPTURE, + Capability::IDEMPOTENCY, + Capability::SUBSCRIPTION_CREDITS, + ]; + } + + /** + * @inheritDoc + * @throws ModelAttributeValidationException|ChargingException|UnsupportedOperationException */ public function createInvoice(Invoice $invoice): Invoice { + $this->assertSupportsAll($invoice->requiredCapabilities()); + $iuguInvoiceData = []; $iuguInvoiceData['customer_id'] = $invoice->customer->id; @@ -1482,10 +1521,7 @@ public function resumeSubscription(Subscription $subscription): Subscription public function cancelSubscription(Subscription $subscription, bool $atPeriodEnd = false): Subscription { if ($atPeriodEnd) { - throw new GatewayException( - 'Iugu does not support cancelling a subscription at the end of the period. ' - . 'Suspend it on the date instead.' - ); + $this->assertSupports(Capability::CANCEL_AT_PERIOD_END, 'Suspenda a assinatura na data desejada.'); } return $this->suspendSubscription($subscription); @@ -1656,13 +1692,14 @@ public function listPlans(int $page = 1, int $limit = 100): array * @param Plan $plan * * @return Plan - * @throws GatewayException + * @throws UnsupportedOperationException */ public function deactivatePlan(Plan $plan): Plan { - throw new GatewayException( - 'Iugu plans have no active flag, so a plan cannot be deactivated. ' - . 'Stop referencing it when creating subscriptions instead.' + throw UnsupportedOperationException::forGateway( + $this, + Capability::PLAN_DEACTIVATION, + 'Planos da Iugu não têm flag de ativo; deixe de referenciar o plano ao criar assinaturas.' ); } @@ -1676,7 +1713,7 @@ public function deactivatePlan(Plan $plan): Plan * @param bool $creating * * @return array - * @throws GatewayException|ModelAttributeValidationException + * @throws GatewayException|ModelAttributeValidationException|UnsupportedOperationException */ private function subscriptionToIuguData(Subscription $subscription, bool $creating = true): array { @@ -1815,13 +1852,15 @@ private function subscriptionItemToIuguData(SubscriptionItem $item): array * @param SubscriptionDiscount $discount * * @return array - * @throws GatewayException + * @throws UnsupportedOperationException|ModelAttributeValidationException */ private function subscriptionDiscountToIuguData(SubscriptionDiscount $discount): array { if (!is_null($discount->percentOff)) { - throw new GatewayException( - 'Iugu does not support percentage discounts on subscriptions. Use amountOff.' + throw UnsupportedOperationException::forGateway( + $this, + Capability::NATIVE_COUPONS, + 'A Iugu não tem desconto percentual em assinatura; use amountOff.' ); } @@ -1830,9 +1869,10 @@ private function subscriptionDiscountToIuguData(SubscriptionDiscount $discount): } if (!is_null($discount->cycles) && $discount->cycles > 1) { - throw new GatewayException( - 'Iugu discounts last either one invoice or until removed, so cycles greater ' - . 'than 1 cannot be represented. Use cycles 1 or null.' + throw UnsupportedOperationException::forGateway( + $this, + Capability::NATIVE_COUPONS, + 'Na Iugu o desconto vale para uma fatura ou até ser removido; use cycles 1 ou nulo.' ); } diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 59cff11..44ccb73 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -23,19 +23,24 @@ use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Models\AutomaticPixCancellation; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Gateways\Concerns\ChecksCapabilities; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; use Potelo\MultiPayment\Exceptions\MultiPaymentException; use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; class StripeGateway implements GatewayContract { + use ChecksCapabilities; + /** * Versão da API Stripe usada pelo pacote. Fixada no código (em vez de herdar o default da * conta no Dashboard) para que upgrades de versão sejam decisão de código, não de configuração. @@ -92,6 +97,42 @@ public function __construct(?StripeClient $client = null) ]); } + /** + * @inheritDoc + */ + public function capabilities(): array + { + return [ + Capability::CREDIT_CARD, + Capability::PIX, + Capability::PARTIAL_REFUND_CARD, + Capability::PARTIAL_REFUND_PIX, + Capability::INVOICE_DUPLICATION, + Capability::IDEMPOTENCY, + ]; + } + + /** + * @inheritDoc + */ + public function notYetImplemented(): array + { + return [ + Capability::BANK_SLIP, + Capability::AUTOMATIC_PIX, + Capability::MULTIPLE_PAYMENT_METHODS, + Capability::DELAYED_CAPTURE, + Capability::IDEMPOTENCY_ALL_ENDPOINTS, + Capability::SUBSCRIPTIONS, + Capability::PLANS, + Capability::PLAN_DEACTIVATION, + Capability::CANCEL_AT_PERIOD_END, + Capability::NATIVE_COUPONS, + Capability::PLAN_CHANGE_PRORATION, + Capability::MANAGES_RECURRENCE, + ]; + } + /** * @inheritDoc */ @@ -478,52 +519,20 @@ private function translateStripeException(\Throwable $e): MultiPaymentException return new GatewayException($e->getMessage(), null, $e); } - /** - * Exceção padrão para operações que a Stripe oferece mas este driver ainda não construiu; - * mais clara que o methodNotFound do despacho por convenção, que sugeriria erro de digitação. - * - * @param string $operation - * @param string $advice orientação enquanto a operação não existe (ex.: usar a Iugu) - * @return GatewayException - */ - private function operationNotImplemented(string $operation, string $advice = ''): GatewayException - { - $message = "A operação [{$operation}] no Stripe ainda não está implementada nesta lib;" - . ' a Stripe suporta o recurso.'; - if ($advice !== '') { - $message .= ' ' . $advice; - } - - return new GatewayException($message); - } - /** * @inheritDoc - * @throws ChargingException|ModelAttributeValidationException + * @throws ChargingException|ModelAttributeValidationException|UnsupportedOperationException */ public function createInvoice(Invoice $invoice): Invoice { - // sem esta guarda a fatura seria criada como pix comum, descartando a recorrência - // silenciosamente, porque o Pix Automático no Stripe ainda não foi construído - if (!empty($invoice->automaticPix)) { - throw $this->operationNotImplemented( - 'createInvoice com Pix Automático', - 'Use a Iugu para Pix Automático por enquanto.' - ); - } + $this->assertSupportsAll($invoice->requiredCapabilities()); $paymentMethod = $this->invoicePaymentMethod($invoice); return match ($paymentMethod) { PaymentMethod::CREDIT_CARD => $this->createCreditCardInvoice($invoice), PaymentMethod::PIX => $this->createPixInvoice($invoice), - PaymentMethod::BANK_SLIP => throw $this->operationNotImplemented( - 'createInvoice com boleto', - 'Use a Iugu para boleto por enquanto.' - ), - default => throw $this->operationNotImplemented( - "createInvoice com o método de pagamento [{$paymentMethod->value}]" - ), + default => throw UnsupportedOperationException::forGateway($this, Capability::forPaymentMethod($paymentMethod)), }; } @@ -534,16 +543,16 @@ public function createInvoice(Invoice $invoice): Invoice * * @param \Potelo\MultiPayment\Models\Invoice $invoice * @return PaymentMethod - * @throws ModelAttributeValidationException + * @throws ModelAttributeValidationException|UnsupportedOperationException */ private function invoicePaymentMethod(Invoice $invoice): PaymentMethod { if (!empty($invoice->availablePaymentMethods)) { if (count($invoice->availablePaymentMethods) > 1) { - throw ModelAttributeValidationException::invalid( - 'Invoice', - 'availablePaymentMethods', - 'this library maps the invoice to a single PaymentIntent, so exactly one payment method per invoice is accepted for now' + throw UnsupportedOperationException::forGateway( + $this, + Capability::MULTIPLE_PAYMENT_METHODS, + 'Informe exatamente um método em availablePaymentMethods.' ); } @@ -1095,7 +1104,7 @@ private static function chargeFailureReason(?string $code, ?string $declineCode) * cancelada — se a criação falhar, o consumidor não fica sem fatura nenhuma. * Restrito a faturas pix pendentes (cartão é síncrono, não há o que duplicar). * - * @throws ModelAttributeValidationException + * @throws ModelAttributeValidationException|UnsupportedOperationException */ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gatewayOptions = []): Invoice { @@ -1112,12 +1121,18 @@ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gat $parsedOriginal = $this->parseInvoice($original, new Invoice()); if ($parsedOriginal->status !== InvoiceStatus::PENDING) { - throw new GatewayException( - "Only pending invoices can be duplicated on the stripe gateway; invoice [{$invoice->id}] is [{$parsedOriginal->status->value}]" + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::INVOICE_DUPLICATION, + "No Stripe só uma fatura Pix pendente pode ser duplicada; a fatura [{$invoice->id}] está [{$parsedOriginal->status->value}]." ); } if ($parsedOriginal->paymentMethod !== PaymentMethod::PIX) { - throw new GatewayException('Only pix invoices can be duplicated on the stripe gateway'); + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::INVOICE_DUPLICATION, + "No Stripe só uma fatura Pix pendente pode ser duplicada; a fatura [{$invoice->id}] não é Pix." + ); } if (empty($parsedOriginal->customer) || empty($parsedOriginal->customer->id)) { throw new GatewayException( @@ -1192,7 +1207,7 @@ public function cancelInvoice(Invoice $invoice): Invoice /** * @inheritDoc - * @throws ModelAttributeValidationException + * @throws ModelAttributeValidationException|UnsupportedOperationException */ public function createCreditCard(CreditCard $creditCard): CreditCard { @@ -1201,10 +1216,10 @@ public function createCreditCard(CreditCard $creditCard): CreditCard } if (empty($creditCard->token)) { // token-only: dados crus exigiriam a liberação de raw card data APIs pela - // Stripe e escopo PCI SAQ D — o cartão é tokenizado client-side - throw new GatewayException( - 'The stripe gateway does not accept raw card data;' - . ' tokenize the card client-side with Stripe.js and provide the resulting id in the CreditCard token' + // Stripe e escopo PCI SAQ D; o cartão é tokenizado client-side + $this->assertSupports( + Capability::RAW_CARD_DATA, + 'Tokenize o cartão no navegador com Stripe.js e informe o id resultante em CreditCard::$token.' ); } @@ -1346,7 +1361,7 @@ private function parseStripeCard(StripePaymentMethod $stripePaymentMethod, ?Cred */ public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice { - throw $this->operationNotImplemented('rescheduleAutomaticPixPayment', 'Use a Iugu para Pix Automático por enquanto.'); + throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); } /** @@ -1354,7 +1369,7 @@ public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice */ public function cancelAutomaticPixScheduledPayment(AutomaticPixCharge $charge): AutomaticPixCancellation { - throw $this->operationNotImplemented('cancelAutomaticPixScheduledPayment', 'Use a Iugu para Pix Automático por enquanto.'); + throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); } /** @@ -1362,7 +1377,7 @@ public function cancelAutomaticPixScheduledPayment(AutomaticPixCharge $charge): */ public function cancelAutomaticPixRecurrence(AutomaticPix $automaticPix): AutomaticPixCancellation { - throw $this->operationNotImplemented('cancelAutomaticPixRecurrence', 'Use a Iugu para Pix Automático por enquanto.'); + throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); } /** @@ -1370,7 +1385,7 @@ public function cancelAutomaticPixRecurrence(AutomaticPix $automaticPix): Automa */ public function getAutomaticPixCancellation(AutomaticPixCancellation $cancellation): AutomaticPixCancellation { - throw $this->operationNotImplemented('getAutomaticPixCancellation', 'Use a Iugu para Pix Automático por enquanto.'); + throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); } /** @@ -1378,7 +1393,7 @@ public function getAutomaticPixCancellation(AutomaticPixCancellation $cancellati */ public function listAutomaticPixCancellations(AutomaticPix $automaticPix, int $page = 1, int $limit = 100): array { - throw $this->operationNotImplemented('listAutomaticPixCancellations', 'Use a Iugu para Pix Automático por enquanto.'); + throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); } /** diff --git a/src/Helpers/CapabilitiesTable.php b/src/Helpers/CapabilitiesTable.php new file mode 100644 index 0000000..c04649c --- /dev/null +++ b/src/Helpers/CapabilitiesTable.php @@ -0,0 +1,66 @@ + $gateways nome do gateway como chave + * @return string + */ + public static function markdown(array $gateways): string + { + $header = '| Capability | Significado | ' . implode(' | ', array_map('ucfirst', array_keys($gateways))) . ' |'; + $separator = '|---|---|' . str_repeat('---|', count($gateways)); + + $rows = []; + foreach (Capability::cases() as $capability) { + $cells = array_map( + static fn (DeclaresCapabilities $gateway) => self::cell($gateway, $capability), + array_values($gateways) + ); + $rows[] = "| `{$capability->name}` | {$capability->description()} | " . implode(' | ', $cells) . ' |'; + } + + return implode("\n", array_merge([$header, $separator], $rows)) . "\n"; + } + + /** + * Célula da matriz para um gateway e uma capability. + * + * @param DeclaresCapabilities $gateway + * @param Capability $capability + * @return string + */ + public static function cell(DeclaresCapabilities $gateway, Capability $capability): string + { + if ($gateway->supports($capability)) { + return self::SUPPORTED; + } + + if (in_array($capability, $gateway->notYetImplemented(), true)) { + return self::NOT_IMPLEMENTED; + } + + return self::GATEWAY_LIMITATION; + } +} diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 5959cbc..6cf361b 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -3,6 +3,7 @@ namespace Potelo\MultiPayment\Models; use Carbon\Carbon; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Contracts\GatewayContract; @@ -315,6 +316,11 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate if ($validate) { $this->validate(); } + // resolvido e verificado antes de salvar o cliente, para nenhuma requisição sair + // quando o gateway não suporta a fatura; no update vale a regra do Model (o gateway + // gravado no model prevalece) + $gateway = ConfigurationHelper::resolveGateway($this->gatewayForSave($gateway)); + $this->assertGatewaySupports($gateway); if (empty($this->customer->id)) { $this->customer->save($gateway, $validate); } @@ -324,6 +330,52 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate parent::save($gateway, false); } + /** + * Na criação, além do que o `Model` exige, a fatura precisa da capability de cada método + * selecionável em `availablePaymentMethods` (ou de cartão, quando só `creditCard` foi + * informado), de `MULTIPLE_PAYMENT_METHODS` quando há mais de um método, de + * `AUTOMATIC_PIX` quando `automaticPix` está preenchido e de `RAW_CARD_DATA` quando o + * cartão vem com os dados crus (sem `id` nem `token`). Valor fora de + * `PaymentMethod::selectable()` fica para a validação. Com `id` preenchido, só o que o + * `Model` exige. + * + * @return Capability[] + */ + public function requiredCapabilities(): array + { + $capabilities = parent::requiredCapabilities(); + if (!empty($this->id)) { + return $capabilities; + } + + $methods = []; + foreach ((array) ($this->availablePaymentMethods ?? []) as $method) { + $case = $method instanceof PaymentMethod ? $method : (is_string($method) ? PaymentMethod::tryFrom($method) : null); + if (!is_null($case) && in_array($case, PaymentMethod::selectable(), true)) { + $methods[] = $case; + } + } + + if (empty($methods) && !empty($this->creditCard)) { + $methods[] = PaymentMethod::CREDIT_CARD; + } + + foreach ($methods as $method) { + $capabilities[] = Capability::forPaymentMethod($method); + } + if (count($methods) > 1) { + $capabilities[] = Capability::MULTIPLE_PAYMENT_METHODS; + } + if (!empty($this->automaticPix)) { + $capabilities[] = Capability::AUTOMATIC_PIX; + } + if (!empty($this->creditCard) && empty($this->creditCard->id) && empty($this->creditCard->token)) { + $capabilities[] = Capability::RAW_CARD_DATA; + } + + return array_values(array_unique($capabilities, SORT_REGULAR)); + } + /** * Diz se o dinheiro da fatura foi recebido; delega a `InvoiceStatus::isSettled()`. String * fora do enum devolve falso. diff --git a/src/Models/Model.php b/src/Models/Model.php index c890681..85709d9 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -2,11 +2,13 @@ namespace Potelo\MultiPayment\Models; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Contracts\AcceptsUnknownValue; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; /** @@ -25,6 +27,14 @@ abstract class Model implements \JsonSerializable */ protected const ENUM_CASTS = []; + /** + * Capability que o gateway precisa declarar para operar este model, ou nulo quando qualquer + * gateway serve. `requiredCapabilities()` a devolve junto com as derivadas dos atributos. + * + * @var Capability|null + */ + protected const REQUIRED_CAPABILITY = null; + /** * Opções extras enviadas direto ao gateway. Cada driver mescla este array ao payload que * monta a partir do model, e as chaves daqui sobrepõem as geradas. @@ -240,6 +250,7 @@ public function create(array $data, $gateway = null): void * * @return void * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws UnsupportedOperationException */ public function save(GatewayContract|string|null $gateway = null, bool $validate = true): void { @@ -247,23 +258,70 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate if (property_exists($this, 'id') && !empty($this->id)) { $method = 'update'; $validate = false; - // If gateway from the model is set, we will use it - $gateway = property_exists($this, 'gateway') && !empty($this->gateway) ? $this->gateway : $gateway; } else { $method = 'create'; } $method = $method . $class; + $gateway = $this->gatewayForSave($gateway); if ($validate) { $this->validate(); } $gatewayClass = ConfigurationHelper::resolveGateway($gateway); + $this->assertGatewaySupports($gatewayClass); if (!method_exists($gatewayClass, $method)) { throw GatewayException::methodNotFound(get_class($gatewayClass), $method); } $gatewayClass->$method($this); } + /** + * Gateway que `save()` usa: no update (com `id`), o gateway gravado no model prevalece sobre + * o informado; na criação, o informado prevalece e o do model é o segundo candidato. Nulo + * deixa `ConfigurationHelper` escolher o default. + * + * @param GatewayContract|string|null $gateway + * @return GatewayContract|string|null + */ + protected function gatewayForSave(GatewayContract|string|null $gateway): GatewayContract|string|null + { + $own = property_exists($this, 'gateway') && !empty($this->gateway) ? $this->gateway : null; + + if (property_exists($this, 'id') && !empty($this->id)) { + return $own ?? $gateway; + } + + return $gateway ?? $own; + } + + /** + * Capabilities que o gateway precisa declarar para a operação em curso sobre este model: + * `REQUIRED_CAPABILITY`, quando definida, mais as que o model derivar dos seus atributos. + * + * @return Capability[] + */ + public function requiredCapabilities(): array + { + return is_null(static::REQUIRED_CAPABILITY) ? [] : [static::REQUIRED_CAPABILITY]; + } + + /** + * Lança `UnsupportedOperationException` na primeira capability de `requiredCapabilities()` + * que o gateway não declara, antes de qualquer requisição. + * + * @param GatewayContract $gateway + * @return void + * @throws UnsupportedOperationException + */ + protected function assertGatewaySupports(GatewayContract $gateway): void + { + foreach ($this->requiredCapabilities() as $capability) { + if (!$gateway->supports($capability)) { + throw UnsupportedOperationException::forGateway($gateway, $capability); + } + } + } + /** * Validate the model. * @@ -380,11 +438,13 @@ protected static function getClassName(): string * @return static * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws UnsupportedOperationException */ public function get(GatewayContract|string|null $gateway = null): static { $method = 'get' . static::getClassName(); $gateway = ConfigurationHelper::resolveGateway($gateway); + $this->assertGatewaySupports($gateway); if (!method_exists($gateway, $method)) { throw GatewayException::methodNotFound(get_class($gateway), $method); } @@ -398,11 +458,13 @@ public function get(GatewayContract|string|null $gateway = null): static * @return void * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws UnsupportedOperationException */ public function delete(GatewayContract|string|null $gateway = null): void { $method = 'delete' . static::getClassName(); $gateway = ConfigurationHelper::resolveGateway($gateway); + $this->assertGatewaySupports($gateway); if (!method_exists($gateway, $method)) { throw GatewayException::methodNotFound(get_class($gateway), $method); } diff --git a/src/Models/Plan.php b/src/Models/Plan.php index ccc22d8..419c864 100644 --- a/src/Models/Plan.php +++ b/src/Models/Plan.php @@ -2,6 +2,7 @@ namespace Potelo\MultiPayment\Models; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\PlanInterval; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Exceptions\GatewayException; @@ -28,6 +29,8 @@ class Plan extends Model 'interval' => PlanInterval::class, ]; + protected const REQUIRED_CAPABILITY = Capability::PLANS; + /** * @var string|null */ diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index 06cf022..4ed517f 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -3,11 +3,13 @@ namespace Potelo\MultiPayment\Models; use Carbon\Carbon; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Contracts\SubscriptionContract; use Potelo\MultiPayment\Helpers\ConfigurationHelper; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; /** @@ -34,6 +36,31 @@ class Subscription extends Model 'availablePaymentMethods' => [PaymentMethod::class], ]; + protected const REQUIRED_CAPABILITY = Capability::SUBSCRIPTIONS; + + /** + * Além de `SUBSCRIPTIONS`, a assinatura precisa de `NATIVE_COUPONS` quando algum desconto é + * percentual ou limitado a mais de um ciclo. + * + * @return Capability[] + */ + public function requiredCapabilities(): array + { + $capabilities = parent::requiredCapabilities(); + + foreach ($this->discounts ?? [] as $discount) { + if ( + $discount instanceof SubscriptionDiscount + && (!is_null($discount->percentOff) || (!is_null($discount->cycles) && $discount->cycles > 1)) + ) { + $capabilities[] = Capability::NATIVE_COUPONS; + break; + } + } + + return $capabilities; + } + /** * @var string|null */ @@ -328,6 +355,7 @@ protected function attributesExtraValidation(array $attributes): void * @return void * @throws GatewayException|\Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws UnsupportedOperationException */ public function save(GatewayContract|string|null $gateway = null, bool $validate = true): void { @@ -335,6 +363,11 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate $this->validate(); } + // resolvido e verificado antes de salvar o cliente, para nenhuma requisição sair + // quando o gateway não suporta assinatura; no update vale a regra do Model (o gateway + // gravado no model prevalece) + $gateway = $this->resolveSubscriptionGateway($this->gatewayForSave($gateway)); + if (empty($this->id) && !empty($this->customer) && empty($this->customer->id)) { $this->customer->save($gateway, $validate); } @@ -343,22 +376,25 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate } /** - * Resolve o gateway e garante que ele implementa as operações de assinatura. + * Resolve o gateway e garante que ele declara `Capability::SUBSCRIPTIONS` e implementa + * `SubscriptionContract`. * * @param GatewayContract|string|null $gateway * * @return GatewayContract&SubscriptionContract * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws UnsupportedOperationException * @throws GatewayException */ private function resolveSubscriptionGateway(GatewayContract|string|null $gateway) { $resolved = ConfigurationHelper::resolveGateway($gateway ?? $this->gateway); + $this->assertGatewaySupports($resolved); if (!$resolved instanceof SubscriptionContract) { throw new GatewayException( - 'Gateway [' . get_class($resolved) . '] does not implement SubscriptionContract;' - . ' subscriptions are not yet implemented in this library for that gateway' + 'Gateway [' . get_class($resolved) . '] declares the subscriptions capability' + . ' but does not implement SubscriptionContract' ); } @@ -375,6 +411,7 @@ private function resolveSubscriptionGateway(GatewayContract|string|null $gateway * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + * @throws UnsupportedOperationException */ public function suspend(GatewayContract|string|null $gateway = null): Subscription { @@ -391,6 +428,7 @@ public function suspend(GatewayContract|string|null $gateway = null): Subscripti * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + * @throws UnsupportedOperationException */ public function resume(GatewayContract|string|null $gateway = null): Subscription { @@ -408,6 +446,7 @@ public function resume(GatewayContract|string|null $gateway = null): Subscriptio * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + * @throws UnsupportedOperationException */ public function cancel(bool $atPeriodEnd = false, GatewayContract|string|null $gateway = null): Subscription { @@ -429,6 +468,7 @@ public function cancel(bool $atPeriodEnd = false, GatewayContract|string|null $g * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + * @throws UnsupportedOperationException */ public function changePlan( string $planId, @@ -450,6 +490,7 @@ public function changePlan( * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + * @throws UnsupportedOperationException */ public function previewPlanChange( string $planId, diff --git a/src/MultiPayment.php b/src/MultiPayment.php index 113050b..0a61380 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -3,7 +3,9 @@ namespace Potelo\MultiPayment; use Carbon\Carbon; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\MultiPaymentException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Customer; @@ -52,6 +54,56 @@ public function setGateway($gateway): MultiPayment return $this; } + /** + * Devolve o driver do gateway informado, ou o desta instância quando nenhum é informado. + * + * @param GatewayContract|string|null $gateway + * @return GatewayContract + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function gateway($gateway = null): GatewayContract + { + return is_null($gateway) ? $this->gateway : ConfigurationHelper::resolveGateway($gateway); + } + + /** + * Diz se o gateway (o desta instância, por padrão) suporta a capability. + * + * @param Capability $capability + * @param GatewayContract|string|null $gateway + * @return bool + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function supports(Capability $capability, $gateway = null): bool + { + return $this->gateway($gateway)->supports($capability); + } + + /** + * Capabilities que o gateway (o desta instância, por padrão) oferece e a lib implementa. + * + * @param GatewayContract|string|null $gateway + * @return Capability[] + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function capabilities($gateway = null): array + { + return $this->gateway($gateway)->capabilities(); + } + + /** + * Capabilities que o gateway (o desta instância, por padrão) oferece mas a lib ainda não + * implementa. + * + * @param GatewayContract|string|null $gateway + * @return Capability[] + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function notYetImplemented($gateway = null): array + { + return $this->gateway($gateway)->notYetImplemented(); + } + /** * Charge a customer * @@ -119,7 +171,7 @@ public function newSubscription(): SubscriptionBuilder * @param int $limit * * @return Subscription[] - * @throws GatewayException|GatewayNotAvailableException + * @throws GatewayException|GatewayNotAvailableException|UnsupportedOperationException */ public function listSubscriptions(Customer|string $customer, int $page = 1, int $limit = 100): array { @@ -129,7 +181,7 @@ public function listSubscriptions(Customer|string $customer, int $page = 1, int $customer = $customerModel; } - return $this->gatewayImplementing(SubscriptionContract::class) + return $this->gatewayImplementing(SubscriptionContract::class, Capability::SUBSCRIPTIONS) ->listSubscriptions($customer, $page, $limit); } @@ -140,28 +192,34 @@ public function listSubscriptions(Customer|string $customer, int $page = 1, int * @param int $limit * * @return Plan[] - * @throws GatewayException|GatewayNotAvailableException + * @throws GatewayException|GatewayNotAvailableException|UnsupportedOperationException */ public function listPlans(int $page = 1, int $limit = 100): array { - return $this->gatewayImplementing(PlanContract::class)->listPlans($page, $limit); + return $this->gatewayImplementing(PlanContract::class, Capability::PLANS)->listPlans($page, $limit); } /** - * Ensure this instance's gateway implements the given contract. + * Ensure this instance's gateway declares the capability and implements the contract behind it. * * @param class-string $contract + * @param Capability $capability * * @return GatewayContract + * @throws UnsupportedOperationException * @throws GatewayException */ - private function gatewayImplementing(string $contract): GatewayContract + private function gatewayImplementing(string $contract, Capability $capability): GatewayContract { + if (!$this->gateway->supports($capability)) { + throw UnsupportedOperationException::forGateway($this->gateway, $capability); + } + if (!$this->gateway instanceof $contract) { $contractName = substr(strrchr($contract, '\\'), 1); throw new GatewayException( - 'Gateway [' . get_class($this->gateway) . "] does not implement {$contractName};" - . ' the operations of that contract are not yet implemented in this library for that gateway' + 'Gateway [' . get_class($this->gateway) . "] declares the {$capability->value} capability" + . " but does not implement {$contractName}" ); } diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index 5393cb1..5ebf673 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -6,6 +6,8 @@ use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Facades\MultiPayment; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\ChargingException; use PHPUnit\Framework\Attributes\DataProvider; use Potelo\MultiPayment\Enums\InvoiceStatus; @@ -356,9 +358,12 @@ public function testShouldRejectBankSlipInvoice($gateway) ->addItem('Assinatura mensal', 9900, 1) ->setAvailablePaymentMethods([PaymentMethod::BANK_SLIP]); - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('[createInvoice com boleto] no Stripe ainda não está implementada nesta lib'); - - $invoiceBuilder->create(); + try { + $invoiceBuilder->create(); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::BANK_SLIP, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); + } } } diff --git a/tests/Unit/CapabilityGuardsTest.php b/tests/Unit/CapabilityGuardsTest.php new file mode 100644 index 0000000..5ba16ea --- /dev/null +++ b/tests/Unit/CapabilityGuardsTest.php @@ -0,0 +1,394 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => [ + 'iugu' => ['api_key' => 'iugu-key', 'class' => IuguGateway::class], + 'stripe' => ['api_key' => 'sk_test_fake', 'class' => StripeGateway::class], + ], + ], + ])); + Facade::setFacadeApplication($app); + + $this->stripeHttp = RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testSubscriptionCreationOnStripeFailsBeforeCreatingTheCustomer(): void + { + $customer = new Customer(); + $customer->name = 'Fulano'; + $customer->email = 'fulano@exemplo.com'; + + $builder = (new MultiPayment('stripe'))->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomer($customer); + + $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $builder->create()); + } + + public function testSubscriptionDomainMethodsOnStripeFailBeforeTheNetwork(): void + { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->get('stripe')); + $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->suspend('stripe')); + $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->changePlan('plano_anual', true, 'stripe')); + $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->previewPlanChange('plano_anual', 'stripe')); + $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->resume('stripe')); + $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->cancel(false, 'stripe')); + $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => (new MultiPayment('stripe'))->listSubscriptions('cus_1')); + + $existing = new Subscription(); + $existing->id = 'sub_1'; + $existing->metadata = ['origem' => 'teste']; + $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $existing->save('stripe')); + } + + /** + * No update, o gateway gravado no model prevalece sobre o informado, como em `Model::save()`. + */ + public function testUpdateUsesTheGatewayStoredInTheModel(): void + { + $api = new QueuedIuguApiRequest([]); + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->gateway = 'stripe'; + + $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->save(new IuguGateway($api))); + $this->assertCount(0, $api->calls); + } + + /** + * `Model::delete()` confere a capability antes de procurar o método de despacho. + */ + public function testDeleteChecksTheCapabilityBeforeTheDispatchMethod(): void + { + $plan = new Plan(); + $plan->id = 'plan_1'; + + $this->assertNotImplemented(Capability::PLANS, fn () => $plan->delete('stripe')); + } + + public function testPlanOperationsOnStripeFailBeforeTheNetwork(): void + { + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + $this->assertNotImplemented(Capability::PLANS, fn () => $plan->save('stripe')); + + $existing = new Plan(); + $existing->id = 'plan_1'; + $this->assertNotImplemented(Capability::PLANS, fn () => $existing->get('stripe')); + $this->assertNotImplemented(Capability::PLANS, fn () => (new MultiPayment('stripe'))->listPlans()); + } + + public function testBankSlipChargeOnStripeFailsBeforeCreatingTheCustomer(): void + { + $multiPayment = new MultiPayment('stripe'); + + $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $multiPayment->charge([ + 'items' => [['description' => 'Mensalidade', 'price' => 10000, 'quantity' => 1]], + 'available_payment_methods' => [PaymentMethod::BANK_SLIP->value], + 'customer' => ['name' => 'Fulano', 'email' => 'fulano@exemplo.com', 'tax_document' => '20176996915'], + ])); + } + + public function testAutomaticPixInvoiceOnStripeFailsBeforeCreatingTheCustomer(): void + { + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'recurrence-id'; + + $builder = (new MultiPayment('stripe'))->newInvoice() + ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->addAvailablePaymentMethod(PaymentMethod::PIX) + ->setAutomaticPix($automaticPix); + + $this->assertNotImplemented(Capability::AUTOMATIC_PIX, fn () => $builder->create()); + } + + public function testRawCardInvoiceOnStripeFailsBeforeCreatingTheCustomer(): void + { + $builder = (new MultiPayment('stripe'))->newInvoice() + ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->addAvailablePaymentMethod(PaymentMethod::CREDIT_CARD) + ->addCreditCard('4111111111111111', '12', '2030', '123', 'Fulano', 'Silva'); + + $this->assertUnsupported( + Capability::RAW_CARD_DATA, + UnsupportedOperationException::REASON_GATEWAY_LIMITATION, + 'stripe', + fn () => $builder->create() + ); + $this->assertSame([], $this->stripeHttp->calls); + } + + public function testPercentDiscountSubscriptionOnIuguFailsBeforeCreatingTheCustomer(): void + { + $api = new QueuedIuguApiRequest([]); + $customer = new Customer(); + $customer->name = 'Fulano'; + $customer->email = 'fulano@exemplo.com'; + + $builder = (new MultiPayment(new IuguGateway($api)))->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomer($customer) + ->addPercentDiscount('Anual', 10.0); + + $this->assertUnsupported( + Capability::NATIVE_COUPONS, + UnsupportedOperationException::REASON_GATEWAY_LIMITATION, + 'iugu', + fn () => $builder->create() + ); + $this->assertCount(0, $api->calls); + } + + public function testMultiMethodInvoiceOnStripeFailsBeforeCreatingTheCustomer(): void + { + $builder = (new MultiPayment('stripe'))->newInvoice() + ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->setAvailablePaymentMethods([PaymentMethod::PIX, PaymentMethod::CREDIT_CARD]); + + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $builder->create()); + } + + /** + * A primeira capability recusada é a do método de pagamento, antes da de multi-método. + */ + public function testTheFirstMissingCapabilityIsThePaymentMethod(): void + { + $builder = (new MultiPayment('stripe'))->newInvoice() + ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->setAvailablePaymentMethods([PaymentMethod::PIX, PaymentMethod::BANK_SLIP]); + + $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $builder->create()); + } + + /** + * `requiredCapabilities()` ignora método fora de `PaymentMethod::selectable()`, como + * `AUTOMATIC_PIX` em `availablePaymentMethods`. + */ + public function testInvoiceRequiredCapabilitiesIgnoreNonSelectableMethods(): void + { + $invoice = new Invoice(); + $invoice->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX]; + + $this->assertSame([], $invoice->requiredCapabilities()); + } + + public function testInvoiceRequiredCapabilitiesDeriveFromTheAttributes(): void + { + $invoice = new Invoice(); + $invoice->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::BANK_SLIP]; + $invoice->automaticPix = new AutomaticPix(); + + $this->assertSame( + [Capability::PIX, Capability::BANK_SLIP, Capability::MULTIPLE_PAYMENT_METHODS, Capability::AUTOMATIC_PIX], + $invoice->requiredCapabilities() + ); + + $cardOnly = new Invoice(); + $cardOnly->creditCard = new \Potelo\MultiPayment\Models\CreditCard(); + $cardOnly->creditCard->id = 'pm_1'; + $this->assertSame([Capability::CREDIT_CARD], $cardOnly->requiredCapabilities()); + + $rawCard = new Invoice(); + $rawCard->creditCard = new \Potelo\MultiPayment\Models\CreditCard(); + $rawCard->creditCard->number = '4111111111111111'; + $this->assertSame([Capability::CREDIT_CARD, Capability::RAW_CARD_DATA], $rawCard->requiredCapabilities()); + + $tokenized = new Invoice(); + $tokenized->creditCard = new \Potelo\MultiPayment\Models\CreditCard(); + $tokenized->creditCard->token = 'pm_tok'; + $this->assertSame([Capability::CREDIT_CARD], $tokenized->requiredCapabilities()); + + $subscription = new Subscription(); + $this->assertSame([Capability::SUBSCRIPTIONS], $subscription->requiredCapabilities()); + $discount = new \Potelo\MultiPayment\Models\SubscriptionDiscount(); + $discount->percentOff = 10.0; + $subscription->discounts = [$discount]; + $this->assertSame([Capability::SUBSCRIPTIONS, Capability::NATIVE_COUPONS], $subscription->requiredCapabilities()); + + $existing = new Invoice(); + $existing->id = 'inv_1'; + $existing->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $this->assertSame([], $existing->requiredCapabilities()); + } + + public function testIuguInvoiceWithEveryMethodPassesTheGuardAndCreatesTheCustomerFirst(): void + { + $api = (new QueuedIuguApiRequest([ + (object) [ + 'id' => 'cus_novo', + 'name' => 'Fulano', + 'email' => 'fulano@exemplo.com', + 'cpf_cnpj' => '20176996915', + 'phone' => null, + 'phone_prefix' => null, + 'created_at' => '2026-09-02T09:00:00-03:00', + 'custom_variables' => [], + 'default_payment_method_id' => null, + 'errors' => null, + ], + (object) [ + 'id' => 'inv_1', + 'status' => 'pending', + 'total_cents' => 10000, + 'paid_at' => null, + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'taxes_paid_cents' => null, + 'created_at_iso' => '2026-09-02T09:00:00-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 0, + 'due_date' => '2026-10-01', + 'payment_method' => null, + 'payable_with' => ['credit_card', 'bank_slip', 'pix'], + 'customer_id' => 'cus_novo', + 'customer_name' => 'Fulano', + 'email' => 'fulano@exemplo.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [(object) ['description' => 'Mensalidade', 'price_cents' => 10000, 'quantity' => 1]], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + 'errors' => null, + ], + ]))->installAsSdkRequester(); + + try { + $invoice = (new MultiPayment('iugu'))->newInvoice() + ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->setAvailablePaymentMethods([PaymentMethod::CREDIT_CARD, PaymentMethod::BANK_SLIP, PaymentMethod::PIX]) + ->create(); + } finally { + QueuedIuguApiRequest::restoreSdkRequester(); + } + + $this->assertCount(2, $api->calls); + $this->assertStringEndsWith('/customers', $api->calls[0]['url']); + $this->assertStringEndsWith('/invoices', $api->calls[1]['url']); + $this->assertSame('cus_novo', $invoice->customer->id); + $this->assertSame('inv_1', $invoice->id); + } + + public function testFacadeExposesTheDeclarations(): void + { + $multiPayment = new MultiPayment('stripe'); + + $iugu = new IuguGateway(new QueuedIuguApiRequest([])); + $this->assertInstanceOf(StripeGateway::class, $multiPayment->gateway()); + $this->assertInstanceOf(IuguGateway::class, $multiPayment->gateway('iugu')); + $this->assertSame($iugu, $multiPayment->gateway($iugu)); + $this->assertTrue($multiPayment->supports(Capability::PIX)); + $this->assertFalse($multiPayment->supports(Capability::BANK_SLIP)); + $this->assertTrue($multiPayment->supports(Capability::BANK_SLIP, 'iugu')); + $this->assertTrue($multiPayment->gateway('iugu')->supports(Capability::INSTALLMENTS)); + $this->assertSame((new StripeGateway())->capabilities(), $multiPayment->capabilities()); + $this->assertSame((new StripeGateway())->notYetImplemented(), $multiPayment->notYetImplemented('stripe')); + $this->assertContains(Capability::SUBSCRIPTIONS, $multiPayment->capabilities('iugu')); + $this->assertSame([], $this->stripeHttp->calls); + } + + public function testFacadeDocblockAnnotatesTheCapabilityMethods(): void + { + $docblock = (new \ReflectionClass(\Potelo\MultiPayment\Facades\MultiPayment::class))->getDocComment(); + + foreach (['gateway(', 'supports(', 'capabilities(', 'notYetImplemented('] as $method) { + $this->assertMatchesRegularExpression('/@method static .*' . preg_quote($method, '/') . '/', $docblock, $method); + } + } + + public function testLaravelFacadeResolvesTheCapabilityMethods(): void + { + Facade::getFacadeApplication()->bind('multiPayment', fn () => new MultiPayment('stripe')); + + $this->assertTrue(\Potelo\MultiPayment\Facades\MultiPayment::supports(Capability::PIX)); + $this->assertFalse(\Potelo\MultiPayment\Facades\MultiPayment::supports(Capability::BANK_SLIP)); + $this->assertContains(Capability::SUBSCRIPTIONS, \Potelo\MultiPayment\Facades\MultiPayment::capabilities('iugu')); + $this->assertInstanceOf(StripeGateway::class, \Potelo\MultiPayment\Facades\MultiPayment::gateway()); + } + + /** + * Executa a operação esperando `UnsupportedOperationException` com `not_implemented` para + * a capability informada, e afirma que nenhuma requisição saiu para a Stripe. + */ + private function assertNotImplemented(Capability $capability, callable $operation): void + { + $this->assertUnsupported($capability, UnsupportedOperationException::REASON_NOT_IMPLEMENTED, 'stripe', $operation); + $this->assertSame([], $this->stripeHttp->calls, 'a guarda deixou uma requisição sair'); + } + + /** + * Executa a operação esperando `UnsupportedOperationException` com a capability, o motivo + * e o gateway informados. + */ + private function assertUnsupported(Capability $capability, string $reason, string $gateway, callable $operation): void + { + try { + $operation(); + $this->fail("Esperava UnsupportedOperationException para {$capability->name}"); + } catch (UnsupportedOperationException $e) { + $this->assertSame($capability, $e->capability); + $this->assertSame($gateway, $e->gateway); + $this->assertSame($reason, $e->reason); + } + } +} diff --git a/tests/Unit/Enums/CapabilityTest.php b/tests/Unit/Enums/CapabilityTest.php new file mode 100644 index 0000000..9b9b6a4 --- /dev/null +++ b/tests/Unit/Enums/CapabilityTest.php @@ -0,0 +1,73 @@ +description(); + + $this->assertNotSame('', $description, "{$capability->name} sem docblock"); + $this->assertStringNotContainsString("\n", $description, "{$capability->name} com quebra de linha na descrição"); + $this->assertStringNotContainsString('*', $description, "{$capability->name} com resto de docblock na descrição"); + $this->assertStringEndsWith('.', $description, "{$capability->name} sem ponto final"); + } + + #[DataProvider('capabilityProvider')] + public function testValueIsTheSnakeCaseOfTheCaseName(Capability $capability): void + { + $this->assertSame(strtolower($capability->name), $capability->value); + } + + public static function capabilityProvider(): array + { + $cases = []; + foreach (Capability::cases() as $capability) { + $cases[$capability->name] = [$capability]; + } + + return $cases; + } + + #[DataProvider('paymentMethodProvider')] + public function testEveryPaymentMethodMapsToACapability(PaymentMethod $paymentMethod, Capability $expected): void + { + $this->assertSame($expected, Capability::forPaymentMethod($paymentMethod)); + } + + public static function paymentMethodProvider(): array + { + return [ + 'cartão' => [PaymentMethod::CREDIT_CARD, Capability::CREDIT_CARD], + 'pix' => [PaymentMethod::PIX, Capability::PIX], + 'boleto' => [PaymentMethod::BANK_SLIP, Capability::BANK_SLIP], + 'pix automático' => [PaymentMethod::AUTOMATIC_PIX, Capability::AUTOMATIC_PIX], + ]; + } + + public function testDescriptionJoinsAMultilineDocblockIntoOneLine(): void + { + $this->assertSame( + 'O gateway agenda as cobranças do Pix Automático por conta própria; sem ela, a aplicação é' + . ' o motor de recorrência e chama as operações de `AutomaticPixContract` na periodicidade' + . ' certa.', + Capability::MANAGES_RECURRENCE->description() + ); + } + + public function testPaymentMethodMappingCoversEveryCase(): void + { + $this->assertCount(count(PaymentMethod::cases()), self::paymentMethodProvider()); + } +} diff --git a/tests/Unit/Exceptions/UnsupportedOperationExceptionTest.php b/tests/Unit/Exceptions/UnsupportedOperationExceptionTest.php new file mode 100644 index 0000000..597176f --- /dev/null +++ b/tests/Unit/Exceptions/UnsupportedOperationExceptionTest.php @@ -0,0 +1,141 @@ +assertInstanceOf( + MultiPaymentException::class, + UnsupportedOperationException::notImplemented('stripe', Capability::BANK_SLIP) + ); + } + + public function testNotImplementedAttributesTheGapToTheLibrary(): void + { + $exception = UnsupportedOperationException::notImplemented('stripe', Capability::BANK_SLIP); + + $this->assertSame(Capability::BANK_SLIP, $exception->capability); + $this->assertSame('stripe', $exception->gateway); + $this->assertSame('not_implemented', $exception->reason); + $this->assertTrue($exception->isNotImplemented()); + $this->assertSame( + 'A capability [bank_slip] ainda não está implementada nesta lib para o gateway stripe; o gateway oferece o recurso.', + $exception->getMessage() + ); + $this->assertNull($exception->httpStatus); + } + + public function testGatewayLimitationAttributesTheGapToTheGateway(): void + { + $exception = UnsupportedOperationException::gatewayLimitation('iugu', Capability::PARTIAL_REFUND_PIX, 'Repita sem valor parcial.'); + + $this->assertSame(Capability::PARTIAL_REFUND_PIX, $exception->capability); + $this->assertSame('iugu', $exception->gateway); + $this->assertSame('gateway_limitation', $exception->reason); + $this->assertFalse($exception->isNotImplemented()); + $this->assertSame( + 'O gateway iugu não oferece a capability [partial_refund_pix]. Repita sem valor parcial.', + $exception->getMessage() + ); + } + + public function testRestrictedKeepsTheCapabilityAndUsesTheGivenMessage(): void + { + $exception = UnsupportedOperationException::restricted('stripe', Capability::INVOICE_DUPLICATION, 'Só Pix pendente.'); + + $this->assertSame('Só Pix pendente.', $exception->getMessage()); + $this->assertSame(Capability::INVOICE_DUPLICATION, $exception->capability); + $this->assertSame('gateway_limitation', $exception->reason); + } + + public function testForGatewayReadsTheReasonFromTheDeclaration(): void + { + $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('notYetImplemented')->andReturn([Capability::BANK_SLIP]); + $gateway->shouldReceive('__toString')->andReturn('falso'); + + $notImplemented = UnsupportedOperationException::forGateway($gateway, Capability::BANK_SLIP); + $limitation = UnsupportedOperationException::forGateway($gateway, Capability::INSTALLMENTS, 'Detalhe.'); + + $this->assertSame('not_implemented', $notImplemented->reason); + $this->assertSame('falso', $notImplemented->gateway); + $this->assertSame('gateway_limitation', $limitation->reason); + $this->assertStringEndsWith(' Detalhe.', $limitation->getMessage()); + } + + public function testRefundNotSupportedIsAnUnsupportedOperationWithItsOwnReason(): void + { + $exception = RefundNotSupportedException::boletoNoRefund('iugu'); + + $this->assertInstanceOf(UnsupportedOperationException::class, $exception); + $this->assertSame(Capability::REFUND_BANK_SLIP, $exception->capability); + $this->assertSame('iugu', $exception->gateway); + $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $exception->reason); + $this->assertSame('bank_slip', $exception->paymentMethod); + $this->assertTrue($exception->manualRefundRequired); + $this->assertFalse($exception->isNotImplemented()); + } + + public function testRefundNotSupportedCapabilityPerReason(): void + { + $this->assertSame( + Capability::PARTIAL_REFUND_PIX, + RefundNotSupportedException::pixPartialNotSupported('iugu', 500, 1000)->capability + ); + $this->assertNull(RefundNotSupportedException::alreadyRefunded('stripe', 'pix')->capability); + $this->assertNull( + RefundNotSupportedException::refundWindowExpired('iugu', 'pix', \Carbon\Carbon::parse('2026-05-01'), 90)->capability + ); + $this->assertSame('stripe', RefundNotSupportedException::alreadyRefunded('stripe', 'pix')->gateway); + } + + /** + * O construtor de cinco argumentos continua aceito; gateway e capability ficam vazios. + */ + public function testRefundNotSupportedKeepsThePreviousConstructorSignature(): void + { + $previous = new \RuntimeException('sdk'); + $exception = new RefundNotSupportedException('msg', 'pix', RefundNotSupportedException::REASON_ALREADY_REFUNDED, false, $previous); + + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame('', $exception->gateway); + $this->assertNull($exception->capability); + $this->assertSame('already_refunded', $exception->reason); + } + + public function testRefundNotSupportedIsCaughtByBothNames(): void + { + $caught = []; + + try { + throw RefundNotSupportedException::boletoNoRefund('stripe'); + } catch (RefundNotSupportedException $e) { + $caught[] = 'refund'; + } + + try { + throw RefundNotSupportedException::boletoNoRefund('stripe'); + } catch (UnsupportedOperationException $e) { + $caught[] = 'unsupported'; + } + + $this->assertSame(['refund', 'unsupported'], $caught); + } +} diff --git a/tests/Unit/Gateways/GatewayCapabilitiesTest.php b/tests/Unit/Gateways/GatewayCapabilitiesTest.php new file mode 100644 index 0000000..fb0cb63 --- /dev/null +++ b/tests/Unit/Gateways/GatewayCapabilitiesTest.php @@ -0,0 +1,167 @@ +instance('config', new Repository([ + 'multi-payment.gateways.iugu.api_key' => 'iugu-key', + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + #[DataProvider('matrixProvider')] + public function testDriverDeclaresTheExpectedSupport(string $gateway, Capability $capability, string $expected): void + { + $driver = self::driver($gateway); + + $this->assertSame($expected, CapabilitiesTable::cell($driver, $capability)); + $this->assertSame($expected === self::SUPPORTED, $driver->supports($capability)); + } + + /** + * Uma célula por driver e capability. Linha nova em `Capability` sem entrada aqui falha + * em `testMatrixCoversEveryCapabilityForEveryDriver`. + */ + public static function matrixProvider(): array + { + $matrix = [ + // iugu stripe + Capability::CREDIT_CARD->name => [self::SUPPORTED, self::SUPPORTED], + Capability::PIX->name => [self::SUPPORTED, self::SUPPORTED], + Capability::BANK_SLIP->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], + Capability::AUTOMATIC_PIX->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], + Capability::MULTIPLE_PAYMENT_METHODS->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], + Capability::RAW_CARD_DATA->name => [self::SUPPORTED, self::LIMITATION], + Capability::INSTALLMENTS->name => [self::SUPPORTED, self::LIMITATION], + Capability::DELAYED_CAPTURE->name => [self::NOT_IMPLEMENTED, self::NOT_IMPLEMENTED], + Capability::PARTIAL_REFUND_CARD->name => [self::SUPPORTED, self::SUPPORTED], + Capability::PARTIAL_REFUND_PIX->name => [self::LIMITATION, self::SUPPORTED], + Capability::REFUND_BANK_SLIP->name => [self::LIMITATION, self::LIMITATION], + Capability::INVOICE_DUPLICATION->name => [self::SUPPORTED, self::SUPPORTED], + Capability::IDEMPOTENCY->name => [self::NOT_IMPLEMENTED, self::SUPPORTED], + Capability::IDEMPOTENCY_ALL_ENDPOINTS->name => [self::LIMITATION, self::NOT_IMPLEMENTED], + Capability::SUBSCRIPTIONS->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], + Capability::PLANS->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], + Capability::PLAN_DEACTIVATION->name => [self::LIMITATION, self::NOT_IMPLEMENTED], + Capability::CANCEL_AT_PERIOD_END->name => [self::LIMITATION, self::NOT_IMPLEMENTED], + Capability::NATIVE_COUPONS->name => [self::LIMITATION, self::NOT_IMPLEMENTED], + Capability::PLAN_CHANGE_PRORATION->name => [self::LIMITATION, self::NOT_IMPLEMENTED], + Capability::SUBSCRIPTION_CREDITS->name => [self::NOT_IMPLEMENTED, self::LIMITATION], + Capability::MANAGES_RECURRENCE->name => [self::LIMITATION, self::NOT_IMPLEMENTED], + ]; + + $cases = []; + foreach ($matrix as $name => [$iugu, $stripe]) { + $capability = constant(Capability::class . '::' . $name); + $cases["iugu {$name}"] = ['iugu', $capability, $iugu]; + $cases["stripe {$name}"] = ['stripe', $capability, $stripe]; + } + + return $cases; + } + + public function testMatrixCoversEveryCapabilityForEveryDriver(): void + { + $this->assertCount(count(Capability::cases()) * 2, self::matrixProvider()); + } + + #[DataProvider('driverProvider')] + public function testCapabilitiesAndNotYetImplementedDoNotOverlap(string $gateway): void + { + $driver = self::driver($gateway); + + $overlap = array_filter( + $driver->capabilities(), + static fn (Capability $capability) => in_array($capability, $driver->notYetImplemented(), true) + ); + + $this->assertSame([], $overlap); + $this->assertSame($driver->capabilities(), array_values(array_unique($driver->capabilities(), SORT_REGULAR))); + } + + /** + * A capability de assinaturas (ou planos) só é declarada por driver que implementa o + * contract correspondente, e todo driver que implementa o contract a declara. + */ + #[DataProvider('driverProvider')] + public function testSubscriptionAndPlanCapabilitiesMatchTheContracts(string $gateway): void + { + $driver = self::driver($gateway); + + $this->assertSame($driver instanceof SubscriptionContract, $driver->supports(Capability::SUBSCRIPTIONS)); + $this->assertSame($driver instanceof PlanContract, $driver->supports(Capability::PLANS)); + } + + public static function driverProvider(): array + { + return ['iugu' => ['iugu'], 'stripe' => ['stripe']]; + } + + /** + * O README publica a saída de `composer capabilities:table`; declaração nova sem regerar + * a tabela falha aqui. + */ + public function testReadmeContainsTheGeneratedTable(): void + { + $table = CapabilitiesTable::markdown(['iugu' => self::driver('iugu'), 'stripe' => self::driver('stripe')]); + $readme = file_get_contents(__DIR__ . '/../../../README.md'); + + $this->assertStringContainsString($table, $readme, 'README desatualizado: rode `composer capabilities:table` e cole a saída na seção Capabilities'); + } + + public function testTableHasOneRowPerCapabilityAndOneColumnPerGateway(): void + { + $table = CapabilitiesTable::markdown(['iugu' => self::driver('iugu'), 'stripe' => self::driver('stripe')]); + $lines = explode("\n", trim($table)); + + $this->assertSame('| Capability | Significado | Iugu | Stripe |', $lines[0]); + $this->assertSame('|---|---|---|---|', $lines[1]); + $this->assertCount(count(Capability::cases()) + 2, $lines); + $this->assertStringStartsWith('| `CREDIT_CARD` | Fatura paga com cartão de crédito. | sim | sim |', $lines[2]); + } + + private static function driver(string $gateway): GatewayContract&DeclaresCapabilities + { + return match ($gateway) { + 'iugu' => new IuguGateway(new QueuedIuguApiRequest([])), + 'stripe' => new StripeGateway(), + }; + } +} diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index 9b31c0c..17eb6fe 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -15,6 +15,8 @@ use Potelo\MultiPayment\Models\SubscriptionItem; use Potelo\MultiPayment\Models\SubscriptionDiscount; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use PHPUnit\Framework\Attributes\DataProvider; use Potelo\MultiPayment\Enums\InvoiceStatus; @@ -329,10 +331,19 @@ public function testCancelAtPeriodEndIsRejected(): void $subscription = new Subscription(); $subscription->id = 'sub_1'; - $this->expectException(GatewayException::class); - $this->expectExceptionMessageMatches('/does not support cancelling a subscription at the end/'); + $api = new QueuedIuguApiRequest([]); - (new IuguGateway(new QueuedIuguApiRequest([])))->cancelSubscription($subscription, true); + try { + (new IuguGateway($api))->cancelSubscription($subscription, true); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::CANCEL_AT_PERIOD_END, $e->capability); + $this->assertSame('iugu', $e->gateway); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertFalse($e->isNotImplemented()); + $this->assertStringContainsString('Suspenda a assinatura', $e->getMessage()); + } + $this->assertCount(0, $api->calls); } public function testChangePlanWithoutChargeSendsSkipChargeAndTheNewBillingDate(): void @@ -473,10 +484,17 @@ public function testPercentageDiscountIsRejected(): void $subscription->fill(['plan_id' => 'plano', 'customer' => ['id' => 'cus_1']]); $subscription->discounts = [$discount]; - $this->expectException(GatewayException::class); - $this->expectExceptionMessageMatches('/does not support percentage discounts/'); + $api = new QueuedIuguApiRequest([]); - (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); + try { + (new IuguGateway($api))->createSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::NATIVE_COUPONS, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('use amountOff', $e->getMessage()); + } + $this->assertCount(0, $api->calls); } /** @@ -494,10 +512,17 @@ public function testDiscountLimitedToMoreThanOneCycleIsRejected(): void $subscription->fill(['plan_id' => 'plano', 'customer' => ['id' => 'cus_1']]); $subscription->discounts = [$discount]; - $this->expectException(GatewayException::class); - $this->expectExceptionMessageMatches('/cycles greater than 1/'); + $api = new QueuedIuguApiRequest([]); - (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); + try { + (new IuguGateway($api))->createSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::NATIVE_COUPONS, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('use cycles 1 ou nulo', $e->getMessage()); + } + $this->assertCount(0, $api->calls); } public function testDiscountWithoutAmountOffIsRejectedByTheMapper(): void @@ -746,10 +771,17 @@ public function testDeactivatePlanIsRejected(): void $plan = new Plan(); $plan->id = 'plan_1'; - $this->expectException(GatewayException::class); - $this->expectExceptionMessageMatches('/no active flag/'); + $api = new QueuedIuguApiRequest([]); - (new IuguGateway(new QueuedIuguApiRequest([])))->deactivatePlan($plan); + try { + (new IuguGateway($api))->deactivatePlan($plan); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::PLAN_DEACTIVATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('flag de ativo', $e->getMessage()); + } + $this->assertCount(0, $api->calls); } public function testGatewayErrorsBecomeGatewayException(): void diff --git a/tests/Unit/Gateways/StripeGatewayCreditCardTest.php b/tests/Unit/Gateways/StripeGatewayCreditCardTest.php index 40293d7..88a191c 100644 --- a/tests/Unit/Gateways/StripeGatewayCreditCardTest.php +++ b/tests/Unit/Gateways/StripeGatewayCreditCardTest.php @@ -12,6 +12,8 @@ use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; class StripeGatewayCreditCardTest extends TestCase @@ -48,10 +50,17 @@ public function testCreateCreditCardRejectsRawCardData(): void $creditCard->year = '2030'; $creditCard->cvv = '123'; - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('does not accept raw card data'); - - (new StripeGateway())->createCreditCard($creditCard); + $httpClient = RecordingStripeHttpClient::withResponses([]); + + try { + (new StripeGateway())->createCreditCard($creditCard); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::RAW_CARD_DATA, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('Stripe.js', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); } public function testCreateCreditCardRequiresCustomer(): void diff --git a/tests/Unit/Gateways/StripeGatewayCustomerTest.php b/tests/Unit/Gateways/StripeGatewayCustomerTest.php index 327a4ee..54a7706 100644 --- a/tests/Unit/Gateways/StripeGatewayCustomerTest.php +++ b/tests/Unit/Gateways/StripeGatewayCustomerTest.php @@ -12,6 +12,8 @@ use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -274,17 +276,21 @@ public function testParsesNumberOnlyLine1IntoAddressNumber(): void $this->assertSame('123', $result->address->number); } - public function testUnimplementedOperationThrowsClearGatewayExceptionWithoutHittingTheApi(): void + public function testUnimplementedOperationThrowsUnsupportedOperationExceptionWithoutHittingTheApi(): void { $httpClient = RecordingStripeHttpClient::withResponses([]); try { (new StripeGateway())->rescheduleAutomaticPixPayment(new Invoice()); - $this->fail('Pix Automático no Stripe deveria lançar GatewayException'); - } catch (GatewayException $e) { + $this->fail('Pix Automático no Stripe deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertSame('stripe', $e->gateway); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); + $this->assertTrue($e->isNotImplemented()); $this->assertSame( - 'A operação [rescheduleAutomaticPixPayment] no Stripe ainda não está implementada nesta lib;' - . ' a Stripe suporta o recurso. Use a Iugu para Pix Automático por enquanto.', + 'A capability [automatic_pix] ainda não está implementada nesta lib para o gateway stripe;' + . ' o gateway oferece o recurso.', $e->getMessage() ); } diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index 92a2caf..47fedd9 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -15,6 +15,8 @@ use Potelo\MultiPayment\Models\InvoiceItem; use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\ChargingException; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -114,13 +116,18 @@ public function testCreatesCreditCardInvoiceSavingTokenizedCardFirst(): void public function testRejectsInvoiceWithMultiplePaymentMethods(): void { + $httpClient = RecordingStripeHttpClient::withResponses([]); $invoice = $this->creditCardInvoiceModel(); $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD, PaymentMethod::PIX]; - $this->expectException(ModelAttributeValidationException::class); - $this->expectExceptionMessage('exactly one payment method'); - - (new StripeGateway())->createInvoice($invoice); + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Fatura multi-método no Stripe deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::MULTIPLE_PAYMENT_METHODS, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); + } + $this->assertSame([], $httpClient->calls); } public function testRejectsBankSlipInvoiceAttributingTheLimitationToTheLibrary(): void @@ -131,12 +138,13 @@ public function testRejectsBankSlipInvoiceAttributingTheLimitationToTheLibrary() try { (new StripeGateway())->createInvoice($invoice); - $this->fail('Boleto no Stripe deveria lançar GatewayException'); - } catch (GatewayException $e) { - $this->assertStringContainsString('[createInvoice com boleto] no Stripe ainda não está implementada nesta lib', $e->getMessage()); - $this->assertStringContainsString('Use a Iugu para boleto por enquanto', $e->getMessage()); - $this->assertStringNotContainsStringIgnoringCase('não suporta', $e->getMessage()); - $this->assertStringNotContainsStringIgnoringCase('does not support', $e->getMessage()); + $this->fail('Boleto no Stripe deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::BANK_SLIP, $e->capability); + $this->assertSame('stripe', $e->gateway); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); + $this->assertStringContainsString('ainda não está implementada nesta lib', $e->getMessage()); + $this->assertStringNotContainsStringIgnoringCase('não oferece', $e->getMessage()); } $this->assertSame([], $httpClient->calls); } @@ -240,10 +248,10 @@ public function testRejectsInvoiceWithAutomaticPixUntilSupported(): void try { (new StripeGateway())->createInvoice($invoice); - $this->fail('Pix Automático no Stripe deveria lançar GatewayException'); - } catch (GatewayException $e) { - $this->assertStringContainsString('A operação [createInvoice com Pix Automático] no Stripe ainda não está implementada nesta lib', $e->getMessage()); - $this->assertStringContainsString('Use a Iugu para Pix Automático por enquanto', $e->getMessage()); + $this->fail('Pix Automático no Stripe deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); } $this->assertSame([], $httpClient->calls); } @@ -1205,10 +1213,14 @@ public function testDuplicateRejectsPaidInvoice(): void $invoice = new Invoice(); $invoice->id = 'pi_fake123'; - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('Only pending invoices can be duplicated on the stripe gateway; invoice [pi_fake123] is [paid]'); - - (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + try { + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_DUPLICATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertSame('No Stripe só uma fatura Pix pendente pode ser duplicada; a fatura [pi_fake123] está [paid].', $e->getMessage()); + } } public function testDuplicateRejectsNonPixInvoice(): void @@ -1220,10 +1232,14 @@ public function testDuplicateRejectsNonPixInvoice(): void $invoice = new Invoice(); $invoice->id = 'pi_fake123'; - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('Only pix invoices can be duplicated'); - - (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + try { + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_DUPLICATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('a fatura [pi_fake123] não é Pix', $e->getMessage()); + } } public function testDuplicateInvoiceRequiresId(): void diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php index e9efcbf..305d805 100644 --- a/tests/Unit/SubscriptionTest.php +++ b/tests/Unit/SubscriptionTest.php @@ -17,6 +17,8 @@ use Potelo\MultiPayment\Builders\SubscriptionBuilder; use Potelo\MultiPayment\Models\SubscriptionPlanChange; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use PHPUnit\Framework\Attributes\DataProvider; use Potelo\MultiPayment\Enums\InvoiceStatus; @@ -32,6 +34,30 @@ protected function tearDown(): void parent::tearDown(); } + /** + * Gateway falso que declara assinaturas e implementa `SubscriptionContract`. + */ + private static function subscriptionGateway(): GatewayContract + { + $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway->shouldReceive('supports')->with(Capability::SUBSCRIPTIONS)->andReturn(true); + + return $gateway; + } + + /** + * Gateway falso que não declara capability alguma. + */ + private static function gatewayWithoutCapabilities(): GatewayContract + { + $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('supports')->andReturn(false); + $gateway->shouldReceive('notYetImplemented')->andReturn([]); + $gateway->shouldReceive('__toString')->andReturn('falso'); + + return $gateway; + } + public function testFillBuildsNestedModelsAndParsesDates(): void { $subscription = new Subscription(); @@ -385,7 +411,7 @@ public function testBuilderAssemblesTheSubscription(): void public function testBuilderCreateDelegatesToTheGateway(): void { - $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway = self::subscriptionGateway(); $gateway->shouldReceive('createSubscription') ->once() ->andReturnUsing(function (Subscription $subscription) { @@ -425,7 +451,7 @@ public function testModelDelegatesLifecycleToTheGateway( $subscription = new Subscription(); $subscription->id = 'sub_1'; - $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway = self::subscriptionGateway(); $gateway->shouldReceive($gatewayMethod) ->once() ->with($subscription, ...$gatewayArgs) @@ -455,7 +481,7 @@ public function testPreviewPlanChangeDelegatesToTheGateway(): void $subscription->id = 'sub_1'; $planChange = new SubscriptionPlanChange(); - $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway = self::subscriptionGateway(); $gateway->shouldReceive('previewSubscriptionPlanChange') ->once() ->with($subscription, 'plano_anual') @@ -468,7 +494,7 @@ public function testCreateSavesTheCustomerBeforeTheSubscription(): void { $ordem = []; - $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway = self::subscriptionGateway(); $gateway->shouldReceive('createCustomer') ->once() ->andReturnUsing(function (Customer $customer) use (&$ordem) { @@ -527,7 +553,7 @@ public function testSetItemsAndSetDiscountsReplaceInsteadOfAppending(): void */ public function testUpdateDoesNotRequireCustomerOrPlanId(): void { - $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway = self::subscriptionGateway(); $gateway->shouldReceive('updateSubscription')->once()->andReturnUsing(fn($s) => $s); $gateway->shouldNotReceive('createCustomer'); @@ -543,18 +569,38 @@ public function testUpdateDoesNotRequireCustomerOrPlanId(): void } /** - * Método de domínio recusa gateway sem SubscriptionContract com GatewayException, e não com - * Error do PHP. + * Método de domínio recusa gateway sem a capability de assinaturas com + * `UnsupportedOperationException`, sem chegar ao contract. */ - public function testDomainMethodsRejectAGatewayWithoutTheSubscriptionContract(): void + public function testDomainMethodsRejectAGatewayWithoutTheSubscriptionsCapability(): void + { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + try { + $subscription->suspend(self::gatewayWithoutCapabilities()); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + $this->assertSame('falso', $e->gateway); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + } + } + + /** + * Gateway que declara a capability sem implementar o contract é erro de driver e chega como + * `GatewayException`. + */ + public function testGatewayDeclaringTheCapabilityWithoutTheContractIsADriverError(): void { $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('supports')->with(Capability::SUBSCRIPTIONS)->andReturn(true); $subscription = new Subscription(); $subscription->id = 'sub_1'; $this->expectException(GatewayException::class); - $this->expectExceptionMessageMatches('/does not implement SubscriptionContract; subscriptions are not yet implemented in this library/'); + $this->expectExceptionMessageMatches('/declares the subscriptions capability but does not implement SubscriptionContract/'); $subscription->suspend($gateway); } @@ -565,7 +611,7 @@ public function testDomainMethodsRejectAGatewayWithoutTheSubscriptionContract(): */ public function testUpdateStillValidatesItemsAndPaymentMethods(): void { - $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway = self::subscriptionGateway(); $gateway->shouldNotReceive('updateSubscription'); $subscription = new Subscription(); @@ -582,7 +628,7 @@ public function testUpdateStillValidatesItemsAndPaymentMethods(): void public function testUpdateStillValidatesAvailablePaymentMethods(): void { - $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway = self::subscriptionGateway(); $gateway->shouldNotReceive('updateSubscription'); $subscription = new Subscription(); @@ -609,21 +655,24 @@ public function testPlanWithIdCannotBeSavedAgain(): void } #[DataProvider('listOperationsProvider')] - public function testListOperationsRejectAGatewayWithoutTheContract(string $metodo, array $args, string $contract): void + public function testListOperationsRejectAGatewayWithoutTheCapability(string $metodo, array $args, Capability $capability): void { - $multiPayment = new \Potelo\MultiPayment\MultiPayment(Mockery::mock(GatewayContract::class)); - - $this->expectException(GatewayException::class); - $this->expectExceptionMessageMatches("/does not implement {$contract}; the operations of that contract are not yet implemented in this library/"); + $multiPayment = new \Potelo\MultiPayment\MultiPayment(self::gatewayWithoutCapabilities()); - $multiPayment->{$metodo}(...$args); + try { + $multiPayment->{$metodo}(...$args); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame($capability, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + } } public static function listOperationsProvider(): array { return [ - 'assinaturas' => ['listSubscriptions', ['cus_1'], 'SubscriptionContract'], - 'planos' => ['listPlans', [], 'PlanContract'], + 'assinaturas' => ['listSubscriptions', ['cus_1'], Capability::SUBSCRIPTIONS], + 'planos' => ['listPlans', [], Capability::PLANS], ]; } } From 1fe6bb43a28a394032f2871e2b35c692b463d2a4 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 11:37:06 -0300 Subject: [PATCH 19/32] =?UTF-8?q?feat(exceptions):=20completa=20a=20hierar?= =?UTF-8?q?quia=20com=20CardDeclined,=20RateLimit,=20IdempotencyConflict?= =?UTF-8?q?=20e=20Validation=20e=20normaliza=20c=C3=B3digos=20de=20recusa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CardDeclinedException passa a ser a recusa de cartão, com declineCode (enum DeclineCode), gatewayCode e retryable; ChargingException vira o nome antigo, subclasse dela, ainda instanciada nos drivers para o catch antigo continuar capturando. ValidationException (400/422, fieldErrors), NotFoundException (404), RateLimitException (429, retryAfter) e IdempotencyConflictException (409) herdam de GatewayException. Os mapas de código de recusa ficam em src/Gateways/Iugu/DeclineCodes.php (Tabela de LRs, lida do campo LR ou do texto "LR: xx", sem zeros à esquerda) e src/Gateways/Stripe/DeclineCodes.php (decline_code e code, com advice_code decidindo retryable). Código fora da tabela vira UNKNOWN com o original preservado e registro info no log. Na Iugu, classifyIuguFailure() escolhe a classe pelo status HTTP. Na Stripe, translateStripeException() escolhe pela classe do SDK, e a CardException vira recusa em qualquer operação, inclusive no attach do cartão; o Retry-After é lido do CaseInsensitiveArray que o SDK entrega. Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk --- README.md | 116 +++++- src/Enums/DeclineCode.php | 84 ++++ src/Exceptions/CardDeclinedException.php | 93 +++++ src/Exceptions/ChargingException.php | 22 +- .../IdempotencyConflictException.php | 13 + src/Exceptions/NotFoundException.php | 11 + src/Exceptions/RateLimitException.php | 42 ++ src/Exceptions/ValidationException.php | 115 ++++++ src/Gateways/Iugu/DeclineCodes.php | 219 +++++++++++ src/Gateways/IuguGateway.php | 74 +++- src/Gateways/Stripe/DeclineCodes.php | 111 ++++++ src/Gateways/StripeGateway.php | 185 ++++++--- src/Helpers/LogHelper.php | 27 +- tests/Integration/MultiPaymentTest.php | 4 +- tests/Integration/StripeGatewayTest.php | 40 ++ tests/Unit/Enums/DeclineCodeTest.php | 89 +++++ .../Exceptions/CardDeclinedExceptionTest.php | 104 +++++ .../Exceptions/ExceptionHierarchyTest.php | 111 ++++++ .../Exceptions/ValidationExceptionTest.php | 73 ++++ tests/Unit/Gateways/Iugu/DeclineCodesTest.php | 138 +++++++ .../IuguGatewayExceptionTranslationTest.php | 266 +++++++++++-- tests/Unit/Gateways/IuguGatewayRefundTest.php | 14 +- .../Gateways/RecordingStripeHttpClient.php | 11 +- .../Unit/Gateways/Stripe/DeclineCodesTest.php | 90 +++++ .../Gateways/StripeGatewayCustomerTest.php | 7 +- .../StripeGatewayExceptionTranslationTest.php | 369 +++++++++++++++++- .../Gateways/StripeGatewayInvoiceTest.php | 13 +- 27 files changed, 2283 insertions(+), 158 deletions(-) create mode 100644 src/Enums/DeclineCode.php create mode 100644 src/Exceptions/CardDeclinedException.php create mode 100644 src/Exceptions/IdempotencyConflictException.php create mode 100644 src/Exceptions/NotFoundException.php create mode 100644 src/Exceptions/RateLimitException.php create mode 100644 src/Exceptions/ValidationException.php create mode 100644 src/Gateways/Iugu/DeclineCodes.php create mode 100644 src/Gateways/Stripe/DeclineCodes.php create mode 100644 tests/Unit/Enums/DeclineCodeTest.php create mode 100644 tests/Unit/Exceptions/CardDeclinedExceptionTest.php create mode 100644 tests/Unit/Exceptions/ExceptionHierarchyTest.php create mode 100644 tests/Unit/Exceptions/ValidationExceptionTest.php create mode 100644 tests/Unit/Gateways/Iugu/DeclineCodesTest.php create mode 100644 tests/Unit/Gateways/Stripe/DeclineCodesTest.php diff --git a/README.md b/README.md index 24eebc4..47899bb 100644 --- a/README.md +++ b/README.md @@ -285,17 +285,17 @@ recusado na validação, porque a fatura com Pix Automático é criada com `PIX` - **Bandeiras aceitas no Brasil: somente Visa e Mastercard crédito.** Elo, Hipercard, Amex e débito nacional não são suportados pelo Stripe BR. Para essas bandeiras, roteie a cobrança para outro gateway (ex.: Iugu) — de preferência detectando a bandeira pelo BIN antes de - tokenizar. Para decidir o fallback programaticamente, use `ChargingException::$reason`, - que traz a razão normalizada da recusa (`card_declined`, `brand_not_supported`, - `authentication_required`, `expired_card`, `insufficient_funds`, `incorrect_cvc`...). + tokenizar. Para decidir o fallback programaticamente, use `CardDeclinedException::$declineCode`: + `DeclineCode::BRAND_NOT_SUPPORTED` é a recusa por bandeira (`card_not_supported` na Stripe), e + `retryable` diz se vale repetir com o mesmo cartão (ver [Códigos de recusa](#códigos-de-recusa)). `GatewayNotAvailableException` também sinaliza "tente outro gateway"; `AuthenticationException` sinaliza credencial errada e não deve gerar fallback (ver [Tratamento de erros](#tratamento-de-erros)). - **Cartão salvo não garante cobrança futura.** Salvar o cartão (`newCreditCard()->create()`) faz só o `attach` do PaymentMethod ao cliente, sem autenticar com o emissor. Um cartão que exige autenticação (3DS) é salvo normalmente e recusado na primeira cobrança `off_session`, - com `ChargingException::$reason` igual a `authentication_required`. Essa razão pede ação do - pagador (autenticar o cartão ou informar outro); o gateway respondeu normalmente e não cabe - fallback. Autenticar no momento de salvar (SetupIntent) está planejado para uma versão futura. + com `CardDeclinedException::$declineCode` igual a `DeclineCode::AUTHENTICATION_REQUIRED`. Esse + código pede ação do pagador (autenticar o cartão ou informar outro); o gateway respondeu + normalmente e não cabe fallback. Autenticar no momento de salvar (SetupIntent) está planejado para uma versão futura. - **Pix exige `tax_document` do cliente** (CPF/CNPJ vai nos billing details do pagamento). - **`expires_at` do pix é opcional** (default do Stripe: 4 horas) e, quando informado, deve ficar entre 10 segundos e 14 dias no futuro — diferente da Iugu, onde `expires_at` é a @@ -353,48 +353,116 @@ exceção original em `getPrevious()` (quando o SDK lançou uma; a Iugu devolve corpo JSON sem exceção) e expõem o status HTTP da resposta em `httpStatus` (nulo quando não houve resposta HTTP, como numa falha de rede ou numa validação local). +A árvore, com a indentação marcando a herança: + +``` +MultiPaymentException + ConfigurationException gateway não configurado ou classe inválida + ModelAttributeValidationException atributo obrigatório ausente ou inválido, antes da requisição + UnsupportedOperationException operação fora das capabilities do gateway, antes da requisição + RefundNotSupportedException estorno recusado pela lib antes da requisição + AuthenticationException credencial recusada (401, 403) ou não configurada + GatewayNotAvailableException 5xx, falha de conexão ou timeout + CardDeclinedException cobrança recusada: declineCode, gatewayCode, retryable + ChargingException nome antigo, deprecado; é a classe que os drivers lançam + GatewayException resposta de erro do gateway: httpStatus e getErrors() + ValidationException 400 ou 422: fieldErrors por campo + NotFoundException 404: recurso inexistente no gateway + RateLimitException 429: retryAfter em segundos quando o gateway informa + IdempotencyConflictException 409 na Iugu, idempotency_error na Stripe +``` + | Exceção | Quando | O que fazer | |---|---|---| +| `CardDeclinedException` | O gateway respondeu e a cobrança foi recusada pelo emissor, pelo adquirente ou pelo antifraude. `declineCode` (`DeclineCode`) é o motivo normalizado, `gatewayCode` o código original (`decline_code` da Stripe, LR da Iugu), `retryable` diz se vale repetir com o mesmo cartão | Ramificar por `declineCode`: outro gateway em `BRAND_NOT_SUPPORTED`, nova tentativa só se `retryable`, ação do pagador nos demais (ver [Códigos de recusa](#códigos-de-recusa)) | +| `ChargingException` | Nome antigo de `CardDeclinedException`, deprecado. É a classe que os drivers lançam, então `catch` por qualquer um dos dois nomes captura a mesma exceção | Migrar o `catch` para `CardDeclinedException` | +| `ValidationException` | O gateway recusou o payload (400 ou 422 na Iugu, `invalid_request_error` na Stripe); `fieldErrors` traz as mensagens por campo (`base` para erro sem campo) | Corrigir a chamada; repetir igual falha de novo | +| `NotFoundException` | Recurso inexistente no gateway (404 na Iugu, `resource_missing` na Stripe): id errado, de outra conta ou removido | Conferir o id; não repetir | +| `RateLimitException` | O gateway limitou a taxa de requisições (429); nada foi executado. `retryAfter` traz os segundos do cabeçalho `Retry-After` quando o gateway o envia (o SDK da Iugu não expõe cabeçalhos, então na Iugu fica nulo) | Esperar e repetir | +| `IdempotencyConflictException` | Chave de idempotência reutilizada com outro payload, ou a primeira requisição com a chave ainda em andamento (409 na Iugu, `idempotency_error` na Stripe) | Consultar o resultado da primeira requisição ou usar chave nova; nunca repetir com a mesma chave e outro conteúdo | | `AuthenticationException` | Chave de API inválida, revogada, sem permissão (401 ou 403) ou não configurada | Registrar e alertar. Repetir a chamada ou trocar de gateway não resolve | | `GatewayNotAvailableException` | Erro 5xx, falha de conexão ou timeout | Repetir mais tarde ou tentar outro gateway | -| `ChargingException` | Cobrança recusada pelo gateway (cartão negado etc.); `reason` traz a razão normalizada quando o gateway a informa | Tratar como recusa do pagador; `reason` decide o fallback | | `UnsupportedOperationException` | Operação fora das capabilities do gateway, antes de qualquer requisição; `capability`, `gateway` e `reason` (`not_implemented` ou `gateway_limitation`) dizem qual e por quê | Rotear para um gateway que declare a capability; melhor ainda, consultar `supports()` antes (ver [Capabilities](#capabilities)) | | `RefundNotSupportedException` | Estorno recusado pela lib antes de chamar o gateway (boleto, Pix parcial, já estornada, prazo vencido); herda de `UnsupportedOperationException` e refina `reason` | Ver [Estorno](#estorno) | | `ModelAttributeValidationException` | Atributo obrigatório ausente ou inválido, antes de qualquer requisição | Corrigir a chamada | | `ConfigurationException` | Gateway não configurado ou classe inválida | Corrigir a configuração | -| `GatewayException` | Qualquer outra resposta de erro do gateway (validação, 404, 409, 429); `getErrors()` traz o corpo de erro | Depende do caso; `httpStatus` e `getErrors()` dizem o que aconteceu | +| `GatewayException` | Qualquer outra resposta de erro do gateway, e a classe pai das quatro de resposta acima; `getErrors()` traz o corpo de erro | Depende do caso; `httpStatus` e `getErrors()` dizem o que aconteceu | + +Um `catch` por camada. As subclasses vêm antes de `GatewayException`, senão ela captura tudo: ```php +use Potelo\MultiPayment\Enums\DeclineCode; use Potelo\MultiPayment\Exceptions\GatewayException; -use Potelo\MultiPayment\Exceptions\ChargingException; +use Potelo\MultiPayment\Exceptions\RateLimitException; +use Potelo\MultiPayment\Exceptions\ValidationException; +use Potelo\MultiPayment\Exceptions\CardDeclinedException; use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; try { $invoice = $payment->newInvoice()->/* ... */->create(); -} catch (ChargingException $e) { - return back()->withErrors('Pagamento recusado.'); +} catch (CardDeclinedException $e) { + // o gateway respondeu; a recusa é do pagamento + if ($e->declineCode === DeclineCode::BRAND_NOT_SUPPORTED) { + return $this->chargeOn('iugu'); // outro gateway aceita a bandeira + } + if ($e->retryable) { + return $this->scheduleRetry($order, hours: 24); // falha temporária ou saldo: mesmo cartão, mais tarde + } + return back()->withErrors('Pagamento recusado. Confira os dados do cartão ou use outro.'); +} catch (ValidationException $e) { + return back()->withErrors($e->fieldErrors); // ['email' => ['não é válido']] } catch (UnsupportedOperationException $e) { - report($e); // $e->capability e $e->gateway dizem o que faltou; nada foi enviado + report($e); // nada foi enviado; $e->capability e $e->gateway dizem o que faltou return $this->chargeOn('iugu'); } catch (AuthenticationException $e) { report($e); // credencial errada: alerta, sem retry e sem fallback abort(500); +} catch (RateLimitException $e) { + return $this->retryIn($e->retryAfter ?? 5); // segundos; nulo quando o gateway não informa } catch (GatewayNotAvailableException $e) { - return $this->queueForRetry(); + return $this->retryWithBackoff(); // 5xx ou timeout: repetir mais tarde } catch (GatewayException $e) { - if ($e->httpStatus === 429) { - return $this->retryLater(); - } - report($e); // $e->getPrevious() é a exceção do SDK, com stack trace e corpo + report($e); // 404, 409 e o restante; $e->getPrevious() é a exceção do SDK, com stack trace e corpo throw $e; } ``` -Rate limit (429) e conflito de idempotência (409) ainda chegam como `GatewayException`; o status -está em `httpStatus` para a aplicação ramificar. Exceções próprias para esses casos estão -previstas para uma versão futura. +### Códigos de recusa + +`CardDeclinedException::$declineCode` é um `DeclineCode`, o mesmo vocabulário nos dois gateways. +O código original fica em `$gatewayCode`; código que a tabela do pacote ainda não conhece vira +`UNKNOWN`, com o original preservado e uma linha em nível `info` no log. `$retryable` segue o +código (`DeclineCode::isRetryable()`) e, na Stripe, é sobrescrito pelo `advice_code` quando ele +diz `try_again_later` ou `do_not_try_again`. `requiresPayerAction()` diz se a recusa pede ação +do pagador antes de qualquer nova tentativa. + +| `DeclineCode` | Significado | `retryable` | Stripe (`decline_code` ou `code`) | Iugu (LR) | +|---|---|---|---|---| +| `INSUFFICIENT_FUNDS` | Saldo ou limite insuficiente | sim | `insufficient_funds`, `card_velocity_exceeded`, `withdrawal_count_limit_exceeded` | 51, 61, 65, 70, BL, DM, N4 | +| `EXPIRED_CARD` | Cartão vencido | não | `expired_card` | 54 | +| `INCORRECT_CVC` | CVC incorreto | não | `incorrect_cvc`, `invalid_cvc` | (a tabela da Iugu não tem código próprio) | +| `INCORRECT_NUMBER` | Número do cartão incorreto ou ausente | não | `incorrect_number`, `invalid_number` | 14, 25 | +| `INVALID_CARD` | Outros dados inválidos: validade, conta inexistente, cartão não desbloqueado | não | `invalid_expiry_month`, `invalid_expiry_year`, `invalid_account`, `new_account_information_available`, `incorrect_address`, `incorrect_zip` | 1, 12, 15, 30, 46, 56, 78, 101, 111, 115, 122, 6P, AV, BM, BP, BR, CF, CG, DF, DQ, G4, KA, KE, U3 | +| `LOST_OR_STOLEN` | Perdido, roubado, retido ou bloqueado pelo emissor; não exibir o motivo ao pagador | não | `lost_card`, `stolen_card`, `pickup_card`, `restricted_card` | 4, 41, 43, 62, 146, BN | +| `FRAUD_SUSPECTED` | Suspeita de fraude ou antifraude; tratar como recusa genérica diante do pagador | não | `fraudulent`, `merchant_blacklist` | 7, 59, AF01, AF02, BP171 | +| `AUTHENTICATION_REQUIRED` | O emissor exige autenticação (3DS); a cobrança fora de sessão não atende | não | `authentication_required`, `authentication_not_handled`, `mobile_device_authentication_required` | AI | +| `BRAND_NOT_SUPPORTED` | Bandeira, função (crédito ou débito) ou moeda não aceita nesta cobrança; candidato a outro gateway | não | `card_not_supported`, `currency_not_supported` | 39, 52, 53, 57, 79, 5C, AB, AC, AH, C1, DS, EK, G5 | +| `DO_NOT_HONOR` | O emissor recusou sem detalhar e orienta o pagador a procurá-lo | não | `do_not_honor`, `call_issuer`, `no_action_taken`, `not_permitted`, `security_violation`, `service_not_allowed`, `stop_payment_order`, `transaction_not_allowed`, `revocation_of_authorization`, `revocation_of_all_authorizations`, `do_not_try_again` | 5, 6, 60, 63, 67, 93, 99, 100, 109, 110, 116, 121, 181, 200, B1, B2, BP176, C2, C3, FC, FG, GA, GD, GF, GK, GT, N7, NR, R0, R1, R2, R3, RE, RP, SC | +| `TRY_AGAIN` | Falha temporária no emissor, no adquirente ou na comunicação | sim | `processing_error`, `issuer_not_available`, `reenter_transaction`, `try_again_later`, `approve_with_id` | 19, 28, 85, 89, 90, 91, 92, 96, 98, 911, 912, 999, 99A, 99B, 99C, 99TA, 99Z, AA, AF, AG, BD, BO, BP900, BP901, BP902 | +| `GENERIC` | Recusa sem motivo específico | não | `generic_decline`, `card_declined`, `duplicate_transaction`, `invalid_amount`, `testmode_decline` | 13, 64, 80, 94, 97, FE | +| `UNKNOWN` | Código fora da tabela; o original está em `gatewayCode` | não | qualquer outro | qualquer outro (senha, chip, saque, credenciamento do lojista) | + +As tabelas completas vivem em `src/Gateways/Stripe/DeclineCodes.php` e +`src/Gateways/Iugu/DeclineCodes.php`, com a fonte oficial no cabeçalho de cada uma. Na Iugu, LR +numérico é comparado sem zeros à esquerda (`05` e `5` são o mesmo código); `gatewayCode` guarda +o valor como veio. + +`CardDeclinedException::$reason` (string) continua preenchido e está deprecado: na Stripe traz a +normalização antiga (`card_declined`, `brand_not_supported`, `authentication_required`, +`expired_card`, `insufficient_funds`, `incorrect_cvc`, ou o `code` original); na Iugu, que antes +o deixava nulo, traz o valor de `declineCode`. Compare com `declineCode`. > **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, credencial inválida chegava como > `GatewayNotAvailableException` (Stripe e chave Iugu não configurada) ou como `GatewayException` @@ -410,6 +478,14 @@ previstas para uma versão futura. > chegar como `GatewayException` (ou `GatewayException::methodNotFound`) e passou a lançar > `UnsupportedOperationException`, que herda de `MultiPaymentException`. Um > `catch (GatewayException $e)` sozinho deixa de capturar esses casos. +> +> Ainda na 5.0.0, validação (400, 422), 404, 409 e 429 passaram a chegar como +> `ValidationException`, `NotFoundException`, `IdempotencyConflictException` e +> `RateLimitException`. Todas herdam de `GatewayException`, então `catch (GatewayException $e)` +> continua capturando; um `catch` da subclasse precisa vir antes. A recusa de cartão passou a +> ser `CardDeclinedException` (lançada pelo nome antigo `ChargingException`, que herda dela), +> com a mensagem em português; salvar um cartão recusado pela Stripe (`newCreditCard()->create()`) +> também lança `CardDeclinedException`, onde antes vinha `GatewayException`. ## Utilizando diff --git a/src/Enums/DeclineCode.php b/src/Enums/DeclineCode.php new file mode 100644 index 0000000..6073bad --- /dev/null +++ b/src/Enums/DeclineCode.php @@ -0,0 +1,84 @@ + true, + default => false, + }; + } + + /** + * Diz se a recusa pede ação do pagador antes de qualquer nova tentativa: corrigir dados, + * autenticar o cartão ou informar outro. + * + * @return bool + */ + public function requiresPayerAction(): bool + { + return match ($this) { + self::EXPIRED_CARD, + self::INCORRECT_CVC, + self::INCORRECT_NUMBER, + self::INVALID_CARD, + self::AUTHENTICATION_REQUIRED, + self::BRAND_NOT_SUPPORTED => true, + default => false, + }; + } +} diff --git a/src/Exceptions/CardDeclinedException.php b/src/Exceptions/CardDeclinedException.php new file mode 100644 index 0000000..9318a0c --- /dev/null +++ b/src/Exceptions/CardDeclinedException.php @@ -0,0 +1,93 @@ +value}, {$code})"; + if ($detail !== '') { + $message .= ": {$detail}"; + } + + $exception = new static($message, $previous, $httpStatus); + $exception->declineCode = $declineCode; + $exception->gatewayCode = $gatewayCode === '' ? null : $gatewayCode; + $exception->retryable = $retryable ?? $declineCode->isRetryable(); + $exception->reason = $declineCode->value; + + return $exception; + } +} diff --git a/src/Exceptions/ChargingException.php b/src/Exceptions/ChargingException.php index 1ffd33c..0a1d82b 100644 --- a/src/Exceptions/ChargingException.php +++ b/src/Exceptions/ChargingException.php @@ -2,19 +2,13 @@ namespace Potelo\MultiPayment\Exceptions; -class ChargingException extends MultiPaymentException +/** + * Nome antigo de `CardDeclinedException`. Uma recusa de cartão é capturada tanto por + * `catch (ChargingException $e)` quanto por `catch (CardDeclinedException $e)`, com + * `declineCode`, `gatewayCode` e `retryable` preenchidos. + * + * @deprecated Capture `CardDeclinedException`. Este nome deixa de ser lançado numa versão maior futura. + */ +class ChargingException extends CardDeclinedException { - /** - * @var mixed $chargeResponse The charge response from the gateway - */ - public $chargeResponse; - - /** - * Razão normalizada da falha de cobrança, independente de gateway (ex.: `card_declined`, - * `brand_not_supported`, `authentication_required`), para a aplicação decidir - * programaticamente um fallback de gateway. Nula quando o gateway não a preenche. - * - * @var string|null - */ - public ?string $reason = null; } diff --git a/src/Exceptions/IdempotencyConflictException.php b/src/Exceptions/IdempotencyConflictException.php new file mode 100644 index 0000000..579fd7e --- /dev/null +++ b/src/Exceptions/IdempotencyConflictException.php @@ -0,0 +1,13 @@ +retryAfter = $retryAfter; + + return $exception; + } +} diff --git a/src/Exceptions/ValidationException.php b/src/Exceptions/ValidationException.php new file mode 100644 index 0000000..a4930e5 --- /dev/null +++ b/src/Exceptions/ValidationException.php @@ -0,0 +1,115 @@ + [mensagens]`. Erro que o + * gateway não atribui a um campo fica na chave `base`. + * + * @var array> + */ + public array $fieldErrors = []; + + /** + * Cria a exceção com os erros por campo já normalizados. + * + * @param string $message + * @param array> $fieldErrors + * @param mixed $errors corpo de erro do gateway, como em `GatewayException` + * @param \Throwable|null $previous + * @param int|null $httpStatus + * @return static + */ + public static function withFieldErrors( + string $message, + array $fieldErrors, + $errors = null, + ?\Throwable $previous = null, + ?int $httpStatus = null + ): static { + $exception = new static($message, $errors, $previous, $httpStatus); + $exception->fieldErrors = $fieldErrors; + + return $exception; + } + + /** + * Converte o corpo de erro de um gateway para o formato `campo => [mensagens]`. String vira + * `base`; lista sem chave nomeada vira `base`; valor que não é string vira o seu JSON. + * + * @param mixed $errors string, objeto ou array com os erros do gateway + * @return array> + */ + public static function normalizeFieldErrors($errors): array + { + if ($errors === null || $errors === '' || $errors === []) { + return []; + } + + if (is_string($errors)) { + return ['base' => [$errors]]; + } + + if (is_object($errors)) { + $errors = (array) $errors; + } + + if (!is_array($errors)) { + return ['base' => [json_encode($errors)]]; + } + + $fieldErrors = []; + foreach ($errors as $field => $messages) { + $key = is_int($field) ? 'base' : (string) $field; + foreach (self::messagesOf($messages) as $message) { + $fieldErrors[$key][] = $message; + } + } + + return $fieldErrors; + } + + /** + * Achata o valor de um campo numa lista de strings. + * + * @param mixed $messages + * @return array + */ + private static function messagesOf($messages): array + { + if (is_string($messages)) { + return [$messages]; + } + + if (is_object($messages)) { + $messages = (array) $messages; + } + + if (!is_array($messages)) { + return [json_encode($messages)]; + } + + $list = []; + foreach ($messages as $message) { + if (is_string($message)) { + $list[] = $message; + } elseif (is_object($message) && isset($message->message) && is_string($message->message)) { + $list[] = $message->message; + } elseif (is_array($message) && isset($message['message']) && is_string($message['message'])) { + $list[] = $message['message']; + } else { + $list[] = json_encode($message); + } + } + + return $list; + } +} diff --git a/src/Gateways/Iugu/DeclineCodes.php b/src/Gateways/Iugu/DeclineCodes.php new file mode 100644 index 0000000..0f8df83 --- /dev/null +++ b/src/Gateways/Iugu/DeclineCodes.php @@ -0,0 +1,219 @@ + */ + private const MAP = [ + // saldo ou limite + '51' => DeclineCode::INSUFFICIENT_FUNDS, + '61' => DeclineCode::INSUFFICIENT_FUNDS, + '65' => DeclineCode::INSUFFICIENT_FUNDS, + '70' => DeclineCode::INSUFFICIENT_FUNDS, + 'BL' => DeclineCode::INSUFFICIENT_FUNDS, + 'DM' => DeclineCode::INSUFFICIENT_FUNDS, + 'N4' => DeclineCode::INSUFFICIENT_FUNDS, + + // cartão vencido + '54' => DeclineCode::EXPIRED_CARD, + + // número do cartão + '14' => DeclineCode::INCORRECT_NUMBER, + '25' => DeclineCode::INCORRECT_NUMBER, + + // dados do cartão ou do pagador ("verifique os dados", "dados inválidos", conta inexistente, + // cartão novo não desbloqueado, emissor não localizado pelo BIN) + '1' => DeclineCode::INVALID_CARD, + '12' => DeclineCode::INVALID_CARD, + '15' => DeclineCode::INVALID_CARD, + '30' => DeclineCode::INVALID_CARD, + '46' => DeclineCode::INVALID_CARD, + '56' => DeclineCode::INVALID_CARD, + '78' => DeclineCode::INVALID_CARD, + '101' => DeclineCode::INVALID_CARD, + '111' => DeclineCode::INVALID_CARD, + '115' => DeclineCode::INVALID_CARD, + '122' => DeclineCode::INVALID_CARD, + '6P' => DeclineCode::INVALID_CARD, + 'AV' => DeclineCode::INVALID_CARD, + 'BM' => DeclineCode::INVALID_CARD, + 'BP' => DeclineCode::INVALID_CARD, + 'BR' => DeclineCode::INVALID_CARD, + 'CF' => DeclineCode::INVALID_CARD, + 'CG' => DeclineCode::INVALID_CARD, + 'DF' => DeclineCode::INVALID_CARD, + 'DQ' => DeclineCode::INVALID_CARD, + 'G4' => DeclineCode::INVALID_CARD, + 'KA' => DeclineCode::INVALID_CARD, + 'KE' => DeclineCode::INVALID_CARD, + 'U3' => DeclineCode::INVALID_CARD, + + // cartão perdido, roubado, retido ou bloqueado pelo emissor + '4' => DeclineCode::LOST_OR_STOLEN, + '41' => DeclineCode::LOST_OR_STOLEN, + '43' => DeclineCode::LOST_OR_STOLEN, + '62' => DeclineCode::LOST_OR_STOLEN, + '146' => DeclineCode::LOST_OR_STOLEN, + 'BN' => DeclineCode::LOST_OR_STOLEN, + + // fraude confirmada, suspeita ou antifraude + '7' => DeclineCode::FRAUD_SUSPECTED, + '59' => DeclineCode::FRAUD_SUSPECTED, + 'AF01' => DeclineCode::FRAUD_SUSPECTED, + 'AF02' => DeclineCode::FRAUD_SUSPECTED, + 'BP171' => DeclineCode::FRAUD_SUSPECTED, + + // autenticação do pagador não realizada + 'AI' => DeclineCode::AUTHENTICATION_REQUIRED, + + // transação não permitida para o cartão, função incorreta (crédito ou débito), produto não habilitado + '39' => DeclineCode::BRAND_NOT_SUPPORTED, + '52' => DeclineCode::BRAND_NOT_SUPPORTED, + '53' => DeclineCode::BRAND_NOT_SUPPORTED, + '57' => DeclineCode::BRAND_NOT_SUPPORTED, + '79' => DeclineCode::BRAND_NOT_SUPPORTED, + '5C' => DeclineCode::BRAND_NOT_SUPPORTED, + 'AB' => DeclineCode::BRAND_NOT_SUPPORTED, + 'AC' => DeclineCode::BRAND_NOT_SUPPORTED, + 'AH' => DeclineCode::BRAND_NOT_SUPPORTED, + 'C1' => DeclineCode::BRAND_NOT_SUPPORTED, + 'DS' => DeclineCode::BRAND_NOT_SUPPORTED, + 'EK' => DeclineCode::BRAND_NOT_SUPPORTED, + 'G5' => DeclineCode::BRAND_NOT_SUPPORTED, + + // "contate a central do seu cartão", "não tente novamente", transação negada pelo emissor, + // suspensão de pagamento recorrente pelo emissor, violação de segurança + '5' => DeclineCode::DO_NOT_HONOR, + '6' => DeclineCode::DO_NOT_HONOR, + '60' => DeclineCode::DO_NOT_HONOR, + '63' => DeclineCode::DO_NOT_HONOR, + '67' => DeclineCode::DO_NOT_HONOR, + '93' => DeclineCode::DO_NOT_HONOR, + '99' => DeclineCode::DO_NOT_HONOR, + '100' => DeclineCode::DO_NOT_HONOR, + '109' => DeclineCode::DO_NOT_HONOR, + '110' => DeclineCode::DO_NOT_HONOR, + '116' => DeclineCode::DO_NOT_HONOR, + '121' => DeclineCode::DO_NOT_HONOR, + '181' => DeclineCode::DO_NOT_HONOR, + '200' => DeclineCode::DO_NOT_HONOR, + 'B1' => DeclineCode::DO_NOT_HONOR, + 'B2' => DeclineCode::DO_NOT_HONOR, + 'BP176' => DeclineCode::DO_NOT_HONOR, + 'C2' => DeclineCode::DO_NOT_HONOR, + 'C3' => DeclineCode::DO_NOT_HONOR, + 'FC' => DeclineCode::DO_NOT_HONOR, + 'FG' => DeclineCode::DO_NOT_HONOR, + 'GA' => DeclineCode::DO_NOT_HONOR, + 'GD' => DeclineCode::DO_NOT_HONOR, + 'GF' => DeclineCode::DO_NOT_HONOR, + 'GK' => DeclineCode::DO_NOT_HONOR, + 'GT' => DeclineCode::DO_NOT_HONOR, + 'N7' => DeclineCode::DO_NOT_HONOR, + 'NR' => DeclineCode::DO_NOT_HONOR, + 'R0' => DeclineCode::DO_NOT_HONOR, + 'R1' => DeclineCode::DO_NOT_HONOR, + 'R2' => DeclineCode::DO_NOT_HONOR, + 'R3' => DeclineCode::DO_NOT_HONOR, + 'RE' => DeclineCode::DO_NOT_HONOR, + 'RP' => DeclineCode::DO_NOT_HONOR, + 'SC' => DeclineCode::DO_NOT_HONOR, + + // emissor fora do ar, falha de sistema ou de comunicação, timeout, problema no adquirente + '19' => DeclineCode::TRY_AGAIN, + '28' => DeclineCode::TRY_AGAIN, + '85' => DeclineCode::TRY_AGAIN, + '89' => DeclineCode::TRY_AGAIN, + '90' => DeclineCode::TRY_AGAIN, + '91' => DeclineCode::TRY_AGAIN, + '92' => DeclineCode::TRY_AGAIN, + '96' => DeclineCode::TRY_AGAIN, + '98' => DeclineCode::TRY_AGAIN, + '911' => DeclineCode::TRY_AGAIN, + '912' => DeclineCode::TRY_AGAIN, + '999' => DeclineCode::TRY_AGAIN, + '99A' => DeclineCode::TRY_AGAIN, + '99B' => DeclineCode::TRY_AGAIN, + '99C' => DeclineCode::TRY_AGAIN, + '99TA' => DeclineCode::TRY_AGAIN, + '99Z' => DeclineCode::TRY_AGAIN, + 'AA' => DeclineCode::TRY_AGAIN, + 'AF' => DeclineCode::TRY_AGAIN, + 'AG' => DeclineCode::TRY_AGAIN, + 'BD' => DeclineCode::TRY_AGAIN, + 'BO' => DeclineCode::TRY_AGAIN, + 'BP900' => DeclineCode::TRY_AGAIN, + 'BP901' => DeclineCode::TRY_AGAIN, + 'BP902' => DeclineCode::TRY_AGAIN, + + // valor ou data inválidos para a transação, transação duplicada + '13' => DeclineCode::GENERIC, + '64' => DeclineCode::GENERIC, + '80' => DeclineCode::GENERIC, + '94' => DeclineCode::GENERIC, + '97' => DeclineCode::GENERIC, + 'FE' => DeclineCode::GENERIC, + ]; + + /** + * Traduz o código LR para o vocabulário do pacote. A tabela oficial lista os códigos de um + * dígito com e sem zero à esquerda (`5` e `05`), então código só de dígitos é comparado sem + * os zeros iniciais. Nulo quando o código não está na tabela, para o driver preservar o + * original e registrar no log. + * + * @param string|null $lr + * @return DeclineCode|null + */ + public static function toDeclineCode(?string $lr): ?DeclineCode + { + if ($lr === null || $lr === '') { + return null; + } + + $key = strtoupper($lr); + if (ctype_digit($key)) { + $key = ltrim($key, '0') ?: '0'; + } + + return self::MAP[$key] ?? null; + } + + /** + * Lê o código LR de uma resposta de cobrança recusada: o campo `LR` quando existe, senão o + * trecho `LR: xx` de `info_message` ou `message`. Nulo quando a resposta não traz código. + * + * @param object $charge resposta de `POST /v1/charge` + * @return string|null + */ + public static function extractLr(object $charge): ?string + { + $lr = $charge->LR ?? null; + if (is_string($lr) && trim($lr) !== '') { + return strtoupper(trim($lr)); + } + if (is_int($lr)) { + return (string) $lr; + } + + foreach (['info_message', 'message'] as $field) { + $text = $charge->{$field} ?? null; + if (is_string($text) && preg_match('/\bLR:?\s*([A-Za-z0-9]{1,5})\b/', $text, $matches)) { + return strtoupper($matches[1]); + } + } + + return null; + } +} diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index 60f2026..efc1b6c 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -29,17 +29,24 @@ use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\PlanInterval; +use Potelo\MultiPayment\Enums\DeclineCode; +use Potelo\MultiPayment\Helpers\LogHelper; use Potelo\MultiPayment\Contracts\PlanContract; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Contracts\SubscriptionContract; use Potelo\MultiPayment\Gateways\Concerns\ChecksCapabilities; +use Potelo\MultiPayment\Gateways\Iugu\DeclineCodes as IuguDeclineCodes; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; +use Potelo\MultiPayment\Exceptions\NotFoundException; +use Potelo\MultiPayment\Exceptions\RateLimitException; +use Potelo\MultiPayment\Exceptions\ValidationException; use Potelo\MultiPayment\Exceptions\MultiPaymentException; use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; +use Potelo\MultiPayment\Exceptions\IdempotencyConflictException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract @@ -235,8 +242,10 @@ private function createIuguPaymentToken(CreditCard $creditCard): string * configurada. Erro com corpo JSON passa por `iuguResponseException()`. * * Regras: 401 e 403 viram `AuthenticationException`; 5xx e falha de rede viram - * `GatewayNotAvailableException`; 404 e o restante viram `GatewayException`, com o status - * acessível em `httpStatus` (429 e 409 inclusive). Exceção do próprio pacote passa intacta. + * `GatewayNotAvailableException`; 404 vira `NotFoundException`; os demais status passam por + * `classifyIuguFailure()` (400 e 422 `ValidationException`, 409 + * `IdempotencyConflictException`, 429 `RateLimitException`, o restante `GatewayException`), + * sempre com o status em `httpStatus`. Exceção do próprio pacote passa intacta. * * @param \Throwable $e * @param string $operation descrição da operação, em inglês, para a mensagem @@ -254,7 +263,7 @@ private function translateIuguException(\Throwable $e, string $operation): Multi if ($e instanceof IuguObjectNotFound) { // o SDK lança essa classe para 404 e fetchAPI() a relança sem o código HTTP - return new GatewayException("Error {$operation}: {$e->getMessage()}", null, $e, 404); + return new NotFoundException("Error {$operation}: {$e->getMessage()}", null, $e, 404); } if ($e instanceof \IuguRequestException) { @@ -295,7 +304,11 @@ private function iuguResponseException(string $message, $errors): MultiPaymentEx } /** - * Escolhe a exceção do pacote pelo status HTTP da falha. + * Escolhe a exceção do pacote pelo status HTTP da falha: 401 e 403 `AuthenticationException`, + * 5xx `GatewayNotAvailableException`, 400 e 422 `ValidationException` (com os erros por + * campo, nos dois formatos que a Iugu usa: objeto por campo ou string), 404 + * `NotFoundException`, 409 `IdempotencyConflictException`, 429 `RateLimitException` (sem + * `retryAfter`: o SDK não expõe cabeçalhos) e o restante `GatewayException`. * * @param string $message * @param string $detail texto da resposta, para a mensagem de autenticação @@ -319,7 +332,19 @@ private function classifyIuguFailure( return new GatewayNotAvailableException($message, $previous, $httpStatus); } - return new GatewayException($message, $errors, $previous, $httpStatus); + return match ($httpStatus) { + 400, 422 => ValidationException::withFieldErrors( + $message, + ValidationException::normalizeFieldErrors($errors ?? $detail), + $errors, + $previous, + $httpStatus + ), + 404 => new NotFoundException($message, $errors, $previous, $httpStatus), + 409 => new IdempotencyConflictException($message, $errors, $previous, $httpStatus), + 429 => new RateLimitException($message, $errors, $previous, $httpStatus), + default => new GatewayException($message, $errors, $previous, $httpStatus), + }; } /** @@ -1143,10 +1168,7 @@ private function chargeIuguInvoice(array $iuguInvoiceData) if ($iuguCharge->errors) { throw $this->iuguResponseException('Error charging invoice', $iuguCharge->errors); } elseif (!$iuguCharge->success) { - $exception = new ChargingException('Error charging invoice: ' . $iuguCharge->info_message); - $exception->chargeResponse = $iuguCharge; - $exception->httpStatus = $this->lastIuguHttpStatus(); - throw $exception; + throw $this->cardDeclined($iuguCharge); } // a cobrança devolve só o id; a leitura da fatura é outra requisição e falha como tal @@ -1157,6 +1179,40 @@ private function chargeIuguInvoice(array $iuguInvoiceData) } } + /** + * Traduz uma cobrança recusada (`success` falso em `POST /v1/charge`) para + * `ChargingException`, com o LR lido da resposta e traduzido para `DeclineCode`. LR fora da + * tabela vira `DeclineCode::UNKNOWN`, com o código preservado em `gatewayCode` e registro em + * nível `info`; resposta sem LR também vira `UNKNOWN`, sem registro. + * + * @param object $iuguCharge resposta da cobrança + * @return ChargingException + */ + private function cardDeclined(object $iuguCharge): ChargingException + { + $lr = IuguDeclineCodes::extractLr($iuguCharge); + $declineCode = IuguDeclineCodes::toDeclineCode($lr); + if ($declineCode === null) { + $declineCode = DeclineCode::UNKNOWN; + if ($lr !== null) { + LogHelper::info('Código LR da Iugu sem tradução para DeclineCode', ['gateway' => 'iugu', 'lr' => $lr]); + } + } + + $detail = $iuguCharge->info_message ?? $iuguCharge->message ?? ''; + $exception = ChargingException::declined( + 'iugu', + $declineCode, + $lr, + is_string($detail) ? $detail : '', + null, + $this->lastIuguHttpStatus() + ); + $exception->chargeResponse = $iuguCharge; + + return $exception; + } + /** * @inheritDoc */ diff --git a/src/Gateways/Stripe/DeclineCodes.php b/src/Gateways/Stripe/DeclineCodes.php new file mode 100644 index 0000000..7eef701 --- /dev/null +++ b/src/Gateways/Stripe/DeclineCodes.php @@ -0,0 +1,111 @@ + */ + private const MAP = [ + 'insufficient_funds' => DeclineCode::INSUFFICIENT_FUNDS, + 'card_velocity_exceeded' => DeclineCode::INSUFFICIENT_FUNDS, + 'withdrawal_count_limit_exceeded' => DeclineCode::INSUFFICIENT_FUNDS, + + 'expired_card' => DeclineCode::EXPIRED_CARD, + + 'incorrect_cvc' => DeclineCode::INCORRECT_CVC, + 'invalid_cvc' => DeclineCode::INCORRECT_CVC, + + 'incorrect_number' => DeclineCode::INCORRECT_NUMBER, + 'invalid_number' => DeclineCode::INCORRECT_NUMBER, + + 'invalid_expiry_month' => DeclineCode::INVALID_CARD, + 'invalid_expiry_year' => DeclineCode::INVALID_CARD, + 'invalid_account' => DeclineCode::INVALID_CARD, + 'new_account_information_available' => DeclineCode::INVALID_CARD, + 'incorrect_address' => DeclineCode::INVALID_CARD, + 'incorrect_zip' => DeclineCode::INVALID_CARD, + + 'lost_card' => DeclineCode::LOST_OR_STOLEN, + 'stolen_card' => DeclineCode::LOST_OR_STOLEN, + 'pickup_card' => DeclineCode::LOST_OR_STOLEN, + 'restricted_card' => DeclineCode::LOST_OR_STOLEN, + + 'fraudulent' => DeclineCode::FRAUD_SUSPECTED, + 'merchant_blacklist' => DeclineCode::FRAUD_SUSPECTED, + + 'authentication_required' => DeclineCode::AUTHENTICATION_REQUIRED, + 'authentication_not_handled' => DeclineCode::AUTHENTICATION_REQUIRED, + 'mobile_device_authentication_required' => DeclineCode::AUTHENTICATION_REQUIRED, + + 'card_not_supported' => DeclineCode::BRAND_NOT_SUPPORTED, + 'currency_not_supported' => DeclineCode::BRAND_NOT_SUPPORTED, + + 'do_not_honor' => DeclineCode::DO_NOT_HONOR, + 'do_not_try_again' => DeclineCode::DO_NOT_HONOR, + 'call_issuer' => DeclineCode::DO_NOT_HONOR, + 'no_action_taken' => DeclineCode::DO_NOT_HONOR, + 'not_permitted' => DeclineCode::DO_NOT_HONOR, + 'revocation_of_all_authorizations' => DeclineCode::DO_NOT_HONOR, + 'revocation_of_authorization' => DeclineCode::DO_NOT_HONOR, + 'security_violation' => DeclineCode::DO_NOT_HONOR, + 'service_not_allowed' => DeclineCode::DO_NOT_HONOR, + 'stop_payment_order' => DeclineCode::DO_NOT_HONOR, + 'transaction_not_allowed' => DeclineCode::DO_NOT_HONOR, + + 'processing_error' => DeclineCode::TRY_AGAIN, + 'issuer_not_available' => DeclineCode::TRY_AGAIN, + 'reenter_transaction' => DeclineCode::TRY_AGAIN, + 'try_again_later' => DeclineCode::TRY_AGAIN, + 'approve_with_id' => DeclineCode::TRY_AGAIN, + + 'generic_decline' => DeclineCode::GENERIC, + 'card_declined' => DeclineCode::GENERIC, + 'duplicate_transaction' => DeclineCode::GENERIC, + 'invalid_amount' => DeclineCode::GENERIC, + 'testmode_decline' => DeclineCode::GENERIC, + ]; + + /** + * Traduz o código da Stripe para o vocabulário do pacote. Nulo quando o código não está + * na tabela, para o driver preservar o original e registrar no log. + * + * @param string|null $stripeCode `decline_code` ou, na falta dele, `code` do erro + * @return DeclineCode|null + */ + public static function toDeclineCode(?string $stripeCode): ?DeclineCode + { + if ($stripeCode === null || $stripeCode === '') { + return null; + } + + return self::MAP[$stripeCode] ?? null; + } + + /** + * Lê a orientação de nova tentativa que a Stripe envia em `advice_code` junto com a + * recusa. Nulo quando não há orientação ou ela não fala de nova tentativa + * (`confirm_card_data`), caso em que vale o padrão do `DeclineCode`. + * + * @param string|null $adviceCode + * @return bool|null + */ + public static function retryableFromAdvice(?string $adviceCode): ?bool + { + return match ($adviceCode) { + 'try_again_later' => true, + 'do_not_try_again' => false, + default => null, + }; + } +} diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 44ccb73..caadc57 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -10,8 +10,11 @@ use Stripe\Exception\CardException; use Stripe\Exception\ApiErrorException; use Stripe\Exception\PermissionException; +use Stripe\Exception\IdempotencyException; +use Stripe\Exception\InvalidRequestException; use Stripe\Exception\UnexpectedValueException as StripeUnexpectedValueException; use Stripe\Exception\ApiConnectionException; +use Stripe\Exception\RateLimitException as StripeRateLimitException; use Stripe\Exception\AuthenticationException as StripeAuthenticationException; use Illuminate\Support\Facades\Config; use Potelo\MultiPayment\Models\Pix; @@ -26,15 +29,22 @@ use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Enums\DeclineCode; +use Potelo\MultiPayment\Helpers\LogHelper; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Gateways\Concerns\ChecksCapabilities; +use Potelo\MultiPayment\Gateways\Stripe\DeclineCodes as StripeDeclineCodes; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; +use Potelo\MultiPayment\Exceptions\NotFoundException; +use Potelo\MultiPayment\Exceptions\RateLimitException; +use Potelo\MultiPayment\Exceptions\ValidationException; use Potelo\MultiPayment\Exceptions\MultiPaymentException; use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; +use Potelo\MultiPayment\Exceptions\IdempotencyConflictException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; class StripeGateway implements GatewayContract @@ -467,11 +477,14 @@ private function stripeRequest(callable $request) * Regras: 401 (`AuthenticationException` do SDK, inclusive chave não configurada) e 403 * (`PermissionException`) viram `AuthenticationException`; falha de conexão * (`ApiConnectionException`) e 5xx viram `GatewayNotAvailableException`, inclusive o 5xx - * com corpo não JSON, que o SDK lança como `UnexpectedValueException`; o restante - * (`invalid_request_error`, 429, conflito de idempotência) vira `GatewayException` com - * `type`, `code`, `decline_code` e `param` em `getErrors()` e o status em `httpStatus`. - * Recusa de cartão (`CardException`) é tratada antes, em `stripeChargeRequest()`. Exceção do - * próprio pacote passa intacta. + * com corpo não JSON, que o SDK lança como `UnexpectedValueException`; recusa de cartão + * (`CardException`, `type` `card_error`, em qualquer operação, inclusive o attach do cartão) + * vira `ChargingException` com `declineCode`; `rate_limit_error` vira `RateLimitException`; + * `idempotency_error` vira `IdempotencyConflictException`; `invalid_request_error` vira + * `NotFoundException` quando o `code` é `resource_missing` e `ValidationException` nos demais + * casos; o restante vira `GatewayException`. Todas trazem `type`, `code`, `decline_code` e + * `param` em `getErrors()` e o status em `httpStatus`. Exceção do próprio pacote passa + * intacta. * * @param \Throwable $e * @return MultiPaymentException @@ -502,23 +515,123 @@ private function translateStripeException(\Throwable $e): MultiPaymentException } if ($e instanceof ApiErrorException) { - if ($e->getHttpStatus() >= 500) { - return new GatewayNotAvailableException($e->getMessage(), $e, $e->getHttpStatus()); + $httpStatus = $e->getHttpStatus(); + if ($httpStatus >= 500) { + return new GatewayNotAvailableException($e->getMessage(), $e, $httpStatus); } $error = $e->getError(); - - return new GatewayException($e->getMessage(), array_filter([ + $errors = array_filter([ 'type' => $error?->type, 'code' => $error?->code, 'decline_code' => $error?->decline_code ?? null, 'param' => $error?->param, - ]), $e, $e->getHttpStatus()); + ]); + + if ($e instanceof CardException) { + return $this->cardDeclined($e); + } + + if ($e instanceof StripeRateLimitException) { + return RateLimitException::withRetryAfter( + $e->getMessage(), + $errors, + $e, + $httpStatus, + self::retryAfterFromHeaders($e->getHttpHeaders()) + ); + } + + if ($e instanceof IdempotencyException) { + return new IdempotencyConflictException($e->getMessage(), $errors, $e, $httpStatus); + } + + if ($e instanceof InvalidRequestException) { + if (($error?->code ?? null) === 'resource_missing' || $httpStatus === 404) { + return new NotFoundException($e->getMessage(), $errors, $e, $httpStatus); + } + + $field = is_string($error?->param) && $error->param !== '' ? $error->param : 'base'; + + return ValidationException::withFieldErrors( + $e->getMessage(), + [$field => [$e->getMessage()]], + $errors, + $e, + $httpStatus + ); + } + + return new GatewayException($e->getMessage(), $errors, $e, $httpStatus); } return new GatewayException($e->getMessage(), null, $e); } + /** + * Traduz uma recusa de cartão do stripe-php para `ChargingException`: o `decline_code` (ou, + * na falta dele, o `code`) vira `DeclineCode`, o `advice_code` decide `retryable` quando + * presente, e a resposta bruta vai em `chargeResponse`. Código fora da tabela vira + * `DeclineCode::UNKNOWN`, com o original preservado em `gatewayCode` e registro em nível + * `info`. + * + * @param CardException $e + * @return ChargingException + */ + private function cardDeclined(CardException $e): ChargingException + { + $error = $e->getError(); + $code = $error?->code ?? null; + $stripeDeclineCode = $error?->decline_code ?? null; + $gatewayCode = $stripeDeclineCode ?: $code; + + $declineCode = StripeDeclineCodes::toDeclineCode($gatewayCode); + if ($declineCode === null) { + $declineCode = DeclineCode::UNKNOWN; + if (!empty($gatewayCode)) { + LogHelper::info('Código de recusa da Stripe sem tradução para DeclineCode', ['gateway' => 'stripe', 'code' => $gatewayCode]); + } + } + + $exception = ChargingException::declined( + 'stripe', + $declineCode, + $gatewayCode, + $e->getMessage(), + $e, + $e->getHttpStatus(), + StripeDeclineCodes::retryableFromAdvice($error?->advice_code ?? null) + ); + // array em vez do ErrorObject, para o formato ser o mesmo em qualquer operação + $exception->chargeResponse = $error?->toArray(); + $exception->reason = self::chargeFailureReason($code, $stripeDeclineCode); + + return $exception; + } + + /** + * Lê o cabeçalho `Retry-After` da resposta, em segundos. Nulo quando ausente ou quando não + * é um número inteiro (a forma em data HTTP não é interpretada). O SDK entrega os + * cabeçalhos como `\Stripe\Util\CaseInsensitiveArray`, iterável; um array simples também + * é aceito. + * + * @param iterable|null $headers + * @return int|null + */ + private static function retryAfterFromHeaders(?iterable $headers): ?int + { + foreach ($headers ?? [] as $name => $value) { + if (strtolower((string) $name) !== 'retry-after') { + continue; + } + $value = is_array($value) ? reset($value) : $value; + + return is_numeric($value) ? (int) $value : null; + } + + return null; + } + /** * @inheritDoc * @throws ChargingException|ModelAttributeValidationException|UnsupportedOperationException @@ -586,23 +699,8 @@ private function createCreditCardInvoice(Invoice $invoice): Invoice if (empty($invoice->creditCard->customer)) { $invoice->creditCard->customer = $invoice->customer; } - try { - $invoice->creditCard = $this->createCreditCard($invoice->creditCard); - } catch (GatewayException $e) { - // a Stripe valida o cartão já no attach: recusa nesse ponto é falha de - // cobrança para o consumidor, não erro genérico de gateway - $errors = $e->getErrors(); - if (($errors['type'] ?? null) !== 'card_error') { - throw $e; - } - $exception = new ChargingException('Error charging invoice: ' . $e->getMessage(), $e, $e->httpStatus); - $exception->chargeResponse = $errors; - $exception->reason = self::chargeFailureReason( - $errors['code'] ?? null, - $errors['decline_code'] ?? null - ); - throw $exception; - } + // a Stripe valida o cartão já no attach; a recusa nesse ponto é ChargingException + $invoice->creditCard = $this->createCreditCard($invoice->creditCard); } $stripePaymentIntentData = $this->invoiceToStripeData($invoice); @@ -613,7 +711,7 @@ private function createCreditCardInvoice(Invoice $invoice): Invoice $stripePaymentIntentData = $this->mergeGatewayOptions($stripePaymentIntentData, $invoice); $requestOptions = $this->extractIdempotencyKey($stripePaymentIntentData); - $stripePaymentIntent = $this->stripeChargeRequest(function () use ($stripePaymentIntentData, $requestOptions) { + $stripePaymentIntent = $this->stripeRequest(function () use ($stripePaymentIntentData, $requestOptions) { return $this->client->paymentIntents->create( $this->withExpand($stripePaymentIntentData, self::PAYMENT_INTENT_EXPAND), $requestOptions @@ -843,7 +941,7 @@ public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice ? $invoice->creditCard->id : $invoice->creditCard->token; - $stripePaymentIntent = $this->stripeChargeRequest(function () use ($invoice, $paymentMethodId) { + $stripePaymentIntent = $this->stripeRequest(function () use ($invoice, $paymentMethodId) { $paymentMethodId = $this->resolvePaymentMethodId($paymentMethodId); $stripePaymentMethod = $this->client->paymentMethods->retrieve($paymentMethodId); @@ -1049,33 +1147,8 @@ private static function stripeStatusToMultiPayment(StripePaymentIntent $stripePa } /** - * Igual ao stripeRequest, mas traduz recusa de cartão para ChargingException com a - * resposta bruta e a razão normalizada (habilitador do fallback de gateway na aplicação). - * - * @param callable $request - * @return mixed - * @throws ChargingException|GatewayException|GatewayNotAvailableException - */ - private function stripeChargeRequest(callable $request) - { - return $this->stripeRequest(function () use ($request) { - try { - return $request(); - } catch (CardException $e) { - $exception = new ChargingException('Error charging invoice: ' . $e->getMessage(), $e, $e->getHttpStatus()); - // array em vez do ErrorObject para manter o mesmo formato da recusa no attach - $exception->chargeResponse = $e->getError()?->toArray(); - $exception->reason = self::chargeFailureReason( - $e->getError()?->code, - $e->getError()?->decline_code ?? null - ); - throw $exception; - } - }); - } - - /** - * Normaliza o código de recusa da Stripe para as razões genéricas do pacote. + * Normaliza o código de recusa da Stripe para o valor de `CardDeclinedException::$reason`, que + * mantém o vocabulário das versões anteriores; `declineCode` é a normalização atual. * * @param string|null $code * @param string|null $declineCode diff --git a/src/Helpers/LogHelper.php b/src/Helpers/LogHelper.php index b0fb97e..bc15901 100644 --- a/src/Helpers/LogHelper.php +++ b/src/Helpers/LogHelper.php @@ -20,11 +20,36 @@ final class LogHelper * @return void */ public static function warning(string $message, array $context = []): void + { + self::log('warning', $message, $context); + } + + /** + * Registra uma informação. + * + * @param string $message + * @param array $context + * @return void + */ + public static function info(string $message, array $context = []): void + { + self::log('info', $message, $context); + } + + /** + * Escreve no logger do container quando há um; senão, no `error_log()` do PHP. + * + * @param string $level + * @param string $message + * @param array $context + * @return void + */ + private static function log(string $level, string $message, array $context): void { $app = Facade::getFacadeApplication(); if ($app instanceof Container && $app->bound('log')) { - $app->make('log')->warning($message, $context); + $app->make('log')->{$level}($message, $context); return; } diff --git a/tests/Integration/MultiPaymentTest.php b/tests/Integration/MultiPaymentTest.php index 43ea6e6..60293fc 100644 --- a/tests/Integration/MultiPaymentTest.php +++ b/tests/Integration/MultiPaymentTest.php @@ -205,7 +205,7 @@ public function testShouldDeleteCard() $multiPayment = new \Potelo\MultiPayment\MultiPayment($gateway); $multiPayment->deleteCard($customer->id, $creditCard->id); - $this->expectException(\Potelo\MultiPayment\Exceptions\GatewayException::class); + $this->expectException(\Potelo\MultiPayment\Exceptions\NotFoundException::class); $this->expectExceptionMessage('payment_method: not found'); $multiPayment->getCard($customer->id, $creditCard->id); } @@ -294,7 +294,7 @@ public function testShouldDuplicateInvoice() #[DataProvider('shouldNotGetInvoiceDataProvider')] public function testShouldNotGetInvoice($gateway, $id) { - $this->expectException(\Potelo\MultiPayment\Exceptions\GatewayException::class); + $this->expectException(\Potelo\MultiPayment\Exceptions\NotFoundException::class); $multiPayment = new \Potelo\MultiPayment\MultiPayment($gateway); $multiPayment->getInvoice($id); } diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index 5ebf673..33b31aa 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -9,6 +9,8 @@ use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\ChargingException; +use Potelo\MultiPayment\Exceptions\CardDeclinedException; +use Potelo\MultiPayment\Enums\DeclineCode; use PHPUnit\Framework\Attributes\DataProvider; use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; @@ -100,11 +102,49 @@ public function testShouldRaiseChargingExceptionOnDeclinedCard($gateway) $invoiceBuilder->create(); $this->fail('Expected ChargingException was not thrown'); } catch (ChargingException $exception) { + $this->assertInstanceOf(CardDeclinedException::class, $exception); $this->assertEquals('card_declined', $exception->reason); + $this->assertSame(DeclineCode::GENERIC, $exception->declineCode); + $this->assertSame('generic_decline', $exception->gatewayCode); + $this->assertFalse($exception->retryable); $this->assertNotEmpty($exception->chargeResponse); } } + /** + * Recusa por saldo insuficiente chega com o decline_code da Stripe traduzido para + * INSUFFICIENT_FUNDS e com nova tentativa permitida. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testInsufficientFundsDeclineIsTranslatedToDeclineCode($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoiceBuilder = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer( + $customerData['name'], + $customerData['email'], + $customerData['taxDocument'], + $customerData['birthDate'], + $customerData['phoneArea'], + $customerData['phoneNumber'] + ) + ->addItem('Assinatura mensal', 9900, 1) + ->setAvailablePaymentMethods([PaymentMethod::CREDIT_CARD]) + ->addCreditCardToken('pm_card_chargeDeclinedInsufficientFunds'); + + try { + $invoiceBuilder->create(); + $this->fail('Expected CardDeclinedException was not thrown'); + } catch (CardDeclinedException $exception) { + $this->assertSame(DeclineCode::INSUFFICIENT_FUNDS, $exception->declineCode); + $this->assertSame('insufficient_funds', $exception->gatewayCode); + $this->assertTrue($exception->retryable); + $this->assertSame('insufficient_funds', $exception->reason); + } + } + /** * Deve salvar, buscar, definir como padrão e excluir um cartão tokenizado. * diff --git a/tests/Unit/Enums/DeclineCodeTest.php b/tests/Unit/Enums/DeclineCodeTest.php new file mode 100644 index 0000000..e9437bb --- /dev/null +++ b/tests/Unit/Enums/DeclineCodeTest.php @@ -0,0 +1,89 @@ +assertSame([ + 'insufficient_funds', + 'expired_card', + 'incorrect_cvc', + 'incorrect_number', + 'invalid_card', + 'lost_or_stolen', + 'fraud_suspected', + 'authentication_required', + 'brand_not_supported', + 'do_not_honor', + 'try_again', + 'generic', + 'unknown', + ], array_map(fn (DeclineCode $code) => $code->value, DeclineCode::cases())); + } + + #[DataProvider('retryableProvider')] + public function testIsRetryableTruthTable(DeclineCode $code, bool $expected): void + { + $this->assertSame($expected, $code->isRetryable()); + } + + public static function retryableProvider(): array + { + return [ + 'insufficient_funds' => [DeclineCode::INSUFFICIENT_FUNDS, true], + 'expired_card' => [DeclineCode::EXPIRED_CARD, false], + 'incorrect_cvc' => [DeclineCode::INCORRECT_CVC, false], + 'incorrect_number' => [DeclineCode::INCORRECT_NUMBER, false], + 'invalid_card' => [DeclineCode::INVALID_CARD, false], + 'lost_or_stolen' => [DeclineCode::LOST_OR_STOLEN, false], + 'fraud_suspected' => [DeclineCode::FRAUD_SUSPECTED, false], + 'authentication_required' => [DeclineCode::AUTHENTICATION_REQUIRED, false], + 'brand_not_supported' => [DeclineCode::BRAND_NOT_SUPPORTED, false], + 'do_not_honor' => [DeclineCode::DO_NOT_HONOR, false], + 'try_again' => [DeclineCode::TRY_AGAIN, true], + 'generic' => [DeclineCode::GENERIC, false], + 'unknown' => [DeclineCode::UNKNOWN, false], + ]; + } + + #[DataProvider('payerActionProvider')] + public function testRequiresPayerActionTruthTable(DeclineCode $code, bool $expected): void + { + $this->assertSame($expected, $code->requiresPayerAction()); + } + + public static function payerActionProvider(): array + { + return [ + 'insufficient_funds' => [DeclineCode::INSUFFICIENT_FUNDS, false], + 'expired_card' => [DeclineCode::EXPIRED_CARD, true], + 'incorrect_cvc' => [DeclineCode::INCORRECT_CVC, true], + 'incorrect_number' => [DeclineCode::INCORRECT_NUMBER, true], + 'invalid_card' => [DeclineCode::INVALID_CARD, true], + 'lost_or_stolen' => [DeclineCode::LOST_OR_STOLEN, false], + 'fraud_suspected' => [DeclineCode::FRAUD_SUSPECTED, false], + 'authentication_required' => [DeclineCode::AUTHENTICATION_REQUIRED, true], + 'brand_not_supported' => [DeclineCode::BRAND_NOT_SUPPORTED, true], + 'do_not_honor' => [DeclineCode::DO_NOT_HONOR, false], + 'try_again' => [DeclineCode::TRY_AGAIN, false], + 'generic' => [DeclineCode::GENERIC, false], + 'unknown' => [DeclineCode::UNKNOWN, false], + ]; + } + + public function testEveryCaseHasAOneLineDocblock(): void + { + $reflection = new \ReflectionEnum(DeclineCode::class); + foreach ($reflection->getCases() as $case) { + $doc = $case->getDocComment(); + $this->assertIsString($doc, "{$case->getName()} sem docblock"); + $this->assertMatchesRegularExpression('#^/\*\* .+ \*/$#', $doc, "{$case->getName()} com docblock de mais de uma linha"); + } + } +} diff --git a/tests/Unit/Exceptions/CardDeclinedExceptionTest.php b/tests/Unit/Exceptions/CardDeclinedExceptionTest.php new file mode 100644 index 0000000..01ed96b --- /dev/null +++ b/tests/Unit/Exceptions/CardDeclinedExceptionTest.php @@ -0,0 +1,104 @@ +assertInstanceOf(MultiPaymentException::class, $exception); + $this->assertNotInstanceOf(GatewayException::class, $exception); + } + + public function testDeclinedFillsCodeGatewayCodeRetryableAndReason(): void + { + $previous = new \RuntimeException('sdk'); + + $exception = CardDeclinedException::declined('stripe', DeclineCode::INSUFFICIENT_FUNDS, 'insufficient_funds', 'Your card has insufficient funds.', $previous, 402); + + $this->assertSame(DeclineCode::INSUFFICIENT_FUNDS, $exception->declineCode); + $this->assertSame('insufficient_funds', $exception->gatewayCode); + $this->assertTrue($exception->retryable); + $this->assertSame('insufficient_funds', $exception->reason); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame(402, $exception->httpStatus); + $this->assertNull($exception->chargeResponse); + $this->assertSame( + 'Cartão recusado pelo gateway stripe (insufficient_funds, código insufficient_funds): Your card has insufficient funds.', + $exception->getMessage() + ); + } + + public function testRetryableCanBeOverriddenByTheDriver(): void + { + $forced = CardDeclinedException::declined('stripe', DeclineCode::GENERIC, 'generic_decline', '', null, null, true); + $denied = CardDeclinedException::declined('stripe', DeclineCode::TRY_AGAIN, 'processing_error', '', null, null, false); + + $this->assertTrue($forced->retryable); + $this->assertFalse($denied->retryable); + } + + public function testMessageWithoutCodeAndWithoutDetail(): void + { + $exception = CardDeclinedException::declined('iugu', DeclineCode::UNKNOWN, '', ''); + + $this->assertNull($exception->gatewayCode); + $this->assertSame('Cartão recusado pelo gateway iugu (unknown, sem código)', $exception->getMessage()); + } + + public function testDefaultsBeforeDeclinedIsCalled(): void + { + $exception = new CardDeclinedException('recusado'); + + $this->assertSame(DeclineCode::UNKNOWN, $exception->declineCode); + $this->assertNull($exception->gatewayCode); + $this->assertFalse($exception->retryable); + $this->assertNull($exception->reason); + } + + public function testChargingExceptionIsACardDeclinedExceptionAndIsCaughtByBothNames(): void + { + $exception = ChargingException::declined('iugu', DeclineCode::EXPIRED_CARD, '54', 'Cartão vencido'); + + $this->assertInstanceOf(ChargingException::class, $exception); + $this->assertInstanceOf(CardDeclinedException::class, $exception); + $this->assertSame(DeclineCode::EXPIRED_CARD, $exception->declineCode); + + $caught = []; + try { + throw $exception; + } catch (ChargingException $e) { + $caught[] = 'charging'; + } + try { + throw $exception; + } catch (CardDeclinedException $e) { + $caught[] = 'card_declined'; + } + + $this->assertSame(['charging', 'card_declined'], $caught); + } + + public function testChargingExceptionKeepsThePreviousConstructorSignature(): void + { + $previous = new \RuntimeException('sdk'); + + $exception = new ChargingException('recusado', $previous, 402); + $exception->chargeResponse = ['LR' => '51']; + $exception->reason = 'card_declined'; + + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame(402, $exception->httpStatus); + $this->assertSame(['LR' => '51'], $exception->chargeResponse); + $this->assertSame('card_declined', $exception->reason); + } +} diff --git a/tests/Unit/Exceptions/ExceptionHierarchyTest.php b/tests/Unit/Exceptions/ExceptionHierarchyTest.php new file mode 100644 index 0000000..eb7f40d --- /dev/null +++ b/tests/Unit/Exceptions/ExceptionHierarchyTest.php @@ -0,0 +1,111 @@ +assertNotEmpty($files); + + foreach ($files as $file) { + $class = 'Potelo\\MultiPayment\\Exceptions\\' . basename($file, '.php'); + $this->assertTrue(class_exists($class), "{$class} não existe"); + $this->assertTrue( + $class === MultiPaymentException::class || is_subclass_of($class, MultiPaymentException::class), + "{$class} não herda de MultiPaymentException" + ); + } + } + + #[DataProvider('gatewayResponseProvider')] + public function testGatewayResponseExceptionsExtendGatewayException(string $class): void + { + $this->assertTrue(is_subclass_of($class, GatewayException::class), "{$class} não herda de GatewayException"); + } + + public static function gatewayResponseProvider(): array + { + return [ + 'ValidationException' => [ValidationException::class], + 'NotFoundException' => [NotFoundException::class], + 'RateLimitException' => [RateLimitException::class], + 'IdempotencyConflictException' => [IdempotencyConflictException::class], + ]; + } + + #[DataProvider('outsideGatewayExceptionProvider')] + public function testTheOtherExceptionsStayOutsideGatewayException(string $class): void + { + $this->assertFalse(is_subclass_of($class, GatewayException::class), "{$class} não deveria herdar de GatewayException"); + } + + public static function outsideGatewayExceptionProvider(): array + { + return [ + 'CardDeclinedException' => [CardDeclinedException::class], + 'ChargingException' => [ChargingException::class], + 'AuthenticationException' => [AuthenticationException::class], + 'GatewayNotAvailableException' => [GatewayNotAvailableException::class], + 'UnsupportedOperationException' => [UnsupportedOperationException::class], + 'RefundNotSupportedException' => [RefundNotSupportedException::class], + 'ModelAttributeValidationException' => [ModelAttributeValidationException::class], + 'ConfigurationException' => [ConfigurationException::class], + ]; + } + + public function testChargingExceptionIsTheDeprecatedNameOfCardDeclinedException(): void + { + $this->assertSame(CardDeclinedException::class, get_parent_class(ChargingException::class)); + } + + public function testRateLimitExceptionCarriesRetryAfter(): void + { + $previous = new \RuntimeException('sdk'); + + $exception = RateLimitException::withRetryAfter('Too many requests', ['type' => 'rate_limit_error'], $previous, 429, 7); + + $this->assertSame(7, $exception->retryAfter); + $this->assertSame(['type' => 'rate_limit_error'], $exception->getErrors()); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame(429, $exception->httpStatus); + $this->assertNull((new RateLimitException('erro'))->retryAfter); + } + + public function testNotFoundAndIdempotencyConflictKeepTheGatewayExceptionConstructor(): void + { + $previous = new \RuntimeException('sdk'); + + $notFound = new NotFoundException('Error getting invoice', 'Not Found', $previous, 404); + $conflict = new IdempotencyConflictException('Error creating invoice', ['base' => ['conflito']], $previous, 409); + + $this->assertSame(['Not Found'], $notFound->getErrors()); + $this->assertSame(404, $notFound->httpStatus); + $this->assertSame($previous, $notFound->getPrevious()); + $this->assertSame(['base' => ['conflito']], $conflict->getErrors()); + $this->assertSame(409, $conflict->httpStatus); + } +} diff --git a/tests/Unit/Exceptions/ValidationExceptionTest.php b/tests/Unit/Exceptions/ValidationExceptionTest.php new file mode 100644 index 0000000..4753258 --- /dev/null +++ b/tests/Unit/Exceptions/ValidationExceptionTest.php @@ -0,0 +1,73 @@ + ['inválido']], + ['email' => ['inválido']], + $previous, + 422 + ); + + $this->assertInstanceOf(GatewayException::class, $exception); + $this->assertSame(['email' => ['inválido']], $exception->fieldErrors); + $this->assertSame(['email' => ['inválido']], $exception->getErrors()); + $this->assertSame('Error creating customer - email.0: inválido', $exception->getMessage()); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame(422, $exception->httpStatus); + } + + public function testFieldErrorsAreEmptyByDefault(): void + { + $this->assertSame([], (new ValidationException('erro'))->fieldErrors); + } + + #[DataProvider('normalizeProvider')] + public function testNormalizeFieldErrors($errors, array $expected): void + { + $this->assertSame($expected, ValidationException::normalizeFieldErrors($errors)); + } + + public static function normalizeProvider(): array + { + return [ + 'nulo' => [null, []], + 'string vazia' => ['', []], + 'array vazio' => [[], []], + 'string' => ['Unauthorized', ['base' => ['Unauthorized']]], + 'objeto por campo (formato do SDK da Iugu)' => [ + ['email' => ['não é válido', 'já está em uso'], 'cpf_cnpj' => 'inválido'], + ['email' => ['não é válido', 'já está em uso'], 'cpf_cnpj' => ['inválido']], + ], + 'stdClass por campo' => [ + (object) ['email' => ['não é válido']], + ['email' => ['não é válido']], + ], + 'lista sem campo' => [ + ['erro um', 'erro dois'], + ['base' => ['erro um', 'erro dois']], + ], + 'lista de objetos com message (Pix Automático)' => [ + [(object) ['message' => 'Pagamento não pode ser cancelado'], ['message' => 'Outro']], + ['base' => ['Pagamento não pode ser cancelado', 'Outro']], + ], + 'valor que não é texto vira JSON' => [ + ['amount' => [['min' => 100]]], + ['amount' => ['{"min":100}']], + ], + 'escalar que não é string' => [42, ['base' => ['42']]], + ]; + } +} diff --git a/tests/Unit/Gateways/Iugu/DeclineCodesTest.php b/tests/Unit/Gateways/Iugu/DeclineCodesTest.php new file mode 100644 index 0000000..1cb1811 --- /dev/null +++ b/tests/Unit/Gateways/Iugu/DeclineCodesTest.php @@ -0,0 +1,138 @@ +assertSame($expected, DeclineCodes::toDeclineCode($lr)); + } + + public static function lrProvider(): array + { + return [ + '51' => ['51', DeclineCode::INSUFFICIENT_FUNDS], + '61' => ['61', DeclineCode::INSUFFICIENT_FUNDS], + '70' => ['70', DeclineCode::INSUFFICIENT_FUNDS], + 'DM' => ['DM', DeclineCode::INSUFFICIENT_FUNDS], + '54' => ['54', DeclineCode::EXPIRED_CARD], + '14' => ['14', DeclineCode::INCORRECT_NUMBER], + '25' => ['25', DeclineCode::INCORRECT_NUMBER], + '12' => ['12', DeclineCode::INVALID_CARD], + '56' => ['56', DeclineCode::INVALID_CARD], + 'BM' => ['BM', DeclineCode::INVALID_CARD], + 'G4' => ['G4', DeclineCode::INVALID_CARD], + '4' => ['4', DeclineCode::LOST_OR_STOLEN], + '41' => ['41', DeclineCode::LOST_OR_STOLEN], + '43' => ['43', DeclineCode::LOST_OR_STOLEN], + '62' => ['62', DeclineCode::LOST_OR_STOLEN], + '7' => ['7', DeclineCode::FRAUD_SUSPECTED], + '59' => ['59', DeclineCode::FRAUD_SUSPECTED], + 'AF01' => ['AF01', DeclineCode::FRAUD_SUSPECTED], + 'BP171' => ['BP171', DeclineCode::FRAUD_SUSPECTED], + 'AI' => ['AI', DeclineCode::AUTHENTICATION_REQUIRED], + '57' => ['57', DeclineCode::BRAND_NOT_SUPPORTED], + '39' => ['39', DeclineCode::BRAND_NOT_SUPPORTED], + 'C1' => ['C1', DeclineCode::BRAND_NOT_SUPPORTED], + '5' => ['5', DeclineCode::DO_NOT_HONOR], + '63' => ['63', DeclineCode::DO_NOT_HONOR], + '100' => ['100', DeclineCode::DO_NOT_HONOR], + 'FC' => ['FC', DeclineCode::DO_NOT_HONOR], + 'R0' => ['R0', DeclineCode::DO_NOT_HONOR], + '91' => ['91', DeclineCode::TRY_AGAIN], + '96' => ['96', DeclineCode::TRY_AGAIN], + '99A' => ['99A', DeclineCode::TRY_AGAIN], + '911' => ['911', DeclineCode::TRY_AGAIN], + 'BP902' => ['BP902', DeclineCode::TRY_AGAIN], + '13' => ['13', DeclineCode::GENERIC], + '94' => ['94', DeclineCode::GENERIC], + ]; + } + + public function testLookupIgnoresCase(): void + { + $this->assertSame(DeclineCode::FRAUD_SUSPECTED, DeclineCodes::toDeclineCode('af02')); + } + + #[DataProvider('zeroPaddedProvider')] + public function testLookupIgnoresLeadingZerosOfNumericCodes(string $lr, DeclineCode $expected): void + { + $this->assertSame($expected, DeclineCodes::toDeclineCode($lr)); + } + + public static function zeroPaddedProvider(): array + { + return [ + '01' => ['01', DeclineCode::INVALID_CARD], + '04' => ['04', DeclineCode::LOST_OR_STOLEN], + '05' => ['05', DeclineCode::DO_NOT_HONOR], + '06' => ['06', DeclineCode::DO_NOT_HONOR], + '07' => ['07', DeclineCode::FRAUD_SUSPECTED], + '051 (três dígitos, não é 51)' => ['051', DeclineCode::INSUFFICIENT_FUNDS], + ]; + } + + public function testAllZerosStaysUnmapped(): void + { + // 0 e 00 são "transação autorizada" na tabela e nunca chegam numa recusa + $this->assertNull(DeclineCodes::toDeclineCode('00')); + } + + #[DataProvider('unmappedProvider')] + public function testUnmappedOrEmptyLrIsNull(?string $lr): void + { + $this->assertNull(DeclineCodes::toDeclineCode($lr)); + } + + public static function unmappedProvider(): array + { + return [ + 'nulo' => [null], + 'vazio' => [''], + 'senha (cartão presente)' => ['75'], + 'comerciante inválido' => ['3'], + 'inexistente' => ['ZZ9'], + ]; + } + + public function testExtractLrPrefersTheField(): void + { + $charge = (object) ['LR' => '51', 'info_message' => 'Fulano, Master, XXXXXXXXXXXX1234, LR: 05']; + + $this->assertSame('51', DeclineCodes::extractLr($charge)); + } + + public function testExtractLrNormalizesTheFieldToUpperCaseAndAcceptsInteger(): void + { + $this->assertSame('AF02', DeclineCodes::extractLr((object) ['LR' => ' af02 '])); + $this->assertSame('51', DeclineCodes::extractLr((object) ['LR' => 51])); + } + + #[DataProvider('messageProvider')] + public function testExtractLrReadsTheMessageWhenTheFieldIsAbsent(object $charge, ?string $expected): void + { + $this->assertSame($expected, DeclineCodes::extractLr($charge)); + } + + public static function messageProvider(): array + { + return [ + 'info_message com dois pontos' => [(object) ['info_message' => 'Fulano, Master, XXXXXXXXXXXX1234, LR: 05'], '05'], + 'info_message sem dois pontos' => [(object) ['info_message' => 'Transação não autorizada LR 51'], '51'], + 'message alfanumérico' => [(object) ['message' => 'Recusado (LR: af02)'], 'AF02'], + 'campo LR vazio e mensagem com código' => [(object) ['LR' => '', 'info_message' => 'LR: 54'], '54'], + 'sem código' => [(object) ['info_message' => 'Transação não autorizada'], null], + 'resposta vazia' => [(object) [], null], + ]; + } +} diff --git a/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php b/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php index 16b44f8..9901774 100644 --- a/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php +++ b/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php @@ -15,33 +15,45 @@ use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Gateways\IuguGateway; use PHPUnit\Framework\Attributes\DataProvider; +use Potelo\MultiPayment\Tests\Unit\RecordingLogger; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; +use Potelo\MultiPayment\Exceptions\NotFoundException; +use Potelo\MultiPayment\Exceptions\RateLimitException; +use Potelo\MultiPayment\Exceptions\ValidationException; use Potelo\MultiPayment\Exceptions\MultiPaymentException; +use Potelo\MultiPayment\Exceptions\CardDeclinedException; use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +use Potelo\MultiPayment\Exceptions\IdempotencyConflictException; +use Potelo\MultiPayment\Enums\DeclineCode; use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; /** * Cobre a tradução de falhas do SDK da Iugu para as exceções do pacote: classe escolhida pelo - * status HTTP, exceção original em `getPrevious()` e status em `httpStatus`. Os fluxos que - * passam pelo requester injetado usam `QueuedIuguApiRequest` direto; os que usam recursos - * estáticos do SDK (`Iugu_Customer::create()`, `Iugu_PaymentToken::create()`, `Iugu_Charge`) - * instalam o mesmo fake como requester do SDK. + * status HTTP, exceção original em `getPrevious()`, status em `httpStatus` e recusa de cartão + * com o LR traduzido para `declineCode`. Os fluxos que passam pelo requester injetado usam + * `QueuedIuguApiRequest` direto; os que usam recursos estáticos do SDK + * (`Iugu_Customer::create()`, `Iugu_PaymentToken::create()`, `Iugu_Charge`) instalam o mesmo + * fake como requester do SDK. */ class IuguGatewayExceptionTranslationTest extends TestCase { + private RecordingLogger $logger; + protected function setUp(): void { parent::setUp(); + $this->logger = new RecordingLogger(); $app = new Container(); $app->instance('config', new Repository([ 'multi-payment.gateways.iugu.api_key' => 'test-api-key', 'multi-payment.gateways.iugu.id' => 'account-id', 'multi-payment.environment' => 'testing', ])); + $app->instance('log', $this->logger); Facade::setFacadeApplication($app); } @@ -175,7 +187,7 @@ public function testServerErrorWithJsonBodyBecomesGatewayNotAvailableException() } } - public function testNotFoundKeepsBeingGatewayExceptionWithStatusAndPrevious(): void + public function testNotFoundBecomesNotFoundExceptionWithStatusAndPrevious(): void { // fetchAPI() do SDK relança IuguObjectNotFound sem o código HTTP $original = new \IuguObjectNotFound('invoice: not found'); @@ -183,16 +195,32 @@ public function testNotFoundKeepsBeingGatewayExceptionWithStatusAndPrevious(): v try { (new IuguGateway($api))->getInvoice($this->invoiceWithId()); - $this->fail('Esperava GatewayException'); - } catch (GatewayException $e) { + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertInstanceOf(GatewayException::class, $e); $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); $this->assertSame($original, $e->getPrevious()); $this->assertSame(404, $e->httpStatus); } } + public function testNotFoundWithJsonBodyBecomesNotFoundException(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Not Found'], 404), + ]); + + try { + (new IuguGateway($api))->cancelInvoice($this->invoiceWithId()); + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertSame(404, $e->httpStatus); + $this->assertSame(['Not Found'], $e->getErrors()); + } + } + #[DataProvider('clientErrorProvider')] - public function testOtherHttpErrorsBecomeGatewayExceptionWithStatusExposed(int $status): void + public function testClientErrorsGetTheExceptionOfTheirStatus(int $status, string $expectedClass): void { $api = new QueuedIuguApiRequest([ new QueuedIuguResponse((object) ['errors' => ['base' => ['erro']]], $status), @@ -200,25 +228,97 @@ public function testOtherHttpErrorsBecomeGatewayExceptionWithStatusExposed(int $ try { (new IuguGateway($api))->getInvoice($this->invoiceWithId()); - $this->fail('Esperava GatewayException'); + $this->fail("Esperava {$expectedClass}"); } catch (GatewayException $e) { + $this->assertInstanceOf($expectedClass, $e); $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); $this->assertNotInstanceOf(AuthenticationException::class, $e); $this->assertSame($status, $e->httpStatus); $this->assertSame(['base' => ['erro']], $e->getErrors()); + $this->assertNull($e->getPrevious()); } } public static function clientErrorProvider(): array { return [ - 'validação' => [422], - 'requisição inválida' => [400], - 'conflito de idempotência' => [409], - 'rate limit' => [429], + 'validação' => [422, ValidationException::class], + 'requisição inválida' => [400, ValidationException::class], + 'conflito de idempotência' => [409, IdempotencyConflictException::class], + 'rate limit' => [429, RateLimitException::class], + 'status sem classe própria' => [418, GatewayException::class], ]; } + public function testRateLimitWithHtmlBodyBecomesRateLimitExceptionWithPrevious(): void + { + // 429 com página HTML: o SDK lança IuguRequestException com o status em getCode() + $original = new \IuguRequestException('429 Too Many Requests', 429); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertSame(429, $e->httpStatus); + $this->assertNull($e->retryAfter); + } + } + + public function testValidationErrorsByFieldAreExposedInTheIuguObjectFormat(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => [ + 'email' => ['não é válido', 'já está em uso'], + 'cpf_cnpj' => 'inválido', + ]], 422), + ]); + + try { + (new IuguGateway($api))->updateCustomer(self::customerWithId()); + $this->fail('Esperava ValidationException'); + } catch (ValidationException $e) { + $this->assertSame(422, $e->httpStatus); + $this->assertSame([ + 'email' => ['não é válido', 'já está em uso'], + 'cpf_cnpj' => ['inválido'], + ], $e->fieldErrors); + $this->assertSame(['email' => ['não é válido', 'já está em uso'], 'cpf_cnpj' => 'inválido'], $e->getErrors()); + } + } + + public function testValidationErrorWithNonJsonBodyUsesTheBodyAsBaseField(): void + { + // 422 sem JSON: o SDK lança IuguRequestException com o corpo cru e o status em getCode() + $original = new \IuguRequestException('422 Unprocessable Entity', 422); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->cancelInvoice($this->invoiceWithId()); + $this->fail('Esperava ValidationException'); + } catch (ValidationException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertSame(422, $e->httpStatus); + $this->assertSame(['base' => ['422 Unprocessable Entity']], $e->fieldErrors); + } + } + + public function testValidationErrorAsStringGoesToTheBaseField(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Fatura não pode ser cancelada'], 400), + ]); + + try { + (new IuguGateway($api))->cancelInvoice($this->invoiceWithId()); + $this->fail('Esperava ValidationException'); + } catch (ValidationException $e) { + $this->assertSame(400, $e->httpStatus); + $this->assertSame(['base' => ['Fatura não pode ser cancelada']], $e->fieldErrors); + } + } + public function testJsonErrorBodyWithoutKnownStatusIsStillGatewayException(): void { // ramo defensivo: o SDK real grava o status em toda resposta decodificada, mas se ele @@ -438,7 +538,7 @@ public function testStaticSdkResourceUnauthorizedBodyBecomesAuthenticationExcept $this->assertCount(1, $api->calls); } - public function testInvalidRawCardOnTokenizationBecomesGatewayExceptionWithoutSecondRequest(): void + public function testInvalidRawCardOnTokenizationBecomesValidationExceptionWithoutSecondRequest(): void { $api = (new QueuedIuguApiRequest([ new QueuedIuguResponse((object) ['errors' => ['number' => ['não é válido']]], 422), @@ -446,10 +546,11 @@ public function testInvalidRawCardOnTokenizationBecomesGatewayExceptionWithoutSe try { (new IuguGateway($api))->createCreditCard($this->rawCreditCardModel()); - $this->fail('Esperava GatewayException'); - } catch (GatewayException $e) { + $this->fail('Esperava ValidationException'); + } catch (ValidationException $e) { $this->assertSame(422, $e->httpStatus); $this->assertSame(['number' => ['não é válido']], $e->getErrors()); + $this->assertSame(['number' => ['não é válido']], $e->fieldErrors); $this->assertStringContainsString('payment token', $e->getMessage()); } @@ -521,8 +622,8 @@ public function testFailureReadingTheInvoiceAfterAChargeDoesNotEscapeThePackage( try { (new IuguGateway($api))->chargeInvoiceWithCreditCard($invoice); - $this->fail('Esperava GatewayException'); - } catch (GatewayException $e) { + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { $this->assertInstanceOf(\IuguObjectNotFound::class, $e->getPrevious()); $this->assertSame(404, $e->httpStatus); } @@ -530,25 +631,142 @@ public function testFailureReadingTheInvoiceAfterAChargeDoesNotEscapeThePackage( $this->assertCount(2, $api->calls); } - public function testDeclinedChargeExposesTheHttpStatusOnChargingException(): void + public function testDeclinedChargeBecomesChargingExceptionWithTheLrTranslated(): void { $api = (new QueuedIuguApiRequest([ (object) ['success' => false, 'LR' => '51', 'info_message' => 'Saldo insuficiente'], ]))->installAsSdkRequester(); - $invoice = $this->invoiceWithId(); - $invoice->creditCard = new CreditCard(); - $invoice->creditCard->id = 'pm_1'; - try { - (new IuguGateway($api))->chargeInvoiceWithCreditCard($invoice); + (new IuguGateway($api))->chargeInvoiceWithCreditCard($this->invoiceWithSavedCard()); $this->fail('Esperava ChargingException'); } catch (ChargingException $e) { + $this->assertInstanceOf(CardDeclinedException::class, $e); + $this->assertNotInstanceOf(GatewayException::class, $e); $this->assertSame(200, $e->httpStatus); $this->assertNull($e->getPrevious()); + $this->assertSame(DeclineCode::INSUFFICIENT_FUNDS, $e->declineCode); + $this->assertSame('51', $e->gatewayCode); + $this->assertTrue($e->retryable); + $this->assertSame('insufficient_funds', $e->reason); + $this->assertSame('51', $e->chargeResponse->LR); + $this->assertStringContainsString('iugu', $e->getMessage()); + $this->assertStringContainsString('Saldo insuficiente', $e->getMessage()); + } + + $this->assertSame([], $this->logger->records); + $this->assertCount(1, $api->calls); + } + + #[DataProvider('lrProvider')] + public function testLrIsTranslatedToDeclineCode(string $lr, DeclineCode $expected, bool $retryable): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => false, 'LR' => $lr, 'info_message' => 'Transação não autorizada'], + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->chargeInvoiceWithCreditCard($this->invoiceWithSavedCard()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame($expected, $e->declineCode); + $this->assertSame($lr, $e->gatewayCode); + $this->assertSame($retryable, $e->retryable); + } + + $this->assertSame([], $this->logger->records); + } + + public static function lrProvider(): array + { + return [ + '51 saldo insuficiente' => ['51', DeclineCode::INSUFFICIENT_FUNDS, true], + '61 valor excedido' => ['61', DeclineCode::INSUFFICIENT_FUNDS, true], + '54 cartão vencido' => ['54', DeclineCode::EXPIRED_CARD, false], + '14 número inválido' => ['14', DeclineCode::INCORRECT_NUMBER, false], + '12 verifique os dados' => ['12', DeclineCode::INVALID_CARD, false], + '78 cartão não desbloqueado' => ['78', DeclineCode::INVALID_CARD, false], + '41 cartão perdido' => ['41', DeclineCode::LOST_OR_STOLEN, false], + '43 cartão roubado' => ['43', DeclineCode::LOST_OR_STOLEN, false], + '59 suspeita de fraude' => ['59', DeclineCode::FRAUD_SUSPECTED, false], + 'AF02 antifraude' => ['AF02', DeclineCode::FRAUD_SUSPECTED, false], + 'AI autenticação não realizada' => ['AI', DeclineCode::AUTHENTICATION_REQUIRED, false], + '57 não permitida para o cartão' => ['57', DeclineCode::BRAND_NOT_SUPPORTED, false], + 'AB função incorreta' => ['AB', DeclineCode::BRAND_NOT_SUPPORTED, false], + '5 contate a central' => ['5', DeclineCode::DO_NOT_HONOR, false], + '05 com zero à esquerda, como no exemplo oficial' => ['05', DeclineCode::DO_NOT_HONOR, false], + '93 não tente novamente' => ['93', DeclineCode::DO_NOT_HONOR, false], + 'R1 suspensão de recorrência' => ['R1', DeclineCode::DO_NOT_HONOR, false], + '91 emissor fora do ar' => ['91', DeclineCode::TRY_AGAIN, true], + '96 falha de sistema' => ['96', DeclineCode::TRY_AGAIN, true], + 'AA tempo excedido' => ['AA', DeclineCode::TRY_AGAIN, true], + '94 transação duplicada' => ['94', DeclineCode::GENERIC, false], + ]; + } + + public function testLrIsReadFromTheMessageWhenTheFieldIsAbsent(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => false, 'info_message' => 'JOAO DA SILVA, Master, XXXXXXXXXXXX1234, LR: 54'], + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->chargeInvoiceWithCreditCard($this->invoiceWithSavedCard()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::EXPIRED_CARD, $e->declineCode); + $this->assertSame('54', $e->gatewayCode); } } + public function testUnmappedLrBecomesUnknownAndIsLoggedWithTheCode(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => false, 'LR' => '75', 'info_message' => 'Excedidas tentativas de senha'], + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->chargeInvoiceWithCreditCard($this->invoiceWithSavedCard()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::UNKNOWN, $e->declineCode); + $this->assertSame('75', $e->gatewayCode); + $this->assertFalse($e->retryable); + $this->assertSame('unknown', $e->reason); + } + + $this->assertCount(1, $this->logger->records); + $this->assertSame('info', $this->logger->records[0]['level']); + $this->assertSame(['gateway' => 'iugu', 'lr' => '75'], $this->logger->records[0]['context']); + } + + public function testDeclineWithoutLrBecomesUnknownWithoutCodeAndWithoutLog(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => false, 'message' => 'Transação negada'], + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->chargeInvoiceWithCreditCard($this->invoiceWithSavedCard()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::UNKNOWN, $e->declineCode); + $this->assertNull($e->gatewayCode); + $this->assertStringContainsString('Transação negada', $e->getMessage()); + } + + $this->assertSame([], $this->logger->records); + } + + private function invoiceWithSavedCard(): Invoice + { + $invoice = $this->invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_1'; + + return $invoice; + } + public function testDuplicateInvoiceGoesThroughTheInjectedRequesterAndTranslatesUnauthorized(): void { // Iugu_Invoice::duplicate() do SDK engole a exceção e devolve false; o driver faz o POST direto diff --git a/tests/Unit/Gateways/IuguGatewayRefundTest.php b/tests/Unit/Gateways/IuguGatewayRefundTest.php index 2f7ae8c..342ef5b 100644 --- a/tests/Unit/Gateways/IuguGatewayRefundTest.php +++ b/tests/Unit/Gateways/IuguGatewayRefundTest.php @@ -10,6 +10,7 @@ use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\NotFoundException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -387,14 +388,14 @@ public function testGetInvoiceErrorBecomesGatewayException(): void (new IuguGateway($api))->getInvoice($this->invoiceWithId()); } - public function testGetInvoiceNotFoundBecomesGatewayException(): void + public function testGetInvoiceNotFoundBecomesNotFoundException(): void { $api = new QueuedIuguApiRequest([new \IuguObjectNotFound('{"errors":"Not Found"}', 404)]); try { (new IuguGateway($api))->getInvoice($this->invoiceWithId()); - $this->fail('Esperava GatewayException'); - } catch (GatewayException $e) { + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); } @@ -410,14 +411,15 @@ public function testGetInvoiceOn502BecomesGatewayNotAvailableException(): void (new IuguGateway($api))->getInvoice($this->invoiceWithId()); } - public function testRefundOfUnknownInvoiceBecomesGatewayExceptionWithoutPosting(): void + public function testRefundOfUnknownInvoiceBecomesNotFoundExceptionWithoutPosting(): void { $api = new QueuedIuguApiRequest([new \IuguObjectNotFound('{"errors":"Not Found"}', 404)]); try { (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); - $this->fail('Esperava GatewayException'); - } catch (GatewayException $e) { + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertSame(404, $e->httpStatus); } $this->assertOnlyTheInvoiceWasRead($api); diff --git a/tests/Unit/Gateways/RecordingStripeHttpClient.php b/tests/Unit/Gateways/RecordingStripeHttpClient.php index 5199f02..00e6ff0 100644 --- a/tests/Unit/Gateways/RecordingStripeHttpClient.php +++ b/tests/Unit/Gateways/RecordingStripeHttpClient.php @@ -7,16 +7,16 @@ /** * Fake da camada HTTP do stripe-php, no molde do QueuedIuguApiRequest: devolve respostas * enfileiradas e grava cada chamada para asserção. Cada resposta é um array (corpo JSON, - * status 200), um par [corpo, status] ou um `\Throwable`, lançado no lugar da resposta para - * simular falha de conexão. Um corpo string vai cru, sem codificar em JSON, para simular a - * página HTML de um proxy. + * status 200), um par [corpo, status], uma tripla [corpo, status, cabeçalhos] ou um + * `\Throwable`, lançado no lugar da resposta para simular falha de conexão. Um corpo string + * vai cru, sem codificar em JSON, para simular a página HTML de um proxy. */ class RecordingStripeHttpClient implements \Stripe\HttpClient\ClientInterface { /** @var array */ public array $calls = []; - /** @var array */ + /** @var array */ private array $responses; private function __construct(array $responses) @@ -61,7 +61,8 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode throw $response; } [$body, $code] = $response; + $headers = $response[2] ?? []; - return [is_string($body) ? $body : json_encode($body), $code, []]; + return [is_string($body) ? $body : json_encode($body), $code, $headers]; } } diff --git a/tests/Unit/Gateways/Stripe/DeclineCodesTest.php b/tests/Unit/Gateways/Stripe/DeclineCodesTest.php new file mode 100644 index 0000000..e5376ff --- /dev/null +++ b/tests/Unit/Gateways/Stripe/DeclineCodesTest.php @@ -0,0 +1,90 @@ +assertSame($expected, DeclineCodes::toDeclineCode($stripeCode)); + } + + public static function codeProvider(): array + { + return [ + 'insufficient_funds' => ['insufficient_funds', DeclineCode::INSUFFICIENT_FUNDS], + 'card_velocity_exceeded' => ['card_velocity_exceeded', DeclineCode::INSUFFICIENT_FUNDS], + 'withdrawal_count_limit_exceeded' => ['withdrawal_count_limit_exceeded', DeclineCode::INSUFFICIENT_FUNDS], + 'expired_card' => ['expired_card', DeclineCode::EXPIRED_CARD], + 'incorrect_cvc' => ['incorrect_cvc', DeclineCode::INCORRECT_CVC], + 'invalid_cvc' => ['invalid_cvc', DeclineCode::INCORRECT_CVC], + 'incorrect_number' => ['incorrect_number', DeclineCode::INCORRECT_NUMBER], + 'invalid_number' => ['invalid_number', DeclineCode::INCORRECT_NUMBER], + 'invalid_expiry_month' => ['invalid_expiry_month', DeclineCode::INVALID_CARD], + 'invalid_account' => ['invalid_account', DeclineCode::INVALID_CARD], + 'incorrect_zip' => ['incorrect_zip', DeclineCode::INVALID_CARD], + 'lost_card' => ['lost_card', DeclineCode::LOST_OR_STOLEN], + 'stolen_card' => ['stolen_card', DeclineCode::LOST_OR_STOLEN], + 'pickup_card' => ['pickup_card', DeclineCode::LOST_OR_STOLEN], + 'restricted_card' => ['restricted_card', DeclineCode::LOST_OR_STOLEN], + 'fraudulent' => ['fraudulent', DeclineCode::FRAUD_SUSPECTED], + 'merchant_blacklist' => ['merchant_blacklist', DeclineCode::FRAUD_SUSPECTED], + 'authentication_required' => ['authentication_required', DeclineCode::AUTHENTICATION_REQUIRED], + 'authentication_not_handled' => ['authentication_not_handled', DeclineCode::AUTHENTICATION_REQUIRED], + 'card_not_supported' => ['card_not_supported', DeclineCode::BRAND_NOT_SUPPORTED], + 'currency_not_supported' => ['currency_not_supported', DeclineCode::BRAND_NOT_SUPPORTED], + 'do_not_honor' => ['do_not_honor', DeclineCode::DO_NOT_HONOR], + 'call_issuer' => ['call_issuer', DeclineCode::DO_NOT_HONOR], + 'transaction_not_allowed' => ['transaction_not_allowed', DeclineCode::DO_NOT_HONOR], + 'security_violation' => ['security_violation', DeclineCode::DO_NOT_HONOR], + 'processing_error' => ['processing_error', DeclineCode::TRY_AGAIN], + 'issuer_not_available' => ['issuer_not_available', DeclineCode::TRY_AGAIN], + 'try_again_later' => ['try_again_later', DeclineCode::TRY_AGAIN], + 'generic_decline' => ['generic_decline', DeclineCode::GENERIC], + 'card_declined (code sem decline_code)' => ['card_declined', DeclineCode::GENERIC], + 'duplicate_transaction' => ['duplicate_transaction', DeclineCode::GENERIC], + ]; + } + + #[DataProvider('unmappedProvider')] + public function testUnmappedOrEmptyCodeIsNull(?string $stripeCode): void + { + $this->assertNull(DeclineCodes::toDeclineCode($stripeCode)); + } + + public static function unmappedProvider(): array + { + return [ + 'nulo' => [null], + 'vazio' => [''], + 'PIN (cartão presente)' => ['offline_pin_required'], + 'inexistente' => ['made_up_code'], + ]; + } + + #[DataProvider('adviceProvider')] + public function testAdviceCodeDecidesRetryableOnlyWhenItTalksAboutRetrying(?string $advice, ?bool $expected): void + { + $this->assertSame($expected, DeclineCodes::retryableFromAdvice($advice)); + } + + public static function adviceProvider(): array + { + return [ + 'try_again_later' => ['try_again_later', true], + 'do_not_try_again' => ['do_not_try_again', false], + 'confirm_card_data' => ['confirm_card_data', null], + 'ausente' => [null, null], + ]; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayCustomerTest.php b/tests/Unit/Gateways/StripeGatewayCustomerTest.php index 54a7706..cde539c 100644 --- a/tests/Unit/Gateways/StripeGatewayCustomerTest.php +++ b/tests/Unit/Gateways/StripeGatewayCustomerTest.php @@ -12,6 +12,7 @@ use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\ValidationException; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\AuthenticationException; @@ -331,8 +332,10 @@ public function testApiErrorBecomesGatewayExceptionWithNormalizedErrors(): void try { (new StripeGateway())->getCustomer($customer); - $this->fail('Expected GatewayException was not thrown'); - } catch (GatewayException $exception) { + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $exception) { + $this->assertInstanceOf(GatewayException::class, $exception); + $this->assertSame(['foo' => ['Received unknown parameter: foo']], $exception->fieldErrors); $this->assertSame([ 'type' => 'invalid_request_error', 'code' => 'parameter_unknown', diff --git a/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php b/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php index f5d3ba5..22b30f4 100644 --- a/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php +++ b/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php @@ -3,6 +3,7 @@ namespace Potelo\MultiPayment\Tests\Unit\Gateways; use Stripe\ApiRequestor; +use Stripe\Util\CaseInsensitiveArray; use PHPUnit\Framework\TestCase; use Illuminate\Config\Repository; use Illuminate\Container\Container; @@ -12,35 +13,48 @@ use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\InvoiceItem; -use Stripe\Exception\RateLimitException; +use Stripe\Exception\RateLimitException as StripeRateLimitException; use Stripe\Exception\PermissionException; +use Stripe\Exception\IdempotencyException; use PHPUnit\Framework\Attributes\DataProvider; use Potelo\MultiPayment\Gateways\StripeGateway; use Stripe\Exception\ApiConnectionException; use Stripe\Exception\InvalidRequestException; use Stripe\Exception\UnknownApiErrorException; use Stripe\Exception\UnexpectedValueException as StripeUnexpectedValueException; +use Potelo\MultiPayment\Tests\Unit\RecordingLogger; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; +use Potelo\MultiPayment\Exceptions\NotFoundException; +use Potelo\MultiPayment\Exceptions\RateLimitException; +use Potelo\MultiPayment\Exceptions\ValidationException; +use Potelo\MultiPayment\Exceptions\CardDeclinedException; use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +use Potelo\MultiPayment\Exceptions\IdempotencyConflictException; use Stripe\Exception\AuthenticationException as StripeAuthenticationException; +use Potelo\MultiPayment\Enums\DeclineCode; use Potelo\MultiPayment\Enums\PaymentMethod; /** - * Cobre a tradução de falhas do stripe-php para as exceções do pacote: classe escolhida pelo - * status HTTP, exceção original em `getPrevious()` e status em `httpStatus`. + * Cobre a tradução de falhas do stripe-php para as exceções do pacote: classe escolhida pela + * classe do SDK e pelo status HTTP, exceção original em `getPrevious()`, status em + * `httpStatus` e recusa de cartão com `declineCode` normalizado. */ class StripeGatewayExceptionTranslationTest extends TestCase { + private RecordingLogger $logger; + protected function setUp(): void { parent::setUp(); + $this->logger = new RecordingLogger(); $app = new Container(); $app->instance('config', new Repository([ 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', ])); + $app->instance('log', $this->logger); Facade::setFacadeApplication($app); RecordingStripeHttpClient::withResponses([]); @@ -126,6 +140,7 @@ public function testServerErrorWithHtmlBodyBecomesGatewayNotAvailableExceptionWi public function testClientErrorWithHtmlBodyStaysGatewayExceptionWithStatus(): void { + // página HTML de proxy: sem corpo JSON não há `code` para ler, então só o 5xx é classificado RecordingStripeHttpClient::withResponses([['404 Not Found', 404]]); try { @@ -151,25 +166,102 @@ public function testConnectionFailureBecomesGatewayNotAvailableExceptionWithoutS } } - public function testRateLimitStaysGatewayExceptionWithStatusExposed(): void + public function testRateLimitBecomesRateLimitExceptionWithRetryAfterFromTheHeader(): void { RecordingStripeHttpClient::withResponses([ - [['error' => ['type' => 'rate_limit_error', 'message' => 'Too many requests']], 429], + [['error' => ['type' => 'rate_limit_error', 'message' => 'Too many requests']], 429, ['Retry-After' => '3']], ]); try { (new StripeGateway())->getCustomer($this->customerWithId()); - $this->fail('Esperava GatewayException'); - } catch (GatewayException $e) { + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertInstanceOf(GatewayException::class, $e); $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); - $this->assertNotInstanceOf(AuthenticationException::class, $e); - $this->assertInstanceOf(RateLimitException::class, $e->getPrevious()); + $this->assertInstanceOf(StripeRateLimitException::class, $e->getPrevious()); $this->assertSame(429, $e->httpStatus); + $this->assertSame(3, $e->retryAfter); $this->assertSame('rate_limit_error', $e->getErrors()['type']); } } - public function testInvalidRequestStaysGatewayExceptionWithNormalizedErrorsAndPrevious(): void + public function testRetryAfterIsReadFromTheCaseInsensitiveHeadersOfTheSdk(): void + { + // o CurlClient do stripe-php entrega os cabeçalhos neste objeto, com as chaves em minúsculas + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'rate_limit_error', 'message' => 'Too many requests']], 429, + new CaseInsensitiveArray(['Retry-After' => '3'])], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertSame(3, $e->retryAfter); + } + } + + #[DataProvider('retryAfterHeaderProvider')] + public function testRetryAfterHeaderVariants(array $headers, ?int $expected): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'rate_limit_error', 'message' => 'Too many requests']], 429, $headers], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertSame($expected, $e->retryAfter); + } + } + + public static function retryAfterHeaderProvider(): array + { + return [ + 'minúsculas (HTTP/2)' => [['retry-after' => '10'], 10], + 'valor em lista' => [['Retry-After' => ['5']], 5], + 'data HTTP' => [['Retry-After' => 'Wed, 21 Oct 2026 07:28:00 GMT'], null], + 'vazio' => [['Retry-After' => ''], null], + ]; + } + + public function testRateLimitWithoutRetryAfterHeaderLeavesItNull(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'rate_limit_error', 'message' => 'Too many requests']], 429], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertNull($e->retryAfter); + } + } + + public function testIdempotencyErrorBecomesIdempotencyConflictException(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'idempotency_error', + 'message' => 'Keys for idempotent requests can only be used with the same parameters they were first used with.', + ]], 400], + ]); + + try { + (new StripeGateway())->createCustomer($this->customerModel()); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertInstanceOf(GatewayException::class, $e); + $this->assertNotInstanceOf(ValidationException::class, $e); + $this->assertInstanceOf(IdempotencyException::class, $e->getPrevious()); + $this->assertSame(400, $e->httpStatus); + $this->assertSame('idempotency_error', $e->getErrors()['type']); + } + } + + public function testResourceMissingBecomesNotFoundExceptionWithNormalizedErrorsAndPrevious(): void { RecordingStripeHttpClient::withResponses([ [['error' => [ @@ -182,8 +274,10 @@ public function testInvalidRequestStaysGatewayExceptionWithNormalizedErrorsAndPr try { (new StripeGateway())->getCustomer($this->customerWithId()); - $this->fail('Esperava GatewayException'); - } catch (GatewayException $e) { + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertInstanceOf(GatewayException::class, $e); + $this->assertNotInstanceOf(ValidationException::class, $e); $this->assertInstanceOf(InvalidRequestException::class, $e->getPrevious()); $this->assertSame(404, $e->httpStatus); $this->assertSame([ @@ -194,6 +288,64 @@ public function testInvalidRequestStaysGatewayExceptionWithNormalizedErrorsAndPr } } + public function testInvalidRequestWith404StatusIsNotFoundEvenWithoutResourceMissingCode(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'invalid_request_error', 'message' => 'No such customer']], 404], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertSame(404, $e->httpStatus); + } + } + + public function testInvalidRequestBecomesValidationExceptionWithTheParamAsField(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'invalid_request_error', + 'code' => 'parameter_invalid_integer', + 'param' => 'amount', + 'message' => 'Invalid integer: abc', + ]], 400], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ValidationException'); + } catch (ValidationException $e) { + $this->assertInstanceOf(GatewayException::class, $e); + $this->assertInstanceOf(InvalidRequestException::class, $e->getPrevious()); + $this->assertSame(400, $e->httpStatus); + $this->assertSame(['amount' => ['Invalid integer: abc']], $e->fieldErrors); + $this->assertSame('parameter_invalid_integer', $e->getErrors()['code']); + } + } + + public function testInvalidRequestWithoutParamGoesToTheBaseField(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'invalid_request_error', + 'code' => 'payment_intent_unexpected_state', + 'message' => 'This PaymentIntent could not be canceled.', + ]], 400], + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + try { + (new StripeGateway())->cancelInvoice($invoice); + $this->fail('Esperava ValidationException'); + } catch (ValidationException $e) { + $this->assertSame(['base' => ['This PaymentIntent could not be canceled.']], $e->fieldErrors); + } + } + public function testCardDeclineAttachesTheCardExceptionAndItsStatus(): void { RecordingStripeHttpClient::withResponses([ @@ -209,13 +361,167 @@ public function testCardDeclineAttachesTheCardExceptionAndItsStatus(): void (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); $this->fail('Esperava ChargingException'); } catch (ChargingException $e) { + $this->assertInstanceOf(CardDeclinedException::class, $e); + $this->assertNotInstanceOf(GatewayException::class, $e); $this->assertInstanceOf(CardException::class, $e->getPrevious()); $this->assertSame(402, $e->httpStatus); $this->assertSame('card_declined', $e->reason); + $this->assertSame(DeclineCode::GENERIC, $e->declineCode); + $this->assertSame('generic_decline', $e->gatewayCode); + $this->assertFalse($e->retryable); + $this->assertSame('card_error', $e->chargeResponse['type']); + $this->assertStringContainsString('stripe', $e->getMessage()); + $this->assertStringContainsString('Your card was declined.', $e->getMessage()); } } - public function testCardDeclineDuringAttachKeepsTheWholeExceptionChain(): void + public function testCardDeclineIsCaughtByTheNewName(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'card_error', 'code' => 'expired_card', 'message' => 'Your card has expired.']], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava CardDeclinedException'); + } catch (CardDeclinedException $e) { + // sem decline_code o driver lê o code + $this->assertSame(DeclineCode::EXPIRED_CARD, $e->declineCode); + $this->assertSame('expired_card', $e->gatewayCode); + $this->assertSame('expired_card', $e->reason); + } + } + + #[DataProvider('declineCodeProvider')] + public function testDeclineCodeIsNormalizedAndRetryableFollowsTheCode(string $stripeDeclineCode, DeclineCode $expected, bool $retryable): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => $stripeDeclineCode, + 'message' => 'Your card was declined.', + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame($expected, $e->declineCode); + $this->assertSame($stripeDeclineCode, $e->gatewayCode); + $this->assertSame($retryable, $e->retryable); + } + + $this->assertSame([], $this->logger->records); + } + + public static function declineCodeProvider(): array + { + return [ + 'insufficient_funds' => ['insufficient_funds', DeclineCode::INSUFFICIENT_FUNDS, true], + 'card_velocity_exceeded' => ['card_velocity_exceeded', DeclineCode::INSUFFICIENT_FUNDS, true], + 'expired_card' => ['expired_card', DeclineCode::EXPIRED_CARD, false], + 'incorrect_cvc' => ['incorrect_cvc', DeclineCode::INCORRECT_CVC, false], + 'incorrect_number' => ['incorrect_number', DeclineCode::INCORRECT_NUMBER, false], + 'invalid_expiry_year' => ['invalid_expiry_year', DeclineCode::INVALID_CARD, false], + 'lost_card' => ['lost_card', DeclineCode::LOST_OR_STOLEN, false], + 'stolen_card' => ['stolen_card', DeclineCode::LOST_OR_STOLEN, false], + 'fraudulent' => ['fraudulent', DeclineCode::FRAUD_SUSPECTED, false], + 'authentication_required' => ['authentication_required', DeclineCode::AUTHENTICATION_REQUIRED, false], + 'card_not_supported' => ['card_not_supported', DeclineCode::BRAND_NOT_SUPPORTED, false], + 'do_not_honor' => ['do_not_honor', DeclineCode::DO_NOT_HONOR, false], + 'call_issuer' => ['call_issuer', DeclineCode::DO_NOT_HONOR, false], + 'processing_error' => ['processing_error', DeclineCode::TRY_AGAIN, true], + 'try_again_later' => ['try_again_later', DeclineCode::TRY_AGAIN, true], + 'generic_decline' => ['generic_decline', DeclineCode::GENERIC, false], + ]; + } + + public function testAdviceCodeOverridesTheRetryableDefault(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'generic_decline', + 'advice_code' => 'try_again_later', + 'message' => 'Your card was declined.', + ]], 402], + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'insufficient_funds', + 'advice_code' => 'do_not_try_again', + 'message' => 'Your card has insufficient funds.', + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::GENERIC, $e->declineCode); + $this->assertTrue($e->retryable); + } + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::INSUFFICIENT_FUNDS, $e->declineCode); + $this->assertFalse($e->retryable); + } + + $this->assertSame([], $this->logger->records); + } + + public function testCardErrorWithoutAnyCodeBecomesUnknownWithoutCodeAndWithoutLog(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'card_error', 'message' => 'Your card was declined.']], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::UNKNOWN, $e->declineCode); + $this->assertNull($e->gatewayCode); + $this->assertFalse($e->retryable); + $this->assertNull($e->reason); + } + + $this->assertSame([], $this->logger->records); + } + + public function testUnmappedDeclineCodeBecomesUnknownAndIsLogged(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'offline_pin_required', + 'message' => 'Your card was declined.', + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::UNKNOWN, $e->declineCode); + $this->assertSame('offline_pin_required', $e->gatewayCode); + $this->assertFalse($e->retryable); + $this->assertSame('card_declined', $e->reason); + } + + $this->assertCount(1, $this->logger->records); + $this->assertSame('info', $this->logger->records[0]['level']); + $this->assertSame(['gateway' => 'stripe', 'code' => 'offline_pin_required'], $this->logger->records[0]['context']); + } + + public function testCardDeclineDuringAttachCarriesTheCardExceptionDirectly(): void { RecordingStripeHttpClient::withResponses([ [['error' => [ @@ -235,8 +541,32 @@ public function testCardDeclineDuringAttachKeepsTheWholeExceptionChain(): void $this->fail('Esperava ChargingException'); } catch (ChargingException $e) { $this->assertSame(402, $e->httpStatus); - $this->assertInstanceOf(GatewayException::class, $e->getPrevious()); - $this->assertInstanceOf(CardException::class, $e->getPrevious()->getPrevious()); + $this->assertInstanceOf(CardException::class, $e->getPrevious()); + $this->assertSame(DeclineCode::GENERIC, $e->declineCode); + } + } + + public function testCardDeclineWhenOnlySavingTheCardIsAlsoACardDecline(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'stolen_card', + 'message' => 'Your card was declined.', + ]], 402], + ]); + + $creditCard = new CreditCard(); + $creditCard->token = 'pm_fake123'; + $creditCard->customer = $this->customerWithId(); + + try { + (new StripeGateway())->createCreditCard($creditCard); + $this->fail('Esperava CardDeclinedException'); + } catch (CardDeclinedException $e) { + $this->assertSame(DeclineCode::LOST_OR_STOLEN, $e->declineCode); + $this->assertSame('stolen_card', $e->gatewayCode); } } @@ -262,6 +592,15 @@ private function customerWithId(): Customer return $customer; } + private function customerModel(): Customer + { + $customer = new Customer(); + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + + return $customer; + } + private function creditCardInvoiceModel(): Invoice { $invoice = new Invoice(); diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index 47fedd9..1506fea 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -15,6 +15,7 @@ use Potelo\MultiPayment\Models\InvoiceItem; use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\ValidationException; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\ChargingException; @@ -344,7 +345,7 @@ public function testCancelsPendingInvoice(): void $this->assertSame(InvoiceStatus::CANCELED, $result->status); } - public function testCancelPaidInvoiceBecomesGatewayException(): void + public function testCancelPaidInvoiceBecomesValidationException(): void { RecordingStripeHttpClient::withResponses([ [['error' => [ @@ -359,9 +360,13 @@ public function testCancelPaidInvoiceBecomesGatewayException(): void try { (new StripeGateway())->cancelInvoice($invoice); - $this->fail('Expected GatewayException was not thrown'); - } catch (GatewayException $exception) { + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $exception) { $this->assertSame('payment_intent_unexpected_state', $exception->getErrors()['code']); + $this->assertSame( + ['base' => ['This PaymentIntent could not be canceled because it has a status of succeeded.']], + $exception->fieldErrors + ); } } @@ -1200,7 +1205,7 @@ public function testDuplicateReportsTheNewInvoiceWhenCancelingTheOriginalFails() } catch (GatewayException $e) { $this->assertStringContainsString('Invoice duplicated as [pi_fake456]', $e->getMessage()); // a falha do cancelamento continua acessível, com a exceção do SDK abaixo dela - $this->assertInstanceOf(GatewayException::class, $e->getPrevious()); + $this->assertInstanceOf(ValidationException::class, $e->getPrevious()); $this->assertInstanceOf(InvalidRequestException::class, $e->getPrevious()->getPrevious()); $this->assertSame(400, $e->httpStatus); } From 0a3d8632bda7c458fd56b7562c9a1ec8534cad67 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 12:27:36 -0300 Subject: [PATCH 20/32] =?UTF-8?q?feat(refund):=20introduz=20o=20model=20Re?= =?UTF-8?q?fund,=20hist=C3=B3rico=20de=20estornos=20na=20fatura=20e=20guar?= =?UTF-8?q?das=20de=20segundo=20estorno?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refundInvoice() passa a devolver Refund (id, invoiceId, amount, status em RefundStatus, reason, createdAt, original) nos dois drivers, com a fatura relida em $refund->invoice() e o model do chamador atualizado no lugar. Invoice::$refunds é preenchida na leitura: no Stripe a partir dos refunds do charge (expand latest_charge.refunds), na Iugu um único registro sintético com o acumulado. Guarda amount_exceeds_refundable nos dois drivers, leitura prévia da fatura no Stripe antes das guardas, Invoice::__clone com cópia dos objetos aninhados e lastRefundId removido. Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk --- README.md | 126 +++-- src/Contracts/InvoiceContract.php | 9 +- src/Enums/RefundStatus.php | 74 +++ .../RefundNotSupportedException.php | 36 +- src/Facades/MultiPayment.php | 2 +- src/Gateways/IuguGateway.php | 61 ++- src/Gateways/StripeGateway.php | 160 +++++- src/Models/Invoice.php | 39 +- src/Models/Refund.php | 110 ++++ src/MultiPayment.php | 19 +- tests/Integration/MultiPaymentTest.php | 12 +- tests/Integration/StripeGatewayTest.php | 27 +- tests/Unit/Enums/RefundStatusTest.php | 81 +++ .../RefundNotSupportedExceptionTest.php | 13 + tests/Unit/Gateways/IuguGatewayRefundTest.php | 235 ++++++++- .../Gateways/StripeGatewayInvoiceTest.php | 475 +++++++++++++++--- tests/Unit/RefundTest.php | 217 ++++++++ 17 files changed, 1548 insertions(+), 148 deletions(-) create mode 100644 src/Enums/RefundStatus.php create mode 100644 src/Models/Refund.php create mode 100644 tests/Unit/Enums/RefundStatusTest.php create mode 100644 tests/Unit/RefundTest.php diff --git a/README.md b/README.md index 47899bb..9bd2cc8 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [Models](#models) - [Customer](#customer) - [Invoice](#invoice) + - [Refund](#refund) - [Subscription](#subscription) - [Plan](#plan) @@ -383,7 +384,7 @@ MultiPaymentException | `AuthenticationException` | Chave de API inválida, revogada, sem permissão (401 ou 403) ou não configurada | Registrar e alertar. Repetir a chamada ou trocar de gateway não resolve | | `GatewayNotAvailableException` | Erro 5xx, falha de conexão ou timeout | Repetir mais tarde ou tentar outro gateway | | `UnsupportedOperationException` | Operação fora das capabilities do gateway, antes de qualquer requisição; `capability`, `gateway` e `reason` (`not_implemented` ou `gateway_limitation`) dizem qual e por quê | Rotear para um gateway que declare a capability; melhor ainda, consultar `supports()` antes (ver [Capabilities](#capabilities)) | -| `RefundNotSupportedException` | Estorno recusado pela lib antes de chamar o gateway (boleto, Pix parcial, já estornada, prazo vencido); herda de `UnsupportedOperationException` e refina `reason` | Ver [Estorno](#estorno) | +| `RefundNotSupportedException` | Estorno recusado pela lib antes de chamar o gateway (boleto, Pix parcial, já estornada, valor acima do restante, prazo vencido); herda de `UnsupportedOperationException` e refina `reason` | Ver [Estorno](#estorno) | | `ModelAttributeValidationException` | Atributo obrigatório ausente ou inválido, antes de qualquer requisição | Corrigir a chamada | | `ConfigurationException` | Gateway não configurado ou classe inválida | Corrigir a configuração | | `GatewayException` | Qualquer outra resposta de erro do gateway, e a classe pai das quatro de resposta acima; `getErrors()` traz o corpo de erro | Depende do caso; `httpStatus` e `getErrors()` dizem o que aconteceu | @@ -745,9 +746,9 @@ $foundInvoice = $payment->getInvoice($invoiceId); ```php $payment = new \Potelo\MultiPayment\MultiPayment('stripe'); -// estorno total ou parcial (valor em centavos); guardas e exceção na seção "Estorno" -$payment->refundInvoice($invoiceId); -$payment->refundInvoice($invoiceId, 5000); +// estorno total ou parcial (valor em centavos); devolve um Refund (seção "Estorno") +$refund = $payment->refundInvoice($invoiceId); +$refund = $payment->refundInvoice($invoiceId, 5000); // cancelamento de fatura pendente $payment->cancelInvoice($invoiceId); @@ -762,22 +763,72 @@ $payment->chargeInvoiceWithCreditCard($invoiceId, null, $creditCardId); #### Estorno -Sem valor, o estorno é integral; com valor em centavos, é parcial. O pacote recusa, **antes de -chamar o gateway**, o estorno que a regra do gateway já garante que seria negado, e o faz com -`RefundNotSupportedException` nos dois drivers, para a aplicação não precisar interpretar a -mensagem da Iugu ou da Stripe. +Sem valor, o estorno é integral; com valor em centavos, é parcial. A operação devolve um +`Refund` (`Potelo\MultiPayment\Models\Refund`) com o que o gateway registrou do estorno, e a +fatura relida depois dele fica em `$refund->invoice()`: + +| Campo | Conteúdo | +|---|---| +| `id` | Id do estorno no gateway. Stripe: `re_...`; Iugu: `null`, porque a Iugu não identifica estornos | +| `invoiceId` | Id da fatura estornada | +| `amount` | Valor deste estorno, em centavos | +| `status` | `RefundStatus`: `PENDING`, `SUCCEEDED`, `FAILED`, `CANCELED` ou `UNKNOWN` (status que a lib não reconhece, com aviso no log). Na Iugu é sempre `SUCCEEDED`, porque a API só responde 200 com o estorno feito; na Stripe o estorno de cartão costuma nascer `PENDING` e virar `SUCCEEDED` depois | +| `reason` | Motivo em texto, quando o gateway devolve um (Stripe: `duplicate`, `fraudulent`, `requested_by_customer`); a lib não o envia ao gateway | +| `createdAt` | Data do estorno. Na Iugu é o momento da chamada | +| `original` | Objeto de estorno do gateway (Stripe); `null` na Iugu | +| `invoice()` | Fatura com o estado posterior ao estorno, já relida, sem requisição | ```php -use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; +use Potelo\MultiPayment\Enums\RefundStatus; -$payment = new \Potelo\MultiPayment\MultiPayment('iugu'); +$payment = new \Potelo\MultiPayment\MultiPayment('stripe'); -try { - $invoice = $payment->refundInvoice($invoiceId); // integral - $invoice = $payment->refundInvoice($invoiceId, 5000); // parcial +$refund = $payment->refundInvoice($invoiceId); // integral +$refund = $payment->refundInvoice($invoiceId, 5000); // parcial + +$refund->id; // 're_...' (Stripe) ou null (Iugu) +$refund->amount; // 5000 +$refund->status; // RefundStatus::PENDING ou RefundStatus::SUCCEEDED + +$invoice = $refund->invoice(); +$invoice->status; // InvoiceStatus::REFUNDED ou InvoiceStatus::PARTIALLY_REFUNDED +``` - $invoice->status; // InvoiceStatus::REFUNDED ou InvoiceStatus::PARTIALLY_REFUNDED - $invoice->lastRefundId; // id do estorno no gateway (Stripe: re_...; a Iugu não devolve id) +`$invoice->refund()` num model faz o mesmo e atualiza a própria instância: depois da chamada +`$invoice->status` já é o novo status, e `$refund->invoice()` é a mesma instância. O valor +pedido viaja em `$invoice->refundedAmount`, que a leitura da fatura preenche com o total já +estornado: num model lido do gateway que já teve estorno parcial, defina `refundedAmount` antes +de chamar `refund()` (o novo valor, ou `null` para estornar o restante), senão o acumulado é +reenviado como um novo estorno parcial. + +**Histórico de estornos.** Toda fatura lida do gateway traz `$invoice->refunds`, uma lista de +`Refund` (vazia quando nada foi estornado). Os dois gateways preenchem a lista de formas +diferentes, e a conciliação precisa saber disso: + +- **Stripe**: um `Refund` por estorno feito, com `id`, `status` e `createdAt` próprios, lido + dos refunds do charge. +- **Iugu**: a API só informa o total estornado (`refunded_cents`), então a lista tem no máximo + um `Refund`, sem `id` e sem `createdAt`, com o acumulado. Dois estornos parciais de 2.000 e + 3.000 aparecem como um único registro de 5.000. O `Refund` devolvido por cada chamada de + `refundInvoice()` traz o valor daquela chamada. + +```php +$invoice = $payment->getInvoice($invoiceId); + +foreach ($invoice->refunds as $refund) { + $ledger->record($invoice->id, $refund->id, $refund->amount, $refund->status, $refund->createdAt); +} +``` + +**Recusa antes da requisição.** O pacote recusa, **antes de chamar o gateway**, o estorno que a +regra do gateway já garante que seria negado, e o faz com `RefundNotSupportedException` nos +dois drivers, para a aplicação não precisar interpretar a mensagem da Iugu ou da Stripe. + +```php +use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; + +try { + $refund = $payment->refundInvoice($invoiceId, 5000); } catch (RefundNotSupportedException $e) { // a lib recusou sem chamar o gateway; $e->reason diz por quê if ($e->manualRefundRequired) { @@ -792,22 +843,30 @@ try { | `boleto_no_refund` | Fatura paga com boleto, nos dois gateways | `true` | | `pix_partial_not_supported` | Iugu: valor pedido diferente do valor pago numa fatura Pix. Repita sem valor para estornar o total | `false` | | `already_refunded` | Fatura já lida como `refunded` | `false` | +| `amount_exceeds_refundable` | Valor pedido acima do que ainda pode ser estornado (o restante vai na mensagem). Repita com valor até o restante | `false` | | `refund_window_expired` | Iugu: depois do fim do 90º dia após `paidAt` | `true` | -Na Iugu, as guardas precisam do método de pagamento, do status, da data de pagamento e, no -estorno por valor, do valor pago. Chamar `refundInvoice($id)` só com o id custa **um GET a mais** -para ler a fatura antes do estorno; chamar `$invoice->refund()` num model já lido do gateway e já -pago não paga esse GET. Essa leitura não altera o model do chamador: ele só muda quando o -estorno acontece. No Stripe não há leitura prévia: as guardas usam o que já está no model, e o -estorno parcial de Pix é aceito. - -`lastRefundId` é preenchido só pela operação de estorno (a leitura da fatura o deixa `null`) e é -provisório: dá lugar a um objeto `Refund` numa versão futura. - -> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, estorno de boleto, Pix parcial, -> fatura já estornada e fora do prazo de 90 dias na Iugu iam até a API e voltavam como -> `GatewayException` com a mensagem do gateway. Agora lançam `RefundNotSupportedException`, que herda de `UnsupportedOperationException` (e por ela de `MultiPaymentException`) e **não** de -> `GatewayException`: um `catch (GatewayException $e)` sozinho deixa de capturar esses casos. +Uma fatura `partially_refunded` aceita novos estornos até zerar o restante; pedir exatamente +o que resta é estorno integral. O restante é `paidAmount` na Iugu (que devolve `paid_cents` +líquido do já estornado) e `paidAmount` menos `refundedAmount` na Stripe. + +**Custo da leitura prévia.** As guardas precisam do método de pagamento, do status, na Iugu da +data de pagamento e, no estorno por valor, do quanto ainda pode ser estornado. Chamar `refundInvoice($id)` só com o id +custa **um GET a mais** para ler a fatura antes do estorno, nos dois gateways; chamar +`$invoice->refund()` num model já lido do gateway e pago não paga esse GET. No Stripe, o estorno +por valor sobre uma fatura fora de `PAID` (por exemplo `partially_refunded`) relê a fatura mesmo +com o model preenchido, porque o restante estornável depende do acumulado que o gateway guarda. +Essa leitura não altera o model do chamador: ele só muda quando o estorno acontece. + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, `refundInvoice()` e +> `$invoice->refund()` devolviam a `Invoice` atualizada; agora devolvem o `Refund`, e a fatura +> fica em `$refund->invoice()` (ou na própria instância, no caso de `$invoice->refund()`). O +> campo provisório `Invoice::$lastRefundId` foi removido: o id está em `$refund->id`. Na mesma +> versão, estorno de boleto, Pix parcial, fatura já estornada, valor acima do restante e fora do +> prazo de 90 dias na Iugu deixaram de ir até a API e voltar como `GatewayException`: lançam +> `RefundNotSupportedException`, que herda de `UnsupportedOperationException` (e por ela de +> `MultiPaymentException`), fora da árvore de `GatewayException`: um `catch (GatewayException $e)` +> sozinho deixa de capturar esses casos. #### charge @@ -926,6 +985,15 @@ $invoice->creditCard->customer = $customer; $invoice->save('iugu'); echo $invoice->id; // CB1FA9B5BD1C42B287F4AC7F6259E45D ``` +#### Refund +```php +$invoice = $payment->getInvoice($invoiceId); +$invoice->refundedAmount = 5000; // vazio: estorno integral +$refund = $invoice->refund(); // Refund; $invoice já reflete o estado posterior + +$refund->amount; // 5000 +$invoice->refunds; // Refund[] (ver "Estorno") +``` #### Subscription ```php $subscription = new Subscription(); diff --git a/src/Contracts/InvoiceContract.php b/src/Contracts/InvoiceContract.php index 0a8640a..e6590d1 100644 --- a/src/Contracts/InvoiceContract.php +++ b/src/Contracts/InvoiceContract.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\Refund; use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Exceptions\GatewayException; @@ -38,15 +39,17 @@ public function getInvoice(Invoice $invoice): Invoice; * * Full refund when `refundedAmount` is empty; partial when set. The gateway throws * `RefundNotSupportedException` before any request when its own rules already guarantee - * the refusal (bank slip, partial Pix on Iugu, invoice already refunded, window expired). + * the refusal (bank slip, partial Pix on Iugu, invoice already refunded, amount above the + * refundable remainder, window expired). Returns the created `Refund`, with the invoice + * re-read after the refund in `$refund->invoice`; the given model is updated in place. * * @param Invoice $invoice * - * @return Invoice + * @return Refund * @throws GatewayException * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException */ - public function refundInvoice(Invoice $invoice): Invoice; + public function refundInvoice(Invoice $invoice): Refund; /** * String representation of the gateway diff --git a/src/Enums/RefundStatus.php b/src/Enums/RefundStatus.php new file mode 100644 index 0000000..85f7c1f --- /dev/null +++ b/src/Enums/RefundStatus.php @@ -0,0 +1,74 @@ + true, + default => false, + }; + } + + /** + * Converte o valor de string no caso correspondente. Valor fora do enum devolve `UNKNOWN` + * e registra um aviso no log com o valor original e o gateway. + * + * @param string $value + * @param string|null $gateway + * @return static + */ + public static function fromValue(string $value, ?string $gateway = null): static + { + return self::tryFrom($value) ?? self::unknown($value, $gateway); + } + + /** + * Devolve `UNKNOWN` e registra um aviso no log com o valor original e o gateway. + * + * @param string $value + * @param string|null $gateway + * @return static + */ + public static function unknown(string $value, ?string $gateway = null): static + { + LogHelper::warning( + "Status de estorno desconhecido [{$value}] no gateway [" . ($gateway ?? 'desconhecido') . '], lido como unknown', + ['status' => $value, 'gateway' => $gateway] + ); + + return self::UNKNOWN; + } +} diff --git a/src/Exceptions/RefundNotSupportedException.php b/src/Exceptions/RefundNotSupportedException.php index 42b39a6..d1d1b7b 100644 --- a/src/Exceptions/RefundNotSupportedException.php +++ b/src/Exceptions/RefundNotSupportedException.php @@ -10,10 +10,11 @@ * * É lançada quando a regra do gateway já garante que a API recusaria o estorno: boleto não tem * estorno via API em nenhum gateway, Pix na Iugu só aceita estorno integral, fatura já estornada - * não estorna de novo e a Iugu fecha a janela de estorno 90 dias após o pagamento. O motivo fica - * em `$reason`, no vocabulário do pacote, para a aplicação ramificar sem ler a mensagem. - * `$capability` aponta a capability recusada quando existe uma (`REFUND_BANK_SLIP`, - * `PARTIAL_REFUND_PIX`) e fica nula para fatura já estornada e prazo vencido. + * não estorna de novo, o valor pedido não pode passar do restante estornável e a Iugu fecha a + * janela de estorno 90 dias após o pagamento. O motivo fica em `$reason`, no vocabulário do + * pacote, para a aplicação ramificar sem ler a mensagem. `$capability` aponta a capability + * recusada quando existe uma (`REFUND_BANK_SLIP`, `PARTIAL_REFUND_PIX`) e fica nula para + * fatura já estornada, valor acima do restante e prazo vencido. */ class RefundNotSupportedException extends UnsupportedOperationException { @@ -29,6 +30,9 @@ class RefundNotSupportedException extends UnsupportedOperationException /** O prazo que o gateway dá para estornar após o pagamento já passou; devolução manual. */ public const REASON_REFUND_WINDOW_EXPIRED = 'refund_window_expired'; + /** O valor pedido passa do que ainda pode ser estornado na fatura. */ + public const REASON_AMOUNT_EXCEEDS_REFUNDABLE = 'amount_exceeds_refundable'; + /** * Método de pagamento da fatura (`credit_card`, `bank_slip`, `pix`), ou nulo quando o * gateway não informou o método. @@ -47,7 +51,8 @@ class RefundNotSupportedException extends UnsupportedOperationException /** * Verdadeiro quando a devolução ao cliente precisa acontecer fora do gateway (boleto, * prazo vencido); falso quando não há o que devolver (`already_refunded`) ou o pedido - * pode ser corrigido (`pix_partial_not_supported`: repita sem valor parcial). + * pode ser corrigido (`pix_partial_not_supported`: repita sem valor parcial; + * `amount_exceeds_refundable`: repita com valor até o restante). * * @var bool */ @@ -160,4 +165,25 @@ public static function refundWindowExpired(string $gateway, ?string $paymentMeth $gateway ); } + + /** + * O valor pedido passa do que ainda pode ser estornado na fatura. + * + * @param string $gateway + * @param string|null $paymentMethod + * @param int $requestedAmount valor pedido, em centavos + * @param int $refundableAmount valor que ainda pode ser estornado, em centavos + * @return static + */ + public static function amountExceedsRefundable(string $gateway, ?string $paymentMethod, int $requestedAmount, int $refundableAmount): static + { + return new static( + "O valor pedido ({$requestedAmount} centavos) passa do que ainda pode ser estornado na fatura ({$refundableAmount} centavos) no gateway {$gateway}; repita com valor até o restante.", + $paymentMethod, + self::REASON_AMOUNT_EXCEEDS_REFUNDABLE, + false, + null, + $gateway + ); + } } diff --git a/src/Facades/MultiPayment.php b/src/Facades/MultiPayment.php index 2eeb623..94bbf7f 100644 --- a/src/Facades/MultiPayment.php +++ b/src/Facades/MultiPayment.php @@ -20,7 +20,7 @@ * @method static \Potelo\MultiPayment\Models\Plan[] listPlans(int $page = 1, int $limit = 100) * @method static Invoice getInvoice(string $id) * @method static \Potelo\MultiPayment\Models\Customer getCustomer(string $id) - * @method static Invoice refundInvoice(string $id, ?int $partialValueCents = null) + * @method static \Potelo\MultiPayment\Models\Refund refundInvoice(string $id, ?int $partialValueCents = null) * @method static Invoice duplicateInvoice(Invoice|string $invoice, \Carbon\Carbon $expiresAt, array $gatewayOptions = []) * @method static CreditCard getCard(string $customerId, string $creditCardId) * @method static void deleteCard(string $customerId, string $creditCardId) diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index efc1b6c..87be662 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -12,6 +12,7 @@ use Potelo\MultiPayment\Models\Pix; use Illuminate\Support\Facades\Config; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\Refund; use Potelo\MultiPayment\Models\Address; use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\BankSlip; @@ -27,6 +28,7 @@ use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\RefundStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\PlanInterval; use Potelo\MultiPayment\Enums\DeclineCode; @@ -544,11 +546,14 @@ private static function paymentMethodsToIuguPayableWith(array $paymentMethods): * no estorno por valor, do valor pago. Um model que traz só o `id` custa um GET a mais para * ler a fatura antes do estorno; um model lido do gateway e já pago não paga esse GET. A * leitura prévia acontece numa cópia: o model do chamador só é alterado se o estorno - * acontecer. + * acontecer. A Iugu não devolve um objeto de estorno, então o `Refund` é montado pela lib: + * sem id, com o valor pedido (ou, no estorno integral, o `paid_cents` anterior ao estorno, + * que a Iugu devolve líquido do já estornado) e status `SUCCEEDED`, porque a Iugu só + * responde 200 com o estorno feito. * * @throws ModelAttributeValidationException|RefundNotSupportedException */ - public function refundInvoice(Invoice $invoice): Invoice + public function refundInvoice(Invoice $invoice): Refund { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); @@ -575,17 +580,30 @@ public function refundInvoice(Invoice $invoice): Invoice if (!is_null($requestedAmount) && $requestedAmount !== $current->paidAmount) { $data['partial_value_refund_cents'] = $requestedAmount; } + // o estorno integral devolve o paid_cents anterior, que parseInvoice() vai sobrescrever + $refundableBefore = $current->paidAmount; $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/refund'; $iuguInvoice = $this->iuguRequest('POST', $url, $data, 'refunding invoice'); - return $this->parseInvoice($iuguInvoice, $invoice); + $invoice = $this->parseInvoice($iuguInvoice, $invoice); + + $refund = new Refund(); + $refund->invoiceId = $invoice->id; + $refund->amount = $requestedAmount ?? $refundableBefore ?? $invoice->refundedAmount; + $refund->status = RefundStatus::SUCCEEDED; + $refund->createdAt = Carbon::now(); + $refund->gateway = 'iugu'; + $refund->invoice = $invoice; + + return $refund; } /** * Lança antes da rede quando a Iugu certamente recusaria o estorno: boleto não tem estorno - * pela API, fatura em `refunded` é terminal, Pix só estorna o valor integral e o prazo de - * estorno termina no fim do 90º dia após o pagamento. + * pela API, fatura em `refunded` é terminal, o valor pedido não pode passar do que resta + * (`paid_cents`, que a Iugu já devolve líquido do que foi estornado), Pix só estorna o valor + * integral e o prazo de estorno termina no fim do 90º dia após o pagamento. * * @param Invoice $invoice * @param int|null $requestedAmount valor pedido em centavos; nulo é estorno integral @@ -602,6 +620,15 @@ private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmou throw RefundNotSupportedException::alreadyRefunded('iugu', $invoice->paymentMethod?->value); } + if (!is_null($requestedAmount) && !is_null($invoice->paidAmount) && $requestedAmount > $invoice->paidAmount) { + throw RefundNotSupportedException::amountExceedsRefundable( + 'iugu', + $invoice->paymentMethod?->value, + $requestedAmount, + $invoice->paidAmount + ); + } + if ( $invoice->paymentMethod === PaymentMethod::PIX && !is_null($requestedAmount) @@ -982,6 +1009,29 @@ public function __toString() return 'iugu'; } + /** + * Monta a lista de estornos da fatura a partir de `refunded_cents`. A Iugu não lista os + * estornos nem os identifica, então a lista tem no máximo um `Refund`, sem id, com o + * acumulado estornado; sem estorno a lista é vazia. + * + * @param Invoice $invoice fatura já parseada, com `id`, `refundedAmount` e `paidAt` + * @return Refund[] + */ + private function parseRefunds(Invoice $invoice): array + { + if (empty($invoice->refundedAmount)) { + return []; + } + + $refund = new Refund(); + $refund->invoiceId = $invoice->id; + $refund->amount = $invoice->refundedAmount; + $refund->status = RefundStatus::SUCCEEDED; + $refund->gateway = 'iugu'; + + return [$refund]; + } + /** * Convert the iugu invoice into a MultiPayment invoice * @@ -1006,6 +1056,7 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice $invoice->createdAt = new Carbon($iuguInvoice->created_at_iso); $invoice->paidAmount = $iuguInvoice->paid_cents; $invoice->refundedAmount = $iuguInvoice->refunded_cents; + $invoice->refunds = $this->parseRefunds($invoice); $invoice->expiresAt = !empty($iuguInvoice->due_date) ? new Carbon($iuguInvoice->due_date) : null; if (empty($invoice->paymentMethod)) { diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index caadc57..a28837e 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -19,6 +19,7 @@ use Illuminate\Support\Facades\Config; use Potelo\MultiPayment\Models\Pix; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\Refund; use Potelo\MultiPayment\Models\Address; use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\CreditCard; @@ -28,6 +29,7 @@ use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\RefundStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\DeclineCode; use Potelo\MultiPayment\Helpers\LogHelper; @@ -65,9 +67,23 @@ class StripeGateway implements GatewayContract /** * Expand padrão em toda leitura/criação de PaymentIntent: sem latest_charge expandido, - * paidAmount/refundedAmount/fee ficam vazios no parse. + * paidAmount/refundedAmount/fee ficam vazios no parse, e sem `refunds` do charge (que a + * Stripe não inclui por padrão) a lista `Invoice::$refunds` seria reconstruída sem ids. */ - private const PAYMENT_INTENT_EXPAND = ['latest_charge.balance_transaction']; + private const PAYMENT_INTENT_EXPAND = ['latest_charge.balance_transaction', 'latest_charge.refunds']; + + /** + * Mapa de status do objeto Refund da Stripe para os genéricos do pacote. Lista oficial em + * https://docs.stripe.com/api/refunds/object#refund_object-status; `requires_action` + * (estorno aguardando ação do cliente) lê como pendente. + */ + private const REFUND_STATUSES = [ + 'pending' => RefundStatus::PENDING, + 'requires_action' => RefundStatus::PENDING, + 'succeeded' => RefundStatus::SUCCEEDED, + 'failed' => RefundStatus::FAILED, + 'canceled' => RefundStatus::CANCELED, + ]; /** * Status de dispute da Stripe que significam contestação em aberto: inquiry ou chargeback @@ -884,27 +900,40 @@ public function getInvoice(Invoice $invoice): Invoice /** * @inheritDoc * - * As guardas de estorno usam só o que já está no model, sem leitura prévia: um PaymentIntent - * deste driver não pode ser boleto. + * As guardas de estorno precisam do método de pagamento, do status e, no estorno por valor, + * do quanto ainda pode ser estornado. Um model que traz só o `id` custa um GET a mais para + * ler a fatura antes do estorno; um model lido do gateway, pago e sem estorno anterior não + * paga esse GET. No estorno por valor sobre uma fatura fora de `PAID` a leitura acontece + * mesmo com o model preenchido, porque o restante estornável depende do acumulado que o + * gateway guarda. A leitura prévia acontece numa cópia: o model do chamador só é alterado se + * o estorno acontecer. * * @throws ModelAttributeValidationException|RefundNotSupportedException */ - public function refundInvoice(Invoice $invoice): Invoice + public function refundInvoice(Invoice $invoice): Refund { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); } - if ($invoice->paymentMethod === PaymentMethod::BANK_SLIP) { - throw RefundNotSupportedException::boletoNoRefund('stripe'); - } - if ($invoice->status === InvoiceStatus::REFUNDED) { - throw RefundNotSupportedException::alreadyRefunded('stripe', $invoice->paymentMethod?->value); + + // guardado antes da leitura: parseInvoice() sobrescreve refundedAmount com o já estornado + $requestedAmount = $invoice->refundedAmount ?: null; + + $current = $invoice; + if ( + empty($invoice->paymentMethod) + || empty($invoice->status) + || (!is_null($requestedAmount) && (is_null($invoice->paidAmount) || $invoice->status !== InvoiceStatus::PAID)) + ) { + $current = $this->getInvoice(clone $invoice); } + $this->assertInvoiceIsRefundable($current, $requestedAmount, $current !== $invoice); + // mesma semântica da Iugu: refundedAmount preenchido = estorno parcial; vazio = total $stripeRefundData = ['payment_intent' => $invoice->id]; - if (!empty($invoice->refundedAmount)) { - $stripeRefundData['amount'] = $invoice->refundedAmount; + if (!is_null($requestedAmount)) { + $stripeRefundData['amount'] = $requestedAmount; } $stripeRefundData = $this->mergeGatewayOptions($stripeRefundData, $invoice); $requestOptions = $this->extractIdempotencyKey($stripeRefundData); @@ -913,11 +942,51 @@ public function refundInvoice(Invoice $invoice): Invoice return $this->client->refunds->create($stripeRefundData, $requestOptions); }); - // o refund não devolve o PaymentIntent — refetch para reparse com o charge atualizado + // o refund não devolve o PaymentIntent: refetch para reparse com o charge atualizado $invoice = $this->getInvoice($invoice); - $invoice->lastRefundId = $stripeRefund->id; - return $invoice; + $refund = $this->parseRefund($stripeRefund, $invoice->id); + $refund->invoice = $invoice; + + return $refund; + } + + /** + * Lança antes da rede quando a Stripe certamente recusaria o estorno: boleto não tem estorno + * pela API, fatura em `refunded` é terminal e o valor pedido não pode passar do que resta + * (`amount_captured` menos `amount_refunded` do charge). + * + * @param Invoice $invoice + * @param int|null $requestedAmount valor pedido em centavos; nulo é estorno integral + * @param bool $freshlyRead verdadeiro quando `$invoice` acabou de ser lida do gateway e + * `refundedAmount` é o acumulado; falso quando o model é do + * chamador, em `PAID`, sem estorno anterior + * @return void + * @throws RefundNotSupportedException + */ + private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmount, bool $freshlyRead): void + { + if ($invoice->paymentMethod === PaymentMethod::BANK_SLIP) { + throw RefundNotSupportedException::boletoNoRefund('stripe'); + } + + if ($invoice->status === InvoiceStatus::REFUNDED) { + throw RefundNotSupportedException::alreadyRefunded('stripe', $invoice->paymentMethod?->value); + } + + if (is_null($requestedAmount) || is_null($invoice->paidAmount)) { + return; + } + + $refundable = $invoice->paidAmount - ($freshlyRead ? ($invoice->refundedAmount ?? 0) : 0); + if ($requestedAmount > $refundable) { + throw RefundNotSupportedException::amountExceedsRefundable( + 'stripe', + $invoice->paymentMethod?->value, + $requestedAmount, + $refundable + ); + } } /** @@ -1001,6 +1070,7 @@ private function parseInvoice(StripePaymentIntent $stripePaymentIntent, ?Invoice $invoice->amount = $stripePaymentIntent->amount; $invoice->paidAmount = $paidCharge?->amount_captured; $invoice->refundedAmount = $paidCharge?->amount_refunded; + $invoice->refunds = $this->parseRefunds($paidCharge, $stripePaymentIntent->id); $invoice->paidAt = $paidCharge ? Carbon::createFromTimestamp($paidCharge->created) : null; $balanceTransaction = $paidCharge?->balance_transaction; // a balance transaction do cartão é assíncrona: pode vir nula logo após o confirm @@ -1077,6 +1147,66 @@ private function parseInvoice(StripePaymentIntent $stripePaymentIntent, ?Invoice return $invoice; } + /** + * Monta a lista de estornos da fatura a partir de `refunds` do charge pago, um `Refund` + * por estorno. Sem charge pago ou sem estorno a lista é vazia. Numa resposta em que a + * lista não veio expandida mas `amount_refunded` é maior que zero, devolve um único + * `Refund` sem id com o acumulado, para a lista nunca contradizer `refundedAmount`. + * + * @param object|null $paidCharge + * @param string $invoiceId + * @return Refund[] + */ + private function parseRefunds(?object $paidCharge, string $invoiceId): array + { + if (!$paidCharge || empty($paidCharge->amount_refunded)) { + return []; + } + + // isset() passa pelo __isset e não loga "Undefined property" quando a chave falta + $stripeRefunds = isset($paidCharge->refunds) ? $paidCharge->refunds : null; + if (!is_object($stripeRefunds) || !isset($stripeRefunds->data)) { + $refund = new Refund(); + $refund->invoiceId = $invoiceId; + $refund->amount = $paidCharge->amount_refunded; + $refund->status = RefundStatus::SUCCEEDED; + $refund->gateway = 'stripe'; + + return [$refund]; + } + + return array_map( + fn (object $stripeRefund) => $this->parseRefund($stripeRefund, $invoiceId), + $stripeRefunds->data + ); + } + + /** + * Converte o objeto Refund da Stripe em um `Refund` do MultiPayment. Status fora do mapa + * vira `UNKNOWN` com aviso no log. + * + * @param object $stripeRefund + * @param string $invoiceId + * @return Refund + */ + private function parseRefund(object $stripeRefund, string $invoiceId): Refund + { + $refund = new Refund(); + $refund->id = $stripeRefund->id; + $refund->invoiceId = $invoiceId; + $refund->amount = $stripeRefund->amount; + $refund->status = self::REFUND_STATUSES[$stripeRefund->status ?? ''] + ?? RefundStatus::unknown((string) $stripeRefund->status, 'stripe'); + $refund->reason = $stripeRefund->reason ?? null; + $refund->createdAt = !empty($stripeRefund->created) + ? Carbon::createFromTimestamp($stripeRefund->created) + : null; + $refund->gateway = 'stripe'; + $refund->original = $stripeRefund; + + return $refund; + } + /** * Deriva o status genérico de contestação de um charge pago. Quando a flag `disputed` do * charge é verdadeira, lista as disputes dele em /v1/disputes (um GET a mais). diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 6cf361b..ed74f9a 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -89,14 +89,13 @@ class Invoice extends Model public ?int $refundedAmount = null; /** - * Id do estorno criado pelo gateway na última chamada de `refund()`, quando o gateway - * devolve um (Stripe: `re_...`; a Iugu não devolve id de estorno). Preenchido só pela - * operação de estorno, não pela leitura da fatura. Campo provisório: dá lugar a um objeto - * `Refund` numa versão futura. + * Estornos da fatura, preenchidos na leitura. No Stripe é um `Refund` por estorno feito, + * com id; na Iugu, que só informa o total estornado, é um único `Refund` sem id com o + * acumulado, ou lista vazia quando nada foi estornado. * - * @var string|null + * @var Refund[]|null */ - public ?string $lastRefundId = null; + public ?array $refunds = null; /** * @var Customer|null @@ -412,6 +411,26 @@ public static function isContested(InvoiceStatus|string $status): bool return self::statusFromHelperArgument($status)?->isContested() ?? false; } + /** + * Copia também os objetos aninhados que os drivers preenchem na leitura (`customer` e seu + * `address`, `creditCard`, `bankSlip`, `pix`, `automaticPix`, `automaticPixCharge`), para + * que parsear a cópia não altere o model original. + * + * @return void + */ + public function __clone(): void + { + foreach (['customer', 'creditCard', 'bankSlip', 'pix', 'automaticPix', 'automaticPixCharge'] as $property) { + if (is_object($this->{$property})) { + $this->{$property} = clone $this->{$property}; + } + } + + if (is_object($this->customer?->address)) { + $this->customer->address = clone $this->customer->address; + } + } + /** * Converte o argumento dos helpers estáticos obsoletos em `InvoiceStatus`, sem log para * string fora do enum. @@ -425,13 +444,15 @@ private static function statusFromHelperArgument(InvoiceStatus|string $status): } /** - * Refund the invoice + * Estorna a fatura: integral quando `refundedAmount` está vazio, parcial quando preenchido + * com o valor em centavos. Devolve o `Refund` criado e atualiza esta instância com o + * estado posterior ao estorno (`$refund->invoice()` é esta instância). * - * @return \Potelo\MultiPayment\Models\Invoice + * @return Refund * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException */ - public function refund(): Invoice + public function refund(): Refund { $gateway = ConfigurationHelper::resolveGateway($this->gateway); return $gateway->refundInvoice($this); diff --git a/src/Models/Refund.php b/src/Models/Refund.php new file mode 100644 index 0000000..7a3303e --- /dev/null +++ b/src/Models/Refund.php @@ -0,0 +1,110 @@ + RefundStatus::class, + ]; + + /** + * Id do estorno no gateway. Nulo quando o gateway não identifica o estorno (a Iugu só + * informa o total estornado da fatura). + * + * @var string|null + */ + public ?string $id = null; + + /** + * Id da fatura estornada. + * + * @var string|null + */ + public ?string $invoiceId = null; + + /** + * Valor estornado, em centavos. + * + * @var int|null + */ + public ?int $amount = null; + + /** + * @var RefundStatus|null + */ + protected ?RefundStatus $status = null; + + /** + * Motivo do estorno, em texto livre. Preenchido quando o gateway devolve um; a lib não o + * envia ao gateway. + * + * @var string|null + */ + public ?string $reason = null; + + /** + * @var Carbon|null + */ + public ?Carbon $createdAt = null; + + /** + * @var string|null + */ + public ?string $gateway = null; + + /** + * Objeto de estorno devolvido pelo gateway; nulo quando o gateway não tem um. + * + * @var mixed + */ + public mixed $original = null; + + /** + * Fatura com o estado posterior ao estorno. Preenchida pela operação de estorno; fica + * nula num `Refund` lido de `Invoice::$refunds`. + * + * @var Invoice|null + */ + public ?Invoice $invoice = null; + + /** + * Devolve a fatura estornada. Num `Refund` devolvido pela operação de estorno é a fatura + * já relida, sem requisição; num `Refund` lido de `Invoice::$refunds` custa um GET na + * primeira chamada, e o resultado fica guardado em `$invoice`. + * + * @return Invoice + * @throws ModelAttributeValidationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function invoice(): Invoice + { + if (!empty($this->invoice)) { + return $this->invoice; + } + + if (empty($this->invoiceId)) { + throw ModelAttributeValidationException::required('Refund', 'invoiceId'); + } + + $invoice = new Invoice(); + $invoice->id = $this->invoiceId; + $invoice->gateway = $this->gateway; + + return $this->invoice = $invoice->get($this->gateway); + } +} diff --git a/src/MultiPayment.php b/src/MultiPayment.php index 0a61380..fb690bf 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -8,6 +8,7 @@ use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\Refund; use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\Plan; use Potelo\MultiPayment\Models\Subscription; @@ -286,22 +287,32 @@ public function getCustomer(string $id): Customer } /** - * Refund an invoice + * Estorna uma fatura pelo id: integral sem valor, parcial com o valor em centavos. + * Devolve o `Refund` criado; a fatura relida após o estorno está em `$refund->invoice()`. * * @param string $id * @param int|null $partialValueCents * - * @return \Potelo\MultiPayment\Models\Invoice + * @return \Potelo\MultiPayment\Models\Refund * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException valor parcial zero ou negativo */ - public function refundInvoice(string $id, ?int $partialValueCents = null): Invoice + public function refundInvoice(string $id, ?int $partialValueCents = null): Refund { + if (!is_null($partialValueCents) && $partialValueCents <= 0) { + throw ModelAttributeValidationException::invalid( + 'Invoice', + 'refundedAmount', + 'The partial refund value must be a positive amount in cents; omit it for a full refund.' + ); + } + $invoice = new Invoice(); $invoice->id = $id; $invoice->gateway = $this->gateway; - if ($partialValueCents) { + if (!is_null($partialValueCents)) { $invoice->refundedAmount = $partialValueCents; } diff --git a/tests/Integration/MultiPaymentTest.php b/tests/Integration/MultiPaymentTest.php index 60293fc..7c29112 100644 --- a/tests/Integration/MultiPaymentTest.php +++ b/tests/Integration/MultiPaymentTest.php @@ -11,6 +11,8 @@ use PHPUnit\Framework\Attributes\Group; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\RefundStatus; +use Potelo\MultiPayment\Models\Refund; use Potelo\MultiPayment\Enums\PaymentMethod; class MultiPaymentTest extends TestCase @@ -352,14 +354,22 @@ public function testShouldRefundInvoice(string $gateway, array $data, InvoiceSta $invoice = $invoiceBuilder->create(); sleep(3); - $refundedInvoice = $multiPayment->refundInvoice($invoice->id, $refundedAmount); + $refund = $multiPayment->refundInvoice($invoice->id, $refundedAmount); if (is_null($refundedAmount)) { $refundedAmount = $total; } + $this->assertInstanceOf(Refund::class, $refund); + $this->assertSame($refundedAmount, $refund->amount); + $this->assertSame(RefundStatus::SUCCEEDED, $refund->status); + $this->assertSame($invoice->id, $refund->invoiceId); + + $refundedInvoice = $refund->invoice(); $this->assertSame($status, $refundedInvoice->status); $this->assertEquals($refundedAmount, $refundedInvoice->refundedAmount); $this->assertEquals($total - $refundedAmount, $refundedInvoice->paidAmount); + $this->assertCount(1, $refundedInvoice->refunds); + $this->assertEquals($refundedAmount, $refundedInvoice->refunds[0]->amount); // na Iugu a guarda lê a fatura real antes: já estornada é recusada sem novo POST if ($gateway === 'iugu' && $status === InvoiceStatus::REFUNDED) { diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index 33b31aa..a09e1e0 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -13,6 +13,8 @@ use Potelo\MultiPayment\Enums\DeclineCode; use PHPUnit\Framework\Attributes\DataProvider; use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\RefundStatus; +use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Enums\PaymentMethod; /** @@ -312,9 +314,16 @@ public function testShouldRefundCreditCardInvoiceTotally($gateway) $this->assertEquals(InvoiceStatus::PAID, $invoice->status); - $invoiceRefunded = MultiPayment::setGateway($gateway)->refundInvoice($invoice->id); + $refund = MultiPayment::setGateway($gateway)->refundInvoice($invoice->id); + $this->assertStringStartsWith('re_', $refund->id); + $this->assertEquals(9900, $refund->amount); + $this->assertContains($refund->status, [RefundStatus::PENDING, RefundStatus::SUCCEEDED]); + + $invoiceRefunded = $refund->invoice(); $this->assertEquals(InvoiceStatus::REFUNDED, $invoiceRefunded->status); $this->assertEquals(9900, $invoiceRefunded->refundedAmount); + $this->assertCount(1, $invoiceRefunded->refunds); + $this->assertEquals($refund->id, $invoiceRefunded->refunds[0]->id); } /** @@ -337,10 +346,24 @@ public function testShouldRefundPixInvoicePartially($gateway) }); $this->assertEquals(InvoiceStatus::PAID, $invoicePaid->status); - $invoiceRefunded = MultiPayment::setGateway($gateway)->refundInvoice($invoice->id, 2345); + $refund = MultiPayment::setGateway($gateway)->refundInvoice($invoice->id, 2345); + $this->assertEquals(2345, $refund->amount); + + $invoiceRefunded = $refund->invoice(); $this->assertEquals(InvoiceStatus::PARTIALLY_REFUNDED, $invoiceRefunded->status); $this->assertEquals(2345, $invoiceRefunded->refundedAmount); $this->assertEquals(12345, $invoiceRefunded->paidAmount); + $this->assertCount(1, $invoiceRefunded->refunds); + $this->assertEquals($refund->id, $invoiceRefunded->refunds[0]->id); + + // segundo estorno acima do restante é recusado sem chamar a Stripe + try { + MultiPayment::setGateway($gateway)->refundInvoice($invoice->id, 10001); + $this->fail('Esperava RefundNotSupportedException'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $e->reason); + } + $this->assertEquals(2345, MultiPayment::setGateway($gateway)->getInvoice($invoice->id)->refundedAmount); } /** diff --git a/tests/Unit/Enums/RefundStatusTest.php b/tests/Unit/Enums/RefundStatusTest.php new file mode 100644 index 0000000..429a7e3 --- /dev/null +++ b/tests/Unit/Enums/RefundStatusTest.php @@ -0,0 +1,81 @@ +assertSame([ + 'pending', + 'succeeded', + 'failed', + 'canceled', + 'unknown', + ], array_column(RefundStatus::cases(), 'value')); + } + + public static function isFailedProvider(): array + { + return [ + 'pending' => [RefundStatus::PENDING, false], + 'succeeded' => [RefundStatus::SUCCEEDED, false], + 'failed' => [RefundStatus::FAILED, true], + 'canceled' => [RefundStatus::CANCELED, true], + 'unknown' => [RefundStatus::UNKNOWN, false], + ]; + } + + #[DataProvider('isFailedProvider')] + public function testIsFailedIsTrueOnlyWhenTheMoneyDidNotGoBack(RefundStatus $status, bool $failed): void + { + $this->assertSame($failed, $status->isFailed()); + } + + public function testFromValueReturnsTheMatchingCaseWithoutLogging(): void + { + $logger = $this->bindLogger(); + + $this->assertSame(RefundStatus::SUCCEEDED, RefundStatus::fromValue('succeeded', 'stripe')); + $this->assertSame(RefundStatus::PENDING, RefundStatus::fromValue('pending')); + $this->assertSame([], $logger->records); + } + + public function testFromValueTurnsAnUnknownStringIntoUnknownWithAWarning(): void + { + $logger = $this->bindLogger(); + + $status = RefundStatus::fromValue('status_novo', 'stripe'); + + $this->assertSame(RefundStatus::UNKNOWN, $status); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertStringContainsString('status_novo', $logger->records[0]['message']); + $this->assertStringContainsString('stripe', $logger->records[0]['message']); + $this->assertSame(['status' => 'status_novo', 'gateway' => 'stripe'], $logger->records[0]['context']); + } + + private function bindLogger(): RecordingLogger + { + $app = new Container(); + $app->instance('log', $logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + + return $logger; + } +} diff --git a/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php b/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php index 087c046..26ae025 100644 --- a/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php +++ b/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php @@ -51,6 +51,19 @@ public function testAlreadyRefundedKeepsThePaymentMethod(): void $this->assertFalse($exception->manualRefundRequired); } + public function testAmountExceedsRefundableIsFixableByTheCaller(): void + { + $exception = RefundNotSupportedException::amountExceedsRefundable('stripe', 'credit_card', 11000, 10000); + + $this->assertSame('amount_exceeds_refundable', $exception->reason); + $this->assertSame('credit_card', $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertNull($exception->capability); + $this->assertSame('stripe', $exception->gateway); + $this->assertStringContainsString('11000', $exception->getMessage()); + $this->assertStringContainsString('10000', $exception->getMessage()); + } + public function testRefundWindowExpiredRequiresManualRefund(): void { $exception = RefundNotSupportedException::refundWindowExpired('iugu', 'pix', Carbon::parse('2026-05-01'), 90); diff --git a/tests/Unit/Gateways/IuguGatewayRefundTest.php b/tests/Unit/Gateways/IuguGatewayRefundTest.php index 342ef5b..c7a36fe 100644 --- a/tests/Unit/Gateways/IuguGatewayRefundTest.php +++ b/tests/Unit/Gateways/IuguGatewayRefundTest.php @@ -8,6 +8,8 @@ use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\Customer; +use Potelo\MultiPayment\Models\Refund; use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\NotFoundException; @@ -15,6 +17,7 @@ use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\RefundStatus; use Potelo\MultiPayment\Enums\PaymentMethod; class IuguGatewayRefundTest extends TestCase @@ -100,15 +103,27 @@ public function testFullPixRefundWithoutAmountGoesToTheGateway(): void $this->paidPixInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), ]); - $result = (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + $invoice = $this->invoiceWithId(); + $result = (new IuguGateway($api))->refundInvoice($invoice); $this->assertCount(2, $api->calls); $this->assertSame('POST', $api->calls[1]['method']); $this->assertStringEndsWith('/invoices/inv_1/refund', $api->calls[1]['url']); $this->assertSame([], $api->calls[1]['data']); - $this->assertSame(InvoiceStatus::REFUNDED, $result->status); - $this->assertSame(10000, $result->refundedAmount); - $this->assertNull($result->lastRefundId); + + $this->assertInstanceOf(Refund::class, $result); + $this->assertNull($result->id, 'a Iugu não identifica o estorno'); + $this->assertSame('inv_1', $result->invoiceId); + $this->assertSame(10000, $result->amount); + $this->assertSame(RefundStatus::SUCCEEDED, $result->status); + $this->assertSame('2026-09-02 12:00:00', $result->createdAt->toDateTimeString()); + $this->assertSame('iugu', $result->gateway); + $this->assertNull($result->original); + + $this->assertSame($invoice, $result->invoice()); + $this->assertSame(InvoiceStatus::REFUNDED, $invoice->status); + $this->assertSame(10000, $invoice->refundedAmount); + $this->assertCount(2, $api->calls, 'invoice() não faz requisição'); } /** @@ -127,7 +142,8 @@ public function testPixRefundOfTheFullPaidAmountIsSentAsIntegral(): void $result = (new IuguGateway($api))->refundInvoice($invoice); $this->assertSame([], $api->calls[1]['data']); - $this->assertSame(InvoiceStatus::REFUNDED, $result->status); + $this->assertSame(10000, $result->amount); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); } public function testPartialCardRefundSendsThePartialValue(): void @@ -143,16 +159,20 @@ public function testPartialCardRefundSendsThePartialValue(): void $this->assertSame('POST', $api->calls[1]['method']); $this->assertSame(['partial_value_refund_cents' => 2500], $api->calls[1]['data']); - $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->status); - $this->assertSame(2500, $result->refundedAmount); - $this->assertSame(7500, $result->paidAmount); + $this->assertSame(2500, $result->amount); + $this->assertSame(RefundStatus::SUCCEEDED, $result->status); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->invoice()->status); + $this->assertSame(2500, $result->invoice()->refundedAmount); + $this->assertSame(7500, $result->invoice()->paidAmount); + $this->assertCount(1, $result->invoice()->refunds); + $this->assertSame(2500, $result->invoice()->refunds[0]->amount); } /** - * Fatura parcialmente estornada aceita novo estorno: a guarda `already_refunded` olha só - * `refunded`. Pedir exatamente o que resta vai como integral. + * Fatura parcialmente estornada aceita novo estorno até o restante (`paid_cents`, que a + * Iugu devolve líquido do já estornado). Pedir exatamente o que resta vai como integral. */ - public function testPartiallyRefundedInvoiceAcceptsAnotherRefund(): void + public function testRefundingTheExactRemainderOfAPartiallyRefundedInvoiceGoesAsIntegral(): void { $api = new QueuedIuguApiRequest([ $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), @@ -165,7 +185,121 @@ public function testPartiallyRefundedInvoiceAcceptsAnotherRefund(): void $this->assertCount(2, $api->calls); $this->assertSame([], $api->calls[1]['data']); - $this->assertSame(InvoiceStatus::REFUNDED, $result->status); + $this->assertSame(7500, $result->amount); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); + } + + /** + * O `Refund` devolvido traz o valor deste estorno; `Invoice::$refunds` traz o acumulado + * num único registro, porque a Iugu só informa `refunded_cents`. + */ + public function testSecondPartialRefundWithinTheRemainderGoesToTheGateway(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 4500, 'paid_cents' => 5500]), + ]); + $invoice = $this->invoiceWithId(); + $invoice->refundedAmount = 2000; + + $result = (new IuguGateway($api))->refundInvoice($invoice); + + $this->assertSame(['partial_value_refund_cents' => 2000], $api->calls[1]['data']); + $this->assertSame(2000, $result->amount); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->invoice()->status); + $this->assertSame(4500, $result->invoice()->refundedAmount); + $this->assertCount(1, $result->invoice()->refunds); + $this->assertNull($result->invoice()->refunds[0]->id); + $this->assertSame(4500, $result->invoice()->refunds[0]->amount); + } + + public function testSecondPartialRefundAboveTheRemainderThrowsBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + ]); + $invoice = $this->invoiceWithId(); + $invoice->refundedAmount = 8000; + + $exception = $this->refundExpectingRefusal($api, $invoice); + + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); + $this->assertSame(PaymentMethod::CREDIT_CARD->value, $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertNull($exception->capability); + $this->assertStringContainsString('8000', $exception->getMessage()); + $this->assertStringContainsString('7500', $exception->getMessage()); + $this->assertOnlyTheInvoiceWasRead($api); + $this->assertSame(8000, $invoice->refundedAmount); + } + + /** + * Regressão: a leitura prévia parseia uma cópia, e a cópia precisa ser profunda, senão o + * `customer` do model do chamador recebe os dados da resposta mesmo quando a guarda dispara. + */ + public function testRefusedRefundLeavesTheCallerNestedObjectsUntouched(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + ]); + $invoice = $this->invoiceWithId(); + $invoice->customer = new Customer(); + $invoice->customer->name = 'Nome do chamador'; + $invoice->refundedAmount = 8000; + + $this->refundExpectingRefusal($api, $invoice); + + $this->assertSame('Nome do chamador', $invoice->customer->name); + $this->assertNull($invoice->customer->id); + } + + /** + * Model lido do gateway em `partially_refunded` carrega o acumulado em `refundedAmount`, e + * `refund()` sem alterar o valor reenvia o acumulado como novo estorno parcial. Para + * estornar o restante, o chamador limpa `refundedAmount` antes (documentado no README). + */ + public function testRefundOnAPartiallyRefundedModelReadFromTheGatewayResendsTheAccumulatedAmount(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 5000, 'paid_cents' => 5000]), + ]); + $gateway = new IuguGateway($api); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $result = $gateway->refundInvoice($invoice); + + $this->assertSame(['partial_value_refund_cents' => 2500], $api->calls[1]['data']); + $this->assertSame(2500, $result->amount); + $this->assertSame(5000, $invoice->refundedAmount); + } + + public function testFirstRefundAboveThePaidAmountThrowsBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([$this->paidInvoiceResponse()]); + $invoice = $this->invoiceWithId(); + $invoice->refundedAmount = 10001; + + $exception = $this->refundExpectingRefusal($api, $invoice); + + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); + $this->assertOnlyTheInvoiceWasRead($api); + } + + /** + * Num Pix, valor acima do pago é recusado com `amount_exceeds_refundable`, antes da guarda de + * Pix parcial, porque a orientação de repetir sem valor não resolveria esse pedido. + */ + public function testPixRefundAboveThePaidAmountIsRefusedAsExceeding(): void + { + $api = new QueuedIuguApiRequest([$this->paidPixInvoiceResponse()]); + $invoice = $this->invoiceWithId(); + $invoice->refundedAmount = 15000; + + $exception = $this->refundExpectingRefusal($api, $invoice); + + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); + $this->assertSame(PaymentMethod::PIX->value, $exception->paymentMethod); } /** @@ -226,7 +360,7 @@ public function testPixRefundByAmountWithoutPaidAmountReadsTheInvoiceFirst(): vo $this->assertCount(2, $api->calls); $this->assertSame('GET', $api->calls[0]['method']); $this->assertSame([], $api->calls[1]['data']); - $this->assertSame(InvoiceStatus::REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); } /** @@ -295,7 +429,7 @@ public function testRefundInsideTheNinetyDayWindowGoesToTheGateway(): void $result = (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); $this->assertCount(2, $api->calls); - $this->assertSame(InvoiceStatus::REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); } /** @@ -312,7 +446,7 @@ public function testRefundOnTheLastDayOfTheWindowGoesToTheGateway(): void $result = (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); $this->assertCount(2, $api->calls); - $this->assertSame(InvoiceStatus::REFUNDED, $result->status); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); } public function testRefundOnTheDayAfterTheWindowThrowsBeforeTheNetwork(): void @@ -345,7 +479,50 @@ public function testInvoiceReadFromTheGatewayDoesNotPayTheExtraGet(): void $this->assertCount(2, $api->calls); $this->assertSame('GET', $api->calls[0]['method']); $this->assertSame('POST', $api->calls[1]['method']); - $this->assertSame(InvoiceStatus::REFUNDED, $result->status); + $this->assertSame(10000, $result->amount, 'sem valor pedido, o valor vem do que a Iugu acrescentou a refunded_cents'); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); + } + + /** + * Regressão: model lido do gateway, já parcialmente estornado, com `refundedAmount` limpo + * pelo chamador para pedir o restante. Sem leitura prévia, o valor do `Refund` vem do + * `paid_cents` que o model trazia. + */ + public function testIntegralRefundOfAPartiallyRefundedInvoiceReadFromTheGatewayReportsTheRemainder(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + $gateway = new IuguGateway($api); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $invoice->refundedAmount = null; + $result = $gateway->refundInvoice($invoice); + + $this->assertCount(2, $api->calls); + $this->assertSame([], $api->calls[1]['data']); + $this->assertSame(7500, $result->amount); + $this->assertSame(10000, $invoice->refundedAmount); + $this->assertSame(InvoiceStatus::REFUNDED, $invoice->status); + } + + /** + * Regressão: estorno integral do restante numa fatura já parcialmente estornada, lida do + * gateway. O valor do `Refund` é só o que este estorno devolveu. + */ + public function testIntegralRefundOfTheRemainderReportsOnlyWhatThisRefundReturned(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + + $result = (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + + $this->assertSame([], $api->calls[1]['data']); + $this->assertSame(7500, $result->amount); + $this->assertSame(10000, $result->invoice()->refundedAmount); } public function testGatewayErrorOnRefundBecomesGatewayException(): void @@ -375,7 +552,31 @@ public function testGetInvoiceReadsTheInvoiceById(): void $this->assertSame(PaymentMethod::CREDIT_CARD, $result->paymentMethod); $this->assertSame(10000, $result->paidAmount); $this->assertSame('2026-08-20', $result->paidAt->toDateString()); - $this->assertNull($result->lastRefundId); + $this->assertSame([], $result->refunds); + } + + /** + * A Iugu só informa `refunded_cents`, então a lista de estornos tem um único `Refund`, sem + * id e sem data, com o acumulado. + */ + public function testGetInvoiceListsASingleSyntheticRefundWithTheAccumulatedAmount(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 3000, 'paid_cents' => 7000]), + ]); + + $result = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertCount(1, $result->refunds); + $refund = $result->refunds[0]; + $this->assertInstanceOf(Refund::class, $refund); + $this->assertNull($refund->id); + $this->assertSame('inv_1', $refund->invoiceId); + $this->assertSame(3000, $refund->amount); + $this->assertSame(RefundStatus::SUCCEEDED, $refund->status); + $this->assertNull($refund->createdAt); + $this->assertSame('iugu', $refund->gateway); + $this->assertNull($refund->invoice); } public function testGetInvoiceErrorBecomesGatewayException(): void diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index 1506fea..beaf2e7 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -10,6 +10,7 @@ use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\Refund; use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\InvoiceItem; @@ -23,6 +24,7 @@ use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use PHPUnit\Framework\Attributes\DataProvider; use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\RefundStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Tests\Unit\RecordingLogger; @@ -75,7 +77,7 @@ public function testCreatesCreditCardInvoiceChargingSavedCard(): void // o encoder do stripe-php serializa booleanos como string antes da camada HTTP 'confirm' => 'true', 'off_session' => 'true', - 'expand' => ['latest_charge.balance_transaction'], + 'expand' => ['latest_charge.balance_transaction', 'latest_charge.refunds'], ], $params); $this->assertSame('pi_fake123', $result->id); @@ -229,7 +231,7 @@ public function testCreatesPixInvoiceFullyServerSideAndParsesQrCode(): void ], 'confirm' => 'true', 'payment_method_options' => ['pix' => ['expires_at' => $requestedExpiresAt]], - 'expand' => ['latest_charge.balance_transaction'], + 'expand' => ['latest_charge.balance_transaction', 'latest_charge.refunds'], ], $params); $this->assertSame(InvoiceStatus::PENDING, $result->status); @@ -341,7 +343,7 @@ public function testCancelsPendingInvoice(): void [$method, $url, $params] = $httpClient->calls[0]; $this->assertSame('post', $method); $this->assertSame('/v1/payment_intents/pi_fake123/cancel', parse_url($url, PHP_URL_PATH)); - $this->assertSame(['expand' => ['latest_charge.balance_transaction']], $params); + $this->assertSame(['expand' => ['latest_charge.balance_transaction', 'latest_charge.refunds']], $params); $this->assertSame(InvoiceStatus::CANCELED, $result->status); } @@ -829,7 +831,7 @@ public function testGatewayOptionsOverrideAndExpandIsMerged(): void // a opção do consumidor vence a chave montada pelo gateway $this->assertSame('false', $params['off_session']); // o expand do consumidor é mesclado, não descartado - $this->assertSame(['customer', 'latest_charge.balance_transaction'], $params['expand']); + $this->assertSame(['customer', 'latest_charge.balance_transaction', 'latest_charge.refunds'], $params['expand']); } /** @@ -893,13 +895,21 @@ public function testChargeInvoiceWithCreditCardRequiresTokenOrId(): void (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); } + /** + * Só com o id, o driver lê a fatura antes das guardas (um GET), cria o refund e relê o + * PaymentIntent. O `Refund` devolvido vem do objeto da Stripe e carrega a fatura relida. + */ public function testRefundsInvoiceTotally(): void { $refunded = $this->paidCardPaymentIntentResponse(); $refunded['latest_charge']['amount_refunded'] = 12345; $refunded['latest_charge']['refunded'] = true; + $refunded['latest_charge']['refunds'] = $this->refundListResponse([ + $this->refundResponse('re_fake123', 12345, 'succeeded'), + ]); $httpClient = RecordingStripeHttpClient::withResponses([ - ['id' => 're_fake123', 'object' => 'refund', 'status' => 'pending', 'amount' => 12345], + $this->paidCardPaymentIntentResponse(), + $this->refundResponse('re_fake123', 12345, 'pending'), $refunded, ]); @@ -907,14 +917,31 @@ public function testRefundsInvoiceTotally(): void $invoice->id = 'pi_fake123'; $result = (new StripeGateway())->refundInvoice($invoice); - [$method, $url, $params] = $httpClient->calls[0]; - $this->assertSame('post', $method); - $this->assertSame('/v1/refunds', parse_url($url, PHP_URL_PATH)); + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'post /v1/refunds', + 'get /v1/payment_intents/pi_fake123', + ], $this->calledPaths($httpClient)); // sem amount: estorno total - $this->assertSame(['payment_intent' => 'pi_fake123'], $params); - $this->assertSame(InvoiceStatus::REFUNDED, $result->status); - $this->assertSame(12345, $result->refundedAmount); - $this->assertSame('re_fake123', $result->lastRefundId); + $this->assertSame(['payment_intent' => 'pi_fake123'], $httpClient->calls[1][2]); + + $this->assertInstanceOf(Refund::class, $result); + $this->assertSame('re_fake123', $result->id); + $this->assertSame('pi_fake123', $result->invoiceId); + $this->assertSame(12345, $result->amount); + $this->assertSame(RefundStatus::PENDING, $result->status); + $this->assertSame(1786700100, $result->createdAt->getTimestamp()); + $this->assertSame('requested_by_customer', $result->reason); + $this->assertSame('stripe', $result->gateway); + $this->assertSame('re_fake123', $result->original->id); + + $this->assertSame($invoice, $result->invoice()); + $this->assertSame(InvoiceStatus::REFUNDED, $invoice->status); + $this->assertSame(12345, $invoice->refundedAmount); + $this->assertCount(1, $invoice->refunds); + $this->assertSame('re_fake123', $invoice->refunds[0]->id); + $this->assertSame(RefundStatus::SUCCEEDED, $invoice->refunds[0]->status); + $this->assertCount(3, $httpClient->calls, 'invoice() não faz requisição'); } public function testRefundsInvoicePartially(): void @@ -922,7 +949,8 @@ public function testRefundsInvoicePartially(): void $refunded = $this->paidCardPaymentIntentResponse(); $refunded['latest_charge']['amount_refunded'] = 2345; $httpClient = RecordingStripeHttpClient::withResponses([ - ['id' => 're_fake123', 'object' => 'refund', 'status' => 'pending', 'amount' => 2345], + $this->paidCardPaymentIntentResponse(), + $this->refundResponse('re_fake123', 2345, 'pending'), $refunded, ]); @@ -933,11 +961,11 @@ public function testRefundsInvoicePartially(): void $this->assertSame( ['payment_intent' => 'pi_fake123', 'amount' => 2345], - $httpClient->calls[0][2] + $httpClient->calls[1][2] ); - $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->status); - $this->assertSame(2345, $result->refundedAmount); - $this->assertSame('re_fake123', $result->lastRefundId); + $this->assertSame(2345, $result->amount); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->invoice()->status); + $this->assertSame(2345, $result->invoice()->refundedAmount); } public function testRefundInvoiceRequiresId(): void @@ -950,26 +978,38 @@ public function testRefundInvoiceRequiresId(): void /** * Boleto ainda não existe neste driver; a guarda já nasce coberta para quando entrar. */ - public function testBoletoRefundThrowsBeforeTheNetwork(): void + public function testBoletoRefundWithThePaymentMethodInHandMakesNoRequest(): void { $httpClient = RecordingStripeHttpClient::withResponses([]); $invoice = new Invoice(); $invoice->id = 'pi_fake123'; $invoice->paymentMethod = PaymentMethod::BANK_SLIP; + $invoice->status = InvoiceStatus::PAID; - try { - (new StripeGateway())->refundInvoice($invoice); - $this->fail('Esperava RefundNotSupportedException'); - } catch (RefundNotSupportedException $e) { - $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $e->reason); - $this->assertSame(PaymentMethod::BANK_SLIP->value, $e->paymentMethod); - $this->assertTrue($e->manualRefundRequired); - } + $exception = $this->refundExpectingRefusal($invoice); + $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $exception->reason); + $this->assertSame(PaymentMethod::BANK_SLIP->value, $exception->paymentMethod); + $this->assertTrue($exception->manualRefundRequired); $this->assertSame([], $httpClient->calls); } - public function testAlreadyRefundedInvoiceThrowsBeforeTheNetwork(): void + public function testBoletoRefundThrowsBeforePostingAfterReadingTheInvoice(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['payment_method_types'] = ['boleto']; + $response['latest_charge']['payment_method_details'] = ['type' => 'boleto', 'boleto' => []]; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $exception = $this->refundExpectingRefusal($invoice); + + $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $exception->reason); + $this->assertSame(['get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient)); + } + + public function testAlreadyRefundedInvoiceWithTheStatusInHandMakesNoRequest(): void { $httpClient = RecordingStripeHttpClient::withResponses([]); $invoice = new Invoice(); @@ -977,18 +1017,34 @@ public function testAlreadyRefundedInvoiceThrowsBeforeTheNetwork(): void $invoice->paymentMethod = PaymentMethod::CREDIT_CARD; $invoice->status = InvoiceStatus::REFUNDED; - try { - (new StripeGateway())->refundInvoice($invoice); - $this->fail('Esperava RefundNotSupportedException'); - } catch (RefundNotSupportedException $e) { - $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $e->reason); - $this->assertSame(PaymentMethod::CREDIT_CARD->value, $e->paymentMethod); - $this->assertFalse($e->manualRefundRequired); - } + $exception = $this->refundExpectingRefusal($invoice); + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $exception->reason); + $this->assertSame(PaymentMethod::CREDIT_CARD->value, $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); $this->assertSame([], $httpClient->calls); } + /** + * Só com o id, a leitura prévia é o que faz a guarda de fatura já estornada disparar sem + * um POST que a Stripe recusaria com `charge_already_refunded`. + */ + public function testAlreadyRefundedInvoiceWithOnlyTheIdIsRefusedAfterReadingIt(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 12345; + $response['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $exception = $this->refundExpectingRefusal($invoice); + + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $exception->reason); + $this->assertSame(['get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient)); + $this->assertNull($invoice->status, 'a leitura prévia não altera o model do chamador'); + } + /** * Pix parcial é permitido na Stripe: a guarda da Iugu não pode vazar para cá. */ @@ -997,7 +1053,8 @@ public function testPartialPixRefundGoesToTheGateway(): void $refunded = $this->paidPixPaymentIntentResponse(); $refunded['latest_charge']['amount_refunded'] = 2345; $httpClient = RecordingStripeHttpClient::withResponses([ - ['id' => 're_fake123', 'object' => 'refund', 'status' => 'succeeded', 'amount' => 2345], + $this->paidPixPaymentIntentResponse(), + $this->refundResponse('re_fake123', 2345, 'succeeded'), $refunded, ]); @@ -1007,26 +1064,186 @@ public function testPartialPixRefundGoesToTheGateway(): void $invoice->refundedAmount = 2345; $result = (new StripeGateway())->refundInvoice($invoice); - $this->assertCount(2, $httpClient->calls); - $this->assertSame('post', $httpClient->calls[0][0]); - $this->assertSame('/v1/refunds', parse_url($httpClient->calls[0][1], PHP_URL_PATH)); - $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 2345], $httpClient->calls[0][2]); - $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->status); - $this->assertSame(2345, $result->refundedAmount); - $this->assertSame('re_fake123', $result->lastRefundId); + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'post /v1/refunds', + 'get /v1/payment_intents/pi_fake123', + ], $this->calledPaths($httpClient)); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 2345], $httpClient->calls[1][2]); + $this->assertSame('re_fake123', $result->id); + $this->assertSame(RefundStatus::SUCCEEDED, $result->status); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->invoice()->status); + $this->assertSame(2345, $result->invoice()->refundedAmount); + } + + /** + * Um model lido do gateway, pago e sem estorno anterior não paga o GET extra no estorno + * por valor: o restante estornável é o valor pago. + */ + public function testPaidInvoiceReadFromTheGatewayDoesNotPayTheExtraGet(): void + { + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 2345; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paidCardPaymentIntentResponse(), + $this->refundResponse('re_fake123', 2345, 'succeeded'), + $refunded, + ]); + $gateway = new StripeGateway(); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $invoice->refundedAmount = 2345; + $result = $gateway->refundInvoice($invoice); + + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'post /v1/refunds', + 'get /v1/payment_intents/pi_fake123', + ], $this->calledPaths($httpClient)); + $this->assertSame(2345, $result->amount); + } + + /** + * Fatura parcialmente estornada aceita novo estorno até o restante. Com o status fora de + * `PAID` o driver relê a fatura mesmo com o model preenchido, porque o acumulado que o + * chamador tinha em `refundedAmount` foi sobrescrito pelo valor pedido. + */ + public function testSecondPartialRefundWithinTheRemainderGoesToTheGateway(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 7345; + $httpClient = RecordingStripeHttpClient::withResponses([ + $partiallyRefunded, + $this->refundResponse('re_fake456', 5000, 'succeeded'), + $refunded, + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->paymentMethod = PaymentMethod::CREDIT_CARD; + $invoice->status = InvoiceStatus::PARTIALLY_REFUNDED; + $invoice->paidAmount = 12345; + $invoice->refundedAmount = 5000; + $result = (new StripeGateway())->refundInvoice($invoice); + + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'post /v1/refunds', + 'get /v1/payment_intents/pi_fake123', + ], $this->calledPaths($httpClient)); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 5000], $httpClient->calls[1][2]); + $this->assertSame('re_fake456', $result->id); + $this->assertSame(5000, $result->amount); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->invoice()->status); + $this->assertSame(7345, $result->invoice()->refundedAmount); + } + + /** + * Sem leitura prévia (model em `PAID` com `paidAmount`), o restante é o valor pago e a + * recusa acontece sem nenhuma requisição além da leitura inicial. + */ + public function testRefundAboveThePaidAmountOnAModelReadFromTheGatewayThrowsWithoutAnotherRequest(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + $gateway = new StripeGateway(); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $invoice->refundedAmount = 12346; + + try { + $gateway->refundInvoice($invoice); + $this->fail('Esperava RefundNotSupportedException'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $e->reason); + $this->assertStringContainsString('12345', $e->getMessage()); + } + $this->assertSame(['get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient)); + } + + /** + * Regressão: a leitura prévia parseia uma cópia, e a cópia precisa ser profunda, senão o + * `customer` do model do chamador recebe os dados da resposta mesmo quando a guarda dispara. + */ + public function testRefusedRefundLeavesTheCallerNestedObjectsUntouched(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + RecordingStripeHttpClient::withResponses([$partiallyRefunded]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->customer = new Customer(); + $invoice->customer->name = 'Nome do chamador'; + $invoice->creditCard = new CreditCard(); + $invoice->refundedAmount = 11000; + + $this->refundExpectingRefusal($invoice); + + $this->assertSame('Nome do chamador', $invoice->customer->name); + $this->assertNull($invoice->customer->id); + $this->assertNull($invoice->creditCard->brand); } /** - * Fatura parcialmente estornada aceita novo estorno: a guarda `already_refunded` olha só - * `refunded`. + * Model lido do gateway em `partially_refunded` carrega o acumulado em `refundedAmount`, e + * `refund()` sem alterar o valor reenvia o acumulado como novo estorno parcial. Para + * estornar o restante, o chamador limpa `refundedAmount` antes (documentado no README). */ - public function testPartiallyRefundedInvoiceAcceptsAnotherRefund(): void + public function testRefundOnAPartiallyRefundedModelReadFromTheGatewayResendsTheAccumulatedAmount(): void { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $refundedTwice = $this->paidCardPaymentIntentResponse(); + $refundedTwice['latest_charge']['amount_refunded'] = 4690; + $httpClient = RecordingStripeHttpClient::withResponses([ + $partiallyRefunded, + $partiallyRefunded, + $this->refundResponse('re_fake456', 2345, 'succeeded'), + $refundedTwice, + ]); + $gateway = new StripeGateway(); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $result = $gateway->refundInvoice($invoice); + + $this->assertSame('post /v1/refunds', $this->calledPaths($httpClient)[2]); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 2345], $httpClient->calls[2][2]); + $this->assertSame(2345, $result->amount); + $this->assertSame(4690, $invoice->refundedAmount); + } + + public function testSecondPartialRefundAboveTheRemainderThrowsBeforePosting(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $httpClient = RecordingStripeHttpClient::withResponses([$partiallyRefunded]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->refundedAmount = 11000; + $exception = $this->refundExpectingRefusal($invoice); + + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); + $this->assertSame(PaymentMethod::CREDIT_CARD->value, $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertStringContainsString('11000', $exception->getMessage()); + $this->assertStringContainsString('10000', $exception->getMessage()); + $this->assertSame(['get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient)); + $this->assertSame(11000, $invoice->refundedAmount, 'a leitura prévia não altera o model do chamador'); + } + + public function testRefundingTheExactRemainderOfAPartiallyRefundedInvoiceGoesToTheGateway(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; $refunded = $this->paidCardPaymentIntentResponse(); $refunded['latest_charge']['amount_refunded'] = 12345; $refunded['latest_charge']['refunded'] = true; $httpClient = RecordingStripeHttpClient::withResponses([ - ['id' => 're_fake456', 'object' => 'refund', 'status' => 'succeeded', 'amount' => 10000], + $partiallyRefunded, + $this->refundResponse('re_fake456', 10000, 'succeeded'), $refunded, ]); @@ -1036,23 +1253,112 @@ public function testPartiallyRefundedInvoiceAcceptsAnotherRefund(): void $invoice->refundedAmount = 10000; $result = (new StripeGateway())->refundInvoice($invoice); - $this->assertSame('post', $httpClient->calls[0][0]); - $this->assertSame('/v1/refunds', parse_url($httpClient->calls[0][1], PHP_URL_PATH)); - $this->assertSame(InvoiceStatus::REFUNDED, $result->status); - $this->assertSame('re_fake456', $result->lastRefundId); + $this->assertSame('post', $httpClient->calls[1][0]); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 10000], $httpClient->calls[1][2]); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); + $this->assertSame('re_fake456', $result->id); } - public function testGetInvoiceDoesNotFillLastRefundId(): void + public function testGetInvoiceListsTheRefundsOfTheCharge(): void { $response = $this->paidCardPaymentIntentResponse(); - $response['latest_charge']['amount_refunded'] = 12345; - $response['latest_charge']['refunded'] = true; + $response['latest_charge']['amount_refunded'] = 5345; + $response['latest_charge']['refunds'] = $this->refundListResponse([ + $this->refundResponse('re_fake123', 2345, 'succeeded'), + $this->refundResponse('re_fake456', 3000, 'pending', 1786700200, null), + ]); RecordingStripeHttpClient::withResponses([$response]); $result = $this->getInvoice(); - $this->assertSame(InvoiceStatus::REFUNDED, $result->status); - $this->assertNull($result->lastRefundId); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->status); + $this->assertCount(2, $result->refunds); + $this->assertContainsOnlyInstancesOf(Refund::class, $result->refunds); + $this->assertSame('re_fake123', $result->refunds[0]->id); + $this->assertSame(2345, $result->refunds[0]->amount); + $this->assertSame(RefundStatus::SUCCEEDED, $result->refunds[0]->status); + $this->assertSame('requested_by_customer', $result->refunds[0]->reason); + $this->assertSame(1786700100, $result->refunds[0]->createdAt->getTimestamp()); + $this->assertSame('pi_fake123', $result->refunds[0]->invoiceId); + $this->assertSame('stripe', $result->refunds[0]->gateway); + $this->assertNull($result->refunds[0]->invoice, 'um Refund lido da fatura não carrega a fatura'); + $this->assertSame('re_fake456', $result->refunds[1]->id); + $this->assertSame(RefundStatus::PENDING, $result->refunds[1]->status); + $this->assertNull($result->refunds[1]->reason); + } + + public function testGetInvoiceWithoutRefundHasAnEmptyRefundsList(): void + { + RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $this->assertSame([], $this->getInvoice()->refunds); + } + + /** + * Resposta com estorno mas sem a lista `refunds` expandida: a lista não pode contradizer + * `refundedAmount`, então volta um único `Refund` sem id com o acumulado. + */ + public function testGetInvoiceWithoutTheRefundsListFallsBackToASingleRefundWithoutId(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 2345; + RecordingStripeHttpClient::withResponses([$response]); + + $loggerAnterior = \Stripe\Stripe::getLogger(); + $logger = new RecordingStripeLogger(); + \Stripe\Stripe::setLogger($logger); + + try { + $result = $this->getInvoice(); + } finally { + \Stripe\Stripe::setLogger($loggerAnterior); + } + + $this->assertSame([], $logger->messages, 'ler `refunds` ausente não pode logar Undefined property'); + $this->assertCount(1, $result->refunds); + $this->assertNull($result->refunds[0]->id); + $this->assertSame(2345, $result->refunds[0]->amount); + $this->assertSame(RefundStatus::SUCCEEDED, $result->refunds[0]->status); + } + + #[DataProvider('refundStatusProvider')] + public function testRefundStatusIsMappedToTheGenericEnum(string $stripeStatus, RefundStatus $expected): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 2345; + $response['latest_charge']['refunds'] = $this->refundListResponse([ + $this->refundResponse('re_fake123', 2345, $stripeStatus), + ]); + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame($expected, $this->getInvoice()->refunds[0]->status); + } + + public static function refundStatusProvider(): array + { + return [ + 'pending' => ['pending', RefundStatus::PENDING], + 'requires_action' => ['requires_action', RefundStatus::PENDING], + 'succeeded' => ['succeeded', RefundStatus::SUCCEEDED], + 'failed' => ['failed', RefundStatus::FAILED], + 'canceled' => ['canceled', RefundStatus::CANCELED], + ]; + } + + public function testRefundStatusOutsideTheMapIsReadAsUnknownWithAWarning(): void + { + Facade::getFacadeApplication()->instance('log', $logger = new RecordingLogger()); + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 2345; + $response['latest_charge']['refunds'] = $this->refundListResponse([ + $this->refundResponse('re_fake123', 2345, 'status_novo'), + ]); + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame(RefundStatus::UNKNOWN, $this->getInvoice()->refunds[0]->status); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertSame(['status' => 'status_novo', 'gateway' => 'stripe'], $logger->records[0]['context']); } public function testDuplicatesPendingPixInvoiceCancelingTheOriginal(): void @@ -1104,7 +1410,7 @@ public function testDuplicatesPendingPixInvoiceCancelingTheOriginal(): void ], 'confirm' => 'true', 'payment_method_options' => ['pix' => ['expires_at' => $expiresAt->getTimestamp()]], - 'expand' => ['latest_charge.balance_transaction'], + 'expand' => ['latest_charge.balance_transaction', 'latest_charge.refunds'], ], $httpClient->calls[2][2]); $this->assertSame('pi_fake456', $result->id); @@ -1275,6 +1581,61 @@ private function duplicableCustomerResponse(): array ]; } + private function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + return $invoice; + } + + private function refundExpectingRefusal(Invoice $invoice): RefundNotSupportedException + { + try { + (new StripeGateway())->refundInvoice($invoice); + } catch (RefundNotSupportedException $e) { + return $e; + } + + $this->fail('Esperava RefundNotSupportedException'); + } + + /** + * @return string[] método e caminho de cada chamada, na ordem + */ + private function calledPaths(RecordingStripeHttpClient $httpClient): array + { + return array_map( + static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), + $httpClient->calls + ); + } + + private function refundResponse(string $id, int $amount, string $status, int $created = 1786700100, ?string $reason = 'requested_by_customer'): array + { + return [ + 'id' => $id, + 'object' => 'refund', + 'amount' => $amount, + 'currency' => 'brl', + 'status' => $status, + 'created' => $created, + 'reason' => $reason, + 'payment_intent' => 'pi_fake123', + 'charge' => 'ch_fake123', + ]; + } + + private function refundListResponse(array $refunds): array + { + return [ + 'object' => 'list', + 'data' => $refunds, + 'has_more' => false, + 'url' => '/v1/charges/ch_fake123/refunds', + ]; + } + private function getInvoice(): Invoice { $invoice = new Invoice(); diff --git a/tests/Unit/RefundTest.php b/tests/Unit/RefundTest.php new file mode 100644 index 0000000..a3ae3b8 --- /dev/null +++ b/tests/Unit/RefundTest.php @@ -0,0 +1,217 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => [ + 'iugu' => ['api_key' => 'test-api-key', 'class' => RefundTestIuguGateway::class], + ], + ], + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + RefundTestIuguGateway::$api = null; + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testStatusAcceptsTheStringAndReadsAsTheEnum(): void + { + $refund = new Refund(); + $refund->status = 'succeeded'; + + $this->assertSame(RefundStatus::SUCCEEDED, $refund->status); + $this->assertTrue(isset($refund->status)); + } + + public function testFillConvertsSnakeCaseKeysAndTheStatus(): void + { + $refund = new Refund(); + $refund->fill(['invoice_id' => 'inv_1', 'amount' => 500, 'status' => 'pending', 'reason' => 'duplicado']); + + $this->assertSame('inv_1', $refund->invoiceId); + $this->assertSame(500, $refund->amount); + $this->assertSame(RefundStatus::PENDING, $refund->status); + $this->assertSame('duplicado', $refund->reason); + } + + public function testToArrayEmitsSnakeCaseKeysAndTheStatusValue(): void + { + $refund = new Refund(); + $refund->id = 're_1'; + $refund->invoiceId = 'inv_1'; + $refund->amount = 500; + $refund->status = RefundStatus::SUCCEEDED; + $refund->gateway = 'stripe'; + + $this->assertSame([ + 'id' => 're_1', + 'invoice_id' => 'inv_1', + 'amount' => 500, + 'status' => 'succeeded', + 'gateway' => 'stripe', + ], $refund->toArray()); + } + + public function testInvoiceReturnsTheLoadedInvoiceWithoutARequest(): void + { + RefundTestIuguGateway::$api = new QueuedIuguApiRequest([]); + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + $refund = new Refund(); + $refund->invoiceId = 'inv_1'; + $refund->gateway = 'iugu'; + $refund->invoice = $invoice; + + $this->assertSame($invoice, $refund->invoice()); + $this->assertSame([], RefundTestIuguGateway::$api->calls); + } + + public function testInvoiceReadsTheInvoiceFromTheGatewayOnceWhenNotLoaded(): void + { + RefundTestIuguGateway::$api = new QueuedIuguApiRequest([$this->partiallyRefundedInvoiceResponse()]); + $refund = new Refund(); + $refund->invoiceId = 'inv_1'; + $refund->gateway = 'iugu'; + + $invoice = $refund->invoice(); + + $this->assertSame('inv_1', $invoice->id); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $invoice->status); + $this->assertSame(3000, $invoice->refundedAmount); + $this->assertCount(1, RefundTestIuguGateway::$api->calls); + $this->assertSame('GET', RefundTestIuguGateway::$api->calls[0]['method']); + $this->assertStringEndsWith('/invoices/inv_1', RefundTestIuguGateway::$api->calls[0]['url']); + + $this->assertSame($invoice, $refund->invoice(), 'a segunda chamada devolve a fatura guardada'); + $this->assertCount(1, RefundTestIuguGateway::$api->calls); + $this->assertSame($invoice, $refund->invoice); + } + + public function testInvoiceRequiresTheInvoiceId(): void + { + $refund = new Refund(); + $refund->gateway = 'iugu'; + + $this->expectException(ModelAttributeValidationException::class); + + $refund->invoice(); + } + + /** + * Um `Refund` de `Invoice::$refunds` não aponta de volta para a fatura, senão a + * serialização entraria em ciclo. + */ + public function testInvoiceWithRefundsSerializesWithoutACycle(): void + { + RefundTestIuguGateway::$api = new QueuedIuguApiRequest([$this->partiallyRefundedInvoiceResponse()]); + + $invoice = (new RefundTestIuguGateway())->getInvoice($this->invoiceWithId()); + $json = json_decode(json_encode($invoice), true); + + $this->assertIsArray($json); + $this->assertCount(1, $json['refunds']); + $this->assertSame(3000, $json['refunds'][0]['amount']); + $this->assertSame('succeeded', $json['refunds'][0]['status']); + $this->assertNull($json['refunds'][0]['invoice']); + $this->assertSame('inv_1', $invoice->toArray()['refunds'][0]->invoiceId); + } + + public function testMultiPaymentRejectsAZeroOrNegativePartialValueBeforeAnyRequest(): void + { + RefundTestIuguGateway::$api = new QueuedIuguApiRequest([]); + + foreach ([0, -100] as $value) { + try { + (new MultiPayment('iugu'))->refundInvoice('inv_1', $value); + $this->fail("Esperava ModelAttributeValidationException para {$value}"); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('refundedAmount', $e->getMessage()); + } + } + + $this->assertSame([], RefundTestIuguGateway::$api->calls); + } + + private function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + + return $invoice; + } + + private function partiallyRefundedInvoiceResponse(): object + { + return (object) [ + 'id' => 'inv_1', + 'status' => 'partially_refunded', + 'total_cents' => 10000, + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'taxes_paid_cents' => 250, + 'created_at_iso' => '2026-08-20T09:00:00-03:00', + 'paid_cents' => 7000, + 'refunded_cents' => 3000, + 'due_date' => '2026-08-25', + 'payment_method' => 'iugu_credit_card', + 'payable_with' => 'credit_card', + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [ + (object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1], + ], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + ]; + } +} From ecc3cdf6f8776d4f36ad53f6642d403dfae84079 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 13:45:23 -0300 Subject: [PATCH 21/32] =?UTF-8?q?feat(idempotency):=20exp=C3=B5e=20chave?= =?UTF-8?q?=20de=20idempot=C3=AAncia=20na=20API=20p=C3=BAblica,=20envia=20?= =?UTF-8?q?como=20cabe=C3=A7alho=20e=20cobre=20endpoints=20da=20Iugu=20com?= =?UTF-8?q?=20store=20pr=C3=B3pria?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toda operação de escrita recebe `?string $idempotencyKey = null` como último argumento (contracts, drivers, models, MultiPayment, Facade, Trait) e os builders ganham `withIdempotencyKey()`; o cliente criado junto com fatura ou assinatura recebe `{chave}:customer` - Stripe: a chave vai em `Idempotency-Key` em todo POST, com chave derivada nas requisições secundárias; com chave, as guardas de estado (estorno, duplicação, exclusão de cartão, tax id) cedem ao replay da Stripe - Iugu: cabeçalho nos quatro endpoints que o aceitam (fatura, cobrança, cliente, assinatura) e `IdempotencyStore` nos demais métodos de escrita (`CacheIdempotencyStore` sobre o cache do Laravel com lock, registrada pelo provider; `InMemoryIdempotencyStore` para testes); na reutilização da chave a Iugu responde 409 com `resource_id`, e o driver relê a fatura original - fork Potelo/iugu-php 1.1.0 (cabeçalhos por requisição, status e cabeçalhos da resposta por instância): o driver deixa de usar os recursos estáticos do SDK, `lastIuguHttpStatus()` lê da instância e `RateLimitException::$retryAfter` é preenchido na Iugu - `gateway_options['idempotency_key']` continua aceito com E_USER_DEPRECATED e fica fora do corpo da requisição - corrige `Invoice::fill(['amount' => ...])`, que zerava o item recém-criado Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk --- README.md | 188 +++- composer.json | 4 +- composer.lock | 13 +- src/Builders/Builder.php | 23 +- src/Contracts/AutomaticPixContract.php | 23 +- src/Contracts/CreditCardContract.php | 9 +- src/Contracts/CustomerContract.php | 9 +- src/Contracts/IdempotencyStore.php | 38 + src/Contracts/InvoiceContract.php | 26 +- src/Contracts/PlanContract.php | 6 +- src/Contracts/SubscriptionContract.php | 23 +- src/Enums/Capability.php | 9 +- src/Exceptions/ConfigurationException.php | 31 +- .../IdempotencyConflictException.php | 66 +- src/Facades/MultiPayment.php | 20 +- .../Concerns/ResolvesIdempotencyKey.php | 75 ++ src/Gateways/IuguGateway.php | 743 ++++++++++---- src/Gateways/StripeGateway.php | 296 ++++-- src/Helpers/ConfigurationHelper.php | 34 + src/Idempotency/CacheIdempotencyStore.php | 120 +++ src/Idempotency/IdempotencyKey.php | 23 + src/Idempotency/InMemoryIdempotencyStore.php | 74 ++ src/Models/Customer.php | 23 +- src/Models/Invoice.php | 54 +- src/Models/Model.php | 21 +- src/Models/Plan.php | 5 +- src/Models/Subscription.php | 34 +- src/MultiPayment.php | 77 +- src/Providers/MultiPaymentServiceProvider.php | 12 + src/Traits/MultiPaymentTrait.php | 27 +- src/config/multi-payment.php | 19 + tests/Integration/IdempotencyTest.php | 142 +++ tests/Integration/MultiPaymentTest.php | 2 +- tests/Unit/AutomaticPixTest.php | 8 +- .../Unit/Gateways/GatewayCapabilitiesTest.php | 4 +- .../Gateways/IuguGatewayAutomaticPixTest.php | 2 +- .../Gateways/IuguGatewayIdempotencyTest.php | 957 ++++++++++++++++++ tests/Unit/Gateways/QueuedIuguApiRequest.php | 32 +- tests/Unit/Gateways/QueuedIuguResponse.php | 10 +- .../Gateways/RecordingStripeHttpClient.php | 29 +- .../Gateways/StripeGatewayIdempotencyTest.php | 636 ++++++++++++ .../Gateways/StripeGatewayInvoiceTest.php | 57 +- .../Idempotency/CacheIdempotencyStoreTest.php | 205 ++++ .../InMemoryIdempotencyStoreTest.php | 134 +++ tests/Unit/IdempotencyKeyPropagationTest.php | 351 +++++++ tests/Unit/InvoiceTest.php | 15 + .../MultiPaymentServiceProviderTest.php | 56 + tests/Unit/SubscriptionTest.php | 3 +- 48 files changed, 4290 insertions(+), 478 deletions(-) create mode 100644 src/Contracts/IdempotencyStore.php create mode 100644 src/Gateways/Concerns/ResolvesIdempotencyKey.php create mode 100644 src/Idempotency/CacheIdempotencyStore.php create mode 100644 src/Idempotency/IdempotencyKey.php create mode 100644 src/Idempotency/InMemoryIdempotencyStore.php create mode 100644 tests/Integration/IdempotencyTest.php create mode 100644 tests/Unit/Gateways/IuguGatewayIdempotencyTest.php create mode 100644 tests/Unit/Gateways/StripeGatewayIdempotencyTest.php create mode 100644 tests/Unit/Idempotency/CacheIdempotencyStoreTest.php create mode 100644 tests/Unit/Idempotency/InMemoryIdempotencyStoreTest.php create mode 100644 tests/Unit/IdempotencyKeyPropagationTest.php create mode 100644 tests/Unit/Providers/MultiPaymentServiceProviderTest.php diff --git a/README.md b/README.md index 9bd2cc8..9cba1b4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [Migração das constantes para enum](#migração-das-constantes-para-enum) - [Particularidades do Stripe](#particularidades-do-stripe) - [Opções extras do gateway](#opções-extras-do-gateway) + - [Idempotência](#idempotência) - [Utilizando](#utilizando) - [MultiPayment](#multipayment) - [InvoiceBuilder](#invoicebuilder) @@ -42,6 +43,16 @@ Instale esse pacote pelo composer: composer require potelo/multi-payment "dev-main" ``` +O SDK da Iugu vem do fork `Potelo/iugu-php`, que não está no Packagist, e o Composer não herda +a lista de repositórios de uma dependência. Declare o fork no `composer.json` da aplicação +antes de instalar: + +```json +"repositories": [ + {"type": "git", "url": "https://github.com/Potelo/iugu-php.git"} +] +``` + ## Configuração Após instalar o pacote rode o comando abaixo para publicar as configurações no projeto Laravel ``` @@ -62,6 +73,10 @@ IUGU_APIKEY= #stripe STRIPE_APIKEY= + +#idempotência (opcional; ver a seção Idempotência) +MULTIPAYMENT_IDEMPOTENCY_TTL=86400 +MULTIPAYMENT_IDEMPOTENCY_CACHE_STORE= ``` Opcionalmente você pode configurar o Trait, para facilitar o uso do método `charge` junto a um usuário. @@ -133,8 +148,8 @@ o teste `GatewayCapabilitiesTest` falha quando o README fica defasado em relaç | `PARTIAL_REFUND_PIX` | Estorno de parte do valor numa fatura paga com Pix. | limitação do gateway | sim | | `REFUND_BANK_SLIP` | Estorno pela API de uma fatura paga com boleto. | limitação do gateway | limitação do gateway | | `INVOICE_DUPLICATION` | Segunda via de uma fatura pendente com nova data de vencimento (`duplicateInvoice`). | sim | sim | -| `IDEMPOTENCY` | Chave de idempotência (`gateway_options['idempotency_key']`) honrada na criação de fatura e no estorno. | não implementado | sim | -| `IDEMPOTENCY_ALL_ENDPOINTS` | Chave de idempotência honrada em toda operação de escrita, inclusive cliente, cartão, cancelamento e troca de plano. | limitação do gateway | não implementado | +| `IDEMPOTENCY` | Chave de idempotência (`idempotencyKey`) honrada em toda operação de escrita, pelo gateway ou pela deduplicação da lib (`IdempotencyStore`). | sim | sim | +| `IDEMPOTENCY_ALL_ENDPOINTS` | Chave de idempotência honrada pelo próprio gateway em toda operação de escrita, sem depender da deduplicação da lib. | limitação do gateway | sim | | `SUBSCRIPTIONS` | Assinatura recorrente: criar, buscar, atualizar, suspender, retomar, cancelar, trocar de plano e listar. | sim | não implementado | | `PLANS` | Plano de assinatura: criar, buscar e listar. | sim | não implementado | | `PLAN_DEACTIVATION` | Desativar um plano sem apagá-lo (`deactivatePlan`). | limitação do gateway | não implementado | @@ -151,8 +166,10 @@ Restrições dentro de uma célula "sim": [Particularidades do Stripe](#particularidades-do-stripe)). - **`INSTALLMENTS` na Iugu** é informado em `gateway_options['months']`; a lib não modela parcelas nem lê os campos da fatura parcelada. -- **`IDEMPOTENCY` no Stripe** cobre criação de fatura e estorno; nas demais operações de escrita a - chave não é repassada (`IDEMPOTENCY_ALL_ENDPOINTS`). +- **`IDEMPOTENCY` na Iugu** é honrada pelo gateway só na criação de fatura, cliente e assinatura e + na cobrança com cartão; nas demais operações de escrita a deduplicação é da lib, pela + `IdempotencyStore`, que exige o cache do Laravel configurado (ver [Idempotência](#idempotência)). + Por isso a Iugu não tem `IDEMPOTENCY_ALL_ENDPOINTS`. - **`PARTIAL_REFUND_PIX` e `REFUND_BANK_SLIP`** chegam como `RefundNotSupportedException`, que herda de `UnsupportedOperationException` (ver [Estorno](#estorno)). - **`MANAGES_RECURRENCE`** é informativa: diz quem agenda a cobrança do Pix Automático (ver @@ -315,8 +332,10 @@ recusado na validação, porque a fatura com Pix Automático é criada com `PIX` quando ela é verdadeira, o pacote consulta `/v1/disputes` do charge para decidir entre `disputed` e `chargeback` (ver [Status da fatura](#status-da-fatura)). Fatura sem contestação não paga esse GET. -- **Idempotência**: envie `gateway_options['idempotency_key']` (ou `$invoice->gatewayOptions`) - na criação de faturas e estornos para repassar o cabeçalho `Idempotency-Key` da Stripe. +- **Idempotência em toda escrita.** A chave informada em `idempotencyKey` vai no cabeçalho + `Idempotency-Key` de toda requisição de escrita da operação, inclusive cliente, cartão, + cancelamento e as requisições secundárias, com chaves derivadas (ver + [Idempotência](#idempotência)). ### Opções extras do gateway @@ -346,6 +365,142 @@ uma opção vira uso recorrente, ela deve ser modelada genericamente. > próxima versão maior. A única diferença observável é `toArray()`, que passa a devolver a > chave `gateway_options`. +### Idempotência + +Toda operação de escrita aceita uma chave de idempotência como último argumento +(`?string $idempotencyKey = null`), na fachada, nos models e nos drivers; nos builders ela entra +por `withIdempotencyKey()`. Duas chamadas com a mesma chave produzem um único efeito no gateway: +a segunda devolve o resultado da primeira em vez de criar outra fatura, outro estorno ou outra +troca de plano. + +```php +use Potelo\MultiPayment\Facades\MultiPayment; + +$invoice = MultiPayment::newInvoice() + ->addAvailablePaymentMethod(PaymentMethod::PIX) + ->setCustomer($customer) + ->addItem('Mensalidade', 10000, 1) + ->withIdempotencyKey($order->uuid) // uma chave por intenção de escrita + ->create(); + +MultiPayment::refundInvoice($invoice->id, 5000, idempotencyKey: "refund-{$order->uuid}"); +MultiPayment::cancelInvoice($invoice->id, idempotencyKey: "cancel-{$order->uuid}"); +$subscription->changePlan('plano_anual', idempotencyKey: "upgrade-{$order->uuid}"); +$customer->save('iugu', idempotencyKey: "customer-{$user->id}"); +``` + +**A lib nunca gera uma chave por conta própria.** Sem `idempotencyKey`, a requisição vai sem +deduplicação e um retry cria um segundo registro; é a aplicação que sabe qual pedido, assinatura +ou estorno a chamada representa, então é ela que escolhe a chave (um UUID gravado junto do +pedido, por exemplo). Gerar a chave por baixo esconderia esse risco. Regras da chave: a mesma +chave sempre com o mesmo payload; chave nova para cada nova intenção; retry com a mesma chave +só depois de uma falha em que a resposta não chegou ou o processo caiu. + +Duas regras de payload que valem para a chave: a Stripe compara o payload e recusa a mesma +chave com conteúdo diferente (`IdempotencyConflictException`), então um campo derivado do +instante da chamada, como um `expiresAt` calculado de `now()`, precisa ser gravado junto da +chave e reenviado igual; a Iugu não compara e responde com o recurso original mesmo que o +payload tenha mudado. + +Retry seguro: + +```php +use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +use Potelo\MultiPayment\Exceptions\IdempotencyConflictException; + +$key = $order->idempotency_key ??= (string) Str::uuid(); // gravada antes da primeira tentativa + +try { + $invoice = $payment->newInvoice()->/* ... */->withIdempotencyKey($key)->create(); +} catch (GatewayNotAvailableException $e) { + // timeout ou 5xx: repetir mais tarde com a MESMA chave não cobra duas vezes + ChargeOrder::dispatch($order)->delay(now()->addMinutes(5)); +} catch (IdempotencyConflictException $e) { + // a primeira tentativa ainda está em andamento, ou a chave foi reusada com outro payload: + // consultar o resultado dela em vez de repetir +} +``` + +Quem honra a chave depende da operação e do gateway. A Stripe aceita `Idempotency-Key` em +todo POST e, na repetição, devolve a mesma resposta; a Iugu só aceita o cabeçalho em quatro +endpoints e, na repetição, responde 409 apontando o recurso original (`resource_id`): para +fatura e cobrança com cartão a lib lê essa fatura e a devolve, então a segunda chamada tem o +mesmo resultado da primeira; para cliente e assinatura a Iugu não informa o id +(`resource_id: processing`) e a segunda chamada lança `IdempotencyConflictException`, cabendo à +aplicação consultar o registro que gravou na primeira. Nos demais endpoints da Iugu a lib +deduplica por conta própria com a `IdempotencyStore` (abaixo): + +| Operação | Iugu | Stripe | +|---|---|---| +| Criar fatura (`create()`, `charge()`), com Pix, boleto ou cartão | gateway (`POST /invoices` ou `POST /charge`) | gateway | +| Cobrar fatura com cartão (`chargeInvoiceWithCreditCard`) | gateway (`POST /charge`) | gateway | +| Criar cliente | gateway | gateway | +| Criar assinatura | gateway | (não implementado) | +| Atualizar cliente, definir cartão padrão | store da lib | gateway | +| Salvar cartão, excluir cartão | store da lib | gateway | +| Estornar, cancelar, duplicar fatura | store da lib | gateway | +| Suspender, retomar, cancelar, atualizar assinatura, trocar de plano | store da lib | (não implementado) | +| Criar plano | store da lib | (não implementado) | +| Reagendar e cancelar Pix Automático | store da lib | (não implementado) | + +Quando uma operação faz mais de uma requisição de escrita (salvar o cartão antes de cobrar, +criar o tax id ao atualizar o cliente, remover subitens antes de atualizar a assinatura), a +requisição principal leva a chave informada e as secundárias levam chaves derivadas dela +(`{chave}:card`, `{chave}:tax_id`, `{chave}:remove`...): a Stripe recusa a mesma chave em dois +endpoints, e a derivação é determinística, então um retry reproduz as mesmas chaves. O cliente +criado junto com a fatura ou a assinatura (`Invoice::save()` sem `customer.id`) recebe +`{chave}:customer`, então o retry de `charge()` com cliente novo não cria um segundo cliente +(na Iugu, onde a repetição da chave em cliente responde 409, o retry de `charge()` com cliente +novo lança `IdempotencyConflictException`; consulte a fatura pelo registro da aplicação ou +crie o cliente antes com a própria chave). + +Com chave, as guardas locais que dependem do estado do recurso deixam de recusar um retry: um +estorno sobre fatura já estornada (ou acima do restante) é enviado mesmo assim e a Stripe +repete o refund original quando a chave é a dele (senão a recusa da guarda é a que sobe); a +duplicação de uma fatura já cancelada e a exclusão de um cartão já desvinculado seguem o mesmo +caminho. Na Iugu, o estorno com chave passa inteiro (leitura, guardas e `POST`) pela store, e +o retry devolve o `Refund` da primeira execução. + +**A `IdempotencyStore`.** Nas operações da Iugu marcadas "store da lib", a requisição de escrita +passa por `Potelo\MultiPayment\Contracts\IdempotencyStore`: a primeira execução com a chave é +guardada por 24 horas (`multi-payment.idempotency.ttl`), e as seguintes devolvem a resposta +guardada sem chamar a Iugu. A mesma chave reaparecendo em outra operação (um cancelamento e +depois um estorno com a chave do cancelamento) lança `IdempotencyConflictException` em vez de +devolver a resposta errada. Duas execuções concorrentes com a mesma chave são serializadas por +lock, e a segunda recebe `IdempotencyConflictException`, como a Iugu responde 409. Uma execução +que lança não é guardada (o retry executa de novo). O service provider registra a +`CacheIdempotencyStore`, sobre o cache do Laravel, que exige um store com suporte a lock +(`redis`, `memcached`, `database`, `file`, `array` ou `dynamodb`); sem cache configurado, a +primeira operação com chave num endpoint da store lança `ConfigurationException`. Operações +sem chave nunca tocam a store, e os quatro endpoints nativos da Iugu tampouco. + +```php +// config/multi-payment.php +'idempotency' => [ + 'ttl' => env('MULTIPAYMENT_IDEMPOTENCY_TTL', 86400), // segundos + 'cache_store' => env('MULTIPAYMENT_IDEMPOTENCY_CACHE_STORE'), // nulo: o cache padrão da aplicação + 'prefix' => 'multi-payment:idempotency:', +], + +// outra store: bind próprio no service provider da aplicação +$this->app->bind(IdempotencyStore::class, fn () => new MinhaStore()); + +// testes da aplicação: store em memória, sem cache +$this->app->instance(IdempotencyStore::class, new InMemoryIdempotencyStore()); +``` + +Limite da store: ela só protege de um retry feito **depois** de uma resposta recebida. Se a +Iugu executou a escrita e a resposta se perdeu (timeout), nada foi guardado e o retry com a +mesma chave executa de novo; só o gateway conseguiria deduplicar esse caso, e nesses endpoints +a Iugu não deduplica. Para a troca de plano com cobrança e o estorno, confira o estado da +assinatura ou da fatura antes de repetir depois de um timeout. + +> **Nome antigo.** Até a 4.1.0 a chave ia em `gateway_options['idempotency_key']` e só o +> Stripe a repassava, na criação de fatura e no estorno. A chave nesse lugar continua sendo +> usada, com aviso `E_USER_DEPRECATED`, quando o argumento não é informado (o argumento tem +> precedência), e deixa de ir no corpo da requisição da Iugu, que a ecoava como campo da +> fatura. Sai na próxima versão maior. + ## Tratamento de erros Toda exceção do pacote herda de `MultiPaymentException`. Nenhuma exceção dos SDKs da Iugu ou da @@ -358,7 +513,7 @@ A árvore, com a indentação marcando a herança: ``` MultiPaymentException - ConfigurationException gateway não configurado ou classe inválida + ConfigurationException gateway não configurado, classe inválida ou IdempotencyStore sem cache ModelAttributeValidationException atributo obrigatório ausente ou inválido, antes da requisição UnsupportedOperationException operação fora das capabilities do gateway, antes da requisição RefundNotSupportedException estorno recusado pela lib antes da requisição @@ -370,7 +525,7 @@ MultiPaymentException ValidationException 400 ou 422: fieldErrors por campo NotFoundException 404: recurso inexistente no gateway RateLimitException 429: retryAfter em segundos quando o gateway informa - IdempotencyConflictException 409 na Iugu, idempotency_error na Stripe + IdempotencyConflictException 409 na Iugu, idempotency_error na Stripe, lock da IdempotencyStore ocupado ``` | Exceção | Quando | O que fazer | @@ -379,14 +534,14 @@ MultiPaymentException | `ChargingException` | Nome antigo de `CardDeclinedException`, deprecado. É a classe que os drivers lançam, então `catch` por qualquer um dos dois nomes captura a mesma exceção | Migrar o `catch` para `CardDeclinedException` | | `ValidationException` | O gateway recusou o payload (400 ou 422 na Iugu, `invalid_request_error` na Stripe); `fieldErrors` traz as mensagens por campo (`base` para erro sem campo) | Corrigir a chamada; repetir igual falha de novo | | `NotFoundException` | Recurso inexistente no gateway (404 na Iugu, `resource_missing` na Stripe): id errado, de outra conta ou removido | Conferir o id; não repetir | -| `RateLimitException` | O gateway limitou a taxa de requisições (429); nada foi executado. `retryAfter` traz os segundos do cabeçalho `Retry-After` quando o gateway o envia (o SDK da Iugu não expõe cabeçalhos, então na Iugu fica nulo) | Esperar e repetir | -| `IdempotencyConflictException` | Chave de idempotência reutilizada com outro payload, ou a primeira requisição com a chave ainda em andamento (409 na Iugu, `idempotency_error` na Stripe) | Consultar o resultado da primeira requisição ou usar chave nova; nunca repetir com a mesma chave e outro conteúdo | +| `RateLimitException` | O gateway limitou a taxa de requisições (429); nada foi executado. `retryAfter` traz os segundos do cabeçalho `Retry-After` quando o gateway o envia; nulo quando não envia | Esperar e repetir | +| `IdempotencyConflictException` | Chave de idempotência reutilizada (409 na Iugu em cliente e assinatura; `idempotency_error` na Stripe quando o payload mudou), a primeira requisição com a chave ainda em andamento, ou lock ocupado na `IdempotencyStore` da lib. `resourceId` traz o id do recurso original quando o gateway o informa | Consultar o resultado da primeira requisição (`resourceId` ou o registro da aplicação) ou usar chave nova; nunca repetir com a mesma chave e outro conteúdo | | `AuthenticationException` | Chave de API inválida, revogada, sem permissão (401 ou 403) ou não configurada | Registrar e alertar. Repetir a chamada ou trocar de gateway não resolve | | `GatewayNotAvailableException` | Erro 5xx, falha de conexão ou timeout | Repetir mais tarde ou tentar outro gateway | | `UnsupportedOperationException` | Operação fora das capabilities do gateway, antes de qualquer requisição; `capability`, `gateway` e `reason` (`not_implemented` ou `gateway_limitation`) dizem qual e por quê | Rotear para um gateway que declare a capability; melhor ainda, consultar `supports()` antes (ver [Capabilities](#capabilities)) | | `RefundNotSupportedException` | Estorno recusado pela lib antes de chamar o gateway (boleto, Pix parcial, já estornada, valor acima do restante, prazo vencido); herda de `UnsupportedOperationException` e refina `reason` | Ver [Estorno](#estorno) | | `ModelAttributeValidationException` | Atributo obrigatório ausente ou inválido, antes de qualquer requisição | Corrigir a chamada | -| `ConfigurationException` | Gateway não configurado ou classe inválida | Corrigir a configuração | +| `ConfigurationException` | Gateway não configurado ou classe inválida; `IdempotencyStore` sem registro no container ou sobre um cache sem lock | Corrigir a configuração | | `GatewayException` | Qualquer outra resposta de erro do gateway, e a classe pai das quatro de resposta acima; `getErrors()` traz o corpo de erro | Depende do caso; `httpStatus` e `getErrors()` dizem o que aconteceu | Um `catch` por camada. As subclasses vêm antes de `GatewayException`, senão ela captura tudo: @@ -487,6 +642,13 @@ o deixava nulo, traz o valor de `declineCode`. Compare com `declineCode`. > ser `CardDeclinedException` (lançada pelo nome antigo `ChargingException`, que herda dela), > com a mensagem em português; salvar um cartão recusado pela Stripe (`newCreditCard()->create()`) > também lança `CardDeclinedException`, onde antes vinha `GatewayException`. +> +> Também na 5.0.0, toda operação de escrita ganhou o último argumento `idempotencyKey` (e os +> builders, `withIdempotencyKey()`), inclusive nos contracts dos gateways; quem implementa +> `GatewayContract` fora do pacote precisa acrescentar o parâmetro. Na Iugu, `retryAfter` de +> `RateLimitException` passou a vir preenchido quando o cabeçalho `Retry-After` chega, e todas +> as chamadas ao gateway passaram a usar o requester injetável do driver (o fork +> `Potelo/iugu-php` 1.1.0), o que não muda a API pública. ## Utilizando @@ -759,6 +921,10 @@ $payment->duplicateInvoice($invoiceId, \Carbon\Carbon::now()->addDays(3)); // cobrar uma fatura pendente com cartão (token OU id de cartão salvo) $payment->chargeInvoiceWithCreditCard($invoiceId, 'pm_...'); $payment->chargeInvoiceWithCreditCard($invoiceId, null, $creditCardId); + +// toda operação de escrita aceita a chave de idempotência como último argumento (seção "Idempotência") +$payment->refundInvoice($invoiceId, 5000, idempotencyKey: $uuid); +$payment->cancelInvoice($invoiceId, idempotencyKey: $uuid); ``` #### Estorno diff --git a/composer.json b/composer.json index 6bf25b4..1ebce96 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,9 @@ "php": "^8.3", "illuminate/config": "^10.0|^11.0|^12.0", "illuminate/support": "^10.0|^11.0|^12.0", - "iugu/iugu": "dev-master", + "illuminate/cache": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "iugu/iugu": "1.1.0", "stripe/stripe-php": "^21.3" }, "require-dev": { diff --git a/composer.lock b/composer.lock index fbd26af..338d3fb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7055c2a35e1cca2dcb66b2d18906cbad", + "content-hash": "e397f1b2e4575d158bc685d6ef8d036a", "packages": [ { "name": "brick/math", @@ -1060,11 +1060,11 @@ }, { "name": "iugu/iugu", - "version": "dev-master", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/Potelo/iugu-php.git", - "reference": "1d9a34e428131f8e53e70543d347959e7f65be92" + "reference": "b8a3acfbba84caaab4ab932ec4da2cbab6239166" }, "require": { "ext-curl": "*", @@ -1076,7 +1076,6 @@ "php-vcr/php-vcr": "^1.4", "phpunit/phpunit": "^6" }, - "default-branch": true, "type": "library", "autoload": { "classmap": [ @@ -1104,7 +1103,7 @@ "iugu", "pagamentos" ], - "time": "2025-07-01T00:06:54+00:00" + "time": "2026-09-02T15:54:28+00:00" }, { "name": "laravel/framework", @@ -8791,9 +8790,7 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "iugu/iugu": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/src/Builders/Builder.php b/src/Builders/Builder.php index a3ea529..42cf149 100644 --- a/src/Builders/Builder.php +++ b/src/Builders/Builder.php @@ -11,6 +11,13 @@ class Builder protected GatewayContract $gateway; protected Model $model; + /** + * Chave de idempotência enviada com o `create()`. + * + * @var string|null + */ + protected ?string $idempotencyKey = null; + /** * Builder constructor. * @@ -31,10 +38,24 @@ public function __construct($gateway = null) */ public function create(): Model { - $this->model->save($this->gateway, true); + $this->model->save($this->gateway, true, $this->idempotencyKey); return $this->model; } + /** + * Define a chave de idempotência que `create()` envia ao gateway (ver a seção + * "Idempotência" do README). Nula desliga a deduplicação. + * + * @param string|null $idempotencyKey + * @return $this + */ + public function withIdempotencyKey(?string $idempotencyKey): static + { + $this->idempotencyKey = $idempotencyKey; + + return $this; + } + /** * Returns the model instance. * diff --git a/src/Contracts/AutomaticPixContract.php b/src/Contracts/AutomaticPixContract.php index fe13e76..ef48e5f 100644 --- a/src/Contracts/AutomaticPixContract.php +++ b/src/Contracts/AutomaticPixContract.php @@ -23,22 +23,39 @@ interface AutomaticPixContract { /** + * Pede um novo agendamento de débito para uma fatura de Pix Automático que não foi paga. + * + * @param Invoice $invoice + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return Invoice * @throws GatewayException|GatewayNotAvailableException */ - public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice; + public function rescheduleAutomaticPixPayment(Invoice $invoice, ?string $idempotencyKey = null): Invoice; /** + * Cancela um pagamento agendado da recorrência. + * + * @param AutomaticPixCharge $charge + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return AutomaticPixCancellation * @throws GatewayException|GatewayNotAvailableException */ public function cancelAutomaticPixScheduledPayment( - AutomaticPixCharge $charge + AutomaticPixCharge $charge, + ?string $idempotencyKey = null ): AutomaticPixCancellation; /** + * Cancela a recorrência inteira. + * + * @param AutomaticPix $automaticPix + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return AutomaticPixCancellation * @throws GatewayException|GatewayNotAvailableException */ public function cancelAutomaticPixRecurrence( - AutomaticPix $automaticPix + AutomaticPix $automaticPix, + ?string $idempotencyKey = null ): AutomaticPixCancellation; /** diff --git a/src/Contracts/CreditCardContract.php b/src/Contracts/CreditCardContract.php index 366b781..9f6123c 100644 --- a/src/Contracts/CreditCardContract.php +++ b/src/Contracts/CreditCardContract.php @@ -13,13 +13,14 @@ interface CreditCardContract /** * Create a credit card * - * @param CreditCard $creditCard + * @param CreditCard $creditCard + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return CreditCard * @throws GatewayException|GatewayNotAvailableException * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException */ - public function createCreditCard(CreditCard $creditCard): CreditCard; + public function createCreditCard(CreditCard $creditCard, ?string $idempotencyKey = null): CreditCard; /** * Get a credit card by its ID @@ -31,7 +32,9 @@ public function getCreditCard(CreditCard $creditCard): CreditCard; /** * Delete a credit card * + * @param CreditCard $creditCard + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @throws GatewayException|GatewayNotAvailableException */ - public function deleteCreditCard(CreditCard $creditCard): void; + public function deleteCreditCard(CreditCard $creditCard, ?string $idempotencyKey = null): void; } diff --git a/src/Contracts/CustomerContract.php b/src/Contracts/CustomerContract.php index cdc67e6..65fa3ae 100644 --- a/src/Contracts/CustomerContract.php +++ b/src/Contracts/CustomerContract.php @@ -15,11 +15,12 @@ interface CustomerContract * Create a new customer and return the customer * * @param Customer $customer + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return Customer * @throws GatewayException|GatewayNotAvailableException */ - public function createCustomer(Customer $customer): Customer; + public function createCustomer(Customer $customer, ?string $idempotencyKey = null): Customer; /** * Return one customer based on the customer ID @@ -35,18 +36,20 @@ public function getCustomer(Customer $customer): Customer; * Update an existing customer * * @param Customer $customer + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return Customer * @throws GatewayException|GatewayNotAvailableException */ - public function updateCustomer(Customer $customer): Customer; + public function updateCustomer(Customer $customer, ?string $idempotencyKey = null): Customer; /** * Set the customer's default card * * @param \Potelo\MultiPayment\Models\Customer $customer * @param string $cardId + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return \Potelo\MultiPayment\Models\Customer */ - public function setCustomerDefaultCard(Customer $customer, string $cardId): Customer; + public function setCustomerDefaultCard(Customer $customer, string $cardId, ?string $idempotencyKey = null): Customer; } diff --git a/src/Contracts/IdempotencyStore.php b/src/Contracts/IdempotencyStore.php new file mode 100644 index 0000000..814aa5c --- /dev/null +++ b/src/Contracts/IdempotencyStore.php @@ -0,0 +1,38 @@ +invoice`; the given model is updated in place. * * @param Invoice $invoice + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return Refund * @throws GatewayException * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException */ - public function refundInvoice(Invoice $invoice): Refund; - - /** - * String representation of the gateway - * - * @return string - */ + public function refundInvoice(Invoice $invoice, ?string $idempotencyKey = null): Refund; /** * Charge an invoice with a credit card * * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return \Potelo\MultiPayment\Models\Invoice * @throws \Potelo\MultiPayment\Exceptions\ChargingException * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException */ - public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice; + public function chargeInvoiceWithCreditCard(Invoice $invoice, ?string $idempotencyKey = null): Invoice; /** * Duplicate an invoice @@ -74,18 +71,25 @@ public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice; * @param Invoice $invoice * @param \Carbon\Carbon $expiresAt * @param array $gatewayOptions + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException */ - public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gatewayOptions = []): Invoice; + public function duplicateInvoice( + Invoice $invoice, + Carbon $expiresAt, + array $gatewayOptions = [], + ?string $idempotencyKey = null + ): Invoice; /** * Cancel an invoice. * * @param Invoice $invoice + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return Invoice * @throws GatewayException|GatewayNotAvailableException */ - public function cancelInvoice(Invoice $invoice): Invoice; + public function cancelInvoice(Invoice $invoice, ?string $idempotencyKey = null): Invoice; } diff --git a/src/Contracts/PlanContract.php b/src/Contracts/PlanContract.php index 9b25ed9..15a55bf 100644 --- a/src/Contracts/PlanContract.php +++ b/src/Contracts/PlanContract.php @@ -13,11 +13,12 @@ interface PlanContract * Cria o plano no gateway. * * @param Plan $plan + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Plan * @throws GatewayException|GatewayNotAvailableException */ - public function createPlan(Plan $plan): Plan; + public function createPlan(Plan $plan, ?string $idempotencyKey = null): Plan; /** * Busca o plano no gateway pelo id ou pelo identifier. @@ -44,10 +45,11 @@ public function listPlans(int $page = 1, int $limit = 100): array; * Desativa o plano, impedindo novas assinaturas sem afetar as existentes. * * @param Plan $plan + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Plan * @throws GatewayException|GatewayNotAvailableException * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException */ - public function deactivatePlan(Plan $plan): Plan; + public function deactivatePlan(Plan $plan, ?string $idempotencyKey = null): Plan; } diff --git a/src/Contracts/SubscriptionContract.php b/src/Contracts/SubscriptionContract.php index 17977f2..1f389d5 100644 --- a/src/Contracts/SubscriptionContract.php +++ b/src/Contracts/SubscriptionContract.php @@ -15,11 +15,12 @@ interface SubscriptionContract * Cria a assinatura no gateway. * * @param Subscription $subscription + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Subscription * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException */ - public function createSubscription(Subscription $subscription): Subscription; + public function createSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription; /** * Busca a assinatura no gateway pelo id. @@ -39,31 +40,34 @@ public function getSubscription(Subscription $subscription): Subscription; * sem `id` é sempre criação. * * @param Subscription $subscription + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Subscription * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException */ - public function updateSubscription(Subscription $subscription): Subscription; + public function updateSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription; /** * Suspende a cobrança da assinatura, mantendo-a reativável por resumeSubscription(). * * @param Subscription $subscription + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Subscription * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException */ - public function suspendSubscription(Subscription $subscription): Subscription; + public function suspendSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription; /** * Volta a cobrar uma assinatura suspensa. * * @param Subscription $subscription + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Subscription * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException */ - public function resumeSubscription(Subscription $subscription): Subscription; + public function resumeSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription; /** * Cancela a assinatura. @@ -74,12 +78,17 @@ public function resumeSubscription(Subscription $subscription): Subscription; * * @param Subscription $subscription * @param bool $atPeriodEnd + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Subscription * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException */ - public function cancelSubscription(Subscription $subscription, bool $atPeriodEnd = false): Subscription; + public function cancelSubscription( + Subscription $subscription, + bool $atPeriodEnd = false, + ?string $idempotencyKey = null + ): Subscription; /** * Troca o plano da assinatura. @@ -90,6 +99,7 @@ public function cancelSubscription(Subscription $subscription, bool $atPeriodEnd * @param Subscription $subscription * @param string $planId * @param bool $charge + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Subscription * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException @@ -97,7 +107,8 @@ public function cancelSubscription(Subscription $subscription, bool $atPeriodEnd public function changeSubscriptionPlan( Subscription $subscription, string $planId, - bool $charge = true + bool $charge = true, + ?string $idempotencyKey = null ): Subscription; /** diff --git a/src/Enums/Capability.php b/src/Enums/Capability.php index d58e9dc..c3ce6eb 100644 --- a/src/Enums/Capability.php +++ b/src/Enums/Capability.php @@ -48,12 +48,15 @@ enum Capability: string /** Segunda via de uma fatura pendente com nova data de vencimento (`duplicateInvoice`). */ case INVOICE_DUPLICATION = 'invoice_duplication'; - /** Chave de idempotência (`gateway_options['idempotency_key']`) honrada na criação de fatura e no estorno. */ + /** + * Chave de idempotência (`idempotencyKey`) honrada em toda operação de escrita, pelo + * gateway ou pela deduplicação da lib (`IdempotencyStore`). + */ case IDEMPOTENCY = 'idempotency'; /** - * Chave de idempotência honrada em toda operação de escrita, inclusive cliente, cartão, - * cancelamento e troca de plano. + * Chave de idempotência honrada pelo próprio gateway em toda operação de escrita, sem + * depender da deduplicação da lib. */ case IDEMPOTENCY_ALL_ENDPOINTS = 'idempotency_all_endpoints'; diff --git a/src/Exceptions/ConfigurationException.php b/src/Exceptions/ConfigurationException.php index 02c613f..b86841b 100644 --- a/src/Exceptions/ConfigurationException.php +++ b/src/Exceptions/ConfigurationException.php @@ -41,4 +41,33 @@ public static function GatewayNotFound(string $gateway): self { return new static("Gateway class [{$gateway}] not found."); } -} \ No newline at end of file + + /** + * Nenhuma `IdempotencyStore` registrada no container, e a operação recebeu uma chave de + * idempotência num endpoint que o gateway não deduplica sozinho. + * + * @return self + */ + public static function IdempotencyStoreNotConfigured(): self + { + return new static( + 'Nenhuma IdempotencyStore registrada no container: registre o MultiPaymentServiceProvider ' + . '(que usa o cache do Laravel) ou faça bind de Potelo\\MultiPayment\\Contracts\\IdempotencyStore.' + ); + } + + /** + * O cache store configurado para a `CacheIdempotencyStore` não suporta lock. + * + * @param string $storeClass + * @return self + */ + public static function IdempotencyStoreWithoutLock(string $storeClass): self + { + return new static( + "O cache store [{$storeClass}] não suporta lock; a CacheIdempotencyStore exige um store " + . 'com LockProvider (redis, memcached, database, file, array ou dynamodb). Configure ' + . 'multi-payment.idempotency.cache_store com um deles.' + ); + } +} diff --git a/src/Exceptions/IdempotencyConflictException.php b/src/Exceptions/IdempotencyConflictException.php index 579fd7e..55a553f 100644 --- a/src/Exceptions/IdempotencyConflictException.php +++ b/src/Exceptions/IdempotencyConflictException.php @@ -3,11 +3,69 @@ namespace Potelo\MultiPayment\Exceptions; /** - * A chave de idempotência já foi usada com outro payload, ou a requisição original com a - * mesma chave ainda está em andamento (409 na Iugu, `idempotency_error` na Stripe). Não - * repita com a mesma chave e outro conteúdo; consulte o resultado da primeira requisição ou - * use uma chave nova. + * A chave de idempotência já foi usada, ou a requisição original com a mesma chave ainda está + * em andamento (409 na Iugu, `idempotency_error` na Stripe, lock da `IdempotencyStore` + * ocupado). Não repita com a mesma chave e outro conteúdo; consulte o resultado da primeira + * requisição (`resourceId`, quando o gateway o informa) ou use uma chave nova. */ class IdempotencyConflictException extends GatewayException { + /** + * Id do recurso criado pela primeira requisição com a chave, quando o gateway o informa na + * resposta de conflito (a Iugu o devolve em `resource_id` para fatura e cobrança; para + * cliente e assinatura responde `processing`, que fica nulo aqui). + * + * @var string|null + */ + public ?string $resourceId = null; + + /** + * Cria a exceção com o id do recurso original informado pelo gateway. + * + * @param string $message + * @param mixed $errors corpo de erro do gateway, como em `GatewayException` + * @param \Throwable|null $previous + * @param int|null $httpStatus + * @param string|null $resourceId + * @return static + */ + public static function withResourceId( + string $message, + $errors = null, + ?\Throwable $previous = null, + ?int $httpStatus = null, + ?string $resourceId = null + ): static { + $exception = new static($message, $errors, $previous, $httpStatus); + $exception->resourceId = $resourceId; + + return $exception; + } + + /** + * A chave já foi usada, na `IdempotencyStore` da lib, numa operação diferente desta. + * + * @param string $key + * @return static + */ + public static function reusedOnAnotherOperation(string $key): static + { + return new static( + "A chave de idempotência [{$key}] já foi usada em outra operação; use uma chave por operação." + ); + } + + /** + * Outra execução com a mesma chave está em andamento na `IdempotencyStore` da lib. + * + * @param string $key + * @return static + */ + public static function concurrent(string $key): static + { + return new static( + "Outra requisição com a chave de idempotência [{$key}] está em andamento; " + . 'aguarde o resultado dela em vez de repetir.' + ); + } } diff --git a/src/Facades/MultiPayment.php b/src/Facades/MultiPayment.php index 94bbf7f..831e97c 100644 --- a/src/Facades/MultiPayment.php +++ b/src/Facades/MultiPayment.php @@ -11,7 +11,7 @@ /** - * @method static Invoice charge(array $attributes) + * @method static Invoice charge(array $attributes, ?string $idempotencyKey = null) * @method static InvoiceBuilder newInvoice() * @method static CustomerBuilder newCustomer() * @method static CreditCardBuilder newCreditCard() @@ -20,21 +20,21 @@ * @method static \Potelo\MultiPayment\Models\Plan[] listPlans(int $page = 1, int $limit = 100) * @method static Invoice getInvoice(string $id) * @method static \Potelo\MultiPayment\Models\Customer getCustomer(string $id) - * @method static \Potelo\MultiPayment\Models\Refund refundInvoice(string $id, ?int $partialValueCents = null) - * @method static Invoice duplicateInvoice(Invoice|string $invoice, \Carbon\Carbon $expiresAt, array $gatewayOptions = []) + * @method static \Potelo\MultiPayment\Models\Refund refundInvoice(string $id, ?int $partialValueCents = null, ?string $idempotencyKey = null) + * @method static Invoice duplicateInvoice(Invoice|string $invoice, \Carbon\Carbon $expiresAt, array $gatewayOptions = [], ?string $idempotencyKey = null) * @method static CreditCard getCard(string $customerId, string $creditCardId) - * @method static void deleteCard(string $customerId, string $creditCardId) + * @method static void deleteCard(string $customerId, string $creditCardId, ?string $idempotencyKey = null) * @method static \Potelo\MultiPayment\MultiPayment setGateway($gateway) * @method static \Potelo\MultiPayment\Contracts\GatewayContract gateway($gateway = null) * @method static bool supports(\Potelo\MultiPayment\Enums\Capability $capability, $gateway = null) * @method static \Potelo\MultiPayment\Enums\Capability[] capabilities($gateway = null) * @method static \Potelo\MultiPayment\Enums\Capability[] notYetImplemented($gateway = null) - * @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 Invoice chargeInvoiceWithCreditCard($invoice, ?string $creditCardToken = null, ?string $creditCardId = null, ?string $idempotencyKey = null) + * @method static \Potelo\MultiPayment\Models\Customer setDefaultCard(string $customerId, string $creditCardId, ?string $idempotencyKey = null) + * @method static Invoice cancelInvoice(Invoice|string $invoice, ?string $idempotencyKey = null) + * @method static Invoice rescheduleAutomaticPixPayment(Invoice|string $invoice, ?string $idempotencyKey = null) + * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation cancelAutomaticPixRecurrence(\Potelo\MultiPayment\Models\AutomaticPix|string $automaticPix, ?string $idempotencyKey = null) + * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation cancelAutomaticPixScheduledPayment(\Potelo\MultiPayment\Models\AutomaticPixCharge|string $charge, ?string $endToEndId = null, ?string $idempotencyKey = null) * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation getAutomaticPixCancellation(\Potelo\MultiPayment\Models\AutomaticPixCancellation|string $cancellation, ?string $cancellationId = null) * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation[] listAutomaticPixCancellations(\Potelo\MultiPayment\Models\AutomaticPix|string $automaticPix, int $page = 1, int $limit = 100) */ diff --git a/src/Gateways/Concerns/ResolvesIdempotencyKey.php b/src/Gateways/Concerns/ResolvesIdempotencyKey.php new file mode 100644 index 0000000..3118730 --- /dev/null +++ b/src/Gateways/Concerns/ResolvesIdempotencyKey.php @@ -0,0 +1,75 @@ +gatewayOptions[self::LEGACY_IDEMPOTENCY_OPTION] + ?? $extraOptions[self::LEGACY_IDEMPOTENCY_OPTION] + ?? null; + + if (!is_null($legacy)) { + trigger_error( + "gateway_options['idempotency_key'] está obsoleto desde 2026-09-02; passe idempotencyKey como argumento da operação", + E_USER_DEPRECATED + ); + } + + if (!is_null($idempotencyKey)) { + return $idempotencyKey; + } + + return is_scalar($legacy) && (string) $legacy !== '' ? (string) $legacy : null; + } + + /** + * Opções do gateway sem a chave antiga de idempotência, para ela não ir no corpo da + * requisição. + * + * @param array $gatewayOptions + * @return array + */ + protected static function withoutIdempotencyKey(array $gatewayOptions): array + { + unset($gatewayOptions[self::LEGACY_IDEMPOTENCY_OPTION]); + + return $gatewayOptions; + } + + /** + * Chave derivada `{chave}:{sufixo}` para uma requisição secundária da mesma operação (o + * cartão salvo antes da cobrança, a remoção de itens antes do update). A derivação é + * determinística, então um retry reproduz as mesmas chaves. + * + * @param string|null $idempotencyKey + * @param string $suffix + * @return string|null nulo quando não há chave + */ + protected static function derivedIdempotencyKey(?string $idempotencyKey, string $suffix): ?string + { + return IdempotencyKey::derive($idempotencyKey, $suffix); + } +} diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index 87be662..83d23e0 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -3,11 +3,9 @@ namespace Potelo\MultiPayment\Gateways; use Iugu; -use Iugu_Customer; +use APIResource; use Carbon\Carbon; use Iugu_APIRequest; -use Iugu_PaymentToken; -use Iugu_PaymentMethod; use IuguObjectNotFound; use Potelo\MultiPayment\Models\Pix; use Illuminate\Support\Facades\Config; @@ -33,10 +31,13 @@ use Potelo\MultiPayment\Enums\PlanInterval; use Potelo\MultiPayment\Enums\DeclineCode; use Potelo\MultiPayment\Helpers\LogHelper; +use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Contracts\PlanContract; use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Contracts\IdempotencyStore; use Potelo\MultiPayment\Contracts\SubscriptionContract; use Potelo\MultiPayment\Gateways\Concerns\ChecksCapabilities; +use Potelo\MultiPayment\Gateways\Concerns\ResolvesIdempotencyKey; use Potelo\MultiPayment\Gateways\Iugu\DeclineCodes as IuguDeclineCodes; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; @@ -54,6 +55,7 @@ class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract { use ChecksCapabilities; + use ResolvesIdempotencyKey; private const STATUS_PENDING = 'pending'; private const STATUS_PAID = 'paid'; @@ -76,15 +78,26 @@ class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract /** Prazo, em dias após o pagamento, em que a Iugu ainda aceita estorno pela API. */ private const REFUND_WINDOW_DAYS = 90; + /** Prefixo das chaves deste driver na `IdempotencyStore`. */ + private const IDEMPOTENCY_STORE_PREFIX = 'iugu:'; + private Iugu_APIRequest $apiRequest; + private ?IdempotencyStore $idempotencyStore; + /** - * Set iugu api key. + * Configura a chave de API da Iugu e o requester HTTP. Sem requester, usa o compartilhado + * do SDK (`APIResource::API()`); sem store, a `IdempotencyStore` registrada no container é + * resolvida na primeira operação que precisar dela. + * + * @param Iugu_APIRequest|null $apiRequest + * @param IdempotencyStore|null $idempotencyStore */ - public function __construct(?Iugu_APIRequest $apiRequest = null) + public function __construct(?Iugu_APIRequest $apiRequest = null, ?IdempotencyStore $idempotencyStore = null) { Iugu::setApiKey(Config::get('multi-payment.gateways.iugu.api_key')); - $this->apiRequest = $apiRequest ?? new Iugu_APIRequest(); + $this->apiRequest = $apiRequest ?? APIResource::API(); + $this->idempotencyStore = $idempotencyStore; } /** @@ -102,6 +115,7 @@ public function capabilities(): array Capability::INSTALLMENTS, Capability::PARTIAL_REFUND_CARD, Capability::INVOICE_DUPLICATION, + Capability::IDEMPOTENCY, Capability::SUBSCRIPTIONS, Capability::PLANS, ]; @@ -114,18 +128,24 @@ public function notYetImplemented(): array { return [ Capability::DELAYED_CAPTURE, - Capability::IDEMPOTENCY, Capability::SUBSCRIPTION_CREDITS, ]; } /** * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` de `POST /invoices` ou de + * `POST /charge` (fatura com cartão); o cartão salvo antes da cobrança usa a chave derivada + * `{chave}:card` pela `IdempotencyStore`. Na reutilização da chave, a Iugu responde 409 com + * o id da fatura original, que o driver lê e devolve. + * * @throws ModelAttributeValidationException|ChargingException|UnsupportedOperationException */ - public function createInvoice(Invoice $invoice): Invoice + public function createInvoice(Invoice $invoice, ?string $idempotencyKey = null): Invoice { $this->assertSupportsAll($invoice->requiredCapabilities()); + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); $iuguInvoiceData = []; @@ -174,43 +194,65 @@ public function createInvoice(Invoice $invoice): Invoice ); } - if (!empty($invoice->gatewayOptions)) { - foreach ($invoice->gatewayOptions as $option => $value) { - $iuguInvoiceData[$option] = $value; - } + foreach (self::withoutIdempotencyKey($invoice->gatewayOptions) as $option => $value) { + $iuguInvoiceData[$option] = $value; } if (in_array(PaymentMethod::CREDIT_CARD, $payableWith, true) && !empty($invoice->creditCard)) { if (empty($invoice->creditCard->id)) { - $invoice->creditCard = $this->createCreditCard($invoice->creditCard); + if (empty($invoice->creditCard->customer)) { + $invoice->creditCard->customer = $invoice->customer; + } + $invoice->creditCard = $this->createCreditCard( + $invoice->creditCard, + self::derivedIdempotencyKey($idempotencyKey, 'card') + ); } $iuguInvoiceData['customer_payment_method_id'] = $invoice->creditCard->id; - $iuguInvoice = $this->chargeIuguInvoice($iuguInvoiceData); + $iuguInvoice = $this->chargeIuguInvoice($iuguInvoiceData, $idempotencyKey); } else { - try { - $iuguInvoice = \Iugu_Invoice::create($iuguInvoiceData); - } catch (\Exception $e) { - throw $this->translateIuguException($e, 'creating invoice'); - } - if ($iuguInvoice->errors) { - throw $this->iuguResponseException('Error creating invoice', $iuguInvoice->errors); - } + $iuguInvoice = $this->iuguIdempotentRequest( + 'POST', + Iugu::getBaseURI() . '/invoices', + $iuguInvoiceData, + 'creating invoice', + $idempotencyKey, + true, + fn (string $originalId) => $this->fetchIuguInvoice($originalId, 'getting invoice') + ); } return $this->parseInvoice($iuguInvoice, $invoice); } /** - * Tokeniza os dados crus do cartão na Iugu e devolve o token gerado. + * Lê uma fatura da Iugu pelo id. + * + * @param string $id + * @param string $operation descrição da operação, em inglês, para a mensagem + * @return object|array + * @throws MultiPaymentException + */ + private function fetchIuguInvoice(string $id, string $operation): object|array + { + return $this->iuguRequest('GET', Iugu::getBaseURI() . '/invoices/' . rawurlencode($id), [], $operation); + } + + /** + * Tokeniza os dados crus do cartão na Iugu (`POST /payment_token`, deduplicado pela + * `IdempotencyStore` quando há chave) e devolve o token gerado. * * @param CreditCard $creditCard + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * @return string * @throws GatewayException|GatewayNotAvailableException|AuthenticationException */ - private function createIuguPaymentToken(CreditCard $creditCard): string + private function createIuguPaymentToken(CreditCard $creditCard, ?string $idempotencyKey): string { - try { - $iuguToken = Iugu_PaymentToken::create([ + $iuguToken = $this->iuguIdempotentRequest( + 'POST', + Iugu::getBaseURI() . '/payment_token', + [ 'account_id' => Config::get('multi-payment.gateways.iugu.id'), 'method' => 'credit_card', 'test' => Config::get('multi-payment.environment') != 'production', @@ -222,13 +264,13 @@ private function createIuguPaymentToken(CreditCard $creditCard): string 'month' => $creditCard->month, 'year' => $creditCard->year, ], - ]); - } catch (\Exception $e) { - throw $this->translateIuguException($e, 'creating payment token'); - } + ], + 'creating payment token', + $idempotencyKey + ); - if (!empty($iuguToken->errors) || empty($iuguToken->id)) { - throw $this->iuguResponseException('Error creating payment token', $iuguToken->errors); + if (empty($iuguToken->id)) { + throw $this->iuguResponseException('Error creating payment token', null); } return $iuguToken->id; @@ -309,8 +351,9 @@ private function iuguResponseException(string $message, $errors): MultiPaymentEx * Escolhe a exceção do pacote pelo status HTTP da falha: 401 e 403 `AuthenticationException`, * 5xx `GatewayNotAvailableException`, 400 e 422 `ValidationException` (com os erros por * campo, nos dois formatos que a Iugu usa: objeto por campo ou string), 404 - * `NotFoundException`, 409 `IdempotencyConflictException`, 429 `RateLimitException` (sem - * `retryAfter`: o SDK não expõe cabeçalhos) e o restante `GatewayException`. + * `NotFoundException`, 409 `IdempotencyConflictException`, 429 `RateLimitException` (com + * `retryAfter` lido do cabeçalho `Retry-After` quando a Iugu o envia) e o restante + * `GatewayException`. * * @param string $message * @param string $detail texto da resposta, para a mensagem de autenticação @@ -343,47 +386,86 @@ private function classifyIuguFailure( $httpStatus ), 404 => new NotFoundException($message, $errors, $previous, $httpStatus), - 409 => new IdempotencyConflictException($message, $errors, $previous, $httpStatus), - 429 => new RateLimitException($message, $errors, $previous, $httpStatus), + 409 => IdempotencyConflictException::withResourceId( + $message, + $errors, + $previous, + $httpStatus, + self::iuguConflictResourceId($errors ?? $detail) + ), + 429 => RateLimitException::withRetryAfter($message, $errors, $previous, $httpStatus, $this->lastIuguRetryAfter()), default => new GatewayException($message, $errors, $previous, $httpStatus), }; } /** - * Status HTTP da última resposta que o SDK da Iugu conseguiu decodificar. O SDK só o expõe - * na variável global `$iugu_last_api_response_code`, gravada em toda resposta JSON - * (inclusive as de erro); quando a resposta não é JSON o status vai em `getCode()` da - * exceção e esta leitura não é usada. + * Status HTTP da última resposta recebida pelo requester deste driver (JSON ou não), que o + * SDK grava em `Iugu_APIRequest::$lastResponseCode`. Nulo antes da primeira requisição e + * quando não houve resposta. * * @return int|null */ private function lastIuguHttpStatus(): ?int { - $code = $GLOBALS['iugu_last_api_response_code'] ?? null; + $code = $this->apiRequest->lastResponseCode; return is_int($code) && $code > 0 ? $code : null; } /** - * @inheritDoc + * Segundos do cabeçalho `Retry-After` da última resposta, lidos de + * `Iugu_APIRequest::$lastResponseHeaders`. Nulo quando ausente ou quando não é um inteiro. + * + * @return int|null */ - public function createCustomer(Customer $customer): Customer + private function lastIuguRetryAfter(): ?int { - $iuguCustomerData = $this->customerToIuguData($customer); + $value = $this->apiRequest->lastResponseHeaders['retry-after'] ?? null; + $value = is_array($value) ? reset($value) : $value; - try { - $iuguCustomer = Iugu_Customer::create($iuguCustomerData); - } catch (\Exception $e) { - throw $this->translateIuguException($e, 'creating customer'); - } + return is_numeric($value) ? (int) $value : null; + } - if ($iuguCustomer->errors) { - throw $this->iuguResponseException('Error creating customer', $iuguCustomer->errors); + /** + * Id do recurso original numa resposta 409 de chave de idempotência reutilizada. A Iugu + * responde "Essa chave de idempotência já esta em uso: idempotency_key: ..., resource_id: X", + * com o id da fatura em fatura e cobrança e `processing` em cliente e assinatura, que aqui + * vira nulo. + * + * @param mixed $errors corpo de `errors` ou texto da resposta + * @return string|null + */ + private static function iuguConflictResourceId(mixed $errors): ?string + { + $text = is_string($errors) ? $errors : json_encode($errors, JSON_UNESCAPED_UNICODE); + if (!is_string($text) || !preg_match('/resource_id:\s*([A-Za-z0-9_-]+)/', $text, $match)) { + return null; } - $customer->id = $iuguCustomer->id; + return strtolower($match[1]) === 'processing' ? null : $match[1]; + } + + /** + * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` de `POST /customers`. Na + * reutilização da chave a Iugu responde 409 sem o id do cliente original + * (`resource_id: processing`), que chega como `IdempotencyConflictException`. + */ + public function createCustomer(Customer $customer, ?string $idempotencyKey = null): Customer + { + $iuguCustomer = $this->iuguIdempotentRequest( + 'POST', + Iugu::getBaseURI() . '/customers', + $this->customerToIuguData($customer), + 'creating customer', + $this->idempotencyKeyFor($idempotencyKey, $customer), + true + ); + + $customer->id = $iuguCustomer->id ?? null; $customer->gateway = 'iugu'; - $customer->createdAt = new Carbon($iuguCustomer->created_at); + $customer->createdAt = !empty($iuguCustomer->created_at) ? new Carbon($iuguCustomer->created_at) : null; $customer->original = $iuguCustomer; return $customer; @@ -451,26 +533,31 @@ private function multiPaymentToIuguData(array $data): array } /** - * Create a new Credit Card + * @inheritDoc * - * @param CreditCard $creditCard + * Salva o cartão no cliente (`POST /customers/{id}/payment_methods`), tokenizando antes os + * dados crus quando não há token. A Iugu não aceita `Idempotency-Key` nesses endpoints, então + * a chave passa pela `IdempotencyStore`: a informada no `POST` do cartão e `{chave}:token` + * na tokenização. * - * @return CreditCard * @throws GatewayException|ModelAttributeValidationException * @throws GatewayNotAvailableException */ - public function createCreditCard(CreditCard $creditCard): CreditCard + public function createCreditCard(CreditCard $creditCard, ?string $idempotencyKey = null): CreditCard { if (empty($creditCard->customer) || empty($creditCard->customer->id)) { throw ModelAttributeValidationException::required('CreditCard', 'customer'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $creditCard); if (empty($creditCard->token)) { - $creditCard->token = $this->createIuguPaymentToken($creditCard); + $creditCard->token = $this->createIuguPaymentToken( + $creditCard, + self::derivedIdempotencyKey($idempotencyKey, 'token') + ); } $options = [ 'token' => $creditCard->token, - 'customer_id' => $creditCard->customer->id, 'description' => $creditCard->description ?? 'CREDIT CARD', ]; @@ -478,14 +565,13 @@ public function createCreditCard(CreditCard $creditCard): CreditCard $options['set_as_default'] = $creditCard->default; } - try { - $iuguCreditCard = Iugu_PaymentMethod::create($options); - } catch (\Exception $e) { - throw $this->translateIuguException($e, 'creating credit card'); - } - if ($iuguCreditCard->errors) { - throw $this->iuguResponseException('Error creating creditCard: ', $iuguCreditCard->errors); - } + $iuguCreditCard = $this->iuguIdempotentRequest( + 'POST', + Iugu::getBaseURI() . '/customers/' . rawurlencode($creditCard->customer->id) . '/payment_methods', + $options, + 'creating credit card', + $idempotencyKey + ); return $this->parseIuguCard($iuguCreditCard, $creditCard); } @@ -495,10 +581,7 @@ public function createCreditCard(CreditCard $creditCard): CreditCard */ public function getInvoice(Invoice $invoice): Invoice { - $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode((string) $invoice->id); - $iuguInvoice = $this->iuguRequest('GET', $url, [], 'getting invoice'); - - return $this->parseInvoice($iuguInvoice, $invoice); + return $this->parseInvoice($this->fetchIuguInvoice((string) $invoice->id, 'getting invoice'), $invoice); } /** @@ -551,14 +634,41 @@ private static function paymentMethodsToIuguPayableWith(array $paymentMethods): * que a Iugu devolve líquido do já estornado) e status `SUCCEEDED`, porque a Iugu só * responde 200 com o estorno feito. * + * A Iugu não aceita `Idempotency-Key` no estorno, então com chave a operação inteira + * (leitura prévia, guardas e `POST /refund`) passa pela `IdempotencyStore`: a chamada + * seguinte com a mesma chave devolve o `Refund` da primeira sem reler a fatura, que a essa + * altura já estaria estornada e faria a guarda recusar o retry. + * * @throws ModelAttributeValidationException|RefundNotSupportedException */ - public function refundInvoice(Invoice $invoice): Refund + public function refundInvoice(Invoice $invoice, ?string $idempotencyKey = null): Refund { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); + + if (is_null($idempotencyKey)) { + return $this->performIuguRefund($invoice); + } + return $this->rememberIuguOperation( + $idempotencyKey, + 'POST ' . Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/refund', + fn () => $this->performIuguRefund($invoice) + ); + } + + /** + * Lê a fatura quando o model não traz o que as guardas precisam, aplica as guardas e faz o + * `POST /refund`, devolvendo o `Refund` montado pela lib. + * + * @param Invoice $invoice + * @return Refund + * @throws RefundNotSupportedException + */ + private function performIuguRefund(Invoice $invoice): Refund + { // guardado antes da leitura: parseInvoice() sobrescreve refundedAmount com o já estornado $requestedAmount = $invoice->refundedAmount ?: null; @@ -653,50 +763,63 @@ private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmou /** * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore` (a Iugu não aceita o cabeçalho + * neste endpoint). */ - public function cancelInvoice(Invoice $invoice): Invoice + public function cancelInvoice(Invoice $invoice, ?string $idempotencyKey = null): Invoice { - $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/cancel'; - - try { - $response = $this->apiRequest->request('PUT', $url); - } catch (\Exception $e) { - throw $this->translateIuguException($e, 'cancelling invoice'); - } + $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode((string) $invoice->id) . '/cancel'; - if (!empty($response->errors)) { - throw $this->iuguResponseException('Error cancelling invoice', (array) $response->errors); - } + $response = $this->iuguIdempotentRequest( + 'PUT', + $url, + [], + 'cancelling invoice', + $this->idempotencyKeyFor($idempotencyKey, $invoice) + ); return $this->parseInvoice($response, $invoice); } /** * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore` (a Iugu não aceita o cabeçalho + * neste endpoint). */ - public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gatewayOptions = []): Invoice - { + public function duplicateInvoice( + Invoice $invoice, + Carbon $expiresAt, + array $gatewayOptions = [], + ?string $idempotencyKey = null + ): Invoice { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice, $gatewayOptions); - $params = array_merge($gatewayOptions, [ + $params = array_merge(self::withoutIdempotencyKey($gatewayOptions), [ 'due_date' => $expiresAt->format('Y-m-d'), ]); - // request cru em vez de Iugu_Invoice::duplicate(): o SDK engole a exceção e devolve false - $iuguInvoice = $this->iuguRequest( + $iuguInvoice = $this->iuguIdempotentRequest( 'POST', Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/duplicate', $params, - 'duplicating invoice' + 'duplicating invoice', + $idempotencyKey ); return $this->parseInvoice($iuguInvoice); } - /** @inheritDoc */ - public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`. + */ + public function rescheduleAutomaticPixPayment(Invoice $invoice, ?string $idempotencyKey = null): Invoice { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); @@ -704,7 +827,13 @@ public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/reschedule_automatic_pix_payment'; - $response = $this->iuguRequest('POST', $url, [], 'rescheduling automatic pix payment'); + $response = $this->iuguIdempotentRequest( + 'POST', + $url, + [], + 'rescheduling automatic pix payment', + $this->idempotencyKeyFor($idempotencyKey, $invoice) + ); if (!empty($response->id) && !empty($response->status) && isset($response->total_cents)) { return $this->parseInvoice($response, $invoice); @@ -716,9 +845,14 @@ public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice return $invoice; } - /** @inheritDoc */ + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`. + */ public function cancelAutomaticPixRecurrence( - AutomaticPix $automaticPix + AutomaticPix $automaticPix, + ?string $idempotencyKey = null ): AutomaticPixCancellation { if (empty($automaticPix->id)) { throw ModelAttributeValidationException::required('AutomaticPix', 'id'); @@ -726,7 +860,13 @@ public function cancelAutomaticPixRecurrence( $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrences/' . rawurlencode($automaticPix->id) . '/cancel'; - $response = $this->iuguRequest('PUT', $url, [], 'cancelling automatic pix recurrence'); + $response = $this->iuguIdempotentRequest( + 'PUT', + $url, + [], + 'cancelling automatic pix recurrence', + $this->idempotencyKeyFor($idempotencyKey, $automaticPix) + ); $cancellation = $this->parseAutomaticPixCancellation($response); $cancellation->recurrenceId = $automaticPix->id; @@ -735,9 +875,14 @@ public function cancelAutomaticPixRecurrence( return $cancellation; } - /** @inheritDoc */ + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`. + */ public function cancelAutomaticPixScheduledPayment( - AutomaticPixCharge $charge + AutomaticPixCharge $charge, + ?string $idempotencyKey = null ): AutomaticPixCancellation { if (empty($charge->id)) { throw ModelAttributeValidationException::required('AutomaticPixCharge', 'id'); @@ -751,11 +896,12 @@ public function cancelAutomaticPixScheduledPayment( 'end_to_end_id' => $charge->endToEndId, ], '', '&', PHP_QUERY_RFC3986); $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrence_payments/cancel?' . $query; - $response = $this->iuguRequest( + $response = $this->iuguIdempotentRequest( 'POST', $url, [], - 'cancelling automatic pix scheduled payment' + 'cancelling automatic pix scheduled payment', + $this->idempotencyKeyFor($idempotencyKey, $charge) ); $cancellation = $this->parseAutomaticPixCancellation($response); @@ -933,16 +1079,26 @@ private function parseAutomaticPixCharge( } /** - * Perform a raw Iugu request while preserving the package exception contract. + * Executa uma requisição à Iugu pelo requester do driver e traduz a falha (exceção do SDK, + * corpo com `errors` ou `success` falso) para a hierarquia do pacote. + * + * @param string $method + * @param string $url + * @param array $data + * @param string $operation descrição da operação, em inglês, para a mensagem + * @param array $headers cabeçalhos extras, no formato 'Nome: valor' + * @return object|array + * @throws MultiPaymentException */ private function iuguRequest( string $method, string $url, array $data, - string $operation + string $operation, + array $headers = [] ): object|array { try { - $response = $this->apiRequest->request($method, $url, $data); + $response = $this->apiRequest->request($method, $url, $data, $headers); } catch (\Exception $e) { throw $this->translateIuguException($e, $operation); } @@ -958,6 +1114,99 @@ private function iuguRequest( return $response; } + /** + * Executa uma requisição de escrita com chave de idempotência. Nos endpoints em que a Iugu + * aceita o cabeçalho (`$nativeSupport`: criar fatura, cliente, assinatura e cobrança direta) + * a chave vai em `Idempotency-Key`; nos demais, a requisição passa pela `IdempotencyStore`, + * que devolve a resposta guardada nas chamadas seguintes com a mesma chave. Sem chave, é + * uma requisição comum. + * + * Na reutilização de uma chave, a Iugu responde 409 com o id do recurso original em + * `resource_id` (fatura e cobrança); com `$fetchOriginal`, o driver lê esse recurso e o + * devolve no lugar da exceção, para a segunda chamada ter o mesmo resultado da primeira. + * + * @param string $method + * @param string $url + * @param array $data + * @param string $operation descrição da operação, em inglês, para a mensagem + * @param string|null $idempotencyKey + * @param bool $nativeSupport a Iugu aceita `Idempotency-Key` neste endpoint + * @param \Closure|null $fetchOriginal recebe o `resource_id` do 409 e devolve o recurso original + * @return object|array + * @throws MultiPaymentException + */ + private function iuguIdempotentRequest( + string $method, + string $url, + array $data, + string $operation, + ?string $idempotencyKey, + bool $nativeSupport = false, + ?\Closure $fetchOriginal = null + ): object|array { + if (is_null($idempotencyKey)) { + return $this->iuguRequest($method, $url, $data, $operation); + } + + if ($nativeSupport) { + try { + return $this->iuguRequest($method, $url, $data, $operation, ['Idempotency-Key: ' . $idempotencyKey]); + } catch (IdempotencyConflictException $e) { + if (is_null($fetchOriginal) || is_null($e->resourceId)) { + throw $e; + } + + return $fetchOriginal($e->resourceId); + } + } + + return $this->rememberIuguOperation( + $idempotencyKey, + $method . ' ' . $url, + fn () => $this->iuguRequest($method, $url, $data, $operation) + ); + } + + /** + * Executa a operação uma única vez por chave pela `IdempotencyStore` e devolve o resultado + * guardado nas chamadas seguintes. O resultado é guardado junto com a assinatura da + * operação (método e url, ou nome da operação); a mesma chave reaparecendo em outra + * operação lança `IdempotencyConflictException` em vez de devolver o resultado errado. + * + * @param string $idempotencyKey + * @param string $fingerprint identifica a operação que a chave cobre + * @param \Closure $operation + * @return mixed + * @throws IdempotencyConflictException + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + private function rememberIuguOperation(string $idempotencyKey, string $fingerprint, \Closure $operation): mixed + { + $stored = $this->idempotencyStore()->remember( + self::IDEMPOTENCY_STORE_PREFIX . $idempotencyKey, + fn () => ['fingerprint' => $fingerprint, 'result' => $operation()], + ConfigurationHelper::idempotencyTtl() + ); + + if (!is_array($stored) || ($stored['fingerprint'] ?? null) !== $fingerprint) { + throw IdempotencyConflictException::reusedOnAnotherOperation($idempotencyKey); + } + + return $stored['result']; + } + + /** + * `IdempotencyStore` deste driver: a injetada no construtor ou a registrada no container, + * resolvida na primeira vez que uma operação precisa dela. + * + * @return IdempotencyStore + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + private function idempotencyStore(): IdempotencyStore + { + return $this->idempotencyStore ??= ConfigurationHelper::resolveIdempotencyStore(); + } + /** * @return array */ @@ -1045,22 +1294,24 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice { $invoice = $invoice ?? new Invoice(); - $invoice->id = $iuguInvoice->id; + // leituras com `??`: a resposta é stdClass do request cru, que avisa em campo ausente + $iuguInvoice = (object) $iuguInvoice; + $invoice->id = $iuguInvoice->id ?? null; $invoice->gateway = 'iugu'; - $invoice->status = self::iuguStatusToMultiPayment($iuguInvoice->status); - $invoice->amount = $iuguInvoice->total_cents; - $invoice->paidAt = $iuguInvoice->paid_at ? new Carbon($iuguInvoice->paid_at) : null; - $invoice->url = $iuguInvoice->secure_url; + $invoice->status = self::iuguStatusToMultiPayment($iuguInvoice->status ?? null); + $invoice->amount = $iuguInvoice->total_cents ?? null; + $invoice->paidAt = !empty($iuguInvoice->paid_at) ? new Carbon($iuguInvoice->paid_at) : null; + $invoice->url = $iuguInvoice->secure_url ?? null; $invoice->fee = $iuguInvoice->taxes_paid_cents ?? null; $invoice->original = $iuguInvoice; - $invoice->createdAt = new Carbon($iuguInvoice->created_at_iso); - $invoice->paidAmount = $iuguInvoice->paid_cents; - $invoice->refundedAmount = $iuguInvoice->refunded_cents; + $invoice->createdAt = !empty($iuguInvoice->created_at_iso) ? new Carbon($iuguInvoice->created_at_iso) : null; + $invoice->paidAmount = $iuguInvoice->paid_cents ?? null; + $invoice->refundedAmount = $iuguInvoice->refunded_cents ?? null; $invoice->refunds = $this->parseRefunds($invoice); $invoice->expiresAt = !empty($iuguInvoice->due_date) ? new Carbon($iuguInvoice->due_date) : null; if (empty($invoice->paymentMethod)) { - $invoice->paymentMethod = $this->iuguToMultiPaymentPaymentMethod($iuguInvoice->payment_method); + $invoice->paymentMethod = $this->iuguToMultiPaymentPaymentMethod($iuguInvoice->payment_method ?? null); } if (!empty($iuguInvoice->payable_with)) { @@ -1071,20 +1322,20 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice $invoice->customer = new Customer(); } - $invoice->customer->id = $iuguInvoice->customer_id; - $invoice->customer->name = $iuguInvoice->customer_name; - $invoice->customer->email = $iuguInvoice->email; - $invoice->customer->phoneNumber = $iuguInvoice->payer_phone; - $invoice->customer->phoneArea = $iuguInvoice->payer_phone_prefix; + $invoice->customer->id = $iuguInvoice->customer_id ?? null; + $invoice->customer->name = $iuguInvoice->customer_name ?? null; + $invoice->customer->email = $iuguInvoice->email ?? null; + $invoice->customer->phoneNumber = $iuguInvoice->payer_phone ?? null; + $invoice->customer->phoneArea = $iuguInvoice->payer_phone_prefix ?? null; $invoice->items = []; - foreach ($iuguInvoice->items as $itemIugu) { + foreach ((array) ($iuguInvoice->items ?? []) as $itemIugu) { $invoiceItem = new InvoiceItem(); $itemIugu = (object) $itemIugu; - $invoiceItem->description = $itemIugu->description; - $invoiceItem->price = $itemIugu->price_cents; - $invoiceItem->quantity = $itemIugu->quantity; + $invoiceItem->description = $itemIugu->description ?? null; + $invoiceItem->price = $itemIugu->price_cents ?? null; + $invoiceItem->quantity = $itemIugu->quantity ?? null; $invoice->items[] = $invoiceItem; } @@ -1093,31 +1344,33 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice $invoice->customer->address = new Address(); } $invoice->customer->address->zipCode = $iuguInvoice->payer_address_zip_code; - $invoice->customer->address->street = $iuguInvoice->payer_address_street; - $invoice->customer->address->number = $iuguInvoice->payer_address_number; - $invoice->customer->address->district = $iuguInvoice->payer_address_district; - $invoice->customer->address->city = $iuguInvoice->payer_address_city; - $invoice->customer->address->state = $iuguInvoice->payer_address_state; - $invoice->customer->address->complement = $iuguInvoice->payer_address_complement; - $invoice->customer->address->country = $iuguInvoice->payer_address_country; + $invoice->customer->address->street = $iuguInvoice->payer_address_street ?? null; + $invoice->customer->address->number = $iuguInvoice->payer_address_number ?? null; + $invoice->customer->address->district = $iuguInvoice->payer_address_district ?? null; + $invoice->customer->address->city = $iuguInvoice->payer_address_city ?? null; + $invoice->customer->address->state = $iuguInvoice->payer_address_state ?? null; + $invoice->customer->address->complement = $iuguInvoice->payer_address_complement ?? null; + $invoice->customer->address->country = $iuguInvoice->payer_address_country ?? null; } if (!empty($iuguInvoice->bank_slip)) { if (empty($invoice->bankSlip)) { $invoice->bankSlip = new BankSlip(); } - $invoice->bankSlip->url = $iuguInvoice->secure_url . '.pdf'; - $invoice->bankSlip->number = $iuguInvoice->bank_slip->digitable_line; - $invoice->bankSlip->barcodeData = $iuguInvoice->bank_slip->barcode_data; - $invoice->bankSlip->barcodeImage = $iuguInvoice->bank_slip->barcode; + $bankSlip = (object) $iuguInvoice->bank_slip; + $invoice->bankSlip->url = ($iuguInvoice->secure_url ?? '') . '.pdf'; + $invoice->bankSlip->number = $bankSlip->digitable_line ?? null; + $invoice->bankSlip->barcodeData = $bankSlip->barcode_data ?? null; + $invoice->bankSlip->barcodeImage = $bankSlip->barcode ?? null; } if (!empty($iuguInvoice->pix)) { if (empty($invoice->pix)) { $invoice->pix = new Pix(); } - $invoice->pix->qrCodeImageUrl = $iuguInvoice->pix->qrcode; - $invoice->pix->qrCodeText = $iuguInvoice->pix->qrcode_text; + $pix = (object) $iuguInvoice->pix; + $invoice->pix->qrCodeImageUrl = $pix->qrcode ?? null; + $invoice->pix->qrCodeText = $pix->qrcode_text ?? null; } if (!empty($iuguInvoice->automatic_pix)) { @@ -1141,15 +1394,18 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice if (empty($invoice->creditCard)) { $invoice->creditCard = new CreditCard(); } + $transaction = (object) $iuguInvoice->credit_card_transaction; $invoice->creditCard->brand = $iuguInvoice->credit_card_brand ?? null; - $invoice->creditCard->lastDigits = $iuguInvoice->credit_card_last_4 ?? $iuguInvoice->credit_card_transaction->last4; + $invoice->creditCard->lastDigits = $iuguInvoice->credit_card_last_4 ?? $transaction->last4 ?? null; $holderName = null; - foreach ($iuguInvoice->variables as $iuguInvoiceVariable) { - if ($iuguInvoiceVariable->variable == 'payment_data.holder_name') { - $holderName = $iuguInvoiceVariable->value; - } else if (empty($invoice->creditCard->lastDigits) && $iuguInvoiceVariable->variable == 'payment_data.display_number') { - $invoice->creditCard->lastDigits = substr($iuguInvoiceVariable->value, -4); + foreach ((array) ($iuguInvoice->variables ?? []) as $iuguInvoiceVariable) { + $iuguInvoiceVariable = (object) $iuguInvoiceVariable; + $variableName = $iuguInvoiceVariable->variable ?? null; + if ($variableName == 'payment_data.holder_name') { + $holderName = $iuguInvoiceVariable->value ?? null; + } else if (empty($invoice->creditCard->lastDigits) && $variableName == 'payment_data.display_number') { + $invoice->creditCard->lastDigits = substr((string) ($iuguInvoiceVariable->value ?? ''), -4); } } @@ -1173,7 +1429,7 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException */ - public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice + public function chargeInvoiceWithCreditCard(Invoice $invoice, ?string $idempotencyKey = null): Invoice { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); @@ -1196,38 +1452,56 @@ public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice $iuguInvoiceData['token'] = $invoice->creditCard->token; } - $iuguInvoice = $this->chargeIuguInvoice($iuguInvoiceData); + $iuguInvoice = $this->chargeIuguInvoice($iuguInvoiceData, $this->idempotencyKeyFor($idempotencyKey, $invoice)); return $this->parseInvoice($iuguInvoice, $invoice); } /** + * Cobra pela cobrança direta da Iugu (`POST /charge`, com a chave de idempotência no + * cabeçalho `Idempotency-Key`) e lê a fatura cobrada, que a resposta só identifica pelo id. + * Recusa de cartão (`success` falso) vira `ChargingException`; chave reutilizada (409 com + * `resource_id`) devolve a fatura da primeira cobrança. + * * @param array $iuguInvoiceData - * @return mixed + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return object * @throws \Potelo\MultiPayment\Exceptions\ChargingException * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws \Potelo\MultiPayment\Exceptions\AuthenticationException */ - private function chargeIuguInvoice(array $iuguInvoiceData) + private function chargeIuguInvoice(array $iuguInvoiceData, ?string $idempotencyKey): object { + $headers = is_null($idempotencyKey) ? [] : ['Idempotency-Key: ' . $idempotencyKey]; + try { - $iuguCharge = \Iugu_Charge::create($iuguInvoiceData); + $iuguCharge = $this->apiRequest->request('POST', Iugu::getBaseURI() . '/charge', $iuguInvoiceData, $headers); } catch (\Exception $e) { throw $this->translateIuguException($e, 'charging invoice'); } - if ($iuguCharge->errors) { - throw $this->iuguResponseException('Error charging invoice', $iuguCharge->errors); - } elseif (!$iuguCharge->success) { + + $iuguCharge = is_array($iuguCharge) ? (object) $iuguCharge : $iuguCharge; + if (!empty($iuguCharge->errors)) { + $exception = $this->iuguResponseException('Error charging invoice', $iuguCharge->errors); + // chave reutilizada: a Iugu aponta a fatura da primeira cobrança, que é o resultado + if ($exception instanceof IdempotencyConflictException && !is_null($exception->resourceId)) { + return $this->fetchIuguInvoice($exception->resourceId, 'getting charged invoice'); + } + + throw $exception; + } + if (empty($iuguCharge->success)) { throw $this->cardDeclined($iuguCharge); } // a cobrança devolve só o id; a leitura da fatura é outra requisição e falha como tal - try { - return $iuguCharge->invoice(); - } catch (\Exception $e) { - throw $this->translateIuguException($e, 'getting charged invoice'); + $invoiceId = $iuguCharge->invoice_id ?? null; + if (empty($invoiceId)) { + throw new GatewayException('Error getting charged invoice: the charge response has no invoice_id'); } + + return $this->fetchIuguInvoice((string) $invoiceId, 'getting charged invoice'); } /** @@ -1269,31 +1543,34 @@ private function cardDeclined(object $iuguCharge): ChargingException */ public function getCustomer(Customer $customer): Customer { - try { - $iuguCustomer = Iugu_Customer::fetch($customer->id); - } catch (\Exception $e) { - throw $this->translateIuguException($e, 'getting customer'); - } - - if (!empty($iuguCustomer->errors)) { - throw $this->iuguResponseException('Error getting customer', $iuguCustomer->errors); - } + $iuguCustomer = $this->iuguRequest( + 'GET', + Iugu::getBaseURI() . '/customers/' . rawurlencode((string) $customer->id), + [], + 'getting customer' + ); return $this->parseCustomer($iuguCustomer, $customer); } - public function updateCustomer(Customer $customer): Customer + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore` (a Iugu não aceita o cabeçalho + * neste endpoint). + */ + public function updateCustomer(Customer $customer, ?string $idempotencyKey = null): Customer { if (empty($customer->id)) { throw ModelAttributeValidationException::required('Customer', 'id'); } - // request cru em vez de Iugu_Customer::save(): o SDK engole a exceção e devolve false - $iuguCustomer = $this->iuguRequest( + $iuguCustomer = $this->iuguIdempotentRequest( 'PUT', Iugu::getBaseURI() . '/customers/' . rawurlencode($customer->id), $this->customerToIuguData($customer), - 'updating customer' + 'updating customer', + $this->idempotencyKeyFor($idempotencyKey, $customer) ); return $this->parseCustomer($iuguCustomer, $customer); @@ -1310,6 +1587,7 @@ public function updateCustomer(Customer $customer): Customer private function parseCustomer($iuguCustomer, ?Customer $customer = null): Customer { $customer = $customer ?? new Customer(); + $iuguCustomer = (object) $iuguCustomer; $valuesInsideCustomVariables = ['birth_date' => null, 'country' => null]; @@ -1402,10 +1680,8 @@ private function customerToIuguData(Customer $customer): array ]; } - if (!empty($customer->gatewayOptions)) { - foreach ($customer->gatewayOptions as $option => $value) { - $iuguCustomerData[$option] = $value; - } + foreach (self::withoutIdempotencyKey($customer->gatewayOptions) as $option => $value) { + $iuguCustomerData[$option] = $value; } if (!empty($customer->defaultCard) && !empty($customer->defaultCard->id)) { @@ -1418,18 +1694,20 @@ private function customerToIuguData(Customer $customer): array /** * @inheritDoc */ - public function setCustomerDefaultCard(Customer $customer, string $cardId): Customer + public function setCustomerDefaultCard(Customer $customer, string $cardId, ?string $idempotencyKey = null): Customer { $customer->defaultCard = new CreditCard(); $customer->defaultCard->id = $cardId; - return $this->updateCustomer($customer); + return $this->updateCustomer($customer, $idempotencyKey); } /** * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`. */ - public function deleteCreditCard(CreditCard $creditCard): void + public function deleteCreditCard(CreditCard $creditCard, ?string $idempotencyKey = null): void { if (empty($creditCard->id)) { throw ModelAttributeValidationException::required('CreditCard', 'id'); @@ -1438,13 +1716,13 @@ public function deleteCreditCard(CreditCard $creditCard): void throw ModelAttributeValidationException::required('CreditCard', 'customer'); } - // request cru em vez de Iugu_PaymentMethod::delete(): o SDK engole a exceção e devolve false - $this->iuguRequest( + $this->iuguIdempotentRequest( 'DELETE', Iugu::getBaseURI() . '/customers/' . rawurlencode($creditCard->customer->id) . '/payment_methods/' . rawurlencode($creditCard->id), [], - 'deleting credit card' + 'deleting credit card', + $this->idempotencyKeyFor($idempotencyKey, $creditCard) ); } @@ -1453,16 +1731,18 @@ public function deleteCreditCard(CreditCard $creditCard): void */ public function getCreditCard(CreditCard $creditCard): CreditCard { - try { - $iuguCustomer = new Iugu_Customer(['id' => $creditCard->customer->id]); - $iuguCreditCard = $iuguCustomer->payment_methods()->fetch($creditCard->id); - } catch (\Exception $e) { - throw $this->translateIuguException($e, 'getting credit card'); - } - if ($iuguCreditCard->errors) { - throw $this->iuguResponseException('Error getting creditCard: ', $iuguCreditCard->errors); + if (empty($creditCard->customer) || empty($creditCard->customer->id)) { + throw ModelAttributeValidationException::required('CreditCard', 'customer'); } + $iuguCreditCard = $this->iuguRequest( + 'GET', + Iugu::getBaseURI() . '/customers/' . rawurlencode($creditCard->customer->id) + . '/payment_methods/' . rawurlencode((string) $creditCard->id), + [], + 'getting credit card' + ); + return $this->parseIuguCard($iuguCreditCard, $creditCard); } @@ -1476,6 +1756,7 @@ private function parseIuguCard(mixed $iuguCreditCard, ?CreditCard $creditCard = if (is_null($creditCard)) { $creditCard = new CreditCard(); } + $iuguCreditCard = (object) $iuguCreditCard; $creditCard->id = $iuguCreditCard->id ?? null; $creditCard->brand = $iuguCreditCard->data->brand ?? null; @@ -1488,28 +1769,36 @@ private function parseIuguCard(mixed $iuguCreditCard, ?CreditCard $creditCard = $creditCard->firstName = $names[0] ?? null; $creditCard->lastName = $names[array_key_last($names)] ?? null; } - $creditCard->lastDigits = $iuguCreditCard->data->last_digits ?? substr($iuguCreditCard->data->display_number, -4); + $displayNumber = $iuguCreditCard->data->display_number ?? null; + $creditCard->lastDigits = $iuguCreditCard->data->last_digits + ?? (is_string($displayNumber) ? substr($displayNumber, -4) : null); $creditCard->gateway = 'iugu'; $creditCard->original = $iuguCreditCard; - $creditCard->createdAt = new Carbon($iuguCreditCard->created_at_iso) ?? null; + $creditCard->createdAt = !empty($iuguCreditCard->created_at_iso) ? new Carbon($iuguCreditCard->created_at_iso) : null; return $creditCard; } /** * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` de `POST /subscriptions`. Na + * reutilização da chave a Iugu responde 409 sem o id da assinatura original + * (`resource_id: processing`), que chega como `IdempotencyConflictException`. */ - public function createSubscription(Subscription $subscription): Subscription + public function createSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription { $data = array_merge( $this->subscriptionToIuguData($subscription), - $subscription->gatewayOptions + self::withoutIdempotencyKey($subscription->gatewayOptions) ); - $response = $this->iuguRequest( + $response = $this->iuguIdempotentRequest( 'POST', Iugu::getBaseURI() . '/subscriptions', $data, - 'creating subscription' + 'creating subscription', + $this->idempotencyKeyFor($idempotencyKey, $subscription), + true ); return $this->parseIuguSubscription($response, $subscription); @@ -1536,16 +1825,20 @@ public function getSubscription(Subscription $subscription): Subscription /** * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`: a informada no `PUT` da + * atualização e `{chave}:remove` na remoção de subitens que a antecede. */ - public function updateSubscription(Subscription $subscription): Subscription + public function updateSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription { if (empty($subscription->id)) { throw ModelAttributeValidationException::required('Subscription', 'id'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); $data = array_merge( $this->subscriptionToIuguData($subscription, false), - $subscription->gatewayOptions + self::withoutIdempotencyKey($subscription->gatewayOptions) ); $subitems = $data['subitems'] ?? null; unset($data['subitems']); @@ -1561,11 +1854,12 @@ public function updateSubscription(Subscription $subscription): Subscription ); if (!empty($toDestroy)) { - $this->iuguRequest( + $this->iuguIdempotentRequest( 'PUT', $this->subscriptionUrl($subscription->id), ['subitems' => $toDestroy], - 'removing subscription items' + 'removing subscription items', + self::derivedIdempotencyKey($idempotencyKey, 'remove') ); } @@ -1574,11 +1868,12 @@ public function updateSubscription(Subscription $subscription): Subscription } } - $response = $this->iuguRequest( + $response = $this->iuguIdempotentRequest( 'PUT', $this->subscriptionUrl($subscription->id), $data, - 'updating subscription' + 'updating subscription', + $idempotencyKey ); return $this->parseIuguSubscription($response, $subscription); @@ -1586,18 +1881,21 @@ public function updateSubscription(Subscription $subscription): Subscription /** * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`. */ - public function suspendSubscription(Subscription $subscription): Subscription + public function suspendSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription { if (empty($subscription->id)) { throw ModelAttributeValidationException::required('Subscription', 'id'); } - $response = $this->iuguRequest( + $response = $this->iuguIdempotentRequest( 'POST', $this->subscriptionUrl($subscription->id) . '/suspend', [], - 'suspending subscription' + 'suspending subscription', + $this->idempotencyKeyFor($idempotencyKey, $subscription) ); return $this->parseIuguSubscription($response, $subscription); @@ -1605,18 +1903,21 @@ public function suspendSubscription(Subscription $subscription): Subscription /** * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`. */ - public function resumeSubscription(Subscription $subscription): Subscription + public function resumeSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription { if (empty($subscription->id)) { throw ModelAttributeValidationException::required('Subscription', 'id'); } - $response = $this->iuguRequest( + $response = $this->iuguIdempotentRequest( 'POST', $this->subscriptionUrl($subscription->id) . '/activate', [], - 'resuming subscription' + 'resuming subscription', + $this->idempotencyKeyFor($idempotencyKey, $subscription) ); return $this->parseIuguSubscription($response, $subscription); @@ -1625,33 +1926,43 @@ public function resumeSubscription(Subscription $subscription): Subscription /** * @inheritDoc */ - public function cancelSubscription(Subscription $subscription, bool $atPeriodEnd = false): Subscription - { + public function cancelSubscription( + Subscription $subscription, + bool $atPeriodEnd = false, + ?string $idempotencyKey = null + ): Subscription { if ($atPeriodEnd) { $this->assertSupports(Capability::CANCEL_AT_PERIOD_END, 'Suspenda a assinatura na data desejada.'); } - return $this->suspendSubscription($subscription); + return $this->suspendSubscription($subscription, $idempotencyKey); } /** * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore` na requisição que aplica a troca + * (`POST change_plan` ou `PUT`); a releitura da assinatura que segue a troca com cobrança + * não a usa. */ public function changeSubscriptionPlan( Subscription $subscription, string $planId, - bool $charge = true + bool $charge = true, + ?string $idempotencyKey = null ): Subscription { if (empty($subscription->id)) { throw ModelAttributeValidationException::required('Subscription', 'id'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); if ($charge) { - $this->iuguRequest( + $this->iuguIdempotentRequest( 'POST', $this->subscriptionUrl($subscription->id) . '/change_plan/' . rawurlencode($planId), [], - 'changing subscription plan' + 'changing subscription plan', + $idempotencyKey ); $subscription->planId = $planId; @@ -1665,11 +1976,12 @@ public function changeSubscriptionPlan( $data['expires_at'] = $subscription->nextBillingAt->format('Y-m-d'); } - $response = $this->iuguRequest( + $response = $this->iuguIdempotentRequest( 'PUT', $this->subscriptionUrl($subscription->id), $data, - 'changing subscription plan' + 'changing subscription plan', + $idempotencyKey ); return $this->parseIuguSubscription($response, $subscription); @@ -1734,14 +2046,18 @@ public function listSubscriptions(Customer $customer, int $page = 1, int $limit /** * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore` (a Iugu não aceita o cabeçalho + * neste endpoint). */ - public function createPlan(Plan $plan): Plan + public function createPlan(Plan $plan, ?string $idempotencyKey = null): Plan { - $response = $this->iuguRequest( + $response = $this->iuguIdempotentRequest( 'POST', Iugu::getBaseURI() . '/plans', - array_merge($this->planToIuguData($plan), $plan->gatewayOptions), - 'creating plan' + array_merge($this->planToIuguData($plan), self::withoutIdempotencyKey($plan->gatewayOptions)), + 'creating plan', + $this->idempotencyKeyFor($idempotencyKey, $plan) ); return $this->parseIuguPlan($response, $plan); @@ -1797,11 +2113,12 @@ public function listPlans(int $page = 1, int $limit = 100): array * Sempre lança: a Iugu não tem desativação de plano. * * @param Plan $plan + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Plan * @throws UnsupportedOperationException */ - public function deactivatePlan(Plan $plan): Plan + public function deactivatePlan(Plan $plan, ?string $idempotencyKey = null): Plan { throw UnsupportedOperationException::forGateway( $this, diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index a28837e..1a5a3e5 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -18,6 +18,7 @@ use Stripe\Exception\AuthenticationException as StripeAuthenticationException; use Illuminate\Support\Facades\Config; use Potelo\MultiPayment\Models\Pix; +use Potelo\MultiPayment\Models\Model; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Refund; use Potelo\MultiPayment\Models\Address; @@ -35,6 +36,7 @@ use Potelo\MultiPayment\Helpers\LogHelper; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Gateways\Concerns\ChecksCapabilities; +use Potelo\MultiPayment\Gateways\Concerns\ResolvesIdempotencyKey; use Potelo\MultiPayment\Gateways\Stripe\DeclineCodes as StripeDeclineCodes; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; @@ -52,6 +54,7 @@ class StripeGateway implements GatewayContract { use ChecksCapabilities; + use ResolvesIdempotencyKey; /** * Versão da API Stripe usada pelo pacote. Fixada no código (em vez de herdar o default da @@ -135,6 +138,7 @@ public function capabilities(): array Capability::PARTIAL_REFUND_PIX, Capability::INVOICE_DUPLICATION, Capability::IDEMPOTENCY, + Capability::IDEMPOTENCY_ALL_ENDPOINTS, ]; } @@ -148,7 +152,6 @@ public function notYetImplemented(): array Capability::AUTOMATIC_PIX, Capability::MULTIPLE_PAYMENT_METHODS, Capability::DELAYED_CAPTURE, - Capability::IDEMPOTENCY_ALL_ENDPOINTS, Capability::SUBSCRIPTIONS, Capability::PLANS, Capability::PLAN_DEACTIVATION, @@ -161,9 +164,12 @@ public function notYetImplemented(): array /** * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` da criação. */ - public function createCustomer(Customer $customer): Customer + public function createCustomer(Customer $customer, ?string $idempotencyKey = null): Customer { + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $customer); $stripeCustomerData = $this->customerToStripeData($customer); if (!empty($customer->taxDocument)) { @@ -173,8 +179,11 @@ public function createCustomer(Customer $customer): Customer ]]; } - $stripeCustomer = $this->stripeRequest(function () use ($stripeCustomerData) { - return $this->client->customers->create($this->withTaxIdsExpanded($stripeCustomerData)); + $stripeCustomer = $this->stripeRequest(function () use ($stripeCustomerData, $idempotencyKey) { + return $this->client->customers->create( + $this->withTaxIdsExpanded($stripeCustomerData), + self::stripeOptions($idempotencyKey) + ); }); return $this->parseCustomer($stripeCustomer, $customer); @@ -182,23 +191,33 @@ public function createCustomer(Customer $customer): Customer /** * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` do update; a criação de um tax + * id novo, quando o documento mudou, usa a chave derivada `{chave}:tax_id`. + * * @throws ModelAttributeValidationException */ - public function updateCustomer(Customer $customer): Customer + public function updateCustomer(Customer $customer, ?string $idempotencyKey = null): Customer { if (empty($customer->id)) { throw ModelAttributeValidationException::required('Customer', 'id'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $customer); $stripeCustomerData = $this->customerToStripeData($customer); - $stripeCustomer = $this->stripeRequest(function () use ($customer, $stripeCustomerData) { + $stripeCustomer = $this->stripeRequest(function () use ($customer, $stripeCustomerData, $idempotencyKey) { $stripeCustomer = $this->client->customers->update( $customer->id, - $this->withTaxIdsExpanded($stripeCustomerData) + $this->withTaxIdsExpanded($stripeCustomerData), + self::stripeOptions($idempotencyKey) ); - if ($this->syncCustomerTaxDocument($stripeCustomer, $customer->taxDocument)) { + if ($this->syncCustomerTaxDocument( + $stripeCustomer, + $customer->taxDocument, + self::derivedIdempotencyKey($idempotencyKey, 'tax_id') + )) { $stripeCustomer = $this->client->customers->retrieve( $customer->id, ['expand' => ['tax_ids']] @@ -227,12 +246,12 @@ public function getCustomer(Customer $customer): Customer * @inheritDoc * @throws ModelAttributeValidationException */ - public function setCustomerDefaultCard(Customer $customer, string $cardId): Customer + public function setCustomerDefaultCard(Customer $customer, string $cardId, ?string $idempotencyKey = null): Customer { $customer->defaultCard = new CreditCard(); $customer->defaultCard->id = $cardId; - return $this->updateCustomer($customer); + return $this->updateCustomer($customer, $idempotencyKey); } /** @@ -322,10 +341,8 @@ private function customerToStripeData(Customer $customer): array $stripeCustomerData['invoice_settings']['default_payment_method'] = $customer->defaultCard->id; } - if (!empty($customer->gatewayOptions)) { - foreach ($customer->gatewayOptions as $option => $value) { - $stripeCustomerData[$option] = $value; - } + foreach (self::withoutIdempotencyKey($customer->gatewayOptions) as $option => $value) { + $stripeCustomerData[$option] = $value; } return $stripeCustomerData; @@ -421,11 +438,15 @@ private function parseCustomer(StripeCustomer $stripeCustomer, ?Customer $custom * * @param \Stripe\Customer $stripeCustomer customer com `tax_ids` expandido * @param string|null $taxDocument + * @param string|null $idempotencyKey chave da criação do tax id novo * @return bool true se algum tax id foi criado/excluído (o customer precisa de refetch) * @throws ApiErrorException */ - private function syncCustomerTaxDocument(StripeCustomer $stripeCustomer, ?string $taxDocument): bool - { + private function syncCustomerTaxDocument( + StripeCustomer $stripeCustomer, + ?string $taxDocument, + ?string $idempotencyKey = null + ): bool { if (empty($taxDocument)) { return false; } @@ -450,10 +471,18 @@ private function syncCustomerTaxDocument(StripeCustomer $stripeCustomer, ?string $this->client->customers->createTaxId($stripeCustomer->id, [ 'type' => $this->taxDocumentType($taxDocument), 'value' => $taxDocument, - ]); + ], self::stripeOptions($idempotencyKey)); } foreach ($staleTaxIds as $staleTaxIdId) { - $this->client->customers->deleteTaxId($stripeCustomer->id, $staleTaxIdId); + try { + $this->client->customers->deleteTaxId($stripeCustomer->id, $staleTaxIdId); + } catch (InvalidRequestException $e) { + // já excluído por uma tentativa anterior (a Stripe repete o update com o + // tax_ids antigo quando a chave é a mesma): o objetivo já foi atingido + if (($e->getError()?->code ?? null) !== 'resource_missing') { + throw $e; + } + } } return !$alreadyPresent || !empty($staleTaxIds); @@ -650,17 +679,22 @@ private static function retryAfterFromHeaders(?iterable $headers): ?int /** * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` da criação do PaymentIntent; + * o cartão salvo antes da cobrança usa a chave derivada `{chave}:card`. + * * @throws ChargingException|ModelAttributeValidationException|UnsupportedOperationException */ - public function createInvoice(Invoice $invoice): Invoice + public function createInvoice(Invoice $invoice, ?string $idempotencyKey = null): Invoice { $this->assertSupportsAll($invoice->requiredCapabilities()); + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); $paymentMethod = $this->invoicePaymentMethod($invoice); return match ($paymentMethod) { - PaymentMethod::CREDIT_CARD => $this->createCreditCardInvoice($invoice), - PaymentMethod::PIX => $this->createPixInvoice($invoice), + PaymentMethod::CREDIT_CARD => $this->createCreditCardInvoice($invoice, $idempotencyKey), + PaymentMethod::PIX => $this->createPixInvoice($invoice, $idempotencyKey), default => throw UnsupportedOperationException::forGateway($this, Capability::forPaymentMethod($paymentMethod)), }; } @@ -702,10 +736,11 @@ private function invoicePaymentMethod(Invoice $invoice): PaymentMethod * Cria e confirma um PaymentIntent de cartão (síncrono: succeeded ou recusa na hora). * * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @param string|null $idempotencyKey * @return \Potelo\MultiPayment\Models\Invoice * @throws ChargingException|GatewayException|ModelAttributeValidationException */ - private function createCreditCardInvoice(Invoice $invoice): Invoice + private function createCreditCardInvoice(Invoice $invoice, ?string $idempotencyKey): Invoice { if (empty($invoice->creditCard)) { throw ModelAttributeValidationException::required('Invoice', 'creditCard'); @@ -716,7 +751,10 @@ private function createCreditCardInvoice(Invoice $invoice): Invoice $invoice->creditCard->customer = $invoice->customer; } // a Stripe valida o cartão já no attach; a recusa nesse ponto é ChargingException - $invoice->creditCard = $this->createCreditCard($invoice->creditCard); + $invoice->creditCard = $this->createCreditCard( + $invoice->creditCard, + self::derivedIdempotencyKey($idempotencyKey, 'card') + ); } $stripePaymentIntentData = $this->invoiceToStripeData($invoice); @@ -725,12 +763,11 @@ private function createCreditCardInvoice(Invoice $invoice): Invoice $stripePaymentIntentData['confirm'] = true; $stripePaymentIntentData['off_session'] = true; $stripePaymentIntentData = $this->mergeGatewayOptions($stripePaymentIntentData, $invoice); - $requestOptions = $this->extractIdempotencyKey($stripePaymentIntentData); - $stripePaymentIntent = $this->stripeRequest(function () use ($stripePaymentIntentData, $requestOptions) { + $stripePaymentIntent = $this->stripeRequest(function () use ($stripePaymentIntentData, $idempotencyKey) { return $this->client->paymentIntents->create( $this->withExpand($stripePaymentIntentData, self::PAYMENT_INTENT_EXPAND), - $requestOptions + self::stripeOptions($idempotencyKey) ); }); @@ -742,10 +779,11 @@ private function createCreditCardInvoice(Invoice $invoice): Invoice * com o QR code em next_action; o pagamento é assíncrono (acompanhar via getInvoice). * * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @param string|null $idempotencyKey * @return \Potelo\MultiPayment\Models\Invoice * @throws GatewayException|ModelAttributeValidationException */ - private function createPixInvoice(Invoice $invoice): Invoice + private function createPixInvoice(Invoice $invoice, ?string $idempotencyKey): Invoice { // o pix exige CPF/CNPJ no billing_details em produção — falhar cedo evita um // erro obscuro da API (a sandbox não valida, produção sim) @@ -779,12 +817,11 @@ private function createPixInvoice(Invoice $invoice): Invoice $stripePaymentIntentData['payment_method_options']['pix']['expires_at'] = $invoice->expiresAt->getTimestamp(); } $stripePaymentIntentData = $this->mergeGatewayOptions($stripePaymentIntentData, $invoice); - $requestOptions = $this->extractIdempotencyKey($stripePaymentIntentData); - $stripePaymentIntent = $this->stripeRequest(function () use ($stripePaymentIntentData, $requestOptions) { + $stripePaymentIntent = $this->stripeRequest(function () use ($stripePaymentIntentData, $idempotencyKey) { return $this->client->paymentIntents->create( $this->withExpand($stripePaymentIntentData, self::PAYMENT_INTENT_EXPAND), - $requestOptions + self::stripeOptions($idempotencyKey) ); }); @@ -810,22 +847,15 @@ private function pixBillingTaxId(StripePaymentIntent $stripePaymentIntent): ?str } /** - * Extrai a idempotency key das opções do consumidor para enviá-la como cabeçalho da - * requisição (Idempotency-Key) — como parâmetro do payload a API a rejeitaria. + * Opções de requisição do stripe-php com a chave de idempotência, que o SDK envia no + * cabeçalho `Idempotency-Key`. Sem chave, nenhuma opção. * - * @param array $stripeData recebe o payload por referência e remove a chave dele + * @param string|null $idempotencyKey * @return array */ - private function extractIdempotencyKey(array &$stripeData): array + private static function stripeOptions(?string $idempotencyKey): array { - if (!array_key_exists('idempotency_key', $stripeData)) { - return []; - } - - $requestOptions = ['idempotency_key' => $stripeData['idempotency_key']]; - unset($stripeData['idempotency_key']); - - return $requestOptions; + return is_null($idempotencyKey) ? [] : ['idempotency_key' => $idempotencyKey]; } /** @@ -867,15 +897,16 @@ private function invoiceToStripeData(Invoice $invoice): array /** * Mescla as opções extras/override do consumidor por último, para que possam - * sobrescrever qualquer chave montada pelo gateway (válvula de escape do pacote). + * sobrescrever qualquer chave montada pelo gateway (válvula de escape do pacote). A chave + * antiga de idempotência fica de fora do payload. * * @param array $stripeData - * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @param Model $model * @return array */ - private function mergeGatewayOptions(array $stripeData, Invoice $invoice): array + private function mergeGatewayOptions(array $stripeData, Model $model): array { - foreach ($invoice->gatewayOptions ?? [] as $option => $value) { + foreach (self::withoutIdempotencyKey($model->gatewayOptions) as $option => $value) { $stripeData[$option] = $value; } @@ -908,13 +939,20 @@ public function getInvoice(Invoice $invoice): Invoice * gateway guarda. A leitura prévia acontece numa cópia: o model do chamador só é alterado se * o estorno acontecer. * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` da criação do refund; as + * leituras do PaymentIntent não a usam. Com chave, uma guarda de estado (fatura já + * estornada, valor acima do restante) não recusa de imediato: a mesma chave pode ser a de um + * estorno já feito, então o driver envia o refund e deixa a Stripe repetir a resposta + * original; se ela recusar, a recusa da guarda é a que sobe. + * * @throws ModelAttributeValidationException|RefundNotSupportedException */ - public function refundInvoice(Invoice $invoice): Refund + public function refundInvoice(Invoice $invoice, ?string $idempotencyKey = null): Refund { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); // guardado antes da leitura: parseInvoice() sobrescreve refundedAmount com o já estornado $requestedAmount = $invoice->refundedAmount ?: null; @@ -928,19 +966,21 @@ public function refundInvoice(Invoice $invoice): Refund $current = $this->getInvoice(clone $invoice); } - $this->assertInvoiceIsRefundable($current, $requestedAmount, $current !== $invoice); - // mesma semântica da Iugu: refundedAmount preenchido = estorno parcial; vazio = total $stripeRefundData = ['payment_intent' => $invoice->id]; if (!is_null($requestedAmount)) { $stripeRefundData['amount'] = $requestedAmount; } $stripeRefundData = $this->mergeGatewayOptions($stripeRefundData, $invoice); - $requestOptions = $this->extractIdempotencyKey($stripeRefundData); - $stripeRefund = $this->stripeRequest(function () use ($stripeRefundData, $requestOptions) { - return $this->client->refunds->create($stripeRefundData, $requestOptions); - }); + try { + $this->assertInvoiceIsRefundable($current, $requestedAmount, $current !== $invoice); + $stripeRefund = $this->stripeRequest(function () use ($stripeRefundData, $idempotencyKey) { + return $this->client->refunds->create($stripeRefundData, self::stripeOptions($idempotencyKey)); + }); + } catch (RefundNotSupportedException $refusal) { + $stripeRefund = $this->replayStripeRefund($refusal, $stripeRefundData, $idempotencyKey); + } // o refund não devolve o PaymentIntent: refetch para reparse com o charge atualizado $invoice = $this->getInvoice($invoice); @@ -951,6 +991,34 @@ public function refundInvoice(Invoice $invoice): Refund return $refund; } + /** + * Tenta repetir, pela chave de idempotência, um estorno que a guarda de estado recusou: + * a Stripe devolve o refund original quando a chave é a dele. Sem chave, ou quando a recusa + * não é de estado (boleto), ou quando a Stripe também recusa, sobe a recusa da guarda. + * + * @param RefundNotSupportedException $refusal + * @param array $stripeRefundData + * @param string|null $idempotencyKey + * @return object o objeto Refund devolvido pela Stripe + * @throws RefundNotSupportedException + */ + private function replayStripeRefund(RefundNotSupportedException $refusal, array $stripeRefundData, ?string $idempotencyKey): object + { + $stateReasons = [ + RefundNotSupportedException::REASON_ALREADY_REFUNDED, + RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, + ]; + if (is_null($idempotencyKey) || !in_array($refusal->reason, $stateReasons, true)) { + throw $refusal; + } + + try { + return $this->client->refunds->create($stripeRefundData, self::stripeOptions($idempotencyKey)); + } catch (\Exception $e) { + throw $refusal; + } + } + /** * Lança antes da rede quando a Stripe certamente recusaria o estorno: boleto não tem estorno * pela API, fatura em `refunded` é terminal e o valor pedido não pode passar do que resta @@ -991,9 +1059,14 @@ private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmou /** * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` do confirm; o update que + * antecede o confirm usa `{chave}:update` e a conversão de token legado em PaymentMethod + * usa `{chave}:payment_method`. + * * @throws ChargingException|ModelAttributeValidationException */ - public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice + public function chargeInvoiceWithCreditCard(Invoice $invoice, ?string $idempotencyKey = null): Invoice { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); @@ -1004,14 +1077,18 @@ public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice if (empty($invoice->creditCard->token) && empty($invoice->creditCard->id)) { throw new ModelAttributeValidationException('Credit card token or id is required'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); // id = PaymentMethod salvo no customer; token = PaymentMethod criado client-side $paymentMethodId = !empty($invoice->creditCard->id) ? $invoice->creditCard->id : $invoice->creditCard->token; - $stripePaymentIntent = $this->stripeRequest(function () use ($invoice, $paymentMethodId) { - $paymentMethodId = $this->resolvePaymentMethodId($paymentMethodId); + $stripePaymentIntent = $this->stripeRequest(function () use ($invoice, $paymentMethodId, $idempotencyKey) { + $paymentMethodId = $this->resolvePaymentMethodId( + $paymentMethodId, + self::derivedIdempotencyKey($idempotencyKey, 'payment_method') + ); $stripePaymentMethod = $this->client->paymentMethods->retrieve($paymentMethodId); // o PaymentIntent pode ter sido criado para outro método (ex.: pix expirado): @@ -1030,17 +1107,23 @@ public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice ); } + // o customer vai sempre que o cartão tem um (igual ao do PaymentIntent, ou o + // PaymentIntent ainda sem cliente): o payload fica o mesmo num retry com a mesma chave $updateParams = ['payment_method_types' => ['card']]; - if (empty($paymentIntentCustomer) && !empty($stripePaymentMethod->customer)) { + if (!empty($stripePaymentMethod->customer)) { $updateParams['customer'] = $stripePaymentMethod->customer; } - $this->client->paymentIntents->update($invoice->id, $updateParams); + $this->client->paymentIntents->update( + $invoice->id, + $updateParams, + self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'update')) + ); return $this->client->paymentIntents->confirm($invoice->id, [ 'payment_method' => $paymentMethodId, 'off_session' => true, 'expand' => self::PAYMENT_INTENT_EXPAND, - ]); + ], self::stripeOptions($idempotencyKey)); }); return $this->parseInvoice($stripePaymentIntent, $invoice); @@ -1305,15 +1388,24 @@ private static function chargeFailureReason(?string $code, ?string $declineCode) * O PaymentIntent não tem duplicate nativo: a fatura nova é criada com os dados da * original (customer, items, valor) e a nova expiração, e só então a original é * cancelada — se a criação falhar, o consumidor não fica sem fatura nenhuma. - * Restrito a faturas pix pendentes (cartão é síncrono, não há o que duplicar). + * Restrito a faturas pix pendentes (cartão é síncrono, não há o que duplicar). A chave de + * idempotência vai na criação da nova fatura; o cancelamento da original usa + * `{chave}:cancel_original`. Com chave, uma original já cancelada é aceita, porque pode ser + * o resultado de uma tentativa anterior com a mesma chave, que a Stripe repete. * * @throws ModelAttributeValidationException|UnsupportedOperationException */ - public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gatewayOptions = []): Invoice - { + public function duplicateInvoice( + Invoice $invoice, + Carbon $expiresAt, + array $gatewayOptions = [], + ?string $idempotencyKey = null + ): Invoice { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice, $gatewayOptions); + $gatewayOptions = self::withoutIdempotencyKey($gatewayOptions); $original = $this->stripeRequest(function () use ($invoice) { return $this->client->paymentIntents->retrieve( @@ -1323,7 +1415,10 @@ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gat }); $parsedOriginal = $this->parseInvoice($original, new Invoice()); - if ($parsedOriginal->status !== InvoiceStatus::PENDING) { + // com chave, a original cancelada pode ser obra de uma tentativa anterior com a mesma + // chave: a Stripe repete a criação da duplicata e o cancelamento + $replayable = !is_null($idempotencyKey) && $parsedOriginal->status === InvoiceStatus::CANCELED; + if ($parsedOriginal->status !== InvoiceStatus::PENDING && !$replayable) { throw UnsupportedOperationException::restricted( (string) $this, Capability::INVOICE_DUPLICATION, @@ -1368,10 +1463,10 @@ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gat if (!empty($gatewayOptions)) { $duplicated->gatewayOptions = array_merge($duplicated->gatewayOptions, $gatewayOptions); } - $duplicated = $this->createPixInvoice($duplicated); + $duplicated = $this->createPixInvoice($duplicated, $idempotencyKey); try { - $this->cancelInvoice($parsedOriginal); + $this->cancelInvoice($parsedOriginal, self::derivedIdempotencyKey($idempotencyKey, 'cancel_original')); } catch (MultiPaymentException $e) { // a duplicata já existe — propaga o id dela para o consumidor não a perder throw new GatewayException( @@ -1388,20 +1483,25 @@ public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gat /** * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` do cancelamento. + * * @throws ModelAttributeValidationException */ - public function cancelInvoice(Invoice $invoice): Invoice + public function cancelInvoice(Invoice $invoice, ?string $idempotencyKey = null): Invoice { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); // só estados não-terminais são canceláveis; PaymentIntent pago recusa o cancel // com payment_intent_unexpected_state (vira GatewayException) - $stripePaymentIntent = $this->stripeRequest(function () use ($invoice) { + $stripePaymentIntent = $this->stripeRequest(function () use ($invoice, $idempotencyKey) { return $this->client->paymentIntents->cancel( $invoice->id, - ['expand' => self::PAYMENT_INTENT_EXPAND] + ['expand' => self::PAYMENT_INTENT_EXPAND], + self::stripeOptions($idempotencyKey) ); }); @@ -1410,13 +1510,19 @@ public function cancelInvoice(Invoice $invoice): Invoice /** * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` do attach; as requisições + * secundárias usam chaves derivadas: `{chave}:payment_method` na conversão de token legado, + * `{chave}:metadata` na descrição e `{chave}:default` ao marcar como padrão. + * * @throws ModelAttributeValidationException|UnsupportedOperationException */ - public function createCreditCard(CreditCard $creditCard): CreditCard + public function createCreditCard(CreditCard $creditCard, ?string $idempotencyKey = null): CreditCard { if (empty($creditCard->customer) || empty($creditCard->customer->id)) { throw ModelAttributeValidationException::required('CreditCard', 'customer'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $creditCard); if (empty($creditCard->token)) { // token-only: dados crus exigiriam a liberação de raw card data APIs pela // Stripe e escopo PCI SAQ D; o cartão é tokenizado client-side @@ -1426,26 +1532,31 @@ public function createCreditCard(CreditCard $creditCard): CreditCard ); } - $stripePaymentMethod = $this->stripeRequest(function () use ($creditCard) { - $paymentMethodId = $this->resolvePaymentMethodId($creditCard->token); + $stripePaymentMethod = $this->stripeRequest(function () use ($creditCard, $idempotencyKey) { + $paymentMethodId = $this->resolvePaymentMethodId( + $creditCard->token, + self::derivedIdempotencyKey($idempotencyKey, 'payment_method') + ); $stripePaymentMethod = $this->client->paymentMethods->attach( $paymentMethodId, - ['customer' => $creditCard->customer->id] + ['customer' => $creditCard->customer->id], + self::stripeOptions($idempotencyKey) ); // o PaymentMethod da Stripe não tem campo de descrição — vai para metadata if (!empty($creditCard->description)) { $stripePaymentMethod = $this->client->paymentMethods->update( $stripePaymentMethod->id, - ['metadata' => ['description' => $creditCard->description]] + ['metadata' => ['description' => $creditCard->description]], + self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'metadata')) ); } if (!empty($creditCard->default)) { $this->client->customers->update($creditCard->customer->id, [ 'invoice_settings' => ['default_payment_method' => $stripePaymentMethod->id], - ]); + ], self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'default'))); } return $stripePaymentMethod; @@ -1471,14 +1582,24 @@ public function getCreditCard(CreditCard $creditCard): CreditCard /** * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` do detach. Com chave, um cartão + * já sem cliente passa pela checagem de posse, porque pode ter sido desvinculado por uma + * tentativa anterior com a mesma chave. */ - public function deleteCreditCard(CreditCard $creditCard): void + public function deleteCreditCard(CreditCard $creditCard, ?string $idempotencyKey = null): void { - $this->stripeRequest(function () use ($creditCard) { + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $creditCard); + + $this->stripeRequest(function () use ($creditCard, $idempotencyKey) { $stripePaymentMethod = $this->client->paymentMethods->retrieve($creditCard->id); - $this->assertCardBelongsToCustomer($stripePaymentMethod, $creditCard); + // cartão já sem cliente com chave informada: pode ter sido desvinculado por uma + // tentativa anterior com a mesma chave, que a Stripe repete + if (!empty($stripePaymentMethod->customer) || is_null($idempotencyKey)) { + $this->assertCardBelongsToCustomer($stripePaymentMethod, $creditCard); + } - return $this->client->paymentMethods->detach($creditCard->id); + return $this->client->paymentMethods->detach($creditCard->id, null, self::stripeOptions($idempotencyKey)); }); } @@ -1487,16 +1608,17 @@ public function deleteCreditCard(CreditCard $creditCard): void * (tok_...) não são utilizáveis diretamente e viram PaymentMethod antes. * * @param string $token + * @param string|null $idempotencyKey chave da criação do PaymentMethod a partir do token * @return string * @throws ApiErrorException */ - private function resolvePaymentMethodId(string $token): string + private function resolvePaymentMethodId(string $token, ?string $idempotencyKey = null): string { if (str_starts_with($token, 'tok_')) { return $this->client->paymentMethods->create([ 'type' => 'card', 'card' => ['token' => $token], - ])->id; + ], self::stripeOptions($idempotencyKey))->id; } return $token; @@ -1562,7 +1684,7 @@ private function parseStripeCard(StripePaymentMethod $stripePaymentMethod, ?Cred /** * @inheritDoc */ - public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice + public function rescheduleAutomaticPixPayment(Invoice $invoice, ?string $idempotencyKey = null): Invoice { throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); } @@ -1570,16 +1692,20 @@ public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice /** * @inheritDoc */ - public function cancelAutomaticPixScheduledPayment(AutomaticPixCharge $charge): AutomaticPixCancellation - { + public function cancelAutomaticPixScheduledPayment( + AutomaticPixCharge $charge, + ?string $idempotencyKey = null + ): AutomaticPixCancellation { throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); } /** * @inheritDoc */ - public function cancelAutomaticPixRecurrence(AutomaticPix $automaticPix): AutomaticPixCancellation - { + public function cancelAutomaticPixRecurrence( + AutomaticPix $automaticPix, + ?string $idempotencyKey = null + ): AutomaticPixCancellation { throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); } diff --git a/src/Helpers/ConfigurationHelper.php b/src/Helpers/ConfigurationHelper.php index 324b499..c805427 100644 --- a/src/Helpers/ConfigurationHelper.php +++ b/src/Helpers/ConfigurationHelper.php @@ -3,7 +3,10 @@ namespace Potelo\MultiPayment\Helpers; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Facade; +use Illuminate\Contracts\Container\Container; use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Contracts\IdempotencyStore; use Potelo\MultiPayment\Exceptions\ConfigurationException; class ConfigurationHelper @@ -34,4 +37,35 @@ public static function resolveGateway($gateway): GatewayContract } return $gateway; } + + /** + * Resolve a `IdempotencyStore` registrada no container do Laravel (o service provider + * registra a `CacheIdempotencyStore`; a aplicação pode substituí-la por um bind próprio). + * + * @return IdempotencyStore + * @throws ConfigurationException sem container ou sem a store registrada nele + */ + public static function resolveIdempotencyStore(): IdempotencyStore + { + $app = Facade::getFacadeApplication(); + if ($app instanceof Container && $app->bound(IdempotencyStore::class)) { + $store = $app->make(IdempotencyStore::class); + if ($store instanceof IdempotencyStore) { + return $store; + } + } + + throw ConfigurationException::IdempotencyStoreNotConfigured(); + } + + /** + * Prazo, em segundos, em que a `IdempotencyStore` devolve o resultado guardado + * (`multi-payment.idempotency.ttl`, padrão de 24 horas). + * + * @return int + */ + public static function idempotencyTtl(): int + { + return (int) (Config::get('multi-payment.idempotency.ttl') ?? 86400); + } } \ No newline at end of file diff --git a/src/Idempotency/CacheIdempotencyStore.php b/src/Idempotency/CacheIdempotencyStore.php new file mode 100644 index 0000000..d8eed9e --- /dev/null +++ b/src/Idempotency/CacheIdempotencyStore.php @@ -0,0 +1,120 @@ +cacheKey($key); + + $cached = $this->cache->get($cacheKey); + if ($this->isHit($cached)) { + return $cached['result']; + } + + $lock = $this->lockProvider()->lock($cacheKey . ':lock', $this->lockSeconds); + if (!$lock->get()) { + throw IdempotencyConflictException::concurrent($key); + } + + try { + // outra execução pode ter terminado entre a leitura acima e a obtenção do lock + $cached = $this->cache->get($cacheKey); + if ($this->isHit($cached)) { + return $cached['result']; + } + + $result = $operation(); + $this->cache->put($cacheKey, ['result' => $result], $ttlSeconds); + + return $result; + } finally { + $lock->release(); + } + } + + /** + * @inheritDoc + */ + public function has(string $key): bool + { + return $this->isHit($this->cache->get($this->cacheKey($key))); + } + + /** + * Chave no cache: prefixo mais a chave da operação. + * + * @param string $key + * @return string + */ + private function cacheKey(string $key): string + { + return $this->prefix . $key; + } + + /** + * Diz se o valor lido do cache é um resultado guardado. O resultado viaja num envelope para + * que uma operação que devolve nulo também conte como guardada. + * + * @param mixed $cached + * @return bool + */ + private function isHit(mixed $cached): bool + { + return is_array($cached) && array_key_exists('result', $cached); + } + + /** + * Cache store por trás do repositório, que precisa suportar lock. + * + * @return LockProvider + * @throws ConfigurationException + */ + private function lockProvider(): LockProvider + { + $store = $this->cache->getStore(); + if (!$store instanceof LockProvider) { + throw ConfigurationException::IdempotencyStoreWithoutLock(get_class($store)); + } + + return $store; + } +} diff --git a/src/Idempotency/IdempotencyKey.php b/src/Idempotency/IdempotencyKey.php new file mode 100644 index 0000000..da6bb64 --- /dev/null +++ b/src/Idempotency/IdempotencyKey.php @@ -0,0 +1,23 @@ + */ + private array $results = []; + + /** @var array */ + private array $inProgress = []; + + /** + * @inheritDoc + */ + public function remember(string $key, callable $operation, int $ttlSeconds): mixed + { + if ($this->has($key)) { + return $this->results[$key]['result']; + } + + if (isset($this->inProgress[$key])) { + throw IdempotencyConflictException::concurrent($key); + } + + $this->inProgress[$key] = true; + try { + $result = $operation(); + } finally { + unset($this->inProgress[$key]); + } + + $this->results[$key] = ['result' => $result, 'expiresAt' => Carbon::now()->getTimestamp() + $ttlSeconds]; + + return $result; + } + + /** + * @inheritDoc + */ + public function has(string $key): bool + { + if (!isset($this->results[$key])) { + return false; + } + + if ($this->results[$key]['expiresAt'] <= Carbon::now()->getTimestamp()) { + unset($this->results[$key]); + + return false; + } + + return true; + } + + /** + * Esquece o resultado guardado para a chave. + * + * @param string $key + * @return void + */ + public function forget(string $key): void + { + unset($this->results[$key]); + } +} diff --git a/src/Models/Customer.php b/src/Models/Customer.php index 09ab125..fd59966 100644 --- a/src/Models/Customer.php +++ b/src/Models/Customer.php @@ -170,10 +170,19 @@ public function toArray(): array return $array; } - public function setDefaultCard(string $cardId): Customer + /** + * Define o cartão padrão do cliente no gateway. + * + * @param string $cardId + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return Customer + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + */ + public function setDefaultCard(string $cardId, ?string $idempotencyKey = null): Customer { $gateway = ConfigurationHelper::resolveGateway($this->gateway); - return $gateway->setCustomerDefaultCard($this, $cardId); + return $gateway->setCustomerDefaultCard($this, $cardId, $idempotencyKey); } /** @@ -202,18 +211,22 @@ public function getCreditCard(string $creditCardId, GatewayContract|string|null * * @param string $creditCardId * @param \Potelo\MultiPayment\Contracts\GatewayContract|string|null $gateway + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return void * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException */ - public function deleteCreditCard(string $creditCardId, GatewayContract|string|null $gateway = null): void - { + public function deleteCreditCard( + string $creditCardId, + GatewayContract|string|null $gateway = null, + ?string $idempotencyKey = null + ): void { $gateway = ConfigurationHelper::resolveGateway($gateway); $creditCard = new CreditCard(); $creditCard->customer = $this; $creditCard->id = $creditCardId; - $gateway->deleteCreditCard($creditCard); + $gateway->deleteCreditCard($creditCard, $idempotencyKey); } } diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index ed74f9a..f5870df 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -8,6 +8,7 @@ use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Helpers\ConfigurationHelper; +use Potelo\MultiPayment\Idempotency\IdempotencyKey; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; /** @@ -181,14 +182,14 @@ public function fill(array $data): void { if (empty($data['items']) && !empty($data['amount'])) { $invoiceItem = new InvoiceItem(); - $data['items'] = []; $invoiceItem->fill([ 'description' => 'Nova cobrança', 'quantity' => 1, 'price' => $data['amount'], ]); $this->items[] = $invoiceItem; - unset($data['amount']); + // a chave items não pode chegar ao parent::fill(), que sobrescreveria a lista + unset($data['amount'], $data['items']); } elseif (!empty($data['items'])) { $this->items = []; foreach ($data['items'] as $item) { @@ -310,7 +311,7 @@ public function validateAutomaticPixAttribute(): void /** * @inheritDoc */ - public function save(GatewayContract|string|null $gateway = null, bool $validate = true): void + public function save(GatewayContract|string|null $gateway = null, bool $validate = true, ?string $idempotencyKey = null): void { if ($validate) { $this->validate(); @@ -321,12 +322,12 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate $gateway = ConfigurationHelper::resolveGateway($this->gatewayForSave($gateway)); $this->assertGatewaySupports($gateway); if (empty($this->customer->id)) { - $this->customer->save($gateway, $validate); + $this->customer->save($gateway, $validate, IdempotencyKey::derive($idempotencyKey, 'customer')); } if (!empty($this->creditCard) && empty($this->creditCard->id)) { $this->creditCard->customer = $this->customer; } - parent::save($gateway, false); + parent::save($gateway, false, $idempotencyKey); } /** @@ -448,25 +449,29 @@ private static function statusFromHelperArgument(InvoiceStatus|string $status): * com o valor em centavos. Devolve o `Refund` criado e atualiza esta instância com o * estado posterior ao estorno (`$refund->invoice()` é esta instância). * + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * @return Refund * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException */ - public function refund(): Refund + public function refund(?string $idempotencyKey = null): Refund { $gateway = ConfigurationHelper::resolveGateway($this->gateway); - return $gateway->refundInvoice($this); + return $gateway->refundInvoice($this, $idempotencyKey); } /** - * Charge invoice with credit card + * Cobra a fatura com o cartão informado (ou com o já preenchido em `creditCard`). * + * @param CreditCard|null $creditCard + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException * @throws \Potelo\MultiPayment\Exceptions\ChargingException * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException */ - public function chargeInvoiceWithCreditCard(?CreditCard $creditCard = null): Invoice + public function chargeInvoiceWithCreditCard(?CreditCard $creditCard = null, ?string $idempotencyKey = null): Invoice { if (!empty($creditCard)) { $this->creditCard = $creditCard; @@ -474,7 +479,7 @@ public function chargeInvoiceWithCreditCard(?CreditCard $creditCard = null): Inv $gateway = ConfigurationHelper::resolveGateway($this->gateway); - return $gateway->chargeInvoiceWithCreditCard($this); + return $gateway->chargeInvoiceWithCreditCard($this, $idempotencyKey); } /** @@ -482,33 +487,46 @@ public function chargeInvoiceWithCreditCard(?CreditCard $creditCard = null): Inv * * @param \Carbon\Carbon $expiresAt * @param array $gatewayOptions + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return \Potelo\MultiPayment\Models\Invoice * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ - public function duplicate(Carbon $expiresAt, array $gatewayOptions = []): Invoice + public function duplicate(Carbon $expiresAt, array $gatewayOptions = [], ?string $idempotencyKey = null): Invoice { $gateway = ConfigurationHelper::resolveGateway($this->gateway); - return $gateway->duplicateInvoice($this, $expiresAt, $gatewayOptions); + return $gateway->duplicateInvoice($this, $expiresAt, $gatewayOptions, $idempotencyKey); } /** - * Cancel the invoice. + * Cancela a fatura. + * + * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return Invoice + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ - public function cancel(GatewayContract|string|null $gateway = null): Invoice + public function cancel(GatewayContract|string|null $gateway = null, ?string $idempotencyKey = null): Invoice { $gateway = ConfigurationHelper::resolveGateway($gateway ?? $this->gateway); - return $gateway->cancelInvoice($this); + return $gateway->cancelInvoice($this, $idempotencyKey); } /** - * Request a new debit schedule after a failed Automatic Pix payment. + * Pede um novo agendamento de débito depois de um pagamento de Pix Automático que falhou. + * + * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return Invoice + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ - public function rescheduleAutomaticPixPayment(GatewayContract|string|null $gateway = null): Invoice + public function rescheduleAutomaticPixPayment(GatewayContract|string|null $gateway = null, ?string $idempotencyKey = null): Invoice { $gateway = ConfigurationHelper::resolveGateway($gateway ?? $this->gateway); - return $gateway->rescheduleAutomaticPixPayment($this); + return $gateway->rescheduleAutomaticPixPayment($this, $idempotencyKey); } } diff --git a/src/Models/Model.php b/src/Models/Model.php index 85709d9..831f023 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -229,30 +229,34 @@ private static function enumToValue(mixed $value): mixed * Create a new instance of the model with an array of attributes. * * @param array $data - * @param null $gateway + * @param string|GatewayContract|null $gateway + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return void * @throws GatewayException * @throws GatewayNotAvailableException * @throws ModelAttributeValidationException */ - public function create(array $data, $gateway = null): void + public function create(array $data, $gateway = null, ?string $idempotencyKey = null): void { $this->fill($data); - $this->save($gateway); + $this->save($gateway, true, $idempotencyKey); } /** - * If gateway is set, then we will use it to save the model + * Salva o model no gateway: `create{Model}` sem `id`, `update{Model}` com `id` (sem + * validação). A chave de idempotência vai para essa operação; o cliente salvo antes de uma + * fatura ou assinatura recebe a chave derivada `{chave}:customer`. * * @param string|GatewayContract|null $gateway * @param bool $validate + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return void * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException * @throws UnsupportedOperationException */ - public function save(GatewayContract|string|null $gateway = null, bool $validate = true): void + public function save(GatewayContract|string|null $gateway = null, bool $validate = true, ?string $idempotencyKey = null): void { $class = $this->getClassName(); if (property_exists($this, 'id') && !empty($this->id)) { @@ -272,7 +276,7 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate if (!method_exists($gatewayClass, $method)) { throw GatewayException::methodNotFound(get_class($gatewayClass), $method); } - $gatewayClass->$method($this); + $gatewayClass->$method($this, $idempotencyKey); } /** @@ -455,12 +459,13 @@ public function get(GatewayContract|string|null $gateway = null): static * Delete the model instance by id in the gateway. * * @param \Potelo\MultiPayment\Contracts\GatewayContract|string|null $gateway + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return void * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws UnsupportedOperationException */ - public function delete(GatewayContract|string|null $gateway = null): void + public function delete(GatewayContract|string|null $gateway = null, ?string $idempotencyKey = null): void { $method = 'delete' . static::getClassName(); $gateway = ConfigurationHelper::resolveGateway($gateway); @@ -468,7 +473,7 @@ public function delete(GatewayContract|string|null $gateway = null): void if (!method_exists($gateway, $method)) { throw GatewayException::methodNotFound(get_class($gateway), $method); } - $gateway->$method($this); + $gateway->$method($this, $idempotencyKey); } /** diff --git a/src/Models/Plan.php b/src/Models/Plan.php index 419c864..d19df14 100644 --- a/src/Models/Plan.php +++ b/src/Models/Plan.php @@ -91,12 +91,13 @@ class Plan extends Model * * @param GatewayContract|string|null $gateway * @param bool $validate + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return void * @throws GatewayException|\Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException */ - public function save(GatewayContract|string|null $gateway = null, bool $validate = true): void + public function save(GatewayContract|string|null $gateway = null, bool $validate = true, ?string $idempotencyKey = null): void { if (!empty($this->id)) { throw new GatewayException( @@ -104,7 +105,7 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate ); } - parent::save($gateway, $validate); + parent::save($gateway, $validate, $idempotencyKey); } /** diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index 4ed517f..175b997 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -9,6 +9,7 @@ use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Contracts\SubscriptionContract; use Potelo\MultiPayment\Helpers\ConfigurationHelper; +use Potelo\MultiPayment\Idempotency\IdempotencyKey; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -351,13 +352,14 @@ protected function attributesExtraValidation(array $attributes): void * * @param GatewayContract|string|null $gateway * @param bool $validate + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return void * @throws GatewayException|\Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException * @throws UnsupportedOperationException */ - public function save(GatewayContract|string|null $gateway = null, bool $validate = true): void + public function save(GatewayContract|string|null $gateway = null, bool $validate = true, ?string $idempotencyKey = null): void { if ($validate) { $this->validate(); @@ -369,10 +371,10 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate $gateway = $this->resolveSubscriptionGateway($this->gatewayForSave($gateway)); if (empty($this->id) && !empty($this->customer) && empty($this->customer->id)) { - $this->customer->save($gateway, $validate); + $this->customer->save($gateway, $validate, IdempotencyKey::derive($idempotencyKey, 'customer')); } - parent::save($gateway, false); + parent::save($gateway, false, $idempotencyKey); } /** @@ -405,6 +407,7 @@ private function resolveSubscriptionGateway(GatewayContract|string|null $gateway * Suspende a cobrança da assinatura, mantendo-a reativável por resume(). * * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Subscription * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException @@ -413,15 +416,16 @@ private function resolveSubscriptionGateway(GatewayContract|string|null $gateway * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException * @throws UnsupportedOperationException */ - public function suspend(GatewayContract|string|null $gateway = null): Subscription + public function suspend(GatewayContract|string|null $gateway = null, ?string $idempotencyKey = null): Subscription { - return $this->resolveSubscriptionGateway($gateway)->suspendSubscription($this); + return $this->resolveSubscriptionGateway($gateway)->suspendSubscription($this, $idempotencyKey); } /** * Volta a cobrar uma assinatura suspensa. * * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Subscription * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException @@ -430,9 +434,9 @@ public function suspend(GatewayContract|string|null $gateway = null): Subscripti * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException * @throws UnsupportedOperationException */ - public function resume(GatewayContract|string|null $gateway = null): Subscription + public function resume(GatewayContract|string|null $gateway = null, ?string $idempotencyKey = null): Subscription { - return $this->resolveSubscriptionGateway($gateway)->resumeSubscription($this); + return $this->resolveSubscriptionGateway($gateway)->resumeSubscription($this, $idempotencyKey); } /** @@ -440,6 +444,7 @@ public function resume(GatewayContract|string|null $gateway = null): Subscriptio * * @param bool $atPeriodEnd * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Subscription * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException @@ -448,9 +453,12 @@ public function resume(GatewayContract|string|null $gateway = null): Subscriptio * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException * @throws UnsupportedOperationException */ - public function cancel(bool $atPeriodEnd = false, GatewayContract|string|null $gateway = null): Subscription - { - return $this->resolveSubscriptionGateway($gateway)->cancelSubscription($this, $atPeriodEnd); + public function cancel( + bool $atPeriodEnd = false, + GatewayContract|string|null $gateway = null, + ?string $idempotencyKey = null + ): Subscription { + return $this->resolveSubscriptionGateway($gateway)->cancelSubscription($this, $atPeriodEnd, $idempotencyKey); } /** @@ -462,6 +470,7 @@ public function cancel(bool $atPeriodEnd = false, GatewayContract|string|null $g * @param string $planId * @param bool $charge * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Subscription * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException @@ -473,10 +482,11 @@ public function cancel(bool $atPeriodEnd = false, GatewayContract|string|null $g public function changePlan( string $planId, bool $charge = true, - GatewayContract|string|null $gateway = null + GatewayContract|string|null $gateway = null, + ?string $idempotencyKey = null ): Subscription { return $this->resolveSubscriptionGateway($gateway) - ->changeSubscriptionPlan($this, $planId, $charge); + ->changeSubscriptionPlan($this, $planId, $charge, $idempotencyKey); } /** diff --git a/src/MultiPayment.php b/src/MultiPayment.php index fb690bf..1830637 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -109,18 +109,19 @@ public function notYetImplemented($gateway = null): array * Charge a customer * * @param array $attributes + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return Invoice * @throws GatewayException|ModelAttributeValidationException|GatewayNotAvailableException */ - public function charge(array $attributes): Invoice + public function charge(array $attributes, ?string $idempotencyKey = null): Invoice { $invoice = new Invoice(); $invoice->fill($attributes); $invoice->customer = new Customer(); $invoice->customer->fill($attributes['customer']); - $invoice->save($this->gateway); + $invoice->save($this->gateway, true, $idempotencyKey); return $invoice; } @@ -249,13 +250,18 @@ public function getInvoice(string $id): Invoice * @param \Potelo\MultiPayment\Models\Invoice|string $invoice * @param \Carbon\Carbon $expiresAt * @param array $gatewayOptions + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return \Potelo\MultiPayment\Models\Invoice * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ - public function duplicateInvoice(Invoice|string $invoice, Carbon $expiresAt, array $gatewayOptions = []): Invoice - { + public function duplicateInvoice( + Invoice|string $invoice, + Carbon $expiresAt, + array $gatewayOptions = [], + ?string $idempotencyKey = null + ): Invoice { if (is_string($invoice)) { $invoiceInstance = new Invoice(); $invoiceInstance->id = $invoice; @@ -267,7 +273,7 @@ public function duplicateInvoice(Invoice|string $invoice, Carbon $expiresAt, arr $invoice->gateway = $this->gateway; } - return $invoice->duplicate($expiresAt, $gatewayOptions); + return $invoice->duplicate($expiresAt, $gatewayOptions, $idempotencyKey); } /** @@ -292,13 +298,14 @@ public function getCustomer(string $id): Customer * * @param string $id * @param int|null $partialValueCents + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return \Potelo\MultiPayment\Models\Refund * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException valor parcial zero ou negativo */ - public function refundInvoice(string $id, ?int $partialValueCents = null): Refund + public function refundInvoice(string $id, ?int $partialValueCents = null, ?string $idempotencyKey = null): Refund { if (!is_null($partialValueCents) && $partialValueCents <= 0) { throw ModelAttributeValidationException::invalid( @@ -316,19 +323,19 @@ public function refundInvoice(string $id, ?int $partialValueCents = null): Refun $invoice->refundedAmount = $partialValueCents; } - return $invoice->refund(); - + return $invoice->refund($idempotencyKey); } /** * Cancel an invoice. * * @param Invoice|string $invoice + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException */ - public function cancelInvoice(Invoice|string $invoice): Invoice + public function cancelInvoice(Invoice|string $invoice, ?string $idempotencyKey = null): Invoice { if (is_string($invoice)) { $invoiceInstance = new Invoice(); @@ -336,7 +343,7 @@ public function cancelInvoice(Invoice|string $invoice): Invoice $invoice = $invoiceInstance; } - return $invoice->cancel($this->gateway); + return $invoice->cancel($this->gateway, $idempotencyKey); } /** @@ -345,6 +352,7 @@ public function cancelInvoice(Invoice|string $invoice): Invoice * @param Invoice|string $invoice * @param string|null $creditCardToken * @param string|null $creditCardId + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return \Potelo\MultiPayment\Models\Invoice * @@ -354,8 +362,12 @@ public function cancelInvoice(Invoice|string $invoice): Invoice * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException * @throws \Potelo\MultiPayment\Exceptions\MultiPaymentException */ - public function chargeInvoiceWithCreditCard($invoice, ?string $creditCardToken = null, ?string $creditCardId = null): Invoice - { + public function chargeInvoiceWithCreditCard( + $invoice, + ?string $creditCardToken = null, + ?string $creditCardId = null, + ?string $idempotencyKey = null + ): Invoice { if (is_string($invoice)) { $invoiceInstance = new Invoice(); $invoiceInstance->id = $invoice; @@ -381,7 +393,7 @@ public function chargeInvoiceWithCreditCard($invoice, ?string $creditCardToken = $invoice->gateway = $this->gateway; $invoice->creditCard->gateway = $this->gateway; - return $invoice->chargeInvoiceWithCreditCard(); + return $invoice->chargeInvoiceWithCreditCard(null, $idempotencyKey); } /** @@ -408,18 +420,19 @@ public function getCard(string $customerId, string $creditCardId): CreditCard * * @param string $customerId * @param string $creditCardId + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return void * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ - public function deleteCard(string $customerId, string $creditCardId): void + public function deleteCard(string $customerId, string $creditCardId, ?string $idempotencyKey = null): void { $creditCard = new CreditCard(); $creditCard->customer = new Customer(); $creditCard->customer->id = $customerId; $creditCard->id = $creditCardId; - $creditCard->delete($this->gateway); + $creditCard->delete($this->gateway, $idempotencyKey); } /** @@ -427,35 +440,38 @@ public function deleteCard(string $customerId, string $creditCardId): void * * @param string $customerId * @param string $creditCardId + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return \Potelo\MultiPayment\Models\Customer */ - public function setDefaultCard(string $customerId, string $creditCardId): Customer + public function setDefaultCard(string $customerId, string $creditCardId, ?string $idempotencyKey = null): Customer { $customer = new Customer(); $customer->id = $customerId; // sem isso o model resolveria o gateway default, ignorando o setGateway() desta instância $customer->gateway = $this->gateway; - return $customer->setDefaultCard($creditCardId); + return $customer->setDefaultCard($creditCardId, $idempotencyKey); } /** * Cancela uma recorrência de Pix Automático no gateway. * * @param AutomaticPix|string $automaticPix + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return AutomaticPixCancellation * @throws GatewayException * @throws GatewayNotAvailableException */ public function cancelAutomaticPixRecurrence( - AutomaticPix|string $automaticPix - ): AutomaticPixCancellation - { + AutomaticPix|string $automaticPix, + ?string $idempotencyKey = null + ): AutomaticPixCancellation { if (is_string($automaticPix)) { $automaticPixModel = new AutomaticPix(); $automaticPixModel->id = $automaticPix; $automaticPix = $automaticPixModel; } - return $this->gateway->cancelAutomaticPixRecurrence($automaticPix); + return $this->gateway->cancelAutomaticPixRecurrence($automaticPix, $idempotencyKey); } /** @@ -463,12 +479,15 @@ public function cancelAutomaticPixRecurrence( * * @param AutomaticPixCharge|string $charge * @param string|null $endToEndId + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return AutomaticPixCancellation * @throws GatewayException * @throws GatewayNotAvailableException */ public function cancelAutomaticPixScheduledPayment( AutomaticPixCharge|string $charge, - ?string $endToEndId = null + ?string $endToEndId = null, + ?string $idempotencyKey = null ): AutomaticPixCancellation { if (is_string($charge)) { $chargeModel = new AutomaticPixCharge(); @@ -477,13 +496,19 @@ public function cancelAutomaticPixScheduledPayment( $charge = $chargeModel; } - return $this->gateway->cancelAutomaticPixScheduledPayment($charge); + return $this->gateway->cancelAutomaticPixScheduledPayment($charge, $idempotencyKey); } /** - * Request a new Automatic Pix debit schedule for an expired invoice. + * Pede um novo agendamento de débito de Pix Automático para uma fatura que expirou. + * + * @param Invoice|string $invoice + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return Invoice + * @throws GatewayException + * @throws GatewayNotAvailableException */ - public function rescheduleAutomaticPixPayment(Invoice|string $invoice): Invoice + public function rescheduleAutomaticPixPayment(Invoice|string $invoice, ?string $idempotencyKey = null): Invoice { if (is_string($invoice)) { $invoiceModel = new Invoice(); @@ -491,7 +516,7 @@ public function rescheduleAutomaticPixPayment(Invoice|string $invoice): Invoice $invoice = $invoiceModel; } - return $invoice->rescheduleAutomaticPixPayment($this->gateway); + return $invoice->rescheduleAutomaticPixPayment($this->gateway, $idempotencyKey); } /** diff --git a/src/Providers/MultiPaymentServiceProvider.php b/src/Providers/MultiPaymentServiceProvider.php index 7bbcb20..d5a278f 100644 --- a/src/Providers/MultiPaymentServiceProvider.php +++ b/src/Providers/MultiPaymentServiceProvider.php @@ -4,6 +4,8 @@ use Potelo\MultiPayment\MultiPayment; use Illuminate\Support\ServiceProvider; +use Potelo\MultiPayment\Contracts\IdempotencyStore; +use Potelo\MultiPayment\Idempotency\CacheIdempotencyStore; class MultiPaymentServiceProvider extends ServiceProvider { @@ -41,5 +43,15 @@ public function register() $this->app->bind('multiPayment', function ($app) { return $app->make(MultiPayment::class); }); + + // a aplicação troca a store com um bind próprio de IdempotencyStore depois deste + $this->app->bind(IdempotencyStore::class, function ($app) { + $config = $app['config']->get('multi-payment.idempotency', []); + + return new CacheIdempotencyStore( + $app['cache']->store($config['cache_store'] ?? null), + $config['prefix'] ?? CacheIdempotencyStore::DEFAULT_PREFIX + ); + }); } } diff --git a/src/Traits/MultiPaymentTrait.php b/src/Traits/MultiPaymentTrait.php index 15abc59..1b26ad6 100644 --- a/src/Traits/MultiPaymentTrait.php +++ b/src/Traits/MultiPaymentTrait.php @@ -18,11 +18,12 @@ trait MultiPaymentTrait * @param array $options * @param string|null $gatewayName * @param int|null $amount + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return Invoice * @throws GatewayException|ModelAttributeValidationException */ - public function charge(array $options, ?string $gatewayName = null, ?int $amount = null): Invoice + public function charge(array $options, ?string $gatewayName = null, ?int $amount = null, ?string $idempotencyKey = null): Invoice { $payment = new MultiPayment($gatewayName); @@ -33,7 +34,7 @@ public function charge(array $options, ?string $gatewayName = null, ?int $amount if (!empty($amount)) { $options['amount'] = $amount; } - $invoice = $payment->charge($options); + $invoice = $payment->charge($options, $idempotencyKey); if (empty($customerId)) { $this->setCustomerId($gatewayName, $invoice->customer->id); } @@ -82,19 +83,29 @@ private function getGatewayCustomerColumn($gatewayName) } /** - * Set the default credit card of the customer + * Define o cartão padrão do cliente no gateway. + * + * @param string $gatewayName + * @param string $cardId + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return void */ - public function setDefaultCreditCard(string $gatewayName, string $cardId): void + public function setDefaultCreditCard(string $gatewayName, string $cardId, ?string $idempotencyKey = null): void { - MultiPayment::setGateway($gatewayName)->setDefaultCard($this->getGatewayCustomerId($gatewayName), $cardId); + MultiPayment::setGateway($gatewayName)->setDefaultCard($this->getGatewayCustomerId($gatewayName), $cardId, $idempotencyKey); } /** - * Delete a credit card of the customer + * Exclui um cartão do cliente no gateway. + * + * @param string $gatewayName + * @param string $cardId + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return void */ - public function deleteCreditCard(string $gatewayName, string $cardId): void + public function deleteCreditCard(string $gatewayName, string $cardId, ?string $idempotencyKey = null): void { - MultiPayment::setGateway($gatewayName)->deleteCard($this->getGatewayCustomerId($gatewayName), $cardId); + MultiPayment::setGateway($gatewayName)->deleteCard($this->getGatewayCustomerId($gatewayName), $cardId, $idempotencyKey); } /** diff --git a/src/config/multi-payment.php b/src/config/multi-payment.php index 8fa229c..e1151c0 100644 --- a/src/config/multi-payment.php +++ b/src/config/multi-payment.php @@ -21,6 +21,25 @@ */ 'environment' => env('APP_ENV', 'production'), + /* + |-------------------------------------------------------------------------- + | Idempotência + |-------------------------------------------------------------------------- + | + | Deduplicação feita pela lib (IdempotencyStore) nas operações de escrita em que o + | gateway não aceita o cabeçalho Idempotency-Key. A store padrão usa o cache do Laravel; + | para trocar, faça bind de Potelo\MultiPayment\Contracts\IdempotencyStore no container. + | + */ + 'idempotency' => [ + // prazo, em segundos, em que a mesma chave devolve o resultado guardado + 'ttl' => env('MULTIPAYMENT_IDEMPOTENCY_TTL', 86400), + // store de cache do Laravel usada pela CacheIdempotencyStore; nulo usa a padrão da aplicação + 'cache_store' => env('MULTIPAYMENT_IDEMPOTENCY_CACHE_STORE'), + // prefixo das chaves no cache + 'prefix' => 'multi-payment:idempotency:', + ], + /* |-------------------------------------------------------------------------- | Available gateways diff --git a/tests/Integration/IdempotencyTest.php b/tests/Integration/IdempotencyTest.php new file mode 100644 index 0000000..964889d --- /dev/null +++ b/tests/Integration/IdempotencyTest.php @@ -0,0 +1,142 @@ +set('cache.default', 'array'); + } + + #[DataProvider('gatewayProvider')] + public function testCreatingAnInvoiceTwiceWithTheSameKeyReturnsTheSameInvoice(string $gateway): void + { + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + $key = 'multipayment-idem-' . bin2hex(random_bytes(8)); + + $first = $this->pixInvoiceBuilder($gateway, $customer->id)->withIdempotencyKey($key)->create(); + $second = $this->pixInvoiceBuilder($gateway, $customer->id)->withIdempotencyKey($key)->create(); + + $this->assertNotEmpty($first->id); + $this->assertSame($first->id, $second->id); + $this->assertSame(InvoiceStatus::PENDING, $second->status); + } + + #[DataProvider('gatewayProvider')] + public function testCreatingAnInvoiceTwiceWithoutAKeyCreatesTwoInvoices(string $gateway): void + { + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + + $first = $this->pixInvoiceBuilder($gateway, $customer->id)->create(); + $second = $this->pixInvoiceBuilder($gateway, $customer->id)->create(); + + $this->assertNotSame($first->id, $second->id); + } + + /** + * Na Iugu o gateway responde 409 sem o id do cliente original (`resource_id: processing`), + * então a segunda chamada lança em vez de devolver o cliente. + */ + public function testIuguRejectsTheSameKeyOnCustomerCreation(): void + { + $key = 'multipayment-idem-' . bin2hex(random_bytes(8)); + $data = self::customerWithoutAddress(); + $data['email'] = "idem-{$key}@example.com"; + + $first = MultiPayment::setGateway('iugu')->newCustomer() + ->setName($data['name'])->setEmail($data['email'])->setTaxDocument($data['taxDocument']) + ->withIdempotencyKey($key) + ->create(); + $this->assertNotEmpty($first->id); + + try { + MultiPayment::setGateway('iugu')->newCustomer() + ->setName($data['name'])->setEmail($data['email'])->setTaxDocument($data['taxDocument']) + ->withIdempotencyKey($key) + ->create(); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertSame(409, $e->httpStatus); + $this->assertNull($e->resourceId); + } + } + + /** + * O provider só existe para o `TestCase` reconhecer o gateway e pular a pausa da Iugu. + */ + #[DataProvider('stripeProvider')] + public function testStripeRejectsTheSameKeyWithAnotherPayload(string $gateway): void + { + $customer = $this->createCustomer('stripe', self::customerWithoutAddress()); + $key = 'multipayment-idem-' . bin2hex(random_bytes(8)); + + $this->pixInvoiceBuilder('stripe', $customer->id)->withIdempotencyKey($key)->create(); + + $this->expectException(IdempotencyConflictException::class); + + $this->pixInvoiceBuilder('stripe', $customer->id, 2000)->withIdempotencyKey($key)->create(); + } + + public function testIuguCancellationIsDeduplicatedByTheStore(): void + { + $customer = $this->createCustomer('iugu', self::customerWithoutAddress()); + $invoice = $this->pixInvoiceBuilder('iugu', $customer->id)->create(); + $key = 'multipayment-idem-' . bin2hex(random_bytes(8)); + + $first = MultiPayment::setGateway('iugu')->cancelInvoice($invoice->id, $key); + $second = MultiPayment::setGateway('iugu')->cancelInvoice($invoice->id, $key); + + $this->assertSame(InvoiceStatus::CANCELED, $first->status); + $this->assertSame(InvoiceStatus::CANCELED, $second->status); + $this->assertTrue($this->app->make(IdempotencyStore::class)->has('iugu:' . $key)); + } + + public static function gatewayProvider(): array + { + return ['iugu' => ['iugu'], 'stripe' => ['stripe']]; + } + + public static function stripeProvider(): array + { + return ['stripe' => ['stripe']]; + } + + private function pixInvoiceBuilder(string $gateway, string $customerId, int $amount = 1000): \Potelo\MultiPayment\Builders\InvoiceBuilder + { + return MultiPayment::setGateway($gateway)->newInvoice() + ->addAvailablePaymentMethod(PaymentMethod::PIX) + ->setCustomer($this->customerWithId($gateway, $customerId)) + ->addItem('Idempotency sandbox test', $amount, 1) + // data fixa: um expires_at derivado do instante da chamada mudaria o payload entre as + // tentativas, e a Stripe recusa a mesma chave com payload diferente + ->setExpiresAt(now()->addDays(2)->startOfDay()); + } + + private function customerWithId(string $gateway, string $customerId): \Potelo\MultiPayment\Models\Customer + { + $customer = new \Potelo\MultiPayment\Models\Customer(); + $customer->id = $customerId; + $customer->gateway = $gateway; + $customer->name = 'Fake Customer'; + $customer->email = 'email@exemplo.com'; + $customer->taxDocument = '20176996915'; + + return $customer; + } +} diff --git a/tests/Integration/MultiPaymentTest.php b/tests/Integration/MultiPaymentTest.php index 7c29112..0ca5597 100644 --- a/tests/Integration/MultiPaymentTest.php +++ b/tests/Integration/MultiPaymentTest.php @@ -208,7 +208,7 @@ public function testShouldDeleteCard() $multiPayment->deleteCard($customer->id, $creditCard->id); $this->expectException(\Potelo\MultiPayment\Exceptions\NotFoundException::class); - $this->expectExceptionMessage('payment_method: not found'); + $this->expectExceptionMessageMatches('/not found/i'); $multiPayment->getCard($customer->id, $creditCard->id); } diff --git a/tests/Unit/AutomaticPixTest.php b/tests/Unit/AutomaticPixTest.php index b857053..5ca3352 100644 --- a/tests/Unit/AutomaticPixTest.php +++ b/tests/Unit/AutomaticPixTest.php @@ -121,7 +121,7 @@ public function testCancelsScheduledAutomaticPixPaymentThroughScalarContract(): ->once() ->with(Mockery::on(fn (AutomaticPixCharge $charge) => $charge->id === 'payment-id' && $charge->endToEndId === 'end-to-end-id' - )) + ), null) ->andReturn($cancellation); $result = (new MultiPayment($gateway)) @@ -136,7 +136,7 @@ public function testCancelsAutomaticPixRecurrenceThroughModelContract(): void $gateway = Mockery::mock(GatewayContract::class); $gateway->shouldReceive('cancelAutomaticPixRecurrence') ->once() - ->with(Mockery::on(fn (AutomaticPix $automaticPix) => $automaticPix->id === 'recurrence-id')) + ->with(Mockery::on(fn (AutomaticPix $automaticPix) => $automaticPix->id === 'recurrence-id'), null) ->andReturn($cancellation); $result = (new MultiPayment($gateway))->cancelAutomaticPixRecurrence('recurrence-id'); @@ -152,7 +152,7 @@ public function testReschedulesAutomaticPixPaymentThroughInvoiceContract(): void $gateway = Mockery::mock(GatewayContract::class); $gateway->shouldReceive('rescheduleAutomaticPixPayment') ->once() - ->with(Mockery::on(fn (Invoice $model) => $model->id === 'invoice-id')) + ->with(Mockery::on(fn (Invoice $model) => $model->id === 'invoice-id'), null) ->andReturn($invoice); $result = (new MultiPayment($gateway))->rescheduleAutomaticPixPayment('invoice-id'); @@ -199,7 +199,7 @@ public function testCancelsInvoiceThroughGateway(): void $gateway = Mockery::mock(GatewayContract::class); $gateway->shouldReceive('cancelInvoice') ->once() - ->with(Mockery::on(fn (Invoice $invoice) => $invoice->id === 'invoice-id')) + ->with(Mockery::on(fn (Invoice $invoice) => $invoice->id === 'invoice-id'), null) ->andReturn($cancelledInvoice); $result = (new MultiPayment($gateway))->cancelInvoice('invoice-id'); diff --git a/tests/Unit/Gateways/GatewayCapabilitiesTest.php b/tests/Unit/Gateways/GatewayCapabilitiesTest.php index fb0cb63..1e31e2d 100644 --- a/tests/Unit/Gateways/GatewayCapabilitiesTest.php +++ b/tests/Unit/Gateways/GatewayCapabilitiesTest.php @@ -75,8 +75,8 @@ public static function matrixProvider(): array Capability::PARTIAL_REFUND_PIX->name => [self::LIMITATION, self::SUPPORTED], Capability::REFUND_BANK_SLIP->name => [self::LIMITATION, self::LIMITATION], Capability::INVOICE_DUPLICATION->name => [self::SUPPORTED, self::SUPPORTED], - Capability::IDEMPOTENCY->name => [self::NOT_IMPLEMENTED, self::SUPPORTED], - Capability::IDEMPOTENCY_ALL_ENDPOINTS->name => [self::LIMITATION, self::NOT_IMPLEMENTED], + Capability::IDEMPOTENCY->name => [self::SUPPORTED, self::SUPPORTED], + Capability::IDEMPOTENCY_ALL_ENDPOINTS->name => [self::LIMITATION, self::SUPPORTED], Capability::SUBSCRIPTIONS->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], Capability::PLANS->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], Capability::PLAN_DEACTIVATION->name => [self::LIMITATION, self::NOT_IMPLEMENTED], diff --git a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php index 3d2cfd4..88b25f1 100644 --- a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php +++ b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php @@ -309,7 +309,7 @@ public function __construct(private object|array $response) { } - public function request($method, $url, $data = []) + public function request($method, $url, $data = [], $headers = []) { $this->method = $method; $this->url = $url; diff --git a/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php new file mode 100644 index 0000000..e7c243b --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php @@ -0,0 +1,957 @@ +app = new Container(); + $this->app->instance('config', new Repository([ + 'multi-payment.gateways.iugu.api_key' => 'test-api-key', + 'multi-payment.gateways.iugu.id' => 'account-id', + 'multi-payment.environment' => 'testing', + 'multi-payment.idempotency.ttl' => 3600, + ])); + Facade::setFacadeApplication($this->app); + Carbon::setTestNow('2026-09-02 12:00:00'); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + QueuedIuguApiRequest::restoreSdkRequester(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + #[DataProvider('nativeEndpointProvider')] + public function testTheKeyGoesInTheHeaderOfTheEndpointsTheIuguSupports(\Closure $operation, array $responses, string $expectedPath): void + { + $api = new QueuedIuguApiRequest($responses); + $store = new InMemoryIdempotencyStore(); + + $operation(new IuguGateway($api, $store), 'chave-1'); + + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith($expectedPath, $api->calls[0]['url']); + $this->assertSame(['Idempotency-Key: chave-1'], $api->calls[0]['headers']); + $this->assertArrayNotHasKey('idempotency_key', $api->calls[0]['data']); + // o gateway deduplica sozinho: a store não é usada + $this->assertFalse($store->has('iugu:chave-1')); + + // nenhuma outra requisição da operação (a leitura da fatura cobrada) leva a chave + foreach (array_slice($api->calls, 1) as $call) { + $this->assertSame([], $call['headers']); + } + } + + public static function nativeEndpointProvider(): array + { + return [ + 'createInvoice pix (POST /invoices)' => [ + fn (IuguGateway $g, string $key) => $g->createInvoice(self::pixInvoiceModel(), $key), + [self::pendingInvoiceResponse()], + '/invoices', + ], + 'createInvoice com cartão salvo (POST /charge)' => [ + fn (IuguGateway $g, string $key) => $g->createInvoice(self::cardInvoiceModel(), $key), + [(object) ['success' => true, 'invoice_id' => 'inv_1'], self::pendingInvoiceResponse()], + '/charge', + ], + 'chargeInvoiceWithCreditCard (POST /charge)' => [ + function (IuguGateway $g, string $key) { + $invoice = self::invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_1'; + + return $g->chargeInvoiceWithCreditCard($invoice, $key); + }, + [(object) ['success' => true, 'invoice_id' => 'inv_1'], self::pendingInvoiceResponse()], + '/charge', + ], + 'createCustomer (POST /customers)' => [ + fn (IuguGateway $g, string $key) => $g->createCustomer(self::customerModel(), $key), + [self::customerResponse()], + '/customers', + ], + 'createSubscription (POST /subscriptions)' => [ + function (IuguGateway $g, string $key) { + $subscription = new Subscription(); + $subscription->planId = 'plano_mensal'; + $subscription->customer = self::customerWithId(); + + return $g->createSubscription($subscription, $key); + }, + [self::subscriptionResponse()], + '/subscriptions', + ], + ]; + } + + /** + * A segunda chamada com a mesma chave devolve a resposta guardada: a requisição de escrita + * não se repete, só as leituras que a operação faz em volta dela. + */ + #[DataProvider('storeEndpointProvider')] + public function testTheKeyGoesThroughTheStoreOnTheEndpointsTheIuguDoesNotSupport( + \Closure $operation, + array $responses, + string $expectedMethod, + string $expectedPath, + array $responsesForTheRetry = [] + ): void { + $api = new QueuedIuguApiRequest(array_merge($responses, $responsesForTheRetry)); + $store = new InMemoryIdempotencyStore(); + $gateway = new IuguGateway($api, $store); + + $operation($gateway, 'chave-1'); + + $writes = array_filter( + $api->calls, + fn (array $call) => $call['method'] === $expectedMethod && str_ends_with(parse_url($call['url'], PHP_URL_PATH), $expectedPath) + ); + $this->assertCount(1, $writes, "esperava uma requisição {$expectedMethod} {$expectedPath}"); + $this->assertSame([], reset($writes)['headers']); + $this->assertArrayNotHasKey('idempotency_key', reset($writes)['data']); + $this->assertTrue($store->has('iugu:chave-1')); + + $operation($gateway, 'chave-1'); + + $writesAfterRetry = array_filter( + $api->calls, + fn (array $call) => $call['method'] === $expectedMethod && str_ends_with(parse_url($call['url'], PHP_URL_PATH), $expectedPath) + ); + $this->assertCount(1, $writesAfterRetry, 'a segunda chamada com a mesma chave não pode repetir a escrita'); + } + + public static function storeEndpointProvider(): array + { + $paidCardInvoice = self::pendingInvoiceResponse([ + 'status' => 'paid', + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'paid_cents' => 10000, + 'payment_method' => 'iugu_credit_card', + 'payable_with' => 'credit_card', + ]); + $refundedInvoice = self::pendingInvoiceResponse([ + 'status' => 'refunded', + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 10000, + 'payment_method' => 'iugu_credit_card', + ]); + + return [ + 'cancelInvoice (PUT /cancel)' => [ + fn (IuguGateway $g, string $key) => $g->cancelInvoice(self::invoiceWithId(), $key), + [self::pendingInvoiceResponse(['status' => 'canceled'])], + 'PUT', '/invoices/inv_1/cancel', + ], + 'refundInvoice (operação inteira guardada: nem a leitura prévia se repete)' => [ + fn (IuguGateway $g, string $key) => $g->refundInvoice(self::invoiceWithId(), $key), + [$paidCardInvoice, $refundedInvoice], + 'POST', '/invoices/inv_1/refund', + ], + 'duplicateInvoice (POST /duplicate)' => [ + fn (IuguGateway $g, string $key) => $g->duplicateInvoice(self::invoiceWithId(), Carbon::parse('2026-10-01'), [], $key), + [self::pendingInvoiceResponse(['id' => 'inv_2'])], + 'POST', '/invoices/inv_1/duplicate', + ], + 'rescheduleAutomaticPixPayment' => [ + fn (IuguGateway $g, string $key) => $g->rescheduleAutomaticPixPayment(self::invoiceWithId(), $key), + [self::pendingInvoiceResponse()], + 'POST', '/invoices/inv_1/reschedule_automatic_pix_payment', + ], + 'cancelAutomaticPixRecurrence' => [ + function (IuguGateway $g, string $key) { + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'rec_1'; + + return $g->cancelAutomaticPixRecurrence($automaticPix, $key); + }, + [(object) ['cancellation_id' => 'can_1', 'status' => 'requested']], + 'PUT', '/automatic_pix/receiver_recurrences/rec_1/cancel', + ], + 'cancelAutomaticPixScheduledPayment' => [ + function (IuguGateway $g, string $key) { + $charge = new AutomaticPixCharge(); + $charge->id = 'pay_1'; + $charge->endToEndId = 'E123'; + + return $g->cancelAutomaticPixScheduledPayment($charge, $key); + }, + [(object) ['cancellation_id' => 'can_1', 'status' => 'requested']], + 'POST', '/automatic_pix/receiver_recurrence_payments/cancel', + ], + 'updateCustomer (PUT /customers/{id})' => [ + fn (IuguGateway $g, string $key) => $g->updateCustomer(self::customerWithId(), $key), + [self::customerResponse()], + 'PUT', '/customers/cus_1', + ], + 'setCustomerDefaultCard (PUT /customers/{id})' => [ + fn (IuguGateway $g, string $key) => $g->setCustomerDefaultCard(self::customerWithId(), 'pm_1', $key), + [self::customerResponse(['default_payment_method_id' => 'pm_1'])], + 'PUT', '/customers/cus_1', + ], + 'createCreditCard com token (POST /payment_methods)' => [ + fn (IuguGateway $g, string $key) => $g->createCreditCard(self::tokenizedCardModel(), $key), + [self::paymentMethodResponse()], + 'POST', '/customers/cus_1/payment_methods', + ], + 'deleteCreditCard (DELETE /payment_methods/{id})' => [ + fn (IuguGateway $g, string $key) => $g->deleteCreditCard(self::savedCardModel(), $key), + [(object) ['id' => 'pm_1']], + 'DELETE', '/customers/cus_1/payment_methods/pm_1', + ], + 'updateSubscription sem itens (PUT /subscriptions/{id})' => [ + function (IuguGateway $g, string $key) { + $subscription = self::subscriptionWithId(); + $subscription->metadata = ['origem' => 'teste']; + + return $g->updateSubscription($subscription, $key); + }, + [self::subscriptionResponse()], + 'PUT', '/subscriptions/sub_1', + ], + 'suspendSubscription (POST /suspend)' => [ + fn (IuguGateway $g, string $key) => $g->suspendSubscription(self::subscriptionWithId(), $key), + [self::subscriptionResponse(['suspended' => true])], + 'POST', '/subscriptions/sub_1/suspend', + ], + 'resumeSubscription (POST /activate)' => [ + fn (IuguGateway $g, string $key) => $g->resumeSubscription(self::subscriptionWithId(), $key), + [self::subscriptionResponse()], + 'POST', '/subscriptions/sub_1/activate', + ], + 'cancelSubscription (POST /suspend)' => [ + fn (IuguGateway $g, string $key) => $g->cancelSubscription(self::subscriptionWithId(), false, $key), + [self::subscriptionResponse(['suspended' => true])], + 'POST', '/subscriptions/sub_1/suspend', + ], + 'changeSubscriptionPlan com cobrança (POST /change_plan, com a releitura repetida)' => [ + fn (IuguGateway $g, string $key) => $g->changeSubscriptionPlan(self::subscriptionWithId(), 'plano_anual', true, $key), + [(object) ['success' => true], self::subscriptionResponse(['plan_identifier' => 'plano_anual'])], + 'POST', '/subscriptions/sub_1/change_plan/plano_anual', + [self::subscriptionResponse(['plan_identifier' => 'plano_anual'])], + ], + 'changeSubscriptionPlan sem cobrança (PUT /subscriptions/{id})' => [ + fn (IuguGateway $g, string $key) => $g->changeSubscriptionPlan(self::subscriptionWithId(), 'plano_anual', false, $key), + [self::subscriptionResponse(['plan_identifier' => 'plano_anual'])], + 'PUT', '/subscriptions/sub_1', + ], + 'createPlan (POST /plans)' => [ + function (IuguGateway $g, string $key) { + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->identifier = 'plano_mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + return $g->createPlan($plan, $key); + }, + [self::planResponse()], + 'POST', '/plans', + ], + ]; + } + + /** + * O retry do estorno com a mesma chave devolve o `Refund` da primeira execução sem nenhuma + * requisição: a leitura prévia encontraria a fatura já estornada e a guarda recusaria. + */ + public function testRefundRetryReturnsTheStoredRefundWithoutReadingTheInvoiceAgain(): void + { + $paid = self::pendingInvoiceResponse([ + 'status' => 'paid', + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'paid_cents' => 10000, + 'payment_method' => 'iugu_credit_card', + ]); + $refunded = self::pendingInvoiceResponse([ + 'status' => 'refunded', + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 10000, + 'payment_method' => 'iugu_credit_card', + ]); + $api = new QueuedIuguApiRequest([$paid, $refunded]); + $gateway = new IuguGateway($api, new InMemoryIdempotencyStore()); + + $first = $gateway->refundInvoice(self::invoiceWithId(), 'chave-1'); + $second = $gateway->refundInvoice(self::invoiceWithId(), 'chave-1'); + + $this->assertCount(2, $api->calls); + $this->assertSame(10000, $first->amount); + $this->assertSame($first, $second); + } + + public function testTheSameKeyOnAnotherOperationIsAConflict(): void + { + $api = new QueuedIuguApiRequest([ + self::pendingInvoiceResponse(['status' => 'canceled']), + self::pendingInvoiceResponse(['id' => 'inv_2']), + ]); + $gateway = new IuguGateway($api, new InMemoryIdempotencyStore()); + $gateway->cancelInvoice(self::invoiceWithId(), 'chave-1'); + + try { + $gateway->duplicateInvoice(self::invoiceWithId(), Carbon::parse('2026-10-01'), [], 'chave-1'); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertStringContainsString('[chave-1]', $e->getMessage()); + $this->assertStringContainsString('outra operação', $e->getMessage()); + } + + $this->assertCount(1, $api->calls); + } + + #[DataProvider('conflictWithoutResourceIdProvider')] + public function testAReusedKeyWithoutResourceIdOnAnInvoiceWriteSurfacesTheConflict(\Closure $operation): void + { + $api = new QueuedIuguApiRequest([self::iuguConflictResponse('processing')]); + + try { + $operation(new IuguGateway($api), 'chave-1'); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertNull($e->resourceId); + } + + $this->assertCount(1, $api->calls); + } + + public static function conflictWithoutResourceIdProvider(): array + { + return [ + 'POST /invoices' => [fn (IuguGateway $g, string $key) => $g->createInvoice(self::pixInvoiceModel(), $key)], + 'POST /charge' => [fn (IuguGateway $g, string $key) => $g->createInvoice(self::cardInvoiceModel(), $key)], + ]; + } + + /** + * A chave antiga em `gatewayOptions` fica fora do corpo em toda operação, e as demais + * opções continuam entrando. + */ + #[DataProvider('legacyKeyProvider')] + #[IgnoreDeprecations] + public function testTheLegacyKeyStaysOutOfTheBodyOnEveryOperation(\Closure $operation, array $responses, int $writeCall, ?string $expectedStoreKey): void + { + $api = new QueuedIuguApiRequest($responses); + $store = new InMemoryIdempotencyStore(); + + $operation(new IuguGateway($api, $store)); + + $data = $api->calls[$writeCall]['data']; + $this->assertArrayNotHasKey('idempotency_key', $data); + $this->assertSame(1, $data['outra']); + if (!is_null($expectedStoreKey)) { + $this->assertTrue($store->has($expectedStoreKey)); + } else { + $this->assertSame(['Idempotency-Key: chave-antiga'], $api->calls[$writeCall]['headers']); + } + } + + public static function legacyKeyProvider(): array + { + $legacy = ['idempotency_key' => 'chave-antiga', 'outra' => 1]; + + return [ + 'createCustomer' => [ + function (IuguGateway $g) use ($legacy) { + $customer = self::customerModel(); + $customer->gatewayOptions = $legacy; + + return $g->createCustomer($customer); + }, + [self::customerResponse()], 0, null, + ], + 'updateCustomer' => [ + function (IuguGateway $g) use ($legacy) { + $customer = self::customerWithId(); + $customer->gatewayOptions = $legacy; + + return $g->updateCustomer($customer); + }, + [self::customerResponse()], 0, 'iugu:chave-antiga', + ], + 'createSubscription' => [ + function (IuguGateway $g) use ($legacy) { + $subscription = new Subscription(); + $subscription->planId = 'plano_mensal'; + $subscription->customer = self::customerWithId(); + $subscription->gatewayOptions = $legacy; + + return $g->createSubscription($subscription); + }, + [self::subscriptionResponse()], 0, null, + ], + 'updateSubscription' => [ + function (IuguGateway $g) use ($legacy) { + $subscription = self::subscriptionWithId(); + $subscription->gatewayOptions = $legacy; + + return $g->updateSubscription($subscription); + }, + [self::subscriptionResponse()], 0, 'iugu:chave-antiga', + ], + 'createPlan' => [ + function (IuguGateway $g) use ($legacy) { + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->identifier = 'plano_mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + $plan->gatewayOptions = $legacy; + + return $g->createPlan($plan); + }, + [self::planResponse()], 0, 'iugu:chave-antiga', + ], + 'duplicateInvoice (chave nas opções do argumento)' => [ + fn (IuguGateway $g) => $g->duplicateInvoice(self::invoiceWithId(), Carbon::parse('2026-10-01'), $legacy), + [self::pendingInvoiceResponse(['id' => 'inv_2'])], 0, 'iugu:chave-antiga', + ], + ]; + } + + public function testTheCardSavedBeforeAChargeUsesADerivedKeyInTheStore(): void + { + $api = new QueuedIuguApiRequest([ + self::paymentMethodResponse(), + (object) ['success' => true, 'invoice_id' => 'inv_1'], + self::pendingInvoiceResponse(), + ]); + $store = new InMemoryIdempotencyStore(); + + $invoice = self::cardInvoiceModel(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'tok_1'; + (new IuguGateway($api, $store))->createInvoice($invoice, 'chave-1'); + + $this->assertStringEndsWith('/customers/cus_1/payment_methods', $api->calls[0]['url']); + $this->assertSame([], $api->calls[0]['headers']); + $this->assertTrue($store->has('iugu:chave-1:card')); + $this->assertStringEndsWith('/charge', $api->calls[1]['url']); + $this->assertSame(['Idempotency-Key: chave-1'], $api->calls[1]['headers']); + $this->assertFalse($store->has('iugu:chave-1')); + } + + public function testTokenizationOfRawCardDataUsesADerivedKeyInTheStore(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'tok_1', 'method' => 'credit_card'], + self::paymentMethodResponse(), + ]); + $store = new InMemoryIdempotencyStore(); + + $creditCard = self::savedCardModel(); + $creditCard->id = null; + $creditCard->number = '4111111111111111'; + $creditCard->cvv = '123'; + $creditCard->firstName = 'Cliente'; + $creditCard->lastName = 'Teste'; + $creditCard->month = '12'; + $creditCard->year = '2030'; + (new IuguGateway($api, $store))->createCreditCard($creditCard, 'chave-1'); + + $this->assertStringEndsWith('/payment_token', $api->calls[0]['url']); + $this->assertStringEndsWith('/customers/cus_1/payment_methods', $api->calls[1]['url']); + $this->assertTrue($store->has('iugu:chave-1:token')); + $this->assertTrue($store->has('iugu:chave-1')); + } + + public function testRemovingSubscriptionItemsBeforeTheUpdateUsesADerivedKey(): void + { + $api = new QueuedIuguApiRequest([ + self::subscriptionResponse(['subitems' => [ + (object) ['id' => 'sub_item_velho', 'description' => 'Antigo', 'price_cents' => 1000, 'quantity' => 1, 'recurrent' => true], + ]]), + self::subscriptionResponse(), + self::subscriptionResponse(), + ]); + $store = new InMemoryIdempotencyStore(); + + $subscription = self::subscriptionWithId(); + $item = new SubscriptionItem(); + $item->description = 'Novo'; + $item->amount = 2500; + $subscription->items = [$item]; + (new IuguGateway($api, $store))->updateSubscription($subscription, 'chave-1'); + + $this->assertSame(['GET', 'PUT', 'PUT'], array_column($api->calls, 'method')); + $this->assertTrue($store->has('iugu:chave-1:remove')); + $this->assertTrue($store->has('iugu:chave-1')); + } + + public function testWithoutAKeyNoHeaderIsSentAndTheStoreIsNotTouched(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse(), self::pendingInvoiceResponse(['status' => 'canceled'])]); + $store = new class implements IdempotencyStore { + public function remember(string $key, callable $operation, int $ttlSeconds): mixed + { + throw new \LogicException('a store não pode ser usada sem chave'); + } + + public function has(string $key): bool + { + return false; + } + }; + $gateway = new IuguGateway($api, $store); + + $gateway->createInvoice(self::pixInvoiceModel()); + $gateway->cancelInvoice(self::invoiceWithId()); + + $this->assertSame([[], []], array_column($api->calls, 'headers')); + } + + public function testAStoreEndpointWithAKeyButNoStoreConfiguredIsAConfigurationError(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse()]); + + try { + (new IuguGateway($api))->cancelInvoice(self::invoiceWithId(), 'chave-1'); + $this->fail('Esperava ConfigurationException'); + } catch (ConfigurationException $e) { + $this->assertStringContainsString('IdempotencyStore', $e->getMessage()); + } + + $this->assertSame([], $api->calls); + } + + public function testANativeEndpointWithAKeyWorksWithoutAStore(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse()]); + + $invoice = (new IuguGateway($api))->createInvoice(self::pixInvoiceModel(), 'chave-1'); + + $this->assertSame('inv_1', $invoice->id); + $this->assertSame(['Idempotency-Key: chave-1'], $api->calls[0]['headers']); + } + + public function testTheStoreIsResolvedFromTheContainerWhenNotInjected(): void + { + $store = new InMemoryIdempotencyStore(); + $this->app->instance(IdempotencyStore::class, $store); + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse(['status' => 'canceled'])]); + + (new IuguGateway($api))->cancelInvoice(self::invoiceWithId(), 'chave-1'); + + $this->assertTrue($store->has('iugu:chave-1')); + } + + public function testAConflictInTheStoreIsAnIdempotencyConflictExceptionWithoutARequest(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse()]); + $store = new class implements IdempotencyStore { + public function remember(string $key, callable $operation, int $ttlSeconds): mixed + { + throw IdempotencyConflictException::concurrent($key); + } + + public function has(string $key): bool + { + return false; + } + }; + + try { + (new IuguGateway($api, $store))->cancelInvoice(self::invoiceWithId(), 'chave-1'); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertStringContainsString('[iugu:chave-1]', $e->getMessage()); + } + + $this->assertSame([], $api->calls); + } + + public function testTheStoreTtlComesFromTheConfiguration(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse(['status' => 'canceled'])]); + $store = new InMemoryIdempotencyStore(); + + (new IuguGateway($api, $store))->cancelInvoice(self::invoiceWithId(), 'chave-1'); + + Carbon::setTestNow('2026-09-02 12:59:59'); + $this->assertTrue($store->has('iugu:chave-1')); + Carbon::setTestNow('2026-09-02 13:00:00'); + $this->assertFalse($store->has('iugu:chave-1')); + } + + #[IgnoreDeprecations] + public function testTheLegacyGatewayOptionStillWorksWithADeprecationAndStaysOutOfTheBody(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse()]); + + $invoice = self::pixInvoiceModel(); + $invoice->gatewayOptions = ['idempotency_key' => 'chave-antiga', 'expires_in' => 3]; + + $this->expectUserDeprecationMessage("gateway_options['idempotency_key'] está obsoleto desde 2026-09-02; passe idempotencyKey como argumento da operação"); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertSame(['Idempotency-Key: chave-antiga'], $api->calls[0]['headers']); + $this->assertArrayNotHasKey('idempotency_key', $api->calls[0]['data']); + $this->assertSame(3, $api->calls[0]['data']['expires_in']); + } + + #[IgnoreDeprecations] + public function testTheLegacyGatewayOptionAlsoReachesTheStoreEndpoints(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse(['status' => 'canceled'])]); + $store = new InMemoryIdempotencyStore(); + + $invoice = self::invoiceWithId(); + $invoice->gatewayOptions = ['idempotency_key' => 'chave-antiga']; + + $this->expectUserDeprecationMessage("gateway_options['idempotency_key'] está obsoleto desde 2026-09-02; passe idempotencyKey como argumento da operação"); + + (new IuguGateway($api, $store))->cancelInvoice($invoice); + + $this->assertTrue($store->has('iugu:chave-antiga')); + } + + #[IgnoreDeprecations] + public function testTheArgumentWinsOverTheLegacyGatewayOption(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse()]); + + $invoice = self::pixInvoiceModel(); + $invoice->gatewayOptions = ['idempotency_key' => 'chave-antiga']; + + (new IuguGateway($api))->createInvoice($invoice, 'chave-nova'); + + $this->assertSame(['Idempotency-Key: chave-nova'], $api->calls[0]['headers']); + } + + /** + * Na reutilização da chave a Iugu responde 409 com o id da fatura original; o driver a lê e + * devolve, sem cabeçalho na leitura. + */ + public function testAReusedKeyOnInvoiceCreationReturnsTheOriginalInvoice(): void + { + $api = new QueuedIuguApiRequest([ + self::iuguConflictResponse('F53DE68D632E48618DB238F2C1E5D531'), + self::pendingInvoiceResponse(['id' => 'F53DE68D632E48618DB238F2C1E5D531']), + ]); + + $invoice = (new IuguGateway($api))->createInvoice(self::pixInvoiceModel(), 'chave-1'); + + $this->assertSame('F53DE68D632E48618DB238F2C1E5D531', $invoice->id); + $this->assertSame(InvoiceStatus::PENDING, $invoice->status); + $this->assertSame('GET', $api->calls[1]['method']); + $this->assertStringEndsWith('/invoices/F53DE68D632E48618DB238F2C1E5D531', $api->calls[1]['url']); + $this->assertSame([], $api->calls[1]['headers']); + } + + public function testAReusedKeyOnACardChargeReturnsTheOriginalInvoice(): void + { + $api = new QueuedIuguApiRequest([ + self::iuguConflictResponse('495CD9FE89EB496FB3230F97AB857AA9'), + self::pendingInvoiceResponse(['id' => '495CD9FE89EB496FB3230F97AB857AA9', 'status' => 'paid']), + ]); + + $invoice = (new IuguGateway($api))->createInvoice(self::cardInvoiceModel(), 'chave-1'); + + $this->assertSame('495CD9FE89EB496FB3230F97AB857AA9', $invoice->id); + $this->assertStringEndsWith('/charge', $api->calls[0]['url']); + $this->assertStringEndsWith('/invoices/495CD9FE89EB496FB3230F97AB857AA9', $api->calls[1]['url']); + } + + /** + * Para cliente e assinatura a Iugu responde `resource_id: processing`, sem o id do recurso, + * então não há o que ler e a exceção sobe com `resourceId` nulo. + */ + public function testAReusedKeyOnCustomerCreationIsAConflictWithoutResourceId(): void + { + $api = new QueuedIuguApiRequest([self::iuguConflictResponse('processing')]); + + try { + (new IuguGateway($api))->createCustomer(self::customerModel(), 'chave-1'); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertSame(409, $e->httpStatus); + $this->assertNull($e->resourceId); + $this->assertStringContainsString('já esta em uso', $e->getMessage()); + } + + $this->assertCount(1, $api->calls); + } + + public function testTheConflictExceptionCarriesTheResourceIdWhenTheIuguInformsIt(): void + { + $api = new QueuedIuguApiRequest([self::iuguConflictResponse('D51E2917CDFE409FAE08EDCA52D8E8FC')]); + + $subscription = new Subscription(); + $subscription->planId = 'plano_mensal'; + $subscription->customer = self::customerWithId(); + + try { + (new IuguGateway($api))->createSubscription($subscription, 'chave-1'); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertSame('D51E2917CDFE409FAE08EDCA52D8E8FC', $e->resourceId); + } + } + + public function testRetryAfterHeaderOfA429FillsTheRateLimitException(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Too many requests'], 429, ['retry-after' => '7']), + ]); + + try { + (new IuguGateway($api))->getInvoice(self::invoiceWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertSame(429, $e->httpStatus); + $this->assertSame(7, $e->retryAfter); + } + } + + #[DataProvider('retryAfterProvider')] + public function testRetryAfterReadsTheFirstNumericValueOnly(mixed $header, ?int $expected): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Too many requests'], 429, ['retry-after' => $header]), + ]); + + try { + (new IuguGateway($api))->getInvoice(self::invoiceWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertSame($expected, $e->retryAfter); + } + } + + public static function retryAfterProvider(): array + { + return [ + 'cabeçalho repetido vira lista: vale o primeiro' => [['7', '9'], 7], + 'data HTTP não é interpretada' => ['Wed, 02 Sep 2026 12:00:00 GMT', null], + ]; + } + + public function testRetryAfterIsNullWhenTheIuguDoesNotSendIt(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Too many requests'], 429), + ]); + + try { + (new IuguGateway($api))->getInvoice(self::invoiceWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertNull($e->retryAfter); + } + } + + /** + * Resposta 409 da Iugu para chave reutilizada, como observada na sandbox. + */ + private static function iuguConflictResponse(string $resourceId): QueuedIuguResponse + { + return new QueuedIuguResponse((object) ['errors' => [ + "Essa chave de idempotência já esta em uso: idempotency_key: chave-1, resource_id: {$resourceId}", + ]], 409); + } + + private static function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + + return $invoice; + } + + private static function customerWithId(): Customer + { + $customer = self::customerModel(); + $customer->id = 'cus_1'; + + return $customer; + } + + private static function customerModel(): Customer + { + $customer = new Customer(); + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + $customer->taxDocument = '20176996915'; + + return $customer; + } + + private static function pixInvoiceModel(): Invoice + { + $invoice = new Invoice(); + $invoice->customer = self::customerWithId(); + $invoice->availablePaymentMethods = [PaymentMethod::PIX]; + $invoice->expiresAt = Carbon::parse('2026-10-01'); + $item = new InvoiceItem(); + $item->description = 'Item'; + $item->price = 10000; + $item->quantity = 1; + $invoice->items = [$item]; + + return $invoice; + } + + private static function cardInvoiceModel(): Invoice + { + $invoice = self::pixInvoiceModel(); + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD]; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_1'; + + return $invoice; + } + + private static function savedCardModel(): CreditCard + { + $creditCard = new CreditCard(); + $creditCard->id = 'pm_1'; + $creditCard->customer = self::customerWithId(); + + return $creditCard; + } + + private static function tokenizedCardModel(): CreditCard + { + $creditCard = self::savedCardModel(); + $creditCard->id = null; + $creditCard->token = 'tok_1'; + + return $creditCard; + } + + private static function subscriptionWithId(): Subscription + { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + return $subscription; + } + + private static function pendingInvoiceResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'inv_1', + 'status' => 'pending', + 'total_cents' => 10000, + 'paid_at' => null, + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'taxes_paid_cents' => null, + 'created_at_iso' => '2026-09-02T09:00:00-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 0, + 'due_date' => '2026-10-01', + 'payment_method' => null, + 'payable_with' => 'pix', + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [ + (object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1], + ], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + ], $overrides); + } + + private static function customerResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'cus_1', + 'name' => 'Cliente', + 'email' => 'cliente@example.com', + 'cpf_cnpj' => '20176996915', + 'phone' => null, + 'phone_prefix' => null, + 'created_at' => '2026-09-02T09:00:00-03:00', + 'custom_variables' => [], + 'default_payment_method_id' => null, + ], $overrides); + } + + private static function paymentMethodResponse(): object + { + return (object) [ + 'id' => 'pm_1', + 'description' => 'CREDIT CARD', + 'created_at_iso' => '2026-09-02T09:00:00-03:00', + 'data' => (object) ['brand' => 'VISA', 'display_number' => 'XXXX-XXXX-XXXX-4242', 'month' => 12, 'year' => 2030, 'holder_name' => 'Cliente Teste'], + ]; + } + + private static function subscriptionResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'sub_1', + 'customer_id' => 'cus_1', + 'plan_identifier' => 'plano_mensal', + 'price_cents' => 10000, + 'expires_at' => '2026-10-01', + 'created_at' => '2026-09-01T10:00:00-03:00', + 'active' => true, + 'suspended' => false, + 'in_trial' => false, + ], $overrides); + } + + private static function planResponse(): object + { + return (object) [ + 'id' => 'plan_1', + 'identifier' => 'plano_mensal', + 'name' => 'Mensal', + 'interval' => 1, + 'interval_type' => 'months', + 'prices' => [(object) ['value_cents' => 10000, 'currency' => 'BRL']], + ]; + } +} diff --git a/tests/Unit/Gateways/QueuedIuguApiRequest.php b/tests/Unit/Gateways/QueuedIuguApiRequest.php index 90aecd4..038e111 100644 --- a/tests/Unit/Gateways/QueuedIuguApiRequest.php +++ b/tests/Unit/Gateways/QueuedIuguApiRequest.php @@ -5,15 +5,18 @@ use Iugu_APIRequest; /** - * Devolve uma resposta por chamada, na ordem, e guarda todas as chamadas feitas. Uma entrada - * `\Throwable` na fila é lançada em vez de devolvida, para simular o SDK sinalizando 404 ou 5xx; - * uma entrada `QueuedIuguResponse` devolve o corpo com o status HTTP informado, para simular - * erro com corpo JSON (401, 422), que o SDK devolve sem lançar. + * Devolve uma resposta por chamada, na ordem, e guarda todas as chamadas feitas (método, url, + * dados e cabeçalhos extras). Uma entrada `\Throwable` na fila é lançada em vez de devolvida, + * para simular o SDK sinalizando 404 ou 5xx; uma entrada `QueuedIuguResponse` devolve o corpo + * com o status HTTP e os cabeçalhos informados, para simular erro com corpo JSON (401, 422, 429), + * que o SDK devolve sem lançar. * - * Como o SDK, grava o status HTTP de cada resposta devolvida em `$iugu_last_api_response_code`. + * Como o SDK, grava o status e os cabeçalhos de cada resposta em `lastResponseCode` e + * `lastResponseHeaders` da instância. */ class QueuedIuguApiRequest extends Iugu_APIRequest { + /** @var array */ public array $calls = []; /** @@ -21,11 +24,14 @@ class QueuedIuguApiRequest extends Iugu_APIRequest */ public function __construct(private array $responses) { + parent::__construct(); } - public function request($method, $url, $data = []) + public function request($method, $url, $data = [], $headers = []) { - $this->calls[] = ['method' => $method, 'url' => $url, 'data' => $data]; + $this->calls[] = ['method' => $method, 'url' => $url, 'data' => $data, 'headers' => $headers]; + $this->lastResponseCode = null; + $this->lastResponseHeaders = []; if (empty($this->responses)) { throw new \RuntimeException("Sem resposta enfileirada para {$method} {$url}"); @@ -37,20 +43,22 @@ public function request($method, $url, $data = []) } if ($response instanceof QueuedIuguResponse) { - $GLOBALS['iugu_last_api_response_code'] = $response->status; + $this->lastResponseCode = $response->status > 0 ? $response->status : null; + $this->lastResponseHeaders = $response->headers; return $response->body; } - $GLOBALS['iugu_last_api_response_code'] = 200; + $this->lastResponseCode = 200; return $response; } /** - * Instala este fake como requester dos recursos estáticos do SDK (`Iugu_Invoice::create()`, - * `Iugu_Customer::fetch()`, `Iugu_PaymentToken::create()`...), que não recebem o requester - * pelo construtor do gateway. Chame `restoreSdkRequester()` no tearDown. + * Instala este fake como requester compartilhado do SDK (`APIResource::API()`), que é o + * que `IuguGateway` usa quando é construído sem requester (por exemplo, via + * `ConfigurationHelper::resolveGateway()` e `new MultiPayment('iugu')`). Chame + * `restoreSdkRequester()` no tearDown. * * @return $this */ diff --git a/tests/Unit/Gateways/QueuedIuguResponse.php b/tests/Unit/Gateways/QueuedIuguResponse.php index df3365e..239a348 100644 --- a/tests/Unit/Gateways/QueuedIuguResponse.php +++ b/tests/Unit/Gateways/QueuedIuguResponse.php @@ -3,11 +3,15 @@ namespace Potelo\MultiPayment\Tests\Unit\Gateways; /** - * Corpo e status HTTP de uma resposta enfileirada em `QueuedIuguApiRequest`. + * Corpo, status HTTP e cabeçalhos (nome em minúsculas) de uma resposta enfileirada em + * `QueuedIuguApiRequest`. */ final class QueuedIuguResponse { - public function __construct(public readonly object|array $body, public readonly int $status) - { + public function __construct( + public readonly object|array $body, + public readonly int $status, + public readonly array $headers = [] + ) { } } diff --git a/tests/Unit/Gateways/RecordingStripeHttpClient.php b/tests/Unit/Gateways/RecordingStripeHttpClient.php index 00e6ff0..29d9e3f 100644 --- a/tests/Unit/Gateways/RecordingStripeHttpClient.php +++ b/tests/Unit/Gateways/RecordingStripeHttpClient.php @@ -6,14 +6,15 @@ /** * Fake da camada HTTP do stripe-php, no molde do QueuedIuguApiRequest: devolve respostas - * enfileiradas e grava cada chamada para asserção. Cada resposta é um array (corpo JSON, - * status 200), um par [corpo, status], uma tripla [corpo, status, cabeçalhos] ou um + * enfileiradas e grava cada chamada (método, url, parâmetros e cabeçalhos) para asserção. + * Cada resposta é um array (corpo JSON, status 200), um par [corpo, status], uma tripla + * [corpo, status, cabeçalhos] ou um * `\Throwable`, lançado no lugar da resposta para simular falha de conexão. Um corpo string * vai cru, sem codificar em JSON, para simular a página HTML de um proxy. */ class RecordingStripeHttpClient implements \Stripe\HttpClient\ClientInterface { - /** @var array */ + /** @var array método, url, parâmetros e cabeçalhos (`Nome: valor`) */ public array $calls = []; /** @var array */ @@ -46,12 +47,32 @@ public static function withResponses(array $responses): self return $httpClient; } + /** + * Valor de um cabeçalho enviado na chamada de índice `$call`, ou nulo quando ausente. O + * nome é comparado sem diferenciar maiúsculas. + * + * @param int $call + * @param string $name + * @return string|null + */ + public function header(int $call, string $name): ?string + { + foreach ($this->calls[$call][3] ?? [] as $rawHeader) { + [$headerName, $value] = array_pad(explode(':', $rawHeader, 2), 2, ''); + if (strcasecmp(trim($headerName), $name) === 0) { + return trim($value); + } + } + + return null; + } + /** * @inheritDoc */ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null) { - $this->calls[] = [$method, $absUrl, $params]; + $this->calls[] = [$method, $absUrl, $params, $headers]; if (empty($this->responses)) { throw new \RuntimeException("Unexpected Stripe request: {$method} {$absUrl}"); diff --git a/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php new file mode 100644 index 0000000..f51c56b --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php @@ -0,0 +1,636 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + Carbon::setTestNow('2026-09-02 12:00:00'); + + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + /** + * @param array $expectedHeaders `método caminho` na ordem das chamadas, com o cabeçalho + * esperado (nulo em leitura); como mapa ou, quando um + * caminho se repete, como lista de pares + */ + #[DataProvider('operationProvider')] + public function testEveryWriteOfTheOperationCarriesTheKeyOrADerivedOne(\Closure $operation, array $responses, array $expectedHeaders): void + { + $httpClient = RecordingStripeHttpClient::withResponses($responses); + + $operation(new StripeGateway(), 'chave-1'); + + $actual = []; + foreach ($httpClient->calls as $index => [$method, $url, $params]) { + $actual[] = [$method . ' ' . parse_url($url, PHP_URL_PATH), $httpClient->header($index, 'Idempotency-Key')]; + $this->assertArrayNotHasKey('idempotency_key', $params); + } + $expected = array_is_list($expectedHeaders) + ? $expectedHeaders + : array_map(null, array_keys($expectedHeaders), array_values($expectedHeaders)); + $this->assertSame($expected, $actual); + + $keysSent = array_filter(array_column($actual, 1)); + $this->assertSame($keysSent, array_unique($keysSent), 'duas escritas da mesma operação não podem repetir a chave'); + } + + /** + * Fatura já estornada com a mesma chave: a guarda cede e a Stripe repete o refund original. + */ + public function testRefundRetryOnARefundedInvoiceReplaysTheOriginalRefund(): void + { + $refunded = self::paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([ + $refunded, + ['id' => 're_original', 'object' => 'refund', 'amount' => 12345, 'status' => 'succeeded', 'created' => 1786700100, 'reason' => null], + $refunded, + ]); + + $refund = (new StripeGateway())->refundInvoice(self::invoiceWithId(), 'chave-1'); + + $this->assertSame('re_original', $refund->id); + $this->assertSame('chave-1', $httpClient->header(1, 'Idempotency-Key')); + } + + /** + * Sem chave a guarda continua recusando antes da rede. + */ + public function testRefundOnARefundedInvoiceWithoutAKeyIsRefusedBeforeTheNetwork(): void + { + $refunded = self::paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([$refunded]); + + try { + (new StripeGateway())->refundInvoice(self::invoiceWithId()); + $this->fail('Esperava RefundNotSupportedException'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $e->reason); + } + + $this->assertCount(1, $httpClient->calls); + } + + /** + * Com chave e a Stripe recusando o refund (a chave não é a de um estorno anterior), a + * recusa da guarda é a que sobe. + */ + public function testRefundRetryRefusedByStripeSurfacesTheGuardRefusal(): void + { + $refunded = self::paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([ + $refunded, + [['error' => ['type' => 'invalid_request_error', 'code' => 'charge_already_refunded', 'message' => 'Charge has already been refunded.']], 400], + ]); + + try { + (new StripeGateway())->refundInvoice(self::invoiceWithId(), 'outra-chave'); + $this->fail('Esperava RefundNotSupportedException'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $e->reason); + } + + $this->assertCount(2, $httpClient->calls); + } + + public function testDuplicateRetryAcceptsTheAlreadyCanceledOriginalWhenAKeyIsGiven(): void + { + $pendingPix = self::pendingPixPaymentIntentResponse(); + $canceled = array_merge($pendingPix, ['status' => 'canceled', 'next_action' => null]); + $httpClient = RecordingStripeHttpClient::withResponses([ + $canceled, + self::stripeCustomerResponse(), + array_merge($pendingPix, ['id' => 'pi_fake456']), + $canceled, + ]); + + $duplicated = (new StripeGateway())->duplicateInvoice(self::invoiceWithId(), Carbon::now()->addDay(), [], 'chave-1'); + + $this->assertSame('pi_fake456', $duplicated->id); + $this->assertSame('chave-1', $httpClient->header(2, 'Idempotency-Key')); + $this->assertSame('chave-1:cancel_original', $httpClient->header(3, 'Idempotency-Key')); + } + + public function testDuplicateOfACanceledInvoiceWithoutAKeyIsStillRefused(): void + { + $pendingPix = self::pendingPixPaymentIntentResponse(); + RecordingStripeHttpClient::withResponses([array_merge($pendingPix, ['status' => 'canceled', 'next_action' => null])]); + + $this->expectException(UnsupportedOperationException::class); + + (new StripeGateway())->duplicateInvoice(self::invoiceWithId(), Carbon::now()->addDay()); + } + + public function testDeleteRetryOnADetachedCardSkipsTheOwnershipCheckWhenAKeyIsGiven(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::paymentMethodResponse(null), + self::paymentMethodResponse(null), + ]); + $creditCard = self::creditCardModel(); + $creditCard->id = 'pm_fake123'; + + (new StripeGateway())->deleteCreditCard($creditCard, 'chave-1'); + + $this->assertSame('post', $httpClient->calls[1][0]); + $this->assertSame('chave-1', $httpClient->header(1, 'Idempotency-Key')); + } + + public function testDeleteOfADetachedCardWithoutAKeyIsRefused(): void + { + RecordingStripeHttpClient::withResponses([self::paymentMethodResponse(null)]); + $creditCard = self::creditCardModel(); + $creditCard->id = 'pm_fake123'; + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/does not belong/'); + + (new StripeGateway())->deleteCreditCard($creditCard); + } + + /** + * No retry do update de cliente a Stripe repete a resposta antiga (com o tax id já + * trocado), e o `deleteTaxId` do id já excluído responde 404, que é ignorado. + */ + public function testUpdateCustomerRetryIgnoresTheTaxIdAlreadyDeleted(): void + { + $freshCustomer = self::stripeCustomerResponse(); + $freshCustomer['tax_ids']['data'][0]['value'] = '68419761001'; + $httpClient = RecordingStripeHttpClient::withResponses([ + self::stripeCustomerResponse(), + ['id' => 'txi_fake2', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '68419761001'], + [['error' => ['type' => 'invalid_request_error', 'code' => 'resource_missing', 'param' => 'id', 'message' => 'No such tax id']], 404], + $freshCustomer, + ]); + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $customer->taxDocument = '68419761001'; + + $result = (new StripeGateway())->updateCustomer($customer, 'chave-1'); + + $this->assertSame('68419761001', $result->taxDocument); + $this->assertCount(4, $httpClient->calls); + } + + public function testChargeUpdateAlwaysSendsTheCardCustomerSoTheRetryPayloadIsTheSame(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::paymentMethodResponse('cus_fake123'), + self::paidCardPaymentIntentResponse('requires_payment_method'), + self::paidCardPaymentIntentResponse('requires_payment_method'), + self::paidCardPaymentIntentResponse(), + ]); + $invoice = self::invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice, 'chave-1'); + + $this->assertSame(['payment_method_types' => ['card'], 'customer' => 'cus_fake123'], $httpClient->calls[2][2]); + } + + #[DataProvider('operationProvider')] + public function testWithoutAKeyNoHeaderIsSent(\Closure $operation, array $responses, array $expectedHeaders): void + { + $httpClient = RecordingStripeHttpClient::withResponses($responses); + + $operation(new StripeGateway(), null); + + foreach (array_keys($httpClient->calls) as $index) { + $this->assertNull($httpClient->header($index, 'Idempotency-Key')); + } + } + + public static function operationProvider(): array + { + $freshCustomer = self::stripeCustomerResponse(); + $freshCustomer['tax_ids']['data'][0]['value'] = '68419761001'; + + $pendingPix = self::pendingPixPaymentIntentResponse(); + $refunded = self::paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + + return [ + 'createCustomer' => [ + fn (StripeGateway $g, ?string $key) => $g->createCustomer(self::customerModel(), $key), + [self::stripeCustomerResponse()], + ['post /v1/customers' => 'chave-1'], + ], + 'updateCustomer trocando o documento' => [ + function (StripeGateway $g, ?string $key) { + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $customer->taxDocument = '68419761001'; + + return $g->updateCustomer($customer, $key); + }, + [ + self::stripeCustomerResponse(), + ['id' => 'txi_fake2', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '68419761001'], + ['id' => 'txi_fake1', 'object' => 'tax_id', 'deleted' => true], + $freshCustomer, + ], + [ + 'post /v1/customers/cus_fake123' => 'chave-1', + 'post /v1/customers/cus_fake123/tax_ids' => 'chave-1:tax_id', + 'delete /v1/customers/cus_fake123/tax_ids/txi_fake1' => null, + 'get /v1/customers/cus_fake123' => null, + ], + ], + 'setCustomerDefaultCard' => [ + function (StripeGateway $g, ?string $key) { + $customer = new Customer(); + $customer->id = 'cus_fake123'; + + return $g->setCustomerDefaultCard($customer, 'pm_fake123', $key); + }, + [self::stripeCustomerResponse()], + ['post /v1/customers/cus_fake123' => 'chave-1'], + ], + 'createInvoice com cartão salvo' => [ + fn (StripeGateway $g, ?string $key) => $g->createInvoice(self::creditCardInvoiceModel(), $key), + [self::paidCardPaymentIntentResponse()], + ['post /v1/payment_intents' => 'chave-1'], + ], + 'createInvoice salvando o cartão antes' => [ + function (StripeGateway $g, ?string $key) { + $invoice = self::creditCardInvoiceModel(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'pm_fake123'; + + return $g->createInvoice($invoice, $key); + }, + [self::paymentMethodResponse(), self::paidCardPaymentIntentResponse()], + [ + 'post /v1/payment_methods/pm_fake123/attach' => 'chave-1:card', + 'post /v1/payment_intents' => 'chave-1', + ], + ], + 'createInvoice pix' => [ + fn (StripeGateway $g, ?string $key) => $g->createInvoice(self::pixInvoiceModel(), $key), + [$pendingPix], + ['post /v1/payment_intents' => 'chave-1'], + ], + 'refundInvoice' => [ + fn (StripeGateway $g, ?string $key) => $g->refundInvoice(self::invoiceWithId(), $key), + [ + self::paidCardPaymentIntentResponse(), + ['id' => 're_fake123', 'object' => 'refund', 'amount' => 12345, 'status' => 'pending', 'created' => 1786700100, 'reason' => null], + $refunded, + ], + [ + ['get /v1/payment_intents/pi_fake123', null], + ['post /v1/refunds', 'chave-1'], + ['get /v1/payment_intents/pi_fake123', null], + ], + ], + 'chargeInvoiceWithCreditCard com cartão salvo' => [ + function (StripeGateway $g, ?string $key) { + $invoice = self::invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + + return $g->chargeInvoiceWithCreditCard($invoice, $key); + }, + [ + self::paymentMethodResponse('cus_fake123'), + self::paidCardPaymentIntentResponse('requires_payment_method'), + self::paidCardPaymentIntentResponse('requires_payment_method'), + self::paidCardPaymentIntentResponse(), + ], + [ + 'get /v1/payment_methods/pm_fake123' => null, + 'get /v1/payment_intents/pi_fake123' => null, + 'post /v1/payment_intents/pi_fake123' => 'chave-1:update', + 'post /v1/payment_intents/pi_fake123/confirm' => 'chave-1', + ], + ], + 'chargeInvoiceWithCreditCard com token legado' => [ + function (StripeGateway $g, ?string $key) { + $invoice = self::invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'tok_fake123'; + + return $g->chargeInvoiceWithCreditCard($invoice, $key); + }, + [ + self::paymentMethodResponse(), + self::paymentMethodResponse(), + self::paidCardPaymentIntentResponse('requires_payment_method'), + self::paidCardPaymentIntentResponse('requires_payment_method'), + self::paidCardPaymentIntentResponse(), + ], + [ + 'post /v1/payment_methods' => 'chave-1:payment_method', + 'get /v1/payment_methods/pm_fake123' => null, + 'get /v1/payment_intents/pi_fake123' => null, + 'post /v1/payment_intents/pi_fake123' => 'chave-1:update', + 'post /v1/payment_intents/pi_fake123/confirm' => 'chave-1', + ], + ], + 'cancelInvoice' => [ + fn (StripeGateway $g, ?string $key) => $g->cancelInvoice(self::invoiceWithId(), $key), + [array_merge(self::paidCardPaymentIntentResponse('canceled'), ['latest_charge' => null])], + ['post /v1/payment_intents/pi_fake123/cancel' => 'chave-1'], + ], + 'duplicateInvoice' => [ + fn (StripeGateway $g, ?string $key) => $g->duplicateInvoice(self::invoiceWithId(), Carbon::now()->addDay(), [], $key), + [ + $pendingPix, + self::stripeCustomerResponse(), + array_merge($pendingPix, ['id' => 'pi_fake456']), + array_merge($pendingPix, ['status' => 'canceled', 'next_action' => null]), + ], + [ + 'get /v1/payment_intents/pi_fake123' => null, + 'get /v1/customers/cus_fake123' => null, + 'post /v1/payment_intents' => 'chave-1', + 'post /v1/payment_intents/pi_fake123/cancel' => 'chave-1:cancel_original', + ], + ], + 'createCreditCard padrão com descrição' => [ + function (StripeGateway $g, ?string $key) { + $creditCard = self::creditCardModel(); + $creditCard->description = 'principal'; + $creditCard->default = true; + + return $g->createCreditCard($creditCard, $key); + }, + [ + self::paymentMethodResponse(), + self::paymentMethodResponse(), + self::stripeCustomerResponse(), + ], + [ + 'post /v1/payment_methods/pm_fake123/attach' => 'chave-1', + 'post /v1/payment_methods/pm_fake123' => 'chave-1:metadata', + 'post /v1/customers/cus_fake123' => 'chave-1:default', + ], + ], + 'createCreditCard com token legado' => [ + function (StripeGateway $g, ?string $key) { + $creditCard = self::creditCardModel(); + $creditCard->token = 'tok_fake123'; + + return $g->createCreditCard($creditCard, $key); + }, + [self::paymentMethodResponse(), self::paymentMethodResponse()], + [ + 'post /v1/payment_methods' => 'chave-1:payment_method', + 'post /v1/payment_methods/pm_fake123/attach' => 'chave-1', + ], + ], + 'deleteCreditCard' => [ + function (StripeGateway $g, ?string $key) { + $creditCard = self::creditCardModel(); + $creditCard->id = 'pm_fake123'; + + return $g->deleteCreditCard($creditCard, $key); + }, + [self::paymentMethodResponse('cus_fake123'), self::paymentMethodResponse()], + [ + 'get /v1/payment_methods/pm_fake123' => null, + 'post /v1/payment_methods/pm_fake123/detach' => 'chave-1', + ], + ], + ]; + } + + #[IgnoreDeprecations] + public function testTheLegacyKeyInTheDuplicateOptionsIsUsedAndKeptOutOfThePayload(): void + { + $pendingPix = self::pendingPixPaymentIntentResponse(); + $httpClient = RecordingStripeHttpClient::withResponses([ + $pendingPix, + self::stripeCustomerResponse(), + array_merge($pendingPix, ['id' => 'pi_fake456']), + array_merge($pendingPix, ['status' => 'canceled', 'next_action' => null]), + ]); + + $this->expectUserDeprecationMessage("gateway_options['idempotency_key'] está obsoleto desde 2026-09-02; passe idempotencyKey como argumento da operação"); + + (new StripeGateway())->duplicateInvoice(self::invoiceWithId(), Carbon::now()->addDay(), ['idempotency_key' => 'chave-antiga', 'statement_descriptor' => 'DUP']); + + $this->assertSame('chave-antiga', $httpClient->header(2, 'Idempotency-Key')); + $this->assertSame('DUP', $httpClient->calls[2][2]['statement_descriptor']); + $this->assertArrayNotHasKey('idempotency_key', $httpClient->calls[2][2]); + } + + #[IgnoreDeprecations] + public function testTheLegacyKeyOnTheCustomerStaysOutOfThePayload(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::stripeCustomerResponse()]); + + $customer = self::customerModel(); + $customer->gatewayOptions = ['idempotency_key' => 'chave-antiga', 'description' => 'VIP']; + + $this->expectUserDeprecationMessage("gateway_options['idempotency_key'] está obsoleto desde 2026-09-02; passe idempotencyKey como argumento da operação"); + + (new StripeGateway())->createCustomer($customer); + + $this->assertSame('chave-antiga', $httpClient->header(0, 'Idempotency-Key')); + $this->assertSame('VIP', $httpClient->calls[0][2]['description']); + $this->assertArrayNotHasKey('idempotency_key', $httpClient->calls[0][2]); + } + + private static function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + return $invoice; + } + + private static function customerModel(): Customer + { + $customer = new Customer(); + $customer->name = 'Fake Customer'; + $customer->email = 'email@exemplo.com'; + $customer->taxDocument = '20176996915'; + + return $customer; + } + + private static function creditCardModel(): CreditCard + { + $creditCard = new CreditCard(); + $creditCard->token = 'pm_fake123'; + $creditCard->customer = new Customer(); + $creditCard->customer->id = 'cus_fake123'; + + return $creditCard; + } + + private static function creditCardInvoiceModel(): Invoice + { + $invoice = new Invoice(); + $invoice->customer = new Customer(); + $invoice->customer->id = 'cus_fake123'; + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD]; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + $item = new InvoiceItem(); + $item->description = 'Assinatura mensal'; + $item->price = 12345; + $item->quantity = 1; + $invoice->items = [$item]; + + return $invoice; + } + + private static function pixInvoiceModel(): Invoice + { + $invoice = self::creditCardInvoiceModel(); + $invoice->creditCard = null; + $invoice->availablePaymentMethods = [PaymentMethod::PIX]; + $invoice->customer->name = 'Fake Customer'; + $invoice->customer->email = 'email@exemplo.com'; + $invoice->customer->taxDocument = '20176996915'; + + return $invoice; + } + + private static function stripeCustomerResponse(): array + { + return [ + 'id' => 'cus_fake123', + 'object' => 'customer', + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'phone' => null, + 'created' => 1786700000, + 'metadata' => [], + 'address' => null, + 'invoice_settings' => ['default_payment_method' => null], + 'tax_ids' => [ + 'object' => 'list', + 'data' => [ + ['id' => 'txi_fake1', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '20176996915'], + ], + ], + ]; + } + + private static function paymentMethodResponse(?string $customer = null): array + { + return [ + 'id' => 'pm_fake123', + 'object' => 'payment_method', + 'type' => 'card', + 'customer' => $customer, + 'created' => 1786700000, + 'billing_details' => ['name' => 'Faker Teste'], + 'metadata' => [], + 'card' => ['brand' => 'visa', 'last4' => '4242', 'exp_month' => 8, 'exp_year' => 2027], + ]; + } + + private static function paidCardPaymentIntentResponse(string $status = 'succeeded'): array + { + return [ + 'id' => 'pi_fake123', + 'object' => 'payment_intent', + 'status' => $status, + 'amount' => 12345, + 'currency' => 'brl', + 'customer' => 'cus_fake123', + 'created' => 1786700000, + 'payment_method_types' => ['card'], + 'next_action' => null, + 'metadata' => [ + 'item_0_description' => 'Assinatura mensal', + 'item_0_price' => '12345', + 'item_0_quantity' => '1', + ], + 'latest_charge' => $status === 'succeeded' ? [ + 'id' => 'ch_fake123', + 'object' => 'charge', + 'status' => 'succeeded', + 'paid' => true, + 'amount' => 12345, + 'amount_captured' => 12345, + 'amount_refunded' => 0, + 'refunded' => false, + 'disputed' => false, + 'created' => 1786700010, + 'payment_method_details' => [ + 'type' => 'card', + 'card' => ['brand' => 'visa', 'last4' => '4242'], + ], + 'balance_transaction' => [ + 'id' => 'txn_fake123', + 'object' => 'balance_transaction', + 'fee' => 425, + 'currency' => 'brl', + ], + ] : null, + ]; + } + + private static function pendingPixPaymentIntentResponse(): array + { + $response = self::paidCardPaymentIntentResponse('requires_action'); + $response['payment_method_types'] = ['pix']; + $response['next_action'] = [ + 'type' => 'pix_display_qr_code', + 'pix_display_qr_code' => [ + 'data' => '00020126pixcopiaecola', + 'image_url_png' => 'https://qr.stripe.com/test.png', + 'image_url_svg' => 'https://qr.stripe.com/test.svg', + 'expires_at' => 1786800000, + 'hosted_instructions_url' => 'https://payments.stripe.com/qr/instructions/test', + ], + ]; + + return $response; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index beaf2e7..1fd65cf 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -23,6 +23,7 @@ use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\RefundStatus; use Potelo\MultiPayment\Enums\PaymentMethod; @@ -318,18 +319,61 @@ public function testPixInvoiceBillingDetailsOmitsMissingNameAndEmail(): void ); } - public function testIdempotencyKeyFromGatewayOptionsBecomesRequestHeader(): void + public function testIdempotencyKeyArgumentBecomesRequestHeader(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + (new StripeGateway())->createInvoice($this->pixInvoiceModel(), 'chave-unica-123'); + + $this->assertSame('chave-unica-123', $httpClient->header(0, 'Idempotency-Key')); + $this->assertArrayNotHasKey('idempotency_key', $httpClient->calls[0][2]); + } + + public function testWithoutIdempotencyKeyNoHeaderIsSent(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + (new StripeGateway())->createInvoice($this->pixInvoiceModel()); + + $this->assertNull($httpClient->header(0, 'Idempotency-Key')); + } + + /** + * A chave antiga em `gatewayOptions` ainda vira o cabeçalho, com aviso de deprecação, e + * não vaza como parâmetro do payload (a API a rejeitaria). + */ + #[IgnoreDeprecations] + public function testLegacyIdempotencyKeyInGatewayOptionsStillBecomesTheHeaderWithADeprecation(): void { $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); $invoice = $this->pixInvoiceModel(); - $invoice->gatewayOptions = ['idempotency_key' => 'chave-unica-123']; + $invoice->gatewayOptions = ['idempotency_key' => 'chave-antiga']; + + $this->expectUserDeprecationMessage("gateway_options['idempotency_key'] está obsoleto desde 2026-09-02; passe idempotencyKey como argumento da operação"); + (new StripeGateway())->createInvoice($invoice); - // a chave não pode vazar como parâmetro do payload (a API a rejeitaria) + $this->assertSame('chave-antiga', $httpClient->header(0, 'Idempotency-Key')); $this->assertArrayNotHasKey('idempotency_key', $httpClient->calls[0][2]); } + /** + * O argumento tem precedência sobre a chave antiga em `gatewayOptions`. + */ + #[IgnoreDeprecations] + public function testIdempotencyKeyArgumentWinsOverTheLegacyGatewayOption(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + $invoice->gatewayOptions = ['idempotency_key' => 'chave-antiga']; + + (new StripeGateway())->createInvoice($invoice, 'chave-nova'); + + $this->assertSame('chave-nova', $httpClient->header(0, 'Idempotency-Key')); + } + public function testCancelsPendingInvoice(): void { $response = $this->paidCardPaymentIntentResponse(status: 'canceled'); @@ -748,9 +792,10 @@ public function testChargeInvoiceWithCreditCardUpdatesIntentBeforeConfirming(): $this->assertSame(InvoiceStatus::PAID, $result->status); } - public function testChargeInvoiceKeepsMatchingCustomerAndOmitsItFromUpdate(): void + public function testChargeInvoiceWithMatchingCustomerResendsItInTheUpdate(): void { - // PI e PaymentMethod do mesmo customer: nada de customer no update + // PI e PaymentMethod do mesmo customer: o update repete o customer, sem efeito na Stripe, + // para o payload ser o mesmo num retry com a mesma chave de idempotência $httpClient = RecordingStripeHttpClient::withResponses([ $this->paymentMethodResponse(customer: 'cus_fake123'), $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'), @@ -764,7 +809,7 @@ public function testChargeInvoiceKeepsMatchingCustomerAndOmitsItFromUpdate(): vo $invoice->creditCard->id = 'pm_fake123'; (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); - $this->assertSame(['payment_method_types' => ['card']], $httpClient->calls[2][2]); + $this->assertSame(['payment_method_types' => ['card'], 'customer' => 'cus_fake123'], $httpClient->calls[2][2]); } public function testChargeInvoiceRejectsCardFromAnotherCustomer(): void diff --git a/tests/Unit/Idempotency/CacheIdempotencyStoreTest.php b/tests/Unit/Idempotency/CacheIdempotencyStoreTest.php new file mode 100644 index 0000000..9102b62 --- /dev/null +++ b/tests/Unit/Idempotency/CacheIdempotencyStoreTest.php @@ -0,0 +1,205 @@ +cache = new Repository(new ArrayStore()); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + + public function testExecutesTheOperationOnceAndStoresTheResultUnderThePrefixedKey(): void + { + $store = new CacheIdempotencyStore($this->cache); + $executions = 0; + $operation = function () use (&$executions) { + $executions++; + + return (object) ['id' => 'inv_1']; + }; + + $first = $store->remember('chave', $operation, 60); + $second = $store->remember('chave', $operation, 60); + + $this->assertSame(1, $executions); + $this->assertEquals($first, $second); + $this->assertTrue($store->has('chave')); + $this->assertTrue($this->cache->has(CacheIdempotencyStore::DEFAULT_PREFIX . 'chave')); + // o lock é liberado ao fim da execução + $this->assertTrue($this->cache->getStore()->lock(CacheIdempotencyStore::DEFAULT_PREFIX . 'chave:lock')->get()); + } + + public function testACustomPrefixIsApplied(): void + { + $store = new CacheIdempotencyStore($this->cache, 'app:idem:'); + + $store->remember('chave', fn () => 'ok', 60); + + $this->assertTrue($this->cache->has('app:idem:chave')); + $this->assertFalse($this->cache->has(CacheIdempotencyStore::DEFAULT_PREFIX . 'chave')); + } + + public function testANullResultCountsAsStored(): void + { + $store = new CacheIdempotencyStore($this->cache); + $executions = 0; + $operation = function () use (&$executions) { + $executions++; + + return null; + }; + + $this->assertNull($store->remember('chave', $operation, 60)); + $this->assertNull($store->remember('chave', $operation, 60)); + $this->assertSame(1, $executions); + $this->assertTrue($store->has('chave')); + } + + public function testTheResultExpiresAfterTheTtl(): void + { + $store = new CacheIdempotencyStore($this->cache); + $executions = 0; + $operation = function () use (&$executions) { + return ++$executions; + }; + + $store->remember('chave', $operation, 60); + Carbon::setTestNow('2026-09-02 12:01:01'); + + $this->assertFalse($store->has('chave')); + $this->assertSame(2, $store->remember('chave', $operation, 60)); + } + + public function testAnOperationThatThrowsIsNotStoredAndReleasesTheLock(): void + { + $store = new CacheIdempotencyStore($this->cache); + + try { + $store->remember('chave', function () { + throw new \RuntimeException('falha transitória'); + }, 60); + $this->fail('Esperava RuntimeException'); + } catch (\RuntimeException $e) { + $this->assertFalse($store->has('chave')); + } + + $this->assertSame('ok', $store->remember('chave', fn () => 'ok', 60)); + } + + public function testAKeyWhoseLockIsHeldElsewhereIsAConflict(): void + { + $store = new CacheIdempotencyStore($this->cache); + $held = $this->cache->getStore()->lock(CacheIdempotencyStore::DEFAULT_PREFIX . 'chave:lock', 60, 'outro-processo'); + $this->assertTrue($held->get()); + + try { + $store->remember('chave', fn () => 'nunca executa', 60); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertStringContainsString('[chave]', $e->getMessage()); + $this->assertNull($e->httpStatus); + $this->assertFalse($store->has('chave')); + } finally { + $held->release(); + } + } + + public function testACacheStoreWithoutLockSupportIsAConfigurationError(): void + { + $storeWithoutLock = new class implements Store { + private array $items = []; + + public function get($key) + { + return $this->items[$key] ?? null; + } + + public function many(array $keys) + { + return array_map(fn ($key) => $this->get($key), array_combine($keys, $keys)); + } + + public function put($key, $value, $seconds) + { + $this->items[$key] = $value; + + return true; + } + + public function putMany(array $values, $seconds) + { + foreach ($values as $key => $value) { + $this->put($key, $value, $seconds); + } + + return true; + } + + public function increment($key, $value = 1) + { + return $this->items[$key] = ($this->items[$key] ?? 0) + $value; + } + + public function decrement($key, $value = 1) + { + return $this->increment($key, -$value); + } + + public function forever($key, $value) + { + return $this->put($key, $value, 0); + } + + public function forget($key) + { + unset($this->items[$key]); + + return true; + } + + public function flush() + { + $this->items = []; + + return true; + } + + public function getPrefix() + { + return ''; + } + }; + $store = new CacheIdempotencyStore(new Repository($storeWithoutLock)); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/não suporta lock/'); + + $store->remember('chave', fn () => 'nunca executa', 60); + } +} diff --git a/tests/Unit/Idempotency/InMemoryIdempotencyStoreTest.php b/tests/Unit/Idempotency/InMemoryIdempotencyStoreTest.php new file mode 100644 index 0000000..03a6e03 --- /dev/null +++ b/tests/Unit/Idempotency/InMemoryIdempotencyStoreTest.php @@ -0,0 +1,134 @@ + 'inv_1', 'execution' => $executions]; + }; + + $first = $store->remember('chave', $operation, 60); + $second = $store->remember('chave', $operation, 60); + + $this->assertSame(1, $executions); + $this->assertSame($first, $second); + $this->assertTrue($store->has('chave')); + $this->assertFalse($store->has('outra')); + } + + public function testDistinctKeysExecuteSeparately(): void + { + $store = new InMemoryIdempotencyStore(); + $executions = 0; + $operation = function () use (&$executions) { + return ++$executions; + }; + + $this->assertSame(1, $store->remember('a', $operation, 60)); + $this->assertSame(2, $store->remember('b', $operation, 60)); + } + + public function testANullResultIsStoredToo(): void + { + $store = new InMemoryIdempotencyStore(); + $executions = 0; + $operation = function () use (&$executions) { + $executions++; + + return null; + }; + + $this->assertNull($store->remember('chave', $operation, 60)); + $this->assertNull($store->remember('chave', $operation, 60)); + $this->assertSame(1, $executions); + $this->assertTrue($store->has('chave')); + } + + public function testTheResultExpiresAfterTheTtl(): void + { + $store = new InMemoryIdempotencyStore(); + $executions = 0; + $operation = function () use (&$executions) { + return ++$executions; + }; + + $store->remember('chave', $operation, 60); + Carbon::setTestNow('2026-09-02 12:01:00'); + + $this->assertFalse($store->has('chave')); + $this->assertSame(2, $store->remember('chave', $operation, 60)); + } + + public function testAnOperationThatThrowsIsNotStored(): void + { + $store = new InMemoryIdempotencyStore(); + $executions = 0; + $operation = function () use (&$executions) { + $executions++; + if ($executions === 1) { + throw new \RuntimeException('falha transitória'); + } + + return 'ok'; + }; + + try { + $store->remember('chave', $operation, 60); + $this->fail('Esperava RuntimeException'); + } catch (\RuntimeException $e) { + $this->assertFalse($store->has('chave')); + } + + $this->assertSame('ok', $store->remember('chave', $operation, 60)); + $this->assertSame(2, $executions); + } + + public function testAConcurrentExecutionWithTheSameKeyIsAConflict(): void + { + $store = new InMemoryIdempotencyStore(); + + $this->expectException(IdempotencyConflictException::class); + $this->expectExceptionMessageMatches('/\[chave\]/'); + + $store->remember('chave', function () use ($store) { + // reentrância com a mesma chave é a única concorrência possível num só processo + return $store->remember('chave', fn () => 'aninhada', 60); + }, 60); + } + + public function testForgetDropsTheStoredResult(): void + { + $store = new InMemoryIdempotencyStore(); + $store->remember('chave', fn () => 'ok', 60); + + $store->forget('chave'); + + $this->assertFalse($store->has('chave')); + } +} diff --git a/tests/Unit/IdempotencyKeyPropagationTest.php b/tests/Unit/IdempotencyKeyPropagationTest.php new file mode 100644 index 0000000..34176a2 --- /dev/null +++ b/tests/Unit/IdempotencyKeyPropagationTest.php @@ -0,0 +1,351 @@ + método do driver e argumentos recebidos */ + private array $calls = []; + + protected function setUp(): void + { + parent::setUp(); + + $this->calls = []; + $app = new Container(); + $app->instance('config', new Repository(['multi-payment' => [ + 'default' => 'falso', + 'gateways' => ['falso' => ['class' => self::OVERLOADED_GATEWAY]], + ]])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Mockery::close(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testFacadeInvoiceOperationsPassTheKey(): void + { + $gateway = $this->gateway([ + 'refundInvoice' => fn () => new Refund(), + 'cancelInvoice' => fn (Invoice $i) => $i, + 'chargeInvoiceWithCreditCard' => fn (Invoice $i) => $i, + 'duplicateInvoice' => fn () => new Invoice(), + 'rescheduleAutomaticPixPayment' => fn (Invoice $i) => $i, + ]); + $expiresAt = Carbon::parse('2026-10-01'); + + $payment = new MultiPayment($gateway); + $payment->refundInvoice('inv_1', 500, 'k-refund'); + $payment->cancelInvoice('inv_1', 'k-cancel'); + $payment->chargeInvoiceWithCreditCard('inv_1', 'tok_1', null, 'k-charge'); + $payment->duplicateInvoice('inv_1', $expiresAt, ['x' => 1], 'k-dup'); + $payment->rescheduleAutomaticPixPayment('inv_1', 'k-resched'); + + $this->assertSame([ + 'refundInvoice', 'cancelInvoice', 'chargeInvoiceWithCreditCard', 'duplicateInvoice', 'rescheduleAutomaticPixPayment', + ], array_column($this->calls, 0)); + [$refund, $cancel, $charge, $duplicate, $reschedule] = array_column($this->calls, 1); + $this->assertSame('inv_1', $refund[0]->id); + $this->assertSame(500, $refund[0]->refundedAmount); + $this->assertSame('k-refund', $refund[1]); + $this->assertSame(['inv_1', 'k-cancel'], [$cancel[0]->id, $cancel[1]]); + $this->assertSame(['tok_1', 'k-charge'], [$charge[0]->creditCard->token, $charge[1]]); + $this->assertSame($expiresAt, $duplicate[1]); + $this->assertSame([['x' => 1], 'k-dup'], [$duplicate[2], $duplicate[3]]); + $this->assertSame(['inv_1', 'k-resched'], [$reschedule[0]->id, $reschedule[1]]); + } + + public function testFacadeCustomerCardAndAutomaticPixOperationsPassTheKey(): void + { + $gateway = $this->gateway([ + 'deleteCreditCard' => fn () => null, + 'setCustomerDefaultCard' => fn (Customer $c) => $c, + 'cancelAutomaticPixRecurrence' => fn () => new AutomaticPixCancellation(), + 'cancelAutomaticPixScheduledPayment' => fn () => new AutomaticPixCancellation(), + ]); + + $payment = new MultiPayment($gateway); + $payment->deleteCard('cus_1', 'pm_1', 'k-delete'); + $payment->setDefaultCard('cus_1', 'pm_1', 'k-default'); + $payment->cancelAutomaticPixRecurrence('rec_1', 'k-rec'); + $payment->cancelAutomaticPixScheduledPayment('pay_1', 'E1', 'k-pay'); + + [$delete, $default, $recurrence, $payment] = array_column($this->calls, 1); + $this->assertSame(['pm_1', 'cus_1', 'k-delete'], [$delete[0]->id, $delete[0]->customer->id, $delete[1]]); + $this->assertSame(['cus_1', 'pm_1', 'k-default'], [$default[0]->id, $default[1], $default[2]]); + $this->assertInstanceOf(AutomaticPix::class, $recurrence[0]); + $this->assertSame('k-rec', $recurrence[1]); + $this->assertInstanceOf(AutomaticPixCharge::class, $payment[0]); + $this->assertSame('k-pay', $payment[1]); + } + + /** + * `charge()` cria o cliente antes da fatura, com a chave derivada, e a fatura com a chave. + */ + public function testChargeCreatesTheCustomerWithADerivedKeyAndTheInvoiceWithTheKey(): void + { + $gateway = $this->gateway([ + 'createCustomer' => function (Customer $c) { + $c->id = 'cus_novo'; + + return $c; + }, + 'createInvoice' => fn (Invoice $i) => $i, + ]); + + (new MultiPayment($gateway))->charge([ + 'amount' => 10000, + 'available_payment_methods' => ['pix'], + 'customer' => ['name' => 'Cliente', 'email' => 'cliente@example.com', 'tax_document' => '20176996915'], + ], 'k-charge'); + + $this->assertSame(['createCustomer', 'createInvoice'], array_column($this->calls, 0)); + $this->assertSame('k-charge:customer', $this->calls[0][1][1]); + $this->assertSame('cus_novo', $this->calls[1][1][0]->customer->id); + $this->assertSame('k-charge', $this->calls[1][1][1]); + } + + public function testChargeWithoutAKeyCreatesTheCustomerWithoutAKey(): void + { + $gateway = $this->gateway([ + 'createCustomer' => function (Customer $c) { + $c->id = 'cus_novo'; + + return $c; + }, + 'createInvoice' => fn (Invoice $i) => $i, + ]); + + (new MultiPayment($gateway))->charge([ + 'amount' => 10000, + 'available_payment_methods' => ['pix'], + 'customer' => ['name' => 'Cliente', 'email' => 'cliente@example.com'], + ]); + + $this->assertSame([null, null], array_map(fn (array $call) => $call[1][1], $this->calls)); + } + + public function testBuildersPassTheKeyGivenToWithIdempotencyKey(): void + { + $gateway = $this->gateway([ + 'createInvoice' => fn (Invoice $i) => $i, + 'createCustomer' => fn (Customer $c) => $c, + 'createCreditCard' => fn (CreditCard $c) => $c, + ]); + $customer = new Customer(); + $customer->id = 'cus_1'; + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + + $payment = new MultiPayment($gateway); + $payment->newInvoice() + ->setCustomer($customer) + ->addItem('Item', 10000, 1) + ->setAvailablePaymentMethods(['pix']) + ->withIdempotencyKey('k-invoice') + ->create(); + $payment->newCustomer() + ->setName('Cliente') + ->setEmail('cliente@example.com') + ->withIdempotencyKey('k-customer') + ->create(); + $payment->newCreditCard() + ->setCustomer($customer) + ->setToken('tok_1') + ->withIdempotencyKey('k-card') + ->create(); + $payment->newCustomer() + ->setName('Sem chave') + ->setEmail('semchave@example.com') + ->create(); + + $this->assertSame([ + ['createInvoice', 'k-invoice'], + ['createCustomer', 'k-customer'], + ['createCreditCard', 'k-card'], + ['createCustomer', null], + ], array_map(fn (array $call) => [$call[0], $call[1][1]], $this->calls)); + $this->assertSame('tok_1', $this->calls[2][1][0]->token); + } + + public function testModelSaveAndDeletePassTheKey(): void + { + $gateway = $this->gateway([ + 'createCustomer' => fn (Customer $c) => $c, + 'updateCustomer' => fn (Customer $c) => $c, + 'deleteCreditCard' => fn () => null, + ]); + + $customer = new Customer(); + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + $customer->save($gateway, true, 'k-create'); + $customer->id = 'cus_1'; + $customer->save($gateway, true, 'k-update'); + + $creditCard = new CreditCard(); + $creditCard->id = 'pm_1'; + $creditCard->delete($gateway, 'k-delete'); + + $this->assertSame([ + ['createCustomer', 'k-create'], + ['updateCustomer', 'k-update'], + ['deleteCreditCard', 'k-delete'], + ], array_map(fn (array $call) => [$call[0], $call[1][1]], $this->calls)); + } + + public function testInvoiceSaveCreatesTheCustomerWithADerivedKey(): void + { + $gateway = $this->gateway([ + 'createCustomer' => function (Customer $c) { + $c->id = 'cus_novo'; + + return $c; + }, + 'createInvoice' => fn (Invoice $i) => $i, + ]); + + $invoice = new Invoice(); + $invoice->fill([ + 'amount' => 10000, + 'available_payment_methods' => ['pix'], + 'customer' => ['name' => 'Cliente', 'email' => 'cliente@example.com'], + ]); + $invoice->save($gateway, true, 'k-invoice'); + + $this->assertSame([ + ['createCustomer', 'k-invoice:customer'], + ['createInvoice', 'k-invoice'], + ], array_map(fn (array $call) => [$call[0], $call[1][1]], $this->calls)); + } + + public function testModelCreateAndCustomerDeleteCreditCardPassTheKey(): void + { + $gateway = $this->gateway([ + 'createCustomer' => fn (Customer $c) => $c, + 'deleteCreditCard' => fn () => null, + ]); + + $customer = new Customer(); + $customer->create(['name' => 'Cliente', 'email' => 'cliente@example.com'], $gateway, 'k-create'); + $customer->id = 'cus_1'; + $customer->deleteCreditCard('pm_1', $gateway, 'k-delete'); + + $this->assertSame([ + ['createCustomer', 'k-create'], + ['deleteCreditCard', 'k-delete'], + ], array_map(fn (array $call) => [$call[0], $call[1][1]], $this->calls)); + $this->assertSame('cus_1', $this->calls[1][1][0]->customer->id); + } + + public function testSubscriptionSaveCreatesTheCustomerWithADerivedKeyAndPlanSavePassesTheKey(): void + { + $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class, \Potelo\MultiPayment\Contracts\PlanContract::class); + $gateway->shouldReceive('supports')->andReturn(true); + $gateway->shouldReceive('__toString')->andReturn('falso'); + $gateway->shouldReceive('createCustomer')->once() + ->with(Mockery::type(Customer::class), 'k-sub:customer') + ->andReturnUsing(function (Customer $c) { + $c->id = 'cus_novo'; + + return $c; + }); + $gateway->shouldReceive('createSubscription')->once() + ->with(Mockery::type(Subscription::class), 'k-sub') + ->andReturnUsing(fn (Subscription $s) => $s); + $gateway->shouldReceive('createPlan')->once() + ->with(Mockery::type(\Potelo\MultiPayment\Models\Plan::class), 'k-plan') + ->andReturnUsing(fn ($p) => $p); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['name' => 'Cliente', 'email' => 'cliente@example.com']]); + $subscription->save($gateway, true, 'k-sub'); + $this->assertSame('cus_novo', $subscription->customer->id); + + $plan = new \Potelo\MultiPayment\Models\Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + $plan->interval = \Potelo\MultiPayment\Enums\PlanInterval::MONTH; + $plan->save($gateway, true, 'k-plan'); + } + + public function testSubscriptionDomainMethodsPassTheKey(): void + { + $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway->shouldReceive('supports')->andReturn(true); + $gateway->shouldReceive('__toString')->andReturn('falso'); + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $gateway->shouldReceive('suspendSubscription')->once()->with($subscription, 'k-suspend')->andReturn($subscription); + $gateway->shouldReceive('resumeSubscription')->once()->with($subscription, 'k-resume')->andReturn($subscription); + $gateway->shouldReceive('cancelSubscription')->once()->with($subscription, true, 'k-cancel')->andReturn($subscription); + $gateway->shouldReceive('changeSubscriptionPlan')->once()->with($subscription, 'plano_anual', false, 'k-change')->andReturn($subscription); + $gateway->shouldReceive('updateSubscription')->once()->with($subscription, 'k-update')->andReturn($subscription); + + $this->assertSame($subscription, $subscription->suspend($gateway, 'k-suspend')); + $this->assertSame($subscription, $subscription->resume($gateway, 'k-resume')); + $this->assertSame($subscription, $subscription->cancel(true, $gateway, 'k-cancel')); + $this->assertSame($subscription, $subscription->changePlan('plano_anual', false, $gateway, 'k-change')); + $subscription->save($gateway, true, 'k-update'); + $this->assertSame('sub_1', $subscription->id); + } + + /** + * Gateway falso que grava cada chamada em `$this->calls` e responde com o retorno informado + * por método. + * + * @param array $returns método do driver e o que ele devolve + * @return GatewayContract + */ + private function gateway(array $returns): GatewayContract + { + $gateway = Mockery::mock('overload:' . self::OVERLOADED_GATEWAY, GatewayContract::class); + $gateway->shouldReceive('supports')->andReturn(true); + $gateway->shouldReceive('__toString')->andReturn('falso'); + + foreach ($returns as $method => $return) { + $gateway->shouldReceive($method)->andReturnUsing(function (...$args) use ($method, $return) { + $this->calls[] = [$method, $args]; + + return $return(...$args); + }); + } + + return $gateway; + } +} diff --git a/tests/Unit/InvoiceTest.php b/tests/Unit/InvoiceTest.php index 94f44de..2f1519c 100644 --- a/tests/Unit/InvoiceTest.php +++ b/tests/Unit/InvoiceTest.php @@ -38,6 +38,21 @@ public function testIsSettledDelegatesToTheEnumAndAcceptsTheOldString(InvoiceSta $this->assertSame($expected, Invoice::isSettled($status)); } + /** + * `amount` sem `items` vira um único item com o valor; a lista precisa sobreviver ao + * restante do `fill()`. + */ + public function testFillWithAmountAndNoItemsCreatesASingleItem(): void + { + $invoice = new Invoice(); + $invoice->fill(['amount' => 10000, 'available_payment_methods' => ['pix']]); + + $this->assertCount(1, $invoice->items); + $this->assertSame(10000, $invoice->items[0]->price); + $this->assertSame(1, $invoice->items[0]->quantity); + $this->assertNull($invoice->amount); + } + public static function contestedProvider(): array { return [ diff --git a/tests/Unit/Providers/MultiPaymentServiceProviderTest.php b/tests/Unit/Providers/MultiPaymentServiceProviderTest.php new file mode 100644 index 0000000..610f94d --- /dev/null +++ b/tests/Unit/Providers/MultiPaymentServiceProviderTest.php @@ -0,0 +1,56 @@ +set('cache.default', 'array'); + $app['config']->set('multi-payment.idempotency.prefix', 'teste:idem:'); + } + + public function testTheProviderBindsACacheIdempotencyStoreWithTheConfiguredPrefix(): void + { + $store = $this->app->make(IdempotencyStore::class); + + $this->assertInstanceOf(CacheIdempotencyStore::class, $store); + $this->assertSame('ok', $store->remember('chave', fn () => 'ok', 60)); + $this->assertTrue($this->app['cache']->has('teste:idem:chave')); + $this->assertSame($store::class, ConfigurationHelper::resolveIdempotencyStore()::class); + } + + public function testTheApplicationCanReplaceTheStoreWithItsOwnBinding(): void + { + $own = new InMemoryIdempotencyStore(); + $this->app->instance(IdempotencyStore::class, $own); + + $this->assertSame($own, ConfigurationHelper::resolveIdempotencyStore()); + } + + public function testTheTtlComesFromTheConfigurationWithADefaultOfOneDay(): void + { + $this->assertSame(86400, ConfigurationHelper::idempotencyTtl()); + + $this->app['config']->set('multi-payment.idempotency.ttl', 120); + + $this->assertSame(120, ConfigurationHelper::idempotencyTtl()); + } +} diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php index 305d805..e696c0b 100644 --- a/tests/Unit/SubscriptionTest.php +++ b/tests/Unit/SubscriptionTest.php @@ -454,7 +454,8 @@ public function testModelDelegatesLifecycleToTheGateway( $gateway = self::subscriptionGateway(); $gateway->shouldReceive($gatewayMethod) ->once() - ->with($subscription, ...$gatewayArgs) + // o último argumento do contract é a chave de idempotência, nula por padrão + ->with($subscription, ...array_merge($gatewayArgs, [null])) ->andReturn($subscription); $this->assertSame( From 2bcf86918d3bf202e223813ddcc62cead8866791 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 14:25:55 -0300 Subject: [PATCH 22/32] feat(stripe): parse de Invoice com origem PaymentIntent ou Invoice conforme ADR 0005 e fill() estrito nos models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Invoice::$originType (enum InvoiceOriginType: PAYMENT_INTENT ou INVOICE), preenchido nos dois drivers; na Iugu é sempre INVOICE. - StripeGateway: parseInvoice() despacha para parseFromPaymentIntent() (comportamento anterior intacto) ou parseFromStripeInvoice() (fatura de assinatura, só leitura); deriveStatus() concentra a derivação de status com a tabela de precedência (o Invoice manda no ciclo de vida, PaymentIntent e charge refinam; combinação fora da tabela é UNKNOWN com aviso no log). - getInvoice() aceita pi_ e in_ e decide pelo prefixo; o Invoice é lido com expand de payments e o PaymentIntent relido só quando já tem charge (o expand da Stripe para em quatro níveis). - cancelInvoice() anula o Invoice (void) depois de ler a fatura; rascunho lança GatewayException. duplicateInvoice() recusa in_ (INVOICE_DUPLICATION); refundInvoice() e chargeInvoiceWithCreditCard() recusam in_ (SUBSCRIPTIONS, not_implemented). - Pagamento fora da Stripe é lido pelo InvoicePayment do tipo payment_record (amount_paid_off_stripe não vem na API 2026-07-29.dahlia); parsePixDisplay() lê next_action com isset() para um next_action de 3DS não sujar o log. - Model::fill() estrito: chave sem propriedade lança ModelAttributeValidationException::unknownAttribute() com Model::fillableKeys(); prefixo gateway_ e o conteúdo de gateway_options ficam livres; multi-payment.strict_fill desliga na migração. - Fixtures reais da sandbox em tests/fixtures/stripe/ (README separa o gravado do montado); testes unitários por linha da tabela e de fill() estrito; testes de integração que criam o Invoice no SDK e o leem e anulam pela lib. - README: seções "Fatura no Stripe: duas origens" e "fill() estrito", tabela de status com as duas origens, correção de customer.birth_date na tabela de charge(). --- README.md | 152 +++- src/Enums/InvoiceOriginType.php | 16 + .../ModelAttributeValidationException.php | 16 + src/Gateways/IuguGateway.php | 4 + src/Gateways/StripeGateway.php | 636 ++++++++++++-- src/Helpers/ConfigurationHelper.php | 16 + src/Models/Invoice.php | 13 +- src/Models/Model.php | 59 +- src/config/multi-payment.php | 13 + tests/Integration/StripeGatewayTest.php | 110 +++ tests/Unit/Enums/InvoiceOriginTypeTest.php | 39 + .../Gateways/IuguGatewayInvoiceStatusTest.php | 14 + .../Gateways/IuguGatewaySubscriptionTest.php | 20 + .../StripeGatewayStripeInvoiceTest.php | 775 ++++++++++++++++++ tests/Unit/ModelFillTest.php | 186 +++++ tests/fixtures/stripe/README.md | 44 + tests/fixtures/stripe/disputes/lost.json | 98 +++ .../stripe/disputes/needs_response.json | 98 +++ tests/fixtures/stripe/invoices/draft.json | 160 ++++ .../invoices/open_after_declined_attempt.json | 305 +++++++ .../stripe/invoices/open_partially_paid.json | 243 ++++++ .../stripe/invoices/open_processing.json | 243 ++++++ .../stripe/invoices/open_requires_action.json | 263 ++++++ .../invoices/open_requires_capture.json | 243 ++++++ .../invoices/open_requires_confirmation.json | 243 ++++++ .../open_requires_payment_method.json | 243 ++++++ .../invoices/open_without_payment_intent.json | 160 ++++ tests/fixtures/stripe/invoices/paid.json | 243 ++++++ .../stripe/invoices/paid_disputed.json | 243 ++++++ .../stripe/invoices/paid_out_of_band.json | 263 ++++++ .../stripe/invoices/paid_zero_amount_due.json | 160 ++++ .../stripe/invoices/uncollectible.json | 243 ++++++ tests/fixtures/stripe/invoices/void.json | 243 ++++++ .../after_declined_attempt.json | 244 ++++++ .../stripe/payment_intents/disputed.json | 207 +++++ .../fixtures/stripe/payment_intents/paid.json | 207 +++++ .../payment_intents/partially_refunded.json | 236 ++++++ .../stripe/payment_intents/refunded.json | 264 ++++++ .../payment_intents/requires_action.json | 250 ++++++ 39 files changed, 7135 insertions(+), 80 deletions(-) create mode 100644 src/Enums/InvoiceOriginType.php create mode 100644 tests/Unit/Enums/InvoiceOriginTypeTest.php create mode 100644 tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php create mode 100644 tests/Unit/ModelFillTest.php create mode 100644 tests/fixtures/stripe/README.md create mode 100644 tests/fixtures/stripe/disputes/lost.json create mode 100644 tests/fixtures/stripe/disputes/needs_response.json create mode 100644 tests/fixtures/stripe/invoices/draft.json create mode 100644 tests/fixtures/stripe/invoices/open_after_declined_attempt.json create mode 100644 tests/fixtures/stripe/invoices/open_partially_paid.json create mode 100644 tests/fixtures/stripe/invoices/open_processing.json create mode 100644 tests/fixtures/stripe/invoices/open_requires_action.json create mode 100644 tests/fixtures/stripe/invoices/open_requires_capture.json create mode 100644 tests/fixtures/stripe/invoices/open_requires_confirmation.json create mode 100644 tests/fixtures/stripe/invoices/open_requires_payment_method.json create mode 100644 tests/fixtures/stripe/invoices/open_without_payment_intent.json create mode 100644 tests/fixtures/stripe/invoices/paid.json create mode 100644 tests/fixtures/stripe/invoices/paid_disputed.json create mode 100644 tests/fixtures/stripe/invoices/paid_out_of_band.json create mode 100644 tests/fixtures/stripe/invoices/paid_zero_amount_due.json create mode 100644 tests/fixtures/stripe/invoices/uncollectible.json create mode 100644 tests/fixtures/stripe/invoices/void.json create mode 100644 tests/fixtures/stripe/payment_intents/after_declined_attempt.json create mode 100644 tests/fixtures/stripe/payment_intents/disputed.json create mode 100644 tests/fixtures/stripe/payment_intents/paid.json create mode 100644 tests/fixtures/stripe/payment_intents/partially_refunded.json create mode 100644 tests/fixtures/stripe/payment_intents/refunded.json create mode 100644 tests/fixtures/stripe/payment_intents/requires_action.json diff --git a/README.md b/README.md index 9cba1b4..d94e3eb 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,9 @@ STRIPE_APIKEY= #idempotência (opcional; ver a seção Idempotência) MULTIPAYMENT_IDEMPOTENCY_TTL=86400 MULTIPAYMENT_IDEMPOTENCY_CACHE_STORE= + +#fill() estrito (opcional, padrão true; ver a seção "fill() estrito") +MULTIPAYMENT_STRICT_FILL=true ``` Opcionalmente você pode configurar o Trait, para facilitar o uso do método `charge` junto a um usuário. @@ -182,24 +185,27 @@ pacote; o status específico de cada gateway fica em `original`. Os treze estado | `InvoiceStatus` | Significado | Iugu | Stripe | Helper que responde | |---|---|---|---|---| -| `PENDING` | Aguardando pagamento | `pending`, `draft` | PaymentIntent em `requires_payment_method`, `requires_action`, `requires_confirmation` | `isOpen()` | +| `PENDING` | Aguardando pagamento | `pending`, `draft` | PaymentIntent em `requires_payment_method`, `requires_action`, `requires_confirmation`; Invoice `draft` ou `open` sem pagamento em curso | `isOpen()` | | `AUTHORIZED` | Valor reservado no cartão, aguardando captura ou análise | `in_analysis`, `authorized` | PaymentIntent `requires_capture` | `isOpen()` | | `PROCESSING` | Pagamento em processamento no gateway | (não emite) | PaymentIntent `processing` | `isOpen()` | -| `PAID` | Valor recebido | `paid` | PaymentIntent `succeeded` sem estorno nem contestação | `isSettled()` | -| `PARTIALLY_PAID` | Parte do valor recebida, restante em aberto | `partially_paid` | (não emite em venda avulsa) | `isSettled()` e `isOpen()` | -| `EXTERNALLY_PAID` | Quitada fora do gateway, por baixa manual | `externally_paid` | (não emite em venda avulsa) | `isSettled()` | +| `PAID` | Valor recebido | `paid` | PaymentIntent `succeeded` sem estorno nem contestação; Invoice `paid` quitado sem cobrança | `isSettled()` | +| `PARTIALLY_PAID` | Parte do valor recebida, restante em aberto | `partially_paid` | Invoice `open` com parte do `total` em `amount_paid` (não emite em venda avulsa) | `isSettled()` e `isOpen()` | +| `EXTERNALLY_PAID` | Quitada fora do gateway, por baixa manual | `externally_paid` | Invoice `paid` com pagamento registrado fora da Stripe (não emite em venda avulsa) | `isSettled()` | | `PARTIALLY_REFUNDED` | Estorno voluntário, parcial | `partially_refunded` | charge com `amount_refunded` menor que o total | `isSettled()` | | `REFUNDED` | Estorno voluntário, integral | `refunded` | charge com `refunded = true` | `isTerminal()` | | `DISPUTED` | Contestação aberta sobre fatura paga, resolução pendente | `in_protest` | charge `disputed` com dispute em `warning_needs_response`, `warning_under_review`, `needs_response` ou `under_review` | `isContested()` | | `CHARGEBACK` | Contestação perdida: valor devolvido ao cliente pelo gateway | `chargeback` | dispute em `lost` | `isContested()` e `isTerminal()` | -| `CANCELED` | Cancelada antes do pagamento | `canceled` | PaymentIntent `canceled` | `isTerminal()` | -| `EXPIRED` | Venceu sem pagamento | `expired` | (não emite em venda avulsa) | `isTerminal()` | +| `CANCELED` | Cancelada antes do pagamento | `canceled` | PaymentIntent `canceled`; Invoice `void` | `isTerminal()` | +| `EXPIRED` | Venceu sem pagamento | `expired` | Invoice `uncollectible` (não emite em venda avulsa) | `isTerminal()` | | `UNKNOWN` | Status que a lib não reconhece | qualquer outro | qualquer outro | nenhum responde verdadeiro | Dispute ganha (`won`), encerrada sem virar chargeback (`warning_closed`) ou prevenida (`prevented`) não altera o status: a fatura volta a ler como `PAID` (ou como estornada, se houve estorno). Um status fora do mapa vira `UNKNOWN`, com um aviso no log da aplicação (nível -`warning`) contendo o valor original e o gateway, e o valor cru continua em `original`. +`warning`) contendo o valor original e o gateway, e o valor cru continua em `original`. No +Stripe a coluna mistura as duas origens da fatura: a venda avulsa (PaymentIntent) e a fatura +de assinatura (Invoice), cuja regra de precedência está em +[Fatura no Stripe: duas origens](#fatura-no-stripe-duas-origens). Os helpers do enum respondem às perguntas de negócio sem comparar status um a um: @@ -321,8 +327,13 @@ recusado na validação, porque a fatura com Pix Automático é criada com `PIX` - **Pix expirado continua pendente e re-cobrável.** Na Iugu, fatura expirada vira `canceled`; no Stripe ela volta a aguardar pagamento (`pending`) e pode ser paga com cartão via `chargeInvoiceWithCreditCard` ou duplicada com `duplicateInvoice` (nova expiração; - a original é cancelada). Só fatura Pix pendente é duplicável: cartão ou fatura em outro - estado lança `UnsupportedOperationException` (`INVOICE_DUPLICATION`, `gateway_limitation`). + a original é cancelada). Só fatura Pix pendente de venda avulsa é duplicável: cartão, fatura + em outro estado ou fatura de assinatura lança `UnsupportedOperationException` + (`INVOICE_DUPLICATION`, `gateway_limitation`). +- **A fatura tem duas origens.** A venda avulsa é um PaymentIntent (`pi_`) e a fatura de + assinatura é um objeto Invoice da Stripe (`in_`); `getInvoice()` aceita os dois ids e + `Invoice::$originType` diz qual voltou. Ver + [Fatura no Stripe: duas origens](#fatura-no-stripe-duas-origens). - **`url` da fatura**: no pix é a página hospedada com instruções de pagamento (`hosted_instructions_url`); em fatura de cartão é `null` — não assuma `url` preenchida como na Iugu (`secure_url`). @@ -337,6 +348,88 @@ recusado na validação, porque a fatura com Pix Automático é criada com `PIX` cancelamento e as requisições secundárias, com chaves derivadas (ver [Idempotência](#idempotência)). +### Fatura no Stripe: duas origens + +Na Stripe a `Invoice` do pacote pode vir de dois objetos, e `Invoice::$originType` (enum +`Potelo\MultiPayment\Enums\InvoiceOriginType`) diz de qual: + +| `originType` | Objeto da Stripe | Quando | `original` | +|---|---|---|---| +| `PAYMENT_INTENT` | PaymentIntent (`pi_`) | venda avulsa criada pela lib (cartão ou Pix) | `\Stripe\PaymentIntent` | +| `INVOICE` | Invoice (`in_`) | fatura de assinatura gerada pelo Stripe Billing | `\Stripe\Invoice` | + +Na Iugu `originType` é sempre `INVOICE`: toda fatura de lá é o objeto de fatura do gateway. + +`getInvoice()` aceita os dois ids e decide pelo prefixo qual objeto ler: + +```php +use Potelo\MultiPayment\Enums\InvoiceOriginType; + +$payment = new \Potelo\MultiPayment\MultiPayment('stripe'); + +$avulsa = $payment->getInvoice('pi_3UBH...'); // originType PAYMENT_INTENT +$assinatura = $payment->getInvoice('in_1UBH...'); // originType INVOICE + +if ($assinatura->originType === InvoiceOriginType::INVOICE) { + $assinatura->original->hosted_invoice_url; // objeto cru da Stripe, quando precisar do detalhe +} +``` + +O que muda na fatura de origem `INVOICE`: + +- **Line items** vêm dos itens reais do Invoice (`lines.data`, a primeira página, de até dez + itens); na venda avulsa continuam sendo reconstruídos do `metadata` do PaymentIntent. +- **`url`** é a página hospedada da fatura (`hosted_invoice_url`), com o QR Code do Pix quando + for o caso; `pix` continua trazendo o QR Code quando o PaymentIntent tem um. +- **`expiresAt`** é o `due_date` da fatura, quando ela tem um, ou a expiração do QR Code do Pix. +- **`paidAt`** é o instante em que a Stripe marcou a fatura como paga. +- **Uma requisição a mais** quando a fatura já teve tentativa de pagamento: o charge do + PaymentIntent fica além do limite de `expand` da Stripe e é lido num GET à parte. +- **`cancelInvoice()`** anula a fatura (`void`); a Stripe cancela sozinha o PaymentIntent + dela. Rascunho (`draft`) não é anulável e lança `GatewayException` orientando a esperar a + finalização; fatura `paid` ou já anulada lança `ValidationException`, como o PaymentIntent + já pago ou cancelado. +- **`duplicateInvoice()`** é recusado com `UnsupportedOperationException` (`INVOICE_DUPLICATION`, + `gateway_limitation`): a próxima fatura da assinatura é gerada pela Stripe, e um Pix expirado + se resolve com nova tentativa de pagamento da mesma fatura. +- **`refundInvoice()` e `chargeInvoiceWithCreditCard()`** sobre a fatura de assinatura ainda não + estão disponíveis (`UnsupportedOperationException`, `SUBSCRIPTIONS`, `not_implemented`); entram + em uma versão futura junto com a assinatura no Stripe. + +**Precedência de status.** O status do Invoice da Stripe manda no ciclo de vida da fatura; o +PaymentIntent e o charge só refinam o detalhe de pagamento. Um PaymentIntent `succeeded` não +torna paga uma fatura que a Stripe ainda considera `open`, e um PaymentIntent `canceled` não +cancela uma fatura `open`. A tabela completa: + +| Invoice Stripe | PaymentIntent e charge | `InvoiceStatus` | +|---|---|---| +| `draft` | qualquer | `PENDING` | +| `open` | ausente, `requires_payment_method`, `requires_action` ou `requires_confirmation` | `PENDING` | +| `open` | `requires_capture` | `AUTHORIZED` | +| `open` | `processing` | `PROCESSING` | +| `open` | `amount_paid` do Invoice maior que zero e menor que `total` | `PARTIALLY_PAID` | +| `paid` | `succeeded`, charge sem dispute e sem refund | `PAID` | +| `paid` | `succeeded`, charge com refund parcial | `PARTIALLY_REFUNDED` | +| `paid` | `succeeded`, charge com refund total | `REFUNDED` | +| `paid` | charge com dispute aberta | `DISPUTED` | +| `paid` | charge com dispute perdida (`lost`) | `CHARGEBACK` | +| `paid` | pagamento registrado fora da Stripe (`paid_out_of_band`) | `EXTERNALLY_PAID` | +| `paid` | sem PaymentIntent `succeeded`; pagamento do tipo `charge` anexado à fatura | `PAID` | +| `paid` | sem PaymentIntent e `amount_due` zero (avaliação gratuita, saldo de crédito, valor abaixo do mínimo) | `PAID` | +| `void` | qualquer | `CANCELED` | +| `uncollectible` | qualquer | `EXPIRED` | +| qualquer combinação não listada | | `UNKNOWN`, com aviso no log contendo os três status e o id da fatura | + +Duas ressalvas para quem trata status como definitivo: + +- **`uncollectible` lê como `EXPIRED`, mas na Stripe não é terminal**: a fatura pode voltar a + `paid` ou ir a `void` depois. Uma fatura `EXPIRED` de origem `INVOICE` pode, portanto, ler + como `PAID` numa releitura, embora `isTerminal()` responda verdadeiro para `EXPIRED`. +- **`open` com PaymentIntent `succeeded` fica em `UNKNOWN` de propósito**: a Stripe atualiza o + Invoice no mesmo instante em que confirma o pagamento, então essa combinação é uma leitura + no meio da transição ou um pagamento fora do padrão que não quitou a fatura. Se aparecer no + log, releia a fatura. + ### Opções extras do gateway Todo model tem o array público `gatewayOptions`: é a válvula de escape para enviar ao gateway @@ -365,6 +458,35 @@ uma opção vira uso recorrente, ela deve ser modelada genericamente. > próxima versão maior. A única diferença observável é `toArray()`, que passa a devolver a > chave `gateway_options`. +### `fill()` estrito + +`Model::fill()` (e, por consequência, `charge($attributes)`, `create($data)` e os arrays +aninhados de `customer`, `items`, `credit_card`...) lança `ModelAttributeValidationException` +para chave que não corresponde a nenhuma propriedade do model. A mensagem traz o model, a chave +e a lista de chaves aceitas: + +```php +$payment->charge(['amount' => 10000, 'trial_days' => 7, 'customer' => [...]]); +// ModelAttributeValidationException: The `trial_days` key is unknown for the `Invoice` model. +// Accepted keys: id, status, paid_at, amount, ..., gateway_options. +``` + +Duas exceções à regra: chave com prefixo `gateway_` (ou `gateway` em `camelCase`) continua +sendo ignorada em silêncio, e o conteúdo de `gateway_options` é livre (vai inteiro ao gateway). +As chaves aceitas de cada model estão em `Model::fillableKeys()`, em `snake_case`. Use sempre +`snake_case` nos arrays: uma chave escalar em `camelCase` (`taxDocument`) também é aceita, mas +as chaves que viram objeto ou data (`customer`, `items`, `credit_card`, `expires_at`...) só são +convertidas na forma em `snake_case`. + +Para desligar temporariamente durante uma migração, use a configuração +`multi-payment.strict_fill` (variável `MULTIPAYMENT_STRICT_FILL`); com `false`, a chave +desconhecida volta a ser descartada sem erro, como nas versões anteriores. + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, `fill()` descartava chave +> desconhecida em silêncio: um `trial_days` ou um `idempotency_key` fora de `gateway_options` +> simplesmente não faziam nada. Agora lançam. Se a aplicação monta os arrays a partir de dados +> externos, valide as chaves antes ou desligue `strict_fill` enquanto ajusta. + ### Idempotência Toda operação de escrita aceita uma chave de idempotência como último argumento @@ -902,6 +1024,11 @@ Confira `src/MultiPayment/Builders/CustomerBuilder.php` para saber quais método $invoiceId = '312ASDHGZXSGRTET312ASDHGZXSGRTET'; $payment = new \Potelo\MultiPayment\MultiPayment('iugu'); $foundInvoice = $payment->getInvoice($invoiceId); + +// no Stripe o id pode ser de um PaymentIntent (pi_, venda avulsa) ou de um Invoice +// (in_, fatura de assinatura); originType diz qual voltou (ver "Fatura no Stripe: duas origens") +$foundInvoice = (new \Potelo\MultiPayment\MultiPayment('stripe'))->getInvoice('in_1UBH...'); +$foundInvoice->originType; // InvoiceOriginType::INVOICE ``` #### Outras operações de fatura @@ -912,10 +1039,10 @@ $payment = new \Potelo\MultiPayment\MultiPayment('stripe'); $refund = $payment->refundInvoice($invoiceId); $refund = $payment->refundInvoice($invoiceId, 5000); -// cancelamento de fatura pendente +// cancelamento de fatura pendente (no Stripe, a fatura de assinatura in_ é anulada com void) $payment->cancelInvoice($invoiceId); -// duplicar fatura pendente com nova expiração (no Stripe: somente pix; a original é cancelada) +// duplicar fatura pendente com nova expiração (no Stripe: somente pix de venda avulsa; a original é cancelada) $payment->duplicateInvoice($invoiceId, \Carbon\Carbon::now()->addDays(3)); // cobrar uma fatura pendente com cartão (token OU id de cartão salvo) @@ -1089,7 +1216,7 @@ $payment->setGateway('iugu')->charge($options); | `customer.name` | **obrigatório** | string | nome do cliente | `'Nome do cliente'` | | `customer.email` | **obrigatório** | string | email do cliente | `'joaomaria@email.com'` | | `customer.tax_document` | **obrigatório** no Stripe para faturas pix | string | cpf ou cnpj do cliente | `'12345678901'` | -| `birth_date` | | string formato `yyyy-mm-dd` | data de nascimento | `'01/01/1990'` | +| `customer.birth_date` | | string formato `yyyy-mm-dd` | data de nascimento | `'1990-01-01'` | | `customer.phone_number` | | string | telefone | `'999999999'` | | `customer.phone_area` | | string | DDD | `'999999999'` | | `customer.address` | **obrigatório** para o método de pagamento `bank_slip` | array | array com os dados do endereço do cliente | `['street' => 'Rua do cliente'...]` | @@ -1150,6 +1277,7 @@ $invoice->creditCard->cvv = '123'; $invoice->creditCard->customer = $customer; $invoice->save('iugu'); echo $invoice->id; // CB1FA9B5BD1C42B287F4AC7F6259E45D +$invoice->originType; // InvoiceOriginType::INVOICE (na Iugu sempre; no Stripe, PAYMENT_INTENT ou INVOICE) ``` #### Refund ```php diff --git a/src/Enums/InvoiceOriginType.php b/src/Enums/InvoiceOriginType.php new file mode 100644 index 0000000..9eba5c8 --- /dev/null +++ b/src/Enums/InvoiceOriginType.php @@ -0,0 +1,16 @@ +gateway = 'iugu'; + $invoice->originType = InvoiceOriginType::INVOICE; $invoice->original = $response; return $invoice; @@ -1298,6 +1300,7 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice $iuguInvoice = (object) $iuguInvoice; $invoice->id = $iuguInvoice->id ?? null; $invoice->gateway = 'iugu'; + $invoice->originType = InvoiceOriginType::INVOICE; $invoice->status = self::iuguStatusToMultiPayment($iuguInvoice->status ?? null); $invoice->amount = $iuguInvoice->total_cents ?? null; $invoice->paidAt = !empty($iuguInvoice->paid_at) ? new Carbon($iuguInvoice->paid_at) : null; @@ -2666,6 +2669,7 @@ private function parseIuguRecentInvoice(object $iuguSubscription): ?Invoice : null; $invoice->url = $iuguInvoice->secure_url ?? null; $invoice->gateway = 'iugu'; + $invoice->originType = InvoiceOriginType::INVOICE; $invoice->original = $iuguInvoice; return $invoice; diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 1a5a3e5..d9332f1 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Stripe\StripeClient; +use Stripe\Invoice as StripeInvoice; use Stripe\Customer as StripeCustomer; use Stripe\PaymentIntent as StripePaymentIntent; use Stripe\PaymentMethod as StripePaymentMethod; @@ -30,6 +31,7 @@ use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\InvoiceOriginType; use Potelo\MultiPayment\Enums\RefundStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\DeclineCode; @@ -75,6 +77,22 @@ class StripeGateway implements GatewayContract */ private const PAYMENT_INTENT_EXPAND = ['latest_charge.balance_transaction', 'latest_charge.refunds']; + /** + * Expand na leitura de um Invoice da Stripe: `payments` só vem expandido, e é nele que está + * o PaymentIntent da fatura (`payments.data[].payment.payment_intent`). O `expand` da Stripe + * para em quatro níveis, então o `latest_charge` desse PaymentIntent é lido num GET à parte. + */ + private const INVOICE_EXPAND = ['payments.data.payment.payment_intent']; + + /** Prefixo do id de um objeto Invoice da Stripe; o de PaymentIntent é `pi_`. */ + private const STRIPE_INVOICE_ID_PREFIX = 'in_'; + + /** Tipo de InvoicePayment cujo pagamento é um PaymentIntent. */ + private const INVOICE_PAYMENT_TYPE_PAYMENT_INTENT = 'payment_intent'; + + /** Tipo de InvoicePayment registrado quando a fatura é paga fora da Stripe (`paid_out_of_band`). */ + private const INVOICE_PAYMENT_TYPE_PAYMENT_RECORD = 'payment_record'; + /** * Mapa de status do objeto Refund da Stripe para os genéricos do pacote. Lista oficial em * https://docs.stripe.com/api/refunds/object#refund_object-status; `requires_action` @@ -915,9 +933,19 @@ private function mergeGatewayOptions(array $stripeData, Model $model): array /** * @inheritDoc + * + * Aceita o id de um PaymentIntent (`pi_`, cobrança avulsa) ou de um Invoice da Stripe + * (`in_`, fatura de assinatura): o prefixo decide qual objeto é lido e + * `Invoice::$originType` diz qual voltou. A leitura de um Invoice custa um GET a mais + * quando o PaymentIntent dele já teve tentativa de pagamento, porque o charge fica fora do + * limite de níveis do `expand`. */ public function getInvoice(Invoice $invoice): Invoice { + if (self::isStripeInvoiceId($invoice->id)) { + return $this->parseInvoice($this->retrieveStripeInvoice($invoice->id), $invoice); + } + $stripePaymentIntent = $this->stripeRequest(function () use ($invoice) { return $this->client->paymentIntents->retrieve( $invoice->id, @@ -928,6 +956,53 @@ public function getInvoice(Invoice $invoice): Invoice return $this->parseInvoice($stripePaymentIntent, $invoice); } + /** + * Diz se o id é de um objeto Invoice da Stripe (prefixo `in_`). + * + * @param string|null $id + * @return bool + */ + private static function isStripeInvoiceId(?string $id): bool + { + return is_string($id) && str_starts_with($id, self::STRIPE_INVOICE_ID_PREFIX); + } + + /** + * Lê um objeto Invoice da Stripe com os pagamentos e o PaymentIntent deles expandidos. + * + * @param string $id + * @return StripeInvoice + * @throws GatewayException|GatewayNotAvailableException + */ + private function retrieveStripeInvoice(string $id): StripeInvoice + { + return $this->stripeRequest(function () use ($id) { + return $this->client->invoices->retrieve($id, ['expand' => self::INVOICE_EXPAND]); + }); + } + + /** + * Lança `UnsupportedOperationException` (`SUBSCRIPTIONS`, `not_implemented`) quando a fatura + * é um Invoice da Stripe (id `in_`): a lib ainda não implementa escrita sobre a fatura de + * assinatura. + * + * @param Invoice $invoice + * @param string $operation nome da operação, para a mensagem + * @return void + * @throws UnsupportedOperationException + */ + private function assertPaymentIntentOrigin(Invoice $invoice, string $operation): void + { + if (self::isStripeInvoiceId($invoice->id)) { + throw UnsupportedOperationException::forGateway( + $this, + Capability::SUBSCRIPTIONS, + "A operação {$operation} sobre a fatura de assinatura [{$invoice->id}] (objeto Invoice da Stripe)" + . ' ainda não está implementada nesta lib; a leitura por getInvoice() está disponível.' + ); + } + } + /** * @inheritDoc * @@ -952,6 +1027,7 @@ public function refundInvoice(Invoice $invoice, ?string $idempotencyKey = null): if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); } + $this->assertPaymentIntentOrigin($invoice, 'refundInvoice'); $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); // guardado antes da leitura: parseInvoice() sobrescreve refundedAmount com o já estornado @@ -1077,6 +1153,7 @@ public function chargeInvoiceWithCreditCard(Invoice $invoice, ?string $idempoten if (empty($invoice->creditCard->token) && empty($invoice->creditCard->id)) { throw new ModelAttributeValidationException('Credit card token or id is required'); } + $this->assertPaymentIntentOrigin($invoice, 'chargeInvoiceWithCreditCard'); $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); // id = PaymentMethod salvo no customer; token = PaymentMethod criado client-side @@ -1130,14 +1207,33 @@ public function chargeInvoiceWithCreditCard(Invoice $invoice, ?string $idempoten } /** - * Converte o PaymentIntent da Stripe em uma Invoice do MultiPayment. + * Converte o objeto de origem da Stripe em uma Invoice do MultiPayment: PaymentIntent + * (cobrança avulsa, origem `PAYMENT_INTENT`) ou Invoice da Stripe (fatura de assinatura, + * origem `INVOICE`). `Invoice::$originType` diz qual foi e `original` guarda o objeto. + * + * @param \Stripe\PaymentIntent|\Stripe\Invoice $stripeObject + * @param \Potelo\MultiPayment\Models\Invoice|null $invoice + * @return \Potelo\MultiPayment\Models\Invoice + * @throws GatewayException|GatewayNotAvailableException + */ + private function parseInvoice(StripePaymentIntent|StripeInvoice $stripeObject, ?Invoice $invoice = null): Invoice + { + return $stripeObject instanceof StripeInvoice + ? $this->parseFromStripeInvoice($stripeObject, $invoice) + : $this->parseFromPaymentIntent($stripeObject, $invoice); + } + + /** + * Converte o PaymentIntent da Stripe em uma Invoice do MultiPayment (origem + * `PAYMENT_INTENT`). Os line items vêm de `metadata`, onde `invoiceToStripeData()` os + * serializou; `url` é a página de instruções do Pix, nula em cartão. * * @param \Stripe\PaymentIntent $stripePaymentIntent * @param \Potelo\MultiPayment\Models\Invoice|null $invoice * @return \Potelo\MultiPayment\Models\Invoice * @throws GatewayException|GatewayNotAvailableException */ - private function parseInvoice(StripePaymentIntent $stripePaymentIntent, ?Invoice $invoice = null): Invoice + private function parseFromPaymentIntent(StripePaymentIntent $stripePaymentIntent, ?Invoice $invoice = null): Invoice { $invoice = $invoice ?? new Invoice(); @@ -1145,41 +1241,22 @@ private function parseInvoice(StripePaymentIntent $stripePaymentIntent, ?Invoice // não pode alimentar paidAmount/refundedAmount $stripeCharge = is_object($stripePaymentIntent->latest_charge) ? $stripePaymentIntent->latest_charge : null; $paidCharge = ($stripeCharge && $stripeCharge->status === 'succeeded') ? $stripeCharge : null; - $disputeStatus = $paidCharge ? $this->disputeStatus($paidCharge) : null; $invoice->id = $stripePaymentIntent->id; $invoice->gateway = 'stripe'; - $invoice->status = self::stripeStatusToMultiPayment($stripePaymentIntent, $paidCharge, $disputeStatus); + $invoice->originType = InvoiceOriginType::PAYMENT_INTENT; + $invoice->status = $this->deriveStatus(null, $stripePaymentIntent, $paidCharge); $invoice->amount = $stripePaymentIntent->amount; $invoice->paidAmount = $paidCharge?->amount_captured; $invoice->refundedAmount = $paidCharge?->amount_refunded; $invoice->refunds = $this->parseRefunds($paidCharge, $stripePaymentIntent->id); $invoice->paidAt = $paidCharge ? Carbon::createFromTimestamp($paidCharge->created) : null; - $balanceTransaction = $paidCharge?->balance_transaction; - // a balance transaction do cartão é assíncrona: pode vir nula logo após o confirm - // e preenchida num getInvoice posterior - $invoice->fee = is_object($balanceTransaction) ? $balanceTransaction->fee : null; + $invoice->fee = self::chargeFee($paidCharge); $invoice->createdAt = Carbon::createFromTimestamp($stripePaymentIntent->created); $invoice->original = $stripePaymentIntent; - if (!empty($stripePaymentIntent->customer)) { - if (empty($invoice->customer)) { - $invoice->customer = new Customer(); - } - $invoice->customer->id = is_object($stripePaymentIntent->customer) - ? $stripePaymentIntent->customer->id - : $stripePaymentIntent->customer; - } - - $detailsType = $stripeCharge?->payment_method_details?->type; - if (!empty($detailsType)) { - $invoice->paymentMethod = self::PAYMENT_METHOD_TYPES[$detailsType] ?? null; - } elseif (count($stripePaymentIntent->payment_method_types ?? []) === 1) { - $invoice->paymentMethod = self::PAYMENT_METHOD_TYPES[$stripePaymentIntent->payment_method_types[0]] ?? null; - } - if (!empty($invoice->paymentMethod)) { - $invoice->availablePaymentMethods = [$invoice->paymentMethod]; - } + $this->parseInvoiceCustomer($invoice, $stripePaymentIntent->customer ?? null); + $this->parsePaymentMethod($invoice, $stripePaymentIntent, $stripeCharge); // reconstrói os items serializados em metadata pelo invoiceToStripeData $metadata = !empty($stripePaymentIntent->metadata) ? $stripePaymentIntent->metadata->toArray() : []; @@ -1195,39 +1272,292 @@ private function parseInvoice(StripePaymentIntent $stripePaymentIntent, ?Invoice $invoice->items = $items; } + $this->parseCardDetails($invoice, $stripeCharge); + + // sem next_action de pix não há QR utilizável: a página de instruções some junto, + // inclusive num model reutilizado (ex.: fatura pix expirada re-cobrada com cartão) + $invoice->url = $this->parsePixDisplay($invoice, $stripePaymentIntent); + + return $invoice; + } + + /** + * Converte um objeto Invoice da Stripe (fatura de assinatura) em uma Invoice do + * MultiPayment (origem `INVOICE`). O PaymentIntent da fatura vem de `payments` + * (`invoicePaymentIntent()`), e o charge dele alimenta valores, estornos e contestação como + * na cobrança avulsa. Os line items vêm de `lines.data` (a primeira página, de até dez + * itens); `url` é a página hospedada da fatura; `expiresAt` é o `due_date`, quando a fatura + * tem um, ou a expiração do QR Code do Pix. + * + * @param \Stripe\Invoice $stripeInvoice + * @param \Potelo\MultiPayment\Models\Invoice|null $invoice + * @return \Potelo\MultiPayment\Models\Invoice + * @throws GatewayException|GatewayNotAvailableException + */ + private function parseFromStripeInvoice(StripeInvoice $stripeInvoice, ?Invoice $invoice = null): Invoice + { + $invoice = $invoice ?? new Invoice(); + + $stripePaymentIntent = $this->invoicePaymentIntent($stripeInvoice); + $stripeCharge = is_object($stripePaymentIntent?->latest_charge) ? $stripePaymentIntent->latest_charge : null; + $paidCharge = ($stripeCharge && $stripeCharge->status === 'succeeded') ? $stripeCharge : null; + + $invoice->id = $stripeInvoice->id; + $invoice->gateway = 'stripe'; + $invoice->originType = InvoiceOriginType::INVOICE; + $invoice->status = $this->deriveStatus($stripeInvoice, $stripePaymentIntent, $paidCharge); + $invoice->amount = $stripeInvoice->total; + // sem charge pago, o valor recebido é o que o Invoice registra (pagamento externo, + // fatura parcialmente paga ou quitada sem cobrança); fatura em aberto sem nada pago fica nula + $amountPaid = $stripeInvoice->amount_paid ?? 0; + $invoice->paidAmount = $paidCharge?->amount_captured + ?? ($stripeInvoice->status === 'paid' || $amountPaid > 0 ? $amountPaid : null); + $invoice->refundedAmount = $paidCharge?->amount_refunded; + $invoice->refunds = $this->parseRefunds($paidCharge, $stripeInvoice->id); + $paidAt = $stripeInvoice->status_transitions->paid_at ?? $paidCharge?->created; + $invoice->paidAt = !empty($paidAt) ? Carbon::createFromTimestamp($paidAt) : null; + $invoice->fee = self::chargeFee($paidCharge); + $invoice->createdAt = Carbon::createFromTimestamp($stripeInvoice->created); + $invoice->expiresAt = !empty($stripeInvoice->due_date) + ? Carbon::createFromTimestamp($stripeInvoice->due_date) + : null; + $invoice->url = $stripeInvoice->hosted_invoice_url ?? null; + $invoice->original = $stripeInvoice; + + $this->parseInvoiceCustomer($invoice, $stripeInvoice->customer ?? null); + // paga fora da Stripe, o método oferecido pelo PaymentIntent cancelado não diz como + // o dinheiro entrou + if ($invoice->status !== InvoiceStatus::EXTERNALLY_PAID) { + $this->parsePaymentMethod($invoice, $stripePaymentIntent, $stripeCharge); + } + + $items = []; + foreach ($stripeInvoice->lines->data ?? [] as $line) { + $invoiceItem = new InvoiceItem(); + $invoiceItem->description = $line->description ?? null; + $invoiceItem->quantity = isset($line->quantity) ? (int) $line->quantity : 1; + $invoiceItem->price = self::lineItemUnitAmount($line, $invoiceItem->quantity); + $items[] = $invoiceItem; + } + if (!empty($items)) { + $invoice->items = $items; + } + + $this->parseCardDetails($invoice, $stripeCharge); + $this->parsePixDisplay($invoice, $stripePaymentIntent); + + return $invoice; + } + + /** + * Escolhe o PaymentIntent da fatura entre os pagamentos do Invoice (`payments.data`): o + * que está pago, senão o pagamento padrão (`is_default`), senão o primeiro do tipo + * PaymentIntent. Devolve nulo quando a fatura não tem PaymentIntent (rascunho, quitada sem + * cobrança, paga fora da Stripe). Um PaymentIntent que já tem charge, ou que veio só como + * id, é relido com o expand de PaymentIntent, para o charge trazer valores, estornos e a + * flag de contestação. + * + * @param \Stripe\Invoice $stripeInvoice + * @return \Stripe\PaymentIntent|null + * @throws GatewayException|GatewayNotAvailableException + */ + private function invoicePaymentIntent(StripeInvoice $stripeInvoice): ?StripePaymentIntent + { + $candidates = []; + foreach ($stripeInvoice->payments->data ?? [] as $invoicePayment) { + if (($invoicePayment->payment->type ?? null) === self::INVOICE_PAYMENT_TYPE_PAYMENT_INTENT) { + $candidates[] = $invoicePayment; + } + } + + $chosen = null; + foreach ($candidates as $candidate) { + if (($candidate->status ?? null) === 'paid') { + $chosen = $candidate; + break; + } + } + if (is_null($chosen)) { + foreach ($candidates as $candidate) { + if (!empty($candidate->is_default)) { + $chosen = $candidate; + break; + } + } + } + $chosen = $chosen ?? ($candidates[0] ?? null); + if (is_null($chosen)) { + return null; + } + + $stripePaymentIntent = $chosen->payment->payment_intent ?? null; + if ($stripePaymentIntent instanceof StripePaymentIntent && empty($stripePaymentIntent->latest_charge)) { + return $stripePaymentIntent; + } + + $id = is_object($stripePaymentIntent) ? ($stripePaymentIntent->id ?? '') : (string) $stripePaymentIntent; + if ($id === '') { + return null; + } + + return $this->stripeRequest(function () use ($id) { + return $this->client->paymentIntents->retrieve($id, ['expand' => self::PAYMENT_INTENT_EXPAND]); + }); + } + + /** + * Tipo do primeiro pagamento do Invoice com status `paid` (`payment_intent`, `charge` ou + * `payment_record`), ou nulo quando nenhum está pago. + * + * @param \Stripe\Invoice $stripeInvoice + * @return string|null + */ + private static function paidInvoicePaymentType(StripeInvoice $stripeInvoice): ?string + { + foreach ($stripeInvoice->payments->data ?? [] as $invoicePayment) { + if (($invoicePayment->status ?? null) === 'paid') { + return $invoicePayment->payment->type ?? null; + } + } + + return null; + } + + /** + * Valor unitário de um line item do Invoice: `pricing.unit_amount_decimal` quando existe, + * senão o `amount` da linha dividido pela quantidade. + * + * @param object $line + * @param int $quantity + * @return int|null + */ + private static function lineItemUnitAmount(object $line, int $quantity): ?int + { + $unitAmount = $line->pricing->unit_amount_decimal ?? null; + if (is_numeric($unitAmount)) { + return (int) round((float) $unitAmount); + } + + if (!isset($line->amount)) { + return null; + } + + return $quantity > 1 ? intdiv((int) $line->amount, $quantity) : (int) $line->amount; + } + + /** + * Taxa da Stripe no charge pago, lida da balance transaction expandida. A balance + * transaction do cartão é assíncrona: pode vir nula logo após o confirm e preenchida numa + * leitura posterior. + * + * @param object|null $paidCharge + * @return int|null + */ + private static function chargeFee(?object $paidCharge): ?int + { + $balanceTransaction = $paidCharge?->balance_transaction; + + return is_object($balanceTransaction) ? $balanceTransaction->fee : null; + } + + /** + * Preenche o id do cliente da fatura a partir do `customer` do objeto da Stripe (id ou + * objeto expandido); sem cliente no objeto, o model fica como estava. + * + * @param Invoice $invoice + * @param object|string|null $stripeCustomer + * @return void + */ + private function parseInvoiceCustomer(Invoice $invoice, object|string|null $stripeCustomer): void + { + if (empty($stripeCustomer)) { + return; + } + if (empty($invoice->customer)) { + $invoice->customer = new Customer(); + } + $invoice->customer->id = is_object($stripeCustomer) ? $stripeCustomer->id : $stripeCustomer; + } + + /** + * Preenche `paymentMethod` e `availablePaymentMethods` a partir do tipo do charge + * (`payment_method_details.type`) ou, sem charge, do único tipo aceito pelo PaymentIntent. + * + * @param Invoice $invoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @param object|null $stripeCharge + * @return void + */ + private function parsePaymentMethod(Invoice $invoice, ?StripePaymentIntent $stripePaymentIntent, ?object $stripeCharge): void + { + $detailsType = $stripeCharge?->payment_method_details?->type; + if (!empty($detailsType)) { + $invoice->paymentMethod = self::PAYMENT_METHOD_TYPES[$detailsType] ?? null; + } elseif (count($stripePaymentIntent?->payment_method_types ?? []) === 1) { + $invoice->paymentMethod = self::PAYMENT_METHOD_TYPES[$stripePaymentIntent->payment_method_types[0]] ?? null; + } + if (!empty($invoice->paymentMethod)) { + $invoice->availablePaymentMethods = [$invoice->paymentMethod]; + } + } + + /** + * Preenche bandeira e últimos dígitos do cartão a partir de `payment_method_details.card` + * do charge, quando existe. + * + * @param Invoice $invoice + * @param object|null $stripeCharge + * @return void + */ + private function parseCardDetails(Invoice $invoice, ?object $stripeCharge): void + { // `?->` não basta: em cobrança pix o payment_method_details existe e apenas não tem // a chave `card`, e o StripeObject loga "Undefined property" via Stripe::getLogger() // ao ler propriedade ausente. isset() passa pelo __isset e não polui o log. $paymentMethodDetails = $stripeCharge?->payment_method_details; $cardDetails = isset($paymentMethodDetails->card) ? $paymentMethodDetails->card : null; - if (!empty($cardDetails)) { - if (empty($invoice->creditCard)) { - $invoice->creditCard = new CreditCard(); - } - $invoice->creditCard->brand = $cardDetails->brand ?? null; - $invoice->creditCard->lastDigits = $cardDetails->last4 ?? null; - $invoice->creditCard->gateway = 'stripe'; + if (empty($cardDetails)) { + return; } + if (empty($invoice->creditCard)) { + $invoice->creditCard = new CreditCard(); + } + $invoice->creditCard->brand = $cardDetails->brand ?? null; + $invoice->creditCard->lastDigits = $cardDetails->last4 ?? null; + $invoice->creditCard->gateway = 'stripe'; + } - $qrCode = $stripePaymentIntent->next_action?->pix_display_qr_code; - if (!empty($qrCode)) { - if (empty($invoice->pix)) { - $invoice->pix = new Pix(); - } - $invoice->pix->qrCodeText = $qrCode->data ?? null; - $invoice->pix->qrCodeImageUrl = $qrCode->image_url_png ?? null; - $invoice->url = $qrCode->hosted_instructions_url ?? null; - $invoice->expiresAt = !empty($qrCode->expires_at) - ? Carbon::createFromTimestamp($qrCode->expires_at) - : $invoice->expiresAt; - } else { - // sem next_action de pix não há QR utilizável — limpa dados velhos de um model - // reutilizado (ex.: fatura pix expirada re-cobrada com cartão) + /** + * Preenche `pix` (QR Code) e `expiresAt` a partir de `next_action.pix_display_qr_code` do + * PaymentIntent e devolve a página hospedada de instruções. Sem QR Code, limpa `pix` e + * devolve nulo. + * + * @param Invoice $invoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @return string|null + */ + private function parsePixDisplay(Invoice $invoice, ?StripePaymentIntent $stripePaymentIntent): ?string + { + // isset() passa pelo __isset: um next_action de outro tipo (3DS) não tem a chave e o + // StripeObject registraria "Undefined property" no log ao lê-la + $nextAction = $stripePaymentIntent?->next_action; + $qrCode = isset($nextAction->pix_display_qr_code) ? $nextAction->pix_display_qr_code : null; + if (empty($qrCode)) { $invoice->pix = null; - $invoice->url = null; + + return null; } - return $invoice; + if (empty($invoice->pix)) { + $invoice->pix = new Pix(); + } + $invoice->pix->qrCodeText = $qrCode->data ?? null; + $invoice->pix->qrCodeImageUrl = $qrCode->image_url_png ?? null; + $invoice->expiresAt = !empty($qrCode->expires_at) + ? Carbon::createFromTimestamp($qrCode->expires_at) + : $invoice->expiresAt; + + return $qrCode->hosted_instructions_url ?? null; } /** @@ -1324,18 +1654,58 @@ private function disputeStatus(object $stripeCharge): ?InvoiceStatus } /** - * Deriva o status genérico do par PaymentIntent + charge. Estorno não muda o status do - * PaymentIntent na Stripe, então ele vem do charge. Contestação, quando existe, vence os - * dois: uma fatura disputada não lê como paga nem como estornada. `requires_capture` lê - * como `AUTHORIZED` e `processing` como `PROCESSING`; status de PaymentIntent fora do + * Deriva o `InvoiceStatus` do trio Invoice da Stripe, PaymentIntent e charge pago; os dois + * `parse*` de fatura obtêm o status por aqui. + * + * Sem Invoice (origem `PAYMENT_INTENT`) o status vem do PaymentIntent e o charge refina + * estorno e contestação: estorno não muda o status do PaymentIntent na Stripe; + * `requires_capture` lê como `AUTHORIZED`, `processing` como `PROCESSING` e status fora do * mapa devolve `UNKNOWN` com aviso no log. * - * @param \Stripe\PaymentIntent $stripePaymentIntent + * Com Invoice (origem `INVOICE`) o status do Invoice manda no ciclo de vida e PaymentIntent + * e charge só refinam o detalhe de pagamento: `draft` é `PENDING`; `open` é `PENDING`, + * `AUTHORIZED`, `PROCESSING` ou `PARTIALLY_PAID` conforme o PaymentIntent e o + * `amount_paid`; `paid` é `PAID`, estornada ou contestada conforme o charge, + * `EXTERNALLY_PAID` quando o pagamento foi registrado fora da Stripe e `PAID` quando não + * houve cobrança (`amount_due` zero); `void` é `CANCELED` e `uncollectible` é `EXPIRED`. + * Combinação fora dessa tabela devolve `UNKNOWN` com aviso no log contendo os três status + * e o id da fatura. + * + * Contestação, quando existe, vence estorno e status de pagamento nas duas origens. + * + * @param \Stripe\Invoice|null $stripeInvoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @param object|null $paidCharge charge em `succeeded`, expandido + * @return InvoiceStatus + * @throws GatewayException|GatewayNotAvailableException + */ + private function deriveStatus(?StripeInvoice $stripeInvoice, ?StripePaymentIntent $stripePaymentIntent, ?object $paidCharge): InvoiceStatus + { + $disputeStatus = $paidCharge ? $this->disputeStatus($paidCharge) : null; + + if (is_null($stripeInvoice)) { + return self::paymentIntentStatus($stripePaymentIntent, $paidCharge, $disputeStatus); + } + + return match ($stripeInvoice->status) { + 'draft' => InvoiceStatus::PENDING, + 'open' => $this->openInvoiceStatus($stripeInvoice, $stripePaymentIntent), + 'paid' => $this->paidInvoiceStatus($stripeInvoice, $stripePaymentIntent, $paidCharge, $disputeStatus), + 'void' => InvoiceStatus::CANCELED, + 'uncollectible' => InvoiceStatus::EXPIRED, + default => self::unknownInvoiceStatus($stripeInvoice, $stripePaymentIntent), + }; + } + + /** + * Status de um PaymentIntent sem Invoice (origem `PAYMENT_INTENT`). + * + * @param \Stripe\PaymentIntent|null $stripePaymentIntent * @param object|null $paidCharge - * @param InvoiceStatus|null $disputeStatus resultado de disputeStatus() para o charge pago + * @param InvoiceStatus|null $disputeStatus * @return InvoiceStatus */ - private static function stripeStatusToMultiPayment(StripePaymentIntent $stripePaymentIntent, ?object $paidCharge, ?InvoiceStatus $disputeStatus = null): InvoiceStatus + private static function paymentIntentStatus(?StripePaymentIntent $stripePaymentIntent, ?object $paidCharge, ?InvoiceStatus $disputeStatus): InvoiceStatus { if ($disputeStatus !== null) { return $disputeStatus; @@ -1347,7 +1717,7 @@ private static function stripeStatusToMultiPayment(StripePaymentIntent $stripePa : InvoiceStatus::PARTIALLY_REFUNDED; } - return match ($stripePaymentIntent->status) { + return match ($stripePaymentIntent?->status) { 'succeeded' => InvoiceStatus::PAID, 'canceled' => InvoiceStatus::CANCELED, 'requires_capture' => InvoiceStatus::AUTHORIZED, @@ -1355,10 +1725,108 @@ private static function stripeStatusToMultiPayment(StripePaymentIntent $stripePa // pix expirado volta a requires_payment_method (não vira canceled) e segue // re-cobrável; reportar PENDING preserva essa funcionalidade 'requires_action', 'requires_confirmation', 'requires_payment_method' => InvoiceStatus::PENDING, - default => InvoiceStatus::unknown((string) $stripePaymentIntent->status, 'stripe'), + default => InvoiceStatus::unknown((string) $stripePaymentIntent?->status, 'stripe'), + }; + } + + /** + * Status de um Invoice da Stripe em `open`: parcialmente paga quando `amount_paid` está + * entre zero e o `total`; senão o PaymentIntent decide (ausente ou aguardando o cliente é + * `PENDING`, `requires_capture` é `AUTHORIZED`, `processing` é `PROCESSING`). PaymentIntent + * em `succeeded` ou `canceled` numa fatura ainda aberta é transição ou pagamento fora do + * padrão e fica em `UNKNOWN`. + * + * @param \Stripe\Invoice $stripeInvoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @return InvoiceStatus + */ + private function openInvoiceStatus(StripeInvoice $stripeInvoice, ?StripePaymentIntent $stripePaymentIntent): InvoiceStatus + { + $amountPaid = $stripeInvoice->amount_paid ?? 0; + if ($amountPaid > 0 && $amountPaid < ($stripeInvoice->total ?? 0)) { + return InvoiceStatus::PARTIALLY_PAID; + } + + return match ($stripePaymentIntent?->status) { + null, 'requires_payment_method', 'requires_action', 'requires_confirmation' => InvoiceStatus::PENDING, + 'requires_capture' => InvoiceStatus::AUTHORIZED, + 'processing' => InvoiceStatus::PROCESSING, + default => self::unknownInvoiceStatus($stripeInvoice, $stripePaymentIntent), }; } + /** + * Status de um Invoice da Stripe em `paid`. Com o PaymentIntent em `succeeded`, o charge + * refina: contestação, estorno parcial ou total, senão `PAID`. Sem ele, o pagamento + * registrado fora da Stripe (`amount_paid_off_stripe`, ou um InvoicePayment pago do tipo + * `payment_record`) lê como `EXTERNALLY_PAID`; um InvoicePayment pago do tipo `charge` lê + * como `PAID`; e a fatura sem PaymentIntent com `amount_due` zero (avaliação gratuita, + * saldo de crédito, valor abaixo do mínimo) lê como `PAID`. O restante é `UNKNOWN`. + * + * @param \Stripe\Invoice $stripeInvoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @param object|null $paidCharge + * @param InvoiceStatus|null $disputeStatus + * @return InvoiceStatus + */ + private function paidInvoiceStatus( + StripeInvoice $stripeInvoice, + ?StripePaymentIntent $stripePaymentIntent, + ?object $paidCharge, + ?InvoiceStatus $disputeStatus + ): InvoiceStatus { + if ($stripePaymentIntent?->status === 'succeeded') { + return self::paymentIntentStatus($stripePaymentIntent, $paidCharge, $disputeStatus); + } + + if (($stripeInvoice->amount_paid_off_stripe ?? 0) > 0) { + return InvoiceStatus::EXTERNALLY_PAID; + } + + $paidPaymentType = self::paidInvoicePaymentType($stripeInvoice); + if ($paidPaymentType === self::INVOICE_PAYMENT_TYPE_PAYMENT_RECORD) { + return InvoiceStatus::EXTERNALLY_PAID; + } + if ($paidPaymentType === 'charge') { + return InvoiceStatus::PAID; + } + + if (is_null($stripePaymentIntent) && (int) ($stripeInvoice->amount_due ?? 0) === 0) { + return InvoiceStatus::PAID; + } + + return self::unknownInvoiceStatus($stripeInvoice, $stripePaymentIntent); + } + + /** + * Devolve `UNKNOWN` e registra um aviso no log com o id da fatura e os status do Invoice, + * do PaymentIntent e do charge. + * + * @param \Stripe\Invoice $stripeInvoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @return InvoiceStatus + */ + private static function unknownInvoiceStatus(StripeInvoice $stripeInvoice, ?StripePaymentIntent $stripePaymentIntent): InvoiceStatus + { + $stripeCharge = is_object($stripePaymentIntent?->latest_charge) ? $stripePaymentIntent->latest_charge : null; + $context = [ + 'gateway' => 'stripe', + 'invoice_id' => $stripeInvoice->id, + 'invoice_status' => $stripeInvoice->status, + 'payment_intent_status' => $stripePaymentIntent?->status, + 'charge_status' => $stripeCharge?->status, + ]; + + LogHelper::warning( + "Combinação de status sem tradução na fatura [{$stripeInvoice->id}] do gateway [stripe]" + . " (invoice [{$stripeInvoice->status}], payment_intent [" . ($stripePaymentIntent?->status ?? 'ausente') + . '], charge [' . ($stripeCharge?->status ?? 'ausente') . ']), lida como unknown', + $context + ); + + return InvoiceStatus::UNKNOWN; + } + /** * Normaliza o código de recusa da Stripe para o valor de `CardDeclinedException::$reason`, que * mantém o vocabulário das versões anteriores; `declineCode` é a normalização atual. @@ -1391,7 +1859,9 @@ private static function chargeFailureReason(?string $code, ?string $declineCode) * Restrito a faturas pix pendentes (cartão é síncrono, não há o que duplicar). A chave de * idempotência vai na criação da nova fatura; o cancelamento da original usa * `{chave}:cancel_original`. Com chave, uma original já cancelada é aceita, porque pode ser - * o resultado de uma tentativa anterior com a mesma chave, que a Stripe repete. + * o resultado de uma tentativa anterior com a mesma chave, que a Stripe repete. Fatura de + * origem `INVOICE` (id `in_`) é recusada antes da rede: a próxima fatura da assinatura é + * gerada pela Stripe. * * @throws ModelAttributeValidationException|UnsupportedOperationException */ @@ -1404,6 +1874,15 @@ public function duplicateInvoice( if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); } + if (self::isStripeInvoiceId($invoice->id)) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::INVOICE_DUPLICATION, + "No Stripe a fatura de assinatura [{$invoice->id}] (objeto Invoice) não pode ser duplicada:" + . ' a próxima fatura é gerada pela Stripe, e um Pix expirado se resolve com nova tentativa' + . ' de pagamento da mesma fatura.' + ); + } $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice, $gatewayOptions); $gatewayOptions = self::withoutIdempotencyKey($gatewayOptions); @@ -1484,7 +1963,9 @@ public function duplicateInvoice( /** * @inheritDoc * - * A chave de idempotência vai no cabeçalho `Idempotency-Key` do cancelamento. + * Na origem `PAYMENT_INTENT` cancela o PaymentIntent; na origem `INVOICE` (id `in_`) anula o + * Invoice da Stripe (`void`), e a Stripe cancela sozinha o PaymentIntent padrão dele. A + * chave de idempotência vai no cabeçalho `Idempotency-Key` do cancelamento. * * @throws ModelAttributeValidationException */ @@ -1495,6 +1976,10 @@ public function cancelInvoice(Invoice $invoice, ?string $idempotencyKey = null): } $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); + if (self::isStripeInvoiceId($invoice->id)) { + return $this->voidStripeInvoice($invoice, $idempotencyKey); + } + // só estados não-terminais são canceláveis; PaymentIntent pago recusa o cancel // com payment_intent_unexpected_state (vira GatewayException) $stripePaymentIntent = $this->stripeRequest(function () use ($invoice, $idempotencyKey) { @@ -1508,6 +1993,37 @@ public function cancelInvoice(Invoice $invoice, ?string $idempotencyKey = null): return $this->parseInvoice($stripePaymentIntent, $invoice); } + /** + * Anula um Invoice da Stripe. A fatura é lida antes: `draft` lança `GatewayException` + * orientando a esperar a finalização; nos demais estados a Stripe decide, e `paid` ou + * `void` recusam com `ValidationException`, como o PaymentIntent já pago ou cancelado. + * + * @param Invoice $invoice + * @param string|null $idempotencyKey + * @return Invoice + * @throws GatewayException|GatewayNotAvailableException + */ + private function voidStripeInvoice(Invoice $invoice, ?string $idempotencyKey): Invoice + { + $current = $this->retrieveStripeInvoice($invoice->id); + if ($current->status === 'draft') { + throw new GatewayException( + "A fatura [{$invoice->id}] ainda é um rascunho na Stripe e não pode ser cancelada;" + . ' aguarde a finalização dela pela Stripe.' + ); + } + + $stripeInvoice = $this->stripeRequest(function () use ($invoice, $idempotencyKey) { + return $this->client->invoices->voidInvoice( + $invoice->id, + ['expand' => self::INVOICE_EXPAND], + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseInvoice($stripeInvoice, $invoice); + } + /** * @inheritDoc * diff --git a/src/Helpers/ConfigurationHelper.php b/src/Helpers/ConfigurationHelper.php index c805427..6b70e68 100644 --- a/src/Helpers/ConfigurationHelper.php +++ b/src/Helpers/ConfigurationHelper.php @@ -68,4 +68,20 @@ public static function idempotencyTtl(): int { return (int) (Config::get('multi-payment.idempotency.ttl') ?? 86400); } + + /** + * Diz se `Model::fill()` recusa chave desconhecida (`multi-payment.strict_fill`, padrão + * verdadeiro). Sem container do Laravel, ou sem a chave na configuração, vale o padrão. + * + * @return bool + */ + public static function strictFill(): bool + { + $app = Facade::getFacadeApplication(); + if (!$app instanceof Container || !$app->bound('config')) { + return true; + } + + return (bool) ($app->make('config')->get('multi-payment.strict_fill') ?? true); + } } \ No newline at end of file diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index f5870df..3c75020 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -6,6 +6,7 @@ use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Enums\InvoiceOriginType; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Idempotency\IdempotencyKey; @@ -14,12 +15,13 @@ /** * Fatura. * - * As três propriedades abaixo são enums: aceitam na escrita a string do valor ou o caso do + * As quatro propriedades abaixo são enums: aceitam na escrita a string do valor ou o caso do * enum e devolvem sempre o enum (ver `Model::ENUM_CASTS`). * * @property InvoiceStatus|null $status Status genérico; `UNKNOWN` para status que a lib não reconhece. * @property PaymentMethod|null $paymentMethod Método com que a fatura foi (ou será) paga. * @property PaymentMethod[]|null $availablePaymentMethods Métodos aceitos pela fatura. + * @property InvoiceOriginType|null $originType Objeto do gateway de onde a fatura foi lida (`PAYMENT_INTENT` ou `INVOICE`); `original` guarda esse objeto. */ class Invoice extends Model { @@ -57,6 +59,7 @@ class Invoice extends Model 'status' => InvoiceStatus::class, 'paymentMethod' => PaymentMethod::class, 'availablePaymentMethods' => [PaymentMethod::class], + 'originType' => InvoiceOriginType::class, ]; /** @@ -118,6 +121,14 @@ class Invoice extends Model */ protected ?array $availablePaymentMethods = null; + /** + * Preenchido pelo driver na leitura. Na Iugu é sempre `INVOICE`; na Stripe é + * `PAYMENT_INTENT` na cobrança avulsa e `INVOICE` na fatura de assinatura. + * + * @var InvoiceOriginType|null + */ + protected ?InvoiceOriginType $originType = null; + /** * @var CreditCard|null */ diff --git a/src/Models/Model.php b/src/Models/Model.php index 831f023..2acbd9c 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -369,6 +369,12 @@ protected function attributesExtraValidation(array $attributes): void * Fill the model with an array of attributes. Chave em `snake_case` vira a propriedade em * `camelCase`; valor de propriedade de enum (ver `ENUM_CASTS`) pode vir como string. * + * Chave que não corresponde a nenhuma propriedade do model lança + * `ModelAttributeValidationException` com a lista das chaves aceitas, exceto chave com + * prefixo `gateway_` (ou `gateway` em `camelCase`; o conteúdo de `gateway_options` é livre + * e chega inteiro ao gateway). + * Com `multi-payment.strict_fill` em falso, a chave desconhecida é ignorada em silêncio. + * * @param array $data * * @return void @@ -377,17 +383,57 @@ protected function attributesExtraValidation(array $attributes): void public function fill(array $data): void { foreach ($data as $key => $value) { - $key = lcfirst(str_replace('_', '', ucwords($key, '_'))); - if ($key === 'gatewayAdicionalOptions') { + $property = lcfirst(str_replace('_', '', ucwords($key, '_'))); + if ($property === 'gatewayAdicionalOptions') { self::warnGatewayAdicionalOptionsDeprecated(); - $key = 'gatewayOptions'; + $property = 'gatewayOptions'; } - if (property_exists($this, $key)) { - $this->{$key} = isset(static::ENUM_CASTS[$key]) ? $this->castToEnum($key, $value) : $value; + if (!property_exists($this, $property)) { + if (!str_starts_with($property, 'gateway') && ConfigurationHelper::strictFill()) { + throw ModelAttributeValidationException::unknownAttribute( + static::getClassName(), + (string) $key, + static::fillableKeys() + ); + } + continue; } + $this->{$property} = isset(static::ENUM_CASTS[$property]) ? $this->castToEnum($property, $value) : $value; } } + /** + * Chaves que `fill()` aceita, em `snake_case`: as propriedades públicas do model e as de + * enum (ver `ENUM_CASTS`), na ordem de declaração. + * + * @return string[] + */ + public static function fillableKeys(): array + { + $keys = []; + $reflect = new \ReflectionClass(static::class); + foreach ($reflect->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED) as $prop) { + $name = $prop->getName(); + if ($prop->isStatic() || ($prop->isProtected() && !isset(static::ENUM_CASTS[$name]))) { + continue; + } + $keys[] = self::snakeCase($name); + } + + return $keys; + } + + /** + * Converte o nome de uma propriedade em `camelCase` para a chave em `snake_case`. + * + * @param string $name + * @return string + */ + private static function snakeCase(string $name): string + { + return strtolower(preg_replace('/(?{$name})) { - $key = strtolower(preg_replace('/(?{$name}); + $array[self::snakeCase($name)] = self::enumToValue($this->{$name}); } } diff --git a/src/config/multi-payment.php b/src/config/multi-payment.php index e1151c0..8d513ee 100644 --- a/src/config/multi-payment.php +++ b/src/config/multi-payment.php @@ -21,6 +21,19 @@ */ 'environment' => env('APP_ENV', 'production'), + /* + |-------------------------------------------------------------------------- + | fill() estrito + |-------------------------------------------------------------------------- + | + | Com true (padrão), Model::fill() lança ModelAttributeValidationException para chave + | que não corresponde a nenhuma propriedade do model (chaves com prefixo gateway_ e o + | conteúdo de gateway_options ficam livres). Com false, a chave desconhecida é ignorada + | em silêncio, como nas versões anteriores; use só durante a migração. + | + */ + 'strict_fill' => env('MULTIPAYMENT_STRICT_FILL', true), + /* |-------------------------------------------------------------------------- | Idempotência diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index a09e1e0..19dcc53 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -2,8 +2,13 @@ namespace Potelo\MultiPayment\Tests\Integration; +use Carbon\Carbon; +use Stripe\StripeClient; +use Illuminate\Support\Facades\Config; use Potelo\MultiPayment\Tests\TestCase; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Gateways\StripeGateway; +use Potelo\MultiPayment\Enums\InvoiceOriginType; use Potelo\MultiPayment\Facades\MultiPayment; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; @@ -403,6 +408,111 @@ public function testShouldDuplicatePendingPixInvoice($gateway) $this->assertEquals(InvoiceStatus::CANCELED, $originalFetched->status); } + /** + * Fatura de assinatura (objeto Invoice da Stripe) lida por `getInvoice()` com o id `in_`, + * recusada na duplicação e cancelada por `void`. O Invoice é criado direto no SDK porque a + * lib ainda não cria fatura nem assinatura no Stripe. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldReadAndVoidAStripeInvoiceByItsId($gateway) + { + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + $stripeInvoiceId = $this->createOpenStripeInvoice($customer->id); + + $invoice = MultiPayment::setGateway($gateway)->getInvoice($stripeInvoiceId); + + $this->assertSame($stripeInvoiceId, $invoice->id); + $this->assertSame(InvoiceOriginType::INVOICE, $invoice->originType); + $this->assertSame(InvoiceStatus::PENDING, $invoice->status); + $this->assertSame(12345, $invoice->amount); + $this->assertNull($invoice->paidAmount); + $this->assertSame($customer->id, $invoice->customer->id); + $this->assertSame('Assinatura mensal', $invoice->items[0]->description); + $this->assertSame(12345, $invoice->items[0]->price); + $this->assertStringStartsWith('https://invoice.stripe.com/', $invoice->url); + $this->assertInstanceOf(\Stripe\Invoice::class, $invoice->original); + + try { + MultiPayment::setGateway($gateway)->duplicateInvoice($stripeInvoiceId, Carbon::now()->addDay()); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_DUPLICATION, $e->capability); + } + + $canceled = MultiPayment::setGateway($gateway)->cancelInvoice($stripeInvoiceId); + $this->assertSame(InvoiceStatus::CANCELED, $canceled->status); + $this->assertSame(InvoiceOriginType::INVOICE, $canceled->originType); + $this->assertSame('void', $canceled->original->status); + + $this->assertSame(InvoiceStatus::CANCELED, MultiPayment::setGateway($gateway)->getInvoice($stripeInvoiceId)->status); + } + + /** + * Fatura de assinatura paga: o charge do PaymentIntent, lido num GET à parte, alimenta + * valor pago, taxa e cartão. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldReadAPaidStripeInvoiceWithItsCharge($gateway) + { + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + $stripeInvoiceId = $this->createOpenStripeInvoice($customer->id); + + $client = $this->stripeClient(); + $paymentMethod = $client->paymentMethods->attach('pm_card_visa', ['customer' => $customer->id]); + $client->invoices->pay($stripeInvoiceId, ['payment_method' => $paymentMethod->id]); + + $invoice = MultiPayment::setGateway($gateway)->getInvoice($stripeInvoiceId); + + $this->assertSame(InvoiceStatus::PAID, $invoice->status); + $this->assertSame(InvoiceOriginType::INVOICE, $invoice->originType); + $this->assertSame(12345, $invoice->paidAmount); + $this->assertSame(0, $invoice->refundedAmount); + $this->assertSame([], $invoice->refunds); + $this->assertNotNull($invoice->paidAt); + $this->assertSame(PaymentMethod::CREDIT_CARD, $invoice->paymentMethod); + $this->assertSame('visa', $invoice->creditCard->brand); + $this->assertSame('4242', $invoice->creditCard->lastDigits); + } + + /** + * Cria um Invoice `open` de 12345 centavos na sandbox, direto no SDK. + * + * @param string $customerId + * @return string id do Invoice (`in_`) + */ + private function createOpenStripeInvoice(string $customerId): string + { + $client = $this->stripeClient(); + $stripeInvoice = $client->invoices->create([ + 'customer' => $customerId, + 'collection_method' => 'charge_automatically', + 'currency' => 'brl', + 'auto_advance' => false, + ]); + $client->invoiceItems->create([ + 'customer' => $customerId, + 'invoice' => $stripeInvoice->id, + 'amount' => 12345, + 'currency' => 'brl', + 'description' => 'Assinatura mensal', + ]); + $client->invoices->finalizeInvoice($stripeInvoice->id, ['auto_advance' => false]); + + return $stripeInvoice->id; + } + + private function stripeClient(): StripeClient + { + return new StripeClient([ + 'api_key' => Config::get('multi-payment.gateways.stripe.api_key'), + 'stripe_version' => StripeGateway::STRIPE_API_VERSION, + ]); + } + /** * Boleto está fora do escopo do gateway Stripe e deve falhar com mensagem específica. * diff --git a/tests/Unit/Enums/InvoiceOriginTypeTest.php b/tests/Unit/Enums/InvoiceOriginTypeTest.php new file mode 100644 index 0000000..5298936 --- /dev/null +++ b/tests/Unit/Enums/InvoiceOriginTypeTest.php @@ -0,0 +1,39 @@ +assertSame(['payment_intent', 'invoice'], array_column(InvoiceOriginType::cases(), 'value')); + } + + public function testInvoiceAcceptsTheStringOrTheCaseAndAlwaysReturnsTheCase(): void + { + $invoice = new Invoice(); + $this->assertNull($invoice->originType); + + $invoice->originType = 'payment_intent'; + $this->assertSame(InvoiceOriginType::PAYMENT_INTENT, $invoice->originType); + + $invoice->originType = InvoiceOriginType::INVOICE; + $this->assertSame(InvoiceOriginType::INVOICE, $invoice->originType); + $this->assertSame('invoice', $invoice->toArray()['origin_type']); + $this->assertSame('invoice', json_decode(json_encode($invoice), true)['originType']); + } + + public function testUnknownOriginIsRejectedOnWrite(): void + { + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('originType must be one of: payment_intent, invoice'); + + $invoice = new Invoice(); + $invoice->originType = 'checkout_session'; + } +} diff --git a/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php b/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php index a93f7e3..1131dee 100644 --- a/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php +++ b/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php @@ -10,6 +10,7 @@ use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\InvoiceOriginType; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Tests\Unit\RecordingLogger; use PHPUnit\Framework\Attributes\DataProvider; @@ -86,6 +87,19 @@ public function testGetInvoiceParsesEveryIuguStatusFromTheGatewayResponse(string $this->assertSame([], $this->logger->records); } + /** + * Toda fatura da Iugu é lida do objeto de fatura do gateway: `originType` é `INVOICE`. + */ + public function testGetInvoiceMarksTheOriginAsInvoice(): void + { + $api = new QueuedIuguApiRequest([$this->invoiceResponse()]); + + $invoice = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertSame(InvoiceOriginType::INVOICE, $invoice->originType); + $this->assertSame('invoice', $invoice->toArray()['origin_type']); + } + public function testNoIuguStatusIsFlattenedAnymore(): void { $this->assertSame(InvoiceStatus::PARTIALLY_PAID, $this->mapStatus('partially_paid')); diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index 17eb6fe..e4c5571 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -20,6 +20,7 @@ use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use PHPUnit\Framework\Attributes\DataProvider; use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\InvoiceOriginType; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\PlanInterval; use Potelo\MultiPayment\Tests\Unit\RecordingLogger; @@ -822,6 +823,25 @@ public function testDerivesPastDueFromOverdueDateAndUnpaidInvoice(): void $this->assertSame('https://iugu/inv_1', $subscription->latestInvoice->url); } + /** + * A fatura resumida de `recent_invoices` também vem do objeto de fatura da Iugu: + * `originType` é `INVOICE`. + */ + public function testLatestInvoiceMarksTheOriginAsInvoice(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'paid']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(InvoiceOriginType::INVOICE, $subscription->latestInvoice->originType); + } + /** * `expired` conta como dívida, mas o status genérico da fatura é canceled. */ diff --git a/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php new file mode 100644 index 0000000..6d182c7 --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php @@ -0,0 +1,775 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + $app->instance('log', $this->logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testGetInvoiceWithAnInvoiceIdReadsTheStripeInvoiceWithItsPaymentsExpanded(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('invoices/open_requires_payment_method')]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(['get /v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK'], self::calledPaths($httpClient)); + $this->assertSame(['expand' => self::INVOICE_EXPAND], $httpClient->calls[0][2]); + + $this->assertSame('in_1UBHTnPjx0CusuMrjxjg8WhK', $result->id); + $this->assertSame(InvoiceOriginType::INVOICE, $result->originType); + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertSame('stripe', $result->gateway); + $this->assertSame(12345, $result->amount); + $this->assertNull($result->paidAmount); + $this->assertNull($result->refundedAmount); + $this->assertSame([], $result->refunds); + $this->assertNull($result->paidAt); + $this->assertNull($result->fee); + $this->assertSame(1788368263, $result->createdAt->getTimestamp()); + $this->assertNull($result->expiresAt); + $this->assertStringStartsWith('https://invoice.stripe.com/i/', $result->url); + $this->assertSame('cus_VBen1v8T4Qa6XX', $result->customer->id); + $this->assertSame(PaymentMethod::CREDIT_CARD, $result->paymentMethod); + $this->assertSame([PaymentMethod::CREDIT_CARD], $result->availablePaymentMethods); + $this->assertNull($result->creditCard); + $this->assertNull($result->pix); + $this->assertInstanceOf(\Stripe\Invoice::class, $result->original); + $this->assertSame([], $this->logger->records); + } + + public function testLineItemsComeFromTheInvoiceLines(): void + { + RecordingStripeHttpClient::withResponses([self::fixture('invoices/open_requires_payment_method')]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertCount(1, $result->items); + $this->assertSame('Assinatura mensal', $result->items[0]->description); + $this->assertSame(12345, $result->items[0]->price); + $this->assertSame(1, $result->items[0]->quantity); + } + + /** + * Linha com quantidade maior que um usa o valor unitário de `pricing`; sem `pricing`, o + * `amount` da linha dividido pela quantidade. + */ + public function testLineItemPriceIsTheUnitAmount(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $line = $response['lines']['data'][0]; + $withPricing = array_merge($line, ['id' => 'il_1', 'amount' => 20000, 'quantity' => 2, 'description' => 'Com pricing']); + $withPricing['pricing']['unit_amount_decimal'] = '10000'; + $withoutPricing = array_merge($line, ['id' => 'il_2', 'amount' => 3000, 'quantity' => 3, 'description' => 'Sem pricing', 'pricing' => null]); + $response['lines']['data'] = [$withPricing, $withoutPricing]; + RecordingStripeHttpClient::withResponses([$response]); + + $items = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK')->items; + + $this->assertSame([10000, 2], [$items[0]->price, $items[0]->quantity]); + $this->assertSame([1000, 3], [$items[1]->price, $items[1]->quantity]); + } + + public function testDueDateBecomesExpiresAt(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $response['due_date'] = 1789000000; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertInstanceOf(Carbon::class, $result->expiresAt); + $this->assertSame(1789000000, $result->expiresAt->getTimestamp()); + } + + public function testGetInvoiceWithAPaymentIntentIdKeepsThePaymentIntentOrigin(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('payment_intents/paid')]); + + $result = $this->getInvoice('pi_3UBHTpPjx0CusuMr1JTEiHGi'); + + $this->assertSame(['get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi'], self::calledPaths($httpClient)); + $this->assertSame(InvoiceOriginType::PAYMENT_INTENT, $result->originType); + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertInstanceOf(\Stripe\PaymentIntent::class, $result->original); + } + + /** + * Uma linha da tabela de precedência por caso: fixture do Invoice, respostas seguintes + * (PaymentIntent relido e lista de disputes, quando há), status esperado e requisições. + * + * @return array + */ + public static function precedenceTableProvider(): array + { + $invoice = 'get /v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK'; + $paymentIntent = 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi'; + + return [ + 'draft' => ['invoices/draft', [], InvoiceStatus::PENDING, [$invoice]], + 'open sem PaymentIntent' => ['invoices/open_without_payment_intent', [], InvoiceStatus::PENDING, [$invoice]], + 'open + requires_payment_method' => ['invoices/open_requires_payment_method', [], InvoiceStatus::PENDING, [$invoice]], + 'open + requires_payment_method após recusa' => [ + 'invoices/open_after_declined_attempt', + ['payment_intents/after_declined_attempt'], + InvoiceStatus::PENDING, + [$invoice, $paymentIntent], + ], + 'open + requires_action' => [ + 'invoices/open_requires_action', + ['payment_intents/requires_action'], + InvoiceStatus::PENDING, + [$invoice, $paymentIntent], + ], + 'open + requires_confirmation' => ['invoices/open_requires_confirmation', [], InvoiceStatus::PENDING, [$invoice]], + 'open + requires_capture' => ['invoices/open_requires_capture', [], InvoiceStatus::AUTHORIZED, [$invoice]], + 'open + processing' => ['invoices/open_processing', [], InvoiceStatus::PROCESSING, [$invoice]], + 'open parcialmente paga' => ['invoices/open_partially_paid', [], InvoiceStatus::PARTIALLY_PAID, [$invoice]], + 'paid + succeeded sem estorno' => ['invoices/paid', ['payment_intents/paid'], InvoiceStatus::PAID, [$invoice, $paymentIntent]], + 'paid + estorno parcial' => [ + 'invoices/paid', + ['payment_intents/partially_refunded'], + InvoiceStatus::PARTIALLY_REFUNDED, + [$invoice, $paymentIntent], + ], + 'paid + estorno total' => ['invoices/paid', ['payment_intents/refunded'], InvoiceStatus::REFUNDED, [$invoice, $paymentIntent]], + 'paid + dispute aberta' => [ + 'invoices/paid_disputed', + ['payment_intents/disputed', 'disputes/needs_response'], + InvoiceStatus::DISPUTED, + ['get /v1/invoices/in_1UBHU4Pjx0CusuMrOTB0POaR', 'get /v1/payment_intents/pi_3UBHU5Pjx0CusuMr1cNe2BIo', 'get /v1/disputes'], + ], + 'paid + dispute perdida' => [ + 'invoices/paid_disputed', + ['payment_intents/disputed', 'disputes/lost'], + InvoiceStatus::CHARGEBACK, + ['get /v1/invoices/in_1UBHU4Pjx0CusuMrOTB0POaR', 'get /v1/payment_intents/pi_3UBHU5Pjx0CusuMr1cNe2BIo', 'get /v1/disputes'], + ], + 'paid fora da Stripe' => ['invoices/paid_out_of_band', [], InvoiceStatus::EXTERNALLY_PAID, ['get /v1/invoices/in_1UBHUHPjx0CusuMrD81KgsNd']], + 'paid sem cobrança (amount_due zero)' => ['invoices/paid_zero_amount_due', [], InvoiceStatus::PAID, ['get /v1/invoices/in_1UBHUNPjx0CusuMrEG6etDb8']], + 'void' => ['invoices/void', [], InvoiceStatus::CANCELED, ['get /v1/invoices/in_1UBHUBPjx0CusuMrdZ8CWaQW']], + 'uncollectible' => ['invoices/uncollectible', [], InvoiceStatus::EXPIRED, ['get /v1/invoices/in_1UBHUEPjx0CusuMrnkQe23fT']], + ]; + } + + #[DataProvider('precedenceTableProvider')] + public function testDerivesTheStatusFromTheInvoiceThenThePaymentIntentThenTheCharge( + string $invoiceFixture, + array $followingFixtures, + InvoiceStatus $expected, + array $expectedPaths + ): void { + $responses = [self::fixture($invoiceFixture)]; + foreach ($followingFixtures as $fixture) { + $responses[] = self::fixture($fixture); + } + $httpClient = RecordingStripeHttpClient::withResponses($responses); + + $result = $this->getInvoice($responses[0]['id']); + + $this->assertSame($expected, $result->status); + $this->assertSame(InvoiceOriginType::INVOICE, $result->originType); + $this->assertSame($expectedPaths, self::calledPaths($httpClient)); + $this->assertSame([], $this->logger->records); + } + + /** + * O PaymentIntent da fatura só é relido quando já tem charge, e aí com o mesmo expand da + * cobrança avulsa; a Stripe limita o expand a quatro níveis e o charge fica no quinto. + */ + public function testReadsThePaymentIntentSeparatelyOnlyWhenItHasACharge(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(['expand' => self::PAYMENT_INTENT_EXPAND], $httpClient->calls[1][2]); + } + + public function testPaidInvoiceCarriesTheChargeAmountsFeeAndCard(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(12345, $result->paidAmount); + $this->assertSame(0, $result->refundedAmount); + $this->assertSame([], $result->refunds); + $this->assertSame(520, $result->fee); + // paidAt vem de status_transitions.paid_at do Invoice + $this->assertSame(1788368273, $result->paidAt->getTimestamp()); + $this->assertSame(PaymentMethod::CREDIT_CARD, $result->paymentMethod); + $this->assertSame('visa', $result->creditCard->brand); + $this->assertSame('4242', $result->creditCard->lastDigits); + } + + public function testRefundedInvoiceListsTheRefundsOfTheCharge(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('invoices/paid'), + self::fixture('payment_intents/refunded'), + ]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(12345, $result->refundedAmount); + $this->assertCount(2, $result->refunds); + // a Stripe lista os estornos do mais recente para o mais antigo + $this->assertSame([10000, 2345], array_map(static fn ($refund) => $refund->amount, $result->refunds)); + $this->assertSame('in_1UBHTnPjx0CusuMrjxjg8WhK', $result->refunds[0]->invoiceId); + $this->assertStringStartsWith('re_', $result->refunds[0]->id); + } + + public function testDisputedInvoiceListsTheDisputesOfTheCharge(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('invoices/paid_disputed'), + self::fixture('payment_intents/disputed'), + self::fixture('disputes/needs_response'), + ]); + + $result = $this->getInvoice('in_1UBHU4Pjx0CusuMrOTB0POaR'); + + $this->assertSame(InvoiceStatus::DISPUTED, $result->status); + $this->assertSame(12345, $result->paidAmount); + $this->assertSame(['charge' => 'ch_3UBHU5Pjx0CusuMr1q2mLg5W', 'limit' => 100], $httpClient->calls[2][2]); + } + + /** + * Paga fora da Stripe, a fatura tem o PaymentIntent padrão cancelado e um InvoicePayment + * do tipo `payment_record`; o valor recebido vem do Invoice e o método fica em aberto. + */ + public function testExternallyPaidInvoiceReadsTheAmountFromTheInvoice(): void + { + RecordingStripeHttpClient::withResponses([self::fixture('invoices/paid_out_of_band')]); + + $result = $this->getInvoice('in_1UBHUHPjx0CusuMrD81KgsNd'); + + $this->assertSame(InvoiceStatus::EXTERNALLY_PAID, $result->status); + $this->assertSame(12345, $result->paidAmount); + $this->assertNull($result->refundedAmount); + $this->assertNull($result->paymentMethod); + $this->assertSame(1788368296, $result->paidAt->getTimestamp()); + } + + /** + * `amount_paid_off_stripe`, quando a versão da API o devolve, também sinaliza pagamento + * externo. + */ + public function testAmountPaidOffStripeAlsoReadsAsExternallyPaid(): void + { + $response = self::fixture('invoices/paid_out_of_band'); + $response['payments']['data'] = []; + $response['amount_paid_off_stripe'] = 12345; + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame(InvoiceStatus::EXTERNALLY_PAID, $this->getInvoice($response['id'])->status); + } + + public function testInvoicePaidWithoutAChargeHasZeroPaidAmount(): void + { + RecordingStripeHttpClient::withResponses([self::fixture('invoices/paid_zero_amount_due')]); + + $result = $this->getInvoice('in_1UBHUNPjx0CusuMrEG6etDb8'); + + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertSame(0, $result->amount); + $this->assertSame(0, $result->paidAmount); + $this->assertNull($result->paymentMethod); + $this->assertSame('Período de teste', $result->items[0]->description); + } + + public function testPartiallyPaidInvoiceReadsTheAmountPaidSoFar(): void + { + RecordingStripeHttpClient::withResponses([self::fixture('invoices/open_partially_paid')]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(InvoiceStatus::PARTIALLY_PAID, $result->status); + $this->assertSame(5000, $result->paidAmount); + $this->assertTrue($result->status->isSettled()); + $this->assertTrue($result->status->isOpen()); + } + + /** + * Combinações fora da tabela: cada uma vira `UNKNOWN` com um aviso no log que traz os três + * status e o id da fatura. + * + * @return array + */ + public static function unknownCombinationProvider(): array + { + return [ + 'open + succeeded (transição)' => ['open', 'succeeded'], + 'open + canceled' => ['open', 'canceled'], + 'paid + requires_payment_method sem pagamento externo' => ['paid', 'requires_payment_method'], + 'status de Invoice desconhecido' => ['partially_funded', 'requires_payment_method'], + ]; + } + + #[DataProvider('unknownCombinationProvider')] + public function testCombinationOutsideTheTableReadsAsUnknownWithAWarning(string $invoiceStatus, string $paymentIntentStatus): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $response['status'] = $invoiceStatus; + $response['payments']['data'][0]['payment']['payment_intent']['status'] = $paymentIntentStatus; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(InvoiceStatus::UNKNOWN, $result->status); + $this->assertSame($invoiceStatus, $result->original->status); + $this->assertCount(1, $this->logger->records); + $this->assertSame('warning', $this->logger->records[0]['level']); + $this->assertSame([ + 'gateway' => 'stripe', + 'invoice_id' => 'in_1UBHTnPjx0CusuMrjxjg8WhK', + 'invoice_status' => $invoiceStatus, + 'payment_intent_status' => $paymentIntentStatus, + 'charge_status' => null, + ], $this->logger->records[0]['context']); + $this->assertStringContainsString('in_1UBHTnPjx0CusuMrjxjg8WhK', $this->logger->records[0]['message']); + } + + /** + * O aviso traz o status do charge quando o PaymentIntent relido tem um. + */ + public function testUnknownCombinationLogsTheChargeStatusWhenThereIsACharge(): void + { + $paymentIntent = self::fixture('payment_intents/after_declined_attempt'); + $paymentIntent['status'] = 'succeeded'; + RecordingStripeHttpClient::withResponses([self::fixture('invoices/open_after_declined_attempt'), $paymentIntent]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(InvoiceStatus::UNKNOWN, $result->status); + $this->assertSame('succeeded', $this->logger->records[0]['context']['payment_intent_status']); + $this->assertSame('failed', $this->logger->records[0]['context']['charge_status']); + } + + /** + * `paid` sem PaymentIntent e com `amount_due` acima de zero não está na tabela. + */ + public function testPaidInvoiceWithoutAnyPaymentAndAnAmountDueReadsAsUnknown(): void + { + $response = self::fixture('invoices/paid_zero_amount_due'); + $response['amount_due'] = 12345; + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame(InvoiceStatus::UNKNOWN, $this->getInvoice($response['id'])->status); + $this->assertCount(1, $this->logger->records); + $this->assertNull($this->logger->records[0]['context']['payment_intent_status']); + } + + /** + * Entre vários pagamentos do Invoice sem nenhum pago, o padrão (`is_default`) é o + * PaymentIntent da fatura. + */ + public function testTheDefaultPaymentIsTheInvoicePaymentIntent(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $default = $response['payments']['data'][0]; + $other = $default; + $other['id'] = 'inpay_outro'; + $other['is_default'] = false; + $other['payment']['payment_intent']['id'] = 'pi_outro'; + $other['payment']['payment_intent']['status'] = 'processing'; + $response['payments']['data'] = [$other, $default]; + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame(InvoiceStatus::PENDING, $this->getInvoice($response['id'])->status); + } + + /** + * O pagamento pago é o PaymentIntent da fatura, mesmo quando outro pagamento é o padrão. + */ + public function testThePaidPaymentIsTheInvoicePaymentIntentEvenWhenAnotherOneIsTheDefault(): void + { + $response = self::fixture('invoices/paid'); + $paid = $response['payments']['data'][0]; + $paid['is_default'] = false; + $open = $paid; + $open['id'] = 'inpay_aberto'; + $open['is_default'] = true; + $open['status'] = 'open'; + $open['payment']['payment_intent']['id'] = 'pi_aberto'; + $open['payment']['payment_intent']['status'] = 'requires_payment_method'; + $open['payment']['payment_intent']['latest_charge'] = null; + $response['payments']['data'] = [$open, $paid]; + $httpClient = RecordingStripeHttpClient::withResponses([$response, self::fixture('payment_intents/paid')]); + + $result = $this->getInvoice($response['id']); + + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertSame('/v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi', parse_url($httpClient->calls[1][1], PHP_URL_PATH)); + } + + /** + * Sem pagamento padrão nem pago, vale o primeiro do tipo PaymentIntent. + */ + public function testWithoutADefaultOrPaidPaymentTheFirstOneIsUsed(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $first = $response['payments']['data'][0]; + $first['is_default'] = false; + $first['payment']['payment_intent']['status'] = 'processing'; + $second = $first; + $second['id'] = 'inpay_segundo'; + $second['payment']['payment_intent']['id'] = 'pi_segundo'; + $second['payment']['payment_intent']['status'] = 'requires_capture'; + $record = ['id' => 'inpay_registro', 'object' => 'invoice_payment', 'status' => 'open', 'is_default' => false, 'payment' => ['type' => 'payment_record', 'payment_record' => 'pr_x']]; + $response['payments']['data'] = [$record, $first, $second]; + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame(InvoiceStatus::PROCESSING, $this->getInvoice($response['id'])->status); + } + + /** + * InvoicePayment de PaymentIntent sem objeto nem id deixa a fatura sem PaymentIntent. + */ + public function testPaymentIntentPaymentWithoutAnIdIsIgnored(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $response['payments']['data'][0]['payment'] = ['type' => 'payment_intent']; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice($response['id']); + + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertNull($result->paymentMethod); + $this->assertCount(1, $httpClient->calls); + } + + /** + * Pagamento do tipo `charge` anexado à fatura lê como paga, sem refinamento de estorno ou + * contestação. + */ + public function testPaidInvoiceWhosePaymentIsAChargeReadsAsPaid(): void + { + $response = self::fixture('invoices/paid_out_of_band'); + $response['payments']['data'][0]['payment'] = ['type' => 'charge', 'charge' => 'ch_anexado']; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice($response['id']); + + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertSame(12345, $result->paidAmount); + $this->assertCount(1, $httpClient->calls); + $this->assertSame([], $this->logger->records); + } + + public function testPaidAtFallsBackToTheChargeCreationWhenTheInvoiceHasNoPaidAt(): void + { + $response = self::fixture('invoices/paid'); + $response['status_transitions']['paid_at'] = null; + $paymentIntent = self::fixture('payment_intents/paid'); + RecordingStripeHttpClient::withResponses([$response, $paymentIntent]); + + $result = $this->getInvoice($response['id']); + + $this->assertSame($paymentIntent['latest_charge']['created'], $result->paidAt->getTimestamp()); + } + + /** + * Um `next_action` de 3DS não tem `pix_display_qr_code`; o parse não pode ler a chave às + * cegas, senão o StripeObject registra "Undefined property" no logger da Stripe. Vale nas + * duas origens. + * + * @return array + */ + public static function threeDSecureNextActionProvider(): array + { + return [ + 'PaymentIntent' => ['pi_3UBHTpPjx0CusuMr1JTEiHGi', ['payment_intents/requires_action']], + 'Invoice' => ['in_1UBHTnPjx0CusuMrjxjg8WhK', ['invoices/open_requires_action', 'payment_intents/requires_action']], + ]; + } + + #[DataProvider('threeDSecureNextActionProvider')] + public function testThreeDSecureNextActionDoesNotLogAnUndefinedProperty(string $id, array $fixtures): void + { + RecordingStripeHttpClient::withResponses(array_map([self::class, 'fixture'], $fixtures)); + $previousLogger = \Stripe\Stripe::getLogger(); + \Stripe\Stripe::setLogger($stripeLogger = new RecordingStripeLogger()); + + try { + $result = $this->getInvoice($id); + } finally { + \Stripe\Stripe::setLogger($previousLogger); + } + + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertNull($result->pix); + $this->assertSame([], $stripeLogger->messages); + } + + /** + * PaymentIntent que veio só como id (sem expand) é lido num GET. + */ + public function testPaymentIntentGivenAsIdIsRead(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $response['payments']['data'][0]['payment']['payment_intent'] = 'pi_3UBHTpPjx0CusuMr1JTEiHGi'; + $paymentIntent = self::fixture('payment_intents/after_declined_attempt'); + $paymentIntent['status'] = 'processing'; + $httpClient = RecordingStripeHttpClient::withResponses([$response, $paymentIntent]); + + $result = $this->getInvoice($response['id']); + + $this->assertSame(InvoiceStatus::PROCESSING, $result->status); + $this->assertCount(2, $httpClient->calls); + $this->assertSame('/v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi', parse_url($httpClient->calls[1][1], PHP_URL_PATH)); + } + + /** + * Fatura de assinatura paga por Pix: o QR Code vem do PaymentIntent e `url` continua sendo + * a página hospedada da fatura. + */ + public function testPixQrCodeComesFromThePaymentIntentAndUrlStaysTheHostedInvoicePage(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $paymentIntent = &$response['payments']['data'][0]['payment']['payment_intent']; + $paymentIntent['status'] = 'requires_action'; + $paymentIntent['payment_method_types'] = ['pix']; + $paymentIntent['next_action'] = [ + 'type' => 'pix_display_qr_code', + 'pix_display_qr_code' => [ + 'data' => '00020126pixcopiaecola', + 'image_url_png' => 'https://qr.stripe.com/test.png', + 'expires_at' => 1788400000, + 'hosted_instructions_url' => 'https://payments.stripe.com/qr/instructions/test', + ], + ]; + unset($paymentIntent); + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice($response['id']); + + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertSame(PaymentMethod::PIX, $result->paymentMethod); + $this->assertSame('00020126pixcopiaecola', $result->pix->qrCodeText); + $this->assertSame('https://qr.stripe.com/test.png', $result->pix->qrCodeImageUrl); + $this->assertSame(1788400000, $result->expiresAt->getTimestamp()); + $this->assertStringStartsWith('https://invoice.stripe.com/i/', $result->url); + } + + public function testCancelInvoiceVoidsTheStripeInvoiceAfterReadingIt(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('invoices/open_requires_payment_method'), + self::fixture('invoices/void'), + ]); + + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + $result = (new StripeGateway())->cancelInvoice($invoice, 'chave-void'); + + $this->assertSame([ + 'get /v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK', + 'post /v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/void', + ], self::calledPaths($httpClient)); + $this->assertSame(['expand' => self::INVOICE_EXPAND], $httpClient->calls[1][2]); + $this->assertSame('chave-void', $httpClient->header(1, 'Idempotency-Key')); + $this->assertNull($httpClient->header(0, 'Idempotency-Key')); + + $this->assertSame($invoice, $result); + $this->assertSame(InvoiceStatus::CANCELED, $result->status); + $this->assertSame(InvoiceOriginType::INVOICE, $result->originType); + } + + public function testCancelInvoiceRefusesADraftWithoutVoiding(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('invoices/draft')]); + + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + + try { + (new StripeGateway())->cancelInvoice($invoice); + $this->fail('Rascunho deveria lançar GatewayException'); + } catch (GatewayException $e) { + $this->assertStringContainsString('rascunho', $e->getMessage()); + $this->assertStringContainsString('in_1UBHTnPjx0CusuMrjxjg8WhK', $e->getMessage()); + } + $this->assertSame(['get /v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK'], self::calledPaths($httpClient)); + } + + public function testCancelPaidInvoiceBecomesValidationExceptionFromTheStripeRefusal(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('invoices/paid'), + [['error' => [ + 'type' => 'invalid_request_error', + 'message' => 'Invoices with `paid` payments cannot be voided.', + ]], 400], + ]); + + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + + try { + (new StripeGateway())->cancelInvoice($invoice); + $this->fail('Fatura paga deveria lançar ValidationException'); + } catch (ValidationException $e) { + $this->assertSame(400, $e->httpStatus); + $this->assertSame(['base' => ['Invoices with `paid` payments cannot be voided.']], $e->fieldErrors); + } + } + + public function testCancelInvoiceWithAPaymentIntentIdStillCancelsThePaymentIntent(): void + { + $response = self::fixture('payment_intents/after_declined_attempt'); + $response['status'] = 'canceled'; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $invoice = new Invoice(); + $invoice->id = 'pi_3UBHTpPjx0CusuMr1JTEiHGi'; + $result = (new StripeGateway())->cancelInvoice($invoice); + + $this->assertSame(['post /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi/cancel'], self::calledPaths($httpClient)); + $this->assertSame(InvoiceStatus::CANCELED, $result->status); + $this->assertSame(InvoiceOriginType::PAYMENT_INTENT, $result->originType); + } + + public function testDuplicateInvoiceRefusesAStripeInvoiceBeforeAnyRequest(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + + try { + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + $this->fail('Fatura de assinatura deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_DUPLICATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertSame('stripe', $e->gateway); + $this->assertStringContainsString('in_1UBHTnPjx0CusuMrjxjg8WhK', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + public function testRefundInvoiceOnAStripeInvoiceIsNotImplementedYet(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + + try { + (new StripeGateway())->refundInvoice($invoice); + $this->fail('Estorno de fatura de assinatura deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); + $this->assertStringContainsString('refundInvoice', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + public function testChargeInvoiceWithCreditCardOnAStripeInvoiceIsNotImplementedYet(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + + try { + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + $this->fail('Cobrança de fatura de assinatura deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + $this->assertTrue($e->isNotImplemented()); + $this->assertStringContainsString('chargeInvoiceWithCreditCard', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + private function getInvoice(string $id): Invoice + { + $invoice = new Invoice(); + $invoice->id = $id; + + return (new StripeGateway())->getInvoice($invoice); + } + + /** + * @return string[] `método caminho` de cada chamada gravada + */ + private static function calledPaths(RecordingStripeHttpClient $httpClient): array + { + return array_map( + static fn (array $call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), + $httpClient->calls + ); + } + + /** + * Resposta gravada em `tests/fixtures/stripe/.json`, como array. + */ + private static function fixture(string $path): array + { + return json_decode(file_get_contents(__DIR__ . "/../../fixtures/stripe/{$path}.json"), true); + } +} diff --git a/tests/Unit/ModelFillTest.php b/tests/Unit/ModelFillTest.php new file mode 100644 index 0000000..794c1c0 --- /dev/null +++ b/tests/Unit/ModelFillTest.php @@ -0,0 +1,186 @@ +configure(['multi-payment.strict_fill' => true]); + $customer = new Customer(); + + try { + $customer->fill(['name' => 'Ana', 'nome_fantasia' => 'Loja']); + $this->fail('Chave desconhecida deveria lançar ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('`nome_fantasia` key is unknown for the `Customer` model', $e->getMessage()); + $this->assertStringContainsString('name, email, tax_document', $e->getMessage()); + $this->assertStringContainsString('gateway_options', $e->getMessage()); + } + // as chaves anteriores à desconhecida já foram aplicadas + $this->assertSame('Ana', $customer->name); + } + + public function testStrictIsTheDefaultWithoutTheConfigurationKey(): void + { + $this->configure([]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('`trial_days` key is unknown for the `Invoice` model'); + + (new Invoice())->fill(['amount' => 10000, 'trial_days' => 7]); + } + + public function testStrictIsTheDefaultWithoutALaravelContainer(): void + { + Facade::setFacadeApplication(null); + + $this->expectException(ModelAttributeValidationException::class); + + (new Customer())->fill(['nome' => 'Ana']); + } + + public function testPermissiveModeIgnoresUnknownKeysSilently(): void + { + $this->configure(['multi-payment.strict_fill' => false]); + $customer = new Customer(); + + $customer->fill(['name' => 'Ana', 'nome_fantasia' => 'Loja']); + + $this->assertSame('Ana', $customer->name); + $this->assertFalse(property_exists($customer, 'nomeFantasia')); + } + + public function testKeysWithTheGatewayPrefixStayFree(): void + { + $this->configure(['multi-payment.strict_fill' => true]); + $customer = new Customer(); + + $customer->fill(['name' => 'Ana', 'gateway_options' => ['x' => 1], 'gateway_customer_ref' => 'abc', 'gatewayOtherRef' => 'def']); + + $this->assertSame(['x' => 1], $customer->gatewayOptions); + $this->assertFalse(property_exists($customer, 'gatewayCustomerRef')); + $this->assertFalse(property_exists($customer, 'gatewayOtherRef')); + } + + public function testStrictIsTheDefaultWithAContainerWithoutConfig(): void + { + Facade::setFacadeApplication(new Container()); + + $this->expectException(ModelAttributeValidationException::class); + + (new Customer())->fill(['nome' => 'Ana']); + } + + public function testGatewayOptionsContentIsFree(): void + { + $this->configure(['multi-payment.strict_fill' => true]); + $invoice = new Invoice(); + + $invoice->fill(['amount' => 10000, 'gateway_options' => ['qualquer_chave' => 'vale', 'expires_in' => 3]]); + + $this->assertSame(['qualquer_chave' => 'vale', 'expires_in' => 3], $invoice->gatewayOptions); + } + + public function testCamelCaseKeysAreAccepted(): void + { + $this->configure(['multi-payment.strict_fill' => true]); + $customer = new Customer(); + + $customer->fill(['taxDocument' => '20176996915', 'phoneArea' => '71']); + + $this->assertSame('20176996915', $customer->taxDocument); + $this->assertSame('71', $customer->phoneArea); + } + + public function testNestedModelsAreStrictToo(): void + { + $this->configure(['multi-payment.strict_fill' => true]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('`nome` key is unknown for the `Customer` model'); + + (new Invoice())->fill(['amount' => 10000, 'customer' => ['nome' => 'Ana']]); + } + + /** + * As chaves que os `fill()` especializados consomem antes do `Model` (`items`, `customer`, + * `expires_at`, `credit_card`, datas da assinatura) continuam aceitas. + */ + public function testKeysConsumedBySpecializedFillsAreStillAccepted(): void + { + $this->configure(['multi-payment.strict_fill' => true]); + + $invoice = new Invoice(); + $invoice->fill([ + 'items' => [['description' => 'Item', 'price' => 1000, 'quantity' => 1]], + 'customer' => ['name' => 'Ana', 'email' => 'ana@example.com', 'address' => ['zip_code' => '41820330']], + 'expires_at' => '2026-10-01', + 'credit_card' => ['token' => 'pm_x', 'first_name' => 'Ana'], + 'available_payment_methods' => ['credit_card'], + 'origin_type' => 'invoice', + ]); + $this->assertSame('41820330', $invoice->customer->address->zipCode); + $this->assertSame('pm_x', $invoice->creditCard->token); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano', + 'trial_ends_at' => '2026-10-01', + 'next_billing_at' => '2026-11-01', + 'customer' => ['id' => 'cus_1'], + 'latest_invoice' => ['id' => 'inv_1', 'status' => 'paid'], + 'items' => [['description' => 'Extra', 'amount' => 500]], + ]); + $this->assertSame('inv_1', $subscription->latestInvoice->id); + + $card = new CreditCard(); + $card->fill(['token' => 'tok_x', 'customer' => ['id' => 'cus_1'], 'default' => true]); + $this->assertTrue($card->default); + } + + public function testFillableKeysAreTheSnakeCasePropertiesIncludingEnums(): void + { + $this->assertSame(['description', 'price', 'quantity', 'gateway_options'], InvoiceItem::fillableKeys()); + + $keys = Invoice::fillableKeys(); + foreach (['id', 'status', 'amount', 'payment_method', 'available_payment_methods', 'origin_type', 'credit_card', 'expires_at', 'gateway_options'] as $key) { + $this->assertContains($key, $keys); + } + $this->assertContains('interval', Plan::fillableKeys()); + $this->assertContains('status', Refund::fillableKeys()); + } + + private function configure(array $config): void + { + $app = new Container(); + $app->instance('config', new Repository($config)); + Facade::setFacadeApplication($app); + } +} diff --git a/tests/fixtures/stripe/README.md b/tests/fixtures/stripe/README.md new file mode 100644 index 0000000..3af1800 --- /dev/null +++ b/tests/fixtures/stripe/README.md @@ -0,0 +1,44 @@ +# Fixtures da Stripe + +Respostas da sandbox da Stripe, API `2026-07-29.dahlia`, gravadas em 2026-09-02 com o +`expand` que o driver usa (`payments.data.payment.payment_intent` no Invoice; +`latest_charge.balance_transaction` e `latest_charge.refunds` no PaymentIntent). Só o +`client_secret` dos PaymentIntents foi substituído por um placeholder. + +## `invoices/` + +Gravadas na sandbox: + +| Arquivo | Como foi produzida | +|---|---| +| `draft.json` | Invoice criado com um invoice item, antes de finalizar | +| `open_requires_payment_method.json` | finalizado, sem tentativa de pagamento | +| `open_after_declined_attempt.json` | `pay` com `pm_card_chargeCustomerFail` recusado (o PaymentIntent tem `latest_charge`) | +| `open_requires_action.json` | `pay` com `pm_card_authenticationRequired` | +| `paid.json` | `pay` com `pm_card_visa`; o mesmo Invoice serve para os estornos, que só mudam o PaymentIntent | +| `paid_disputed.json` | `pay` com `pm_card_createDispute` | +| `paid_out_of_band.json` | `pay` com `paid_out_of_band` (InvoicePayment do tipo `payment_record`; o PaymentIntent padrão é cancelado) | +| `paid_zero_amount_due.json` | invoice item de valor zero, finalizado (sem PaymentIntent) | +| `void.json` | `void` de um Invoice `open` | +| `uncollectible.json` | `mark_uncollectible` de um Invoice `open` | + +Montadas sobre `open_requires_payment_method.json`, porque a sandbox não produz o estado: + +| Arquivo | Diferença | +|---|---| +| `open_requires_confirmation.json` | status do PaymentIntent | +| `open_requires_capture.json` | status do PaymentIntent | +| `open_processing.json` | status do PaymentIntent | +| `open_partially_paid.json` | `amount_paid` 5000 e `amount_remaining` 7345 | +| `open_without_payment_intent.json` | `payments.data` vazio | + +## `payment_intents/` + +`after_declined_attempt.json`, `paid.json`, `partially_refunded.json`, `refunded.json` e +`disputed.json` são o GET do PaymentIntent dos Invoices acima. `requires_action.json` é +`after_declined_attempt.json` com o status trocado e um `next_action` de 3DS. + +## `disputes/` + +`needs_response.json` é o GET de `/v1/disputes?charge=` do charge disputado; +`lost.json` é o mesmo com o status trocado. diff --git a/tests/fixtures/stripe/disputes/lost.json b/tests/fixtures/stripe/disputes/lost.json new file mode 100644 index 0000000..cc9f4fc --- /dev/null +++ b/tests/fixtures/stripe/disputes/lost.json @@ -0,0 +1,98 @@ +{ + "object": "list", + "count": 1, + "data": [ + { + "id": "du_1UBHU8Pjx0CusuMr3hVB1hzt", + "object": "dispute", + "amount": 12345, + "balance_transaction": "txn_1UBHU9Pjx0CusuMrYjmqrfZA", + "balance_transactions": [ + { + "id": "txn_1UBHU9Pjx0CusuMrYjmqrfZA", + "object": "balance_transaction", + "amount": -12345, + "available_on": 1788912000, + "balance_type": "payments", + "created": 1788368284, + "currency": "brl", + "description": "Chargeback withdrawal for ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "exchange_rate": null, + "fee": 3700, + "fee_details": [ + { + "amount": 3700, + "application": null, + "currency": "brl", + "description": "Dispute fee", + "type": "stripe_fee" + } + ], + "net": -16045, + "reporting_category": "dispute", + "source": "du_1UBHU8Pjx0CusuMr3hVB1hzt", + "status": "pending", + "type": "adjustment" + } + ], + "charge": "ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "created": 1788368284, + "currency": "brl", + "enhanced_eligibility_types": [], + "evidence": { + "access_activity_log": null, + "billing_address": null, + "cancellation_policy": null, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_communication": null, + "customer_email_address": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_purchase_ip": null, + "customer_signature": null, + "duplicate_charge_documentation": null, + "duplicate_charge_explanation": null, + "duplicate_charge_id": null, + "enhanced_evidence": [], + "product_description": "Payment for Invoice", + "receipt": null, + "refund_policy": null, + "refund_policy_disclosure": null, + "refund_refusal_explanation": null, + "service_date": null, + "service_documentation": null, + "shipping_address": null, + "shipping_carrier": null, + "shipping_date": null, + "shipping_documentation": null, + "shipping_tracking_number": null, + "uncategorized_file": null, + "uncategorized_text": null + }, + "evidence_details": { + "due_by": 1789084799, + "enhanced_eligibility": [], + "has_evidence": false, + "past_due": false, + "submission_count": 0 + }, + "is_charge_refundable": false, + "livemode": false, + "metadata": [], + "payment_intent": "pi_3UBHU5Pjx0CusuMr1cNe2BIo", + "payment_method_details": { + "card": { + "brand": "visa", + "case_type": "chargeback", + "network": "visa", + "network_reason_code": "10.4" + }, + "type": "card" + }, + "reason": "fraudulent", + "status": "lost" + } + ], + "has_more": false, + "url": "/v1/disputes" +} diff --git a/tests/fixtures/stripe/disputes/needs_response.json b/tests/fixtures/stripe/disputes/needs_response.json new file mode 100644 index 0000000..fab9fde --- /dev/null +++ b/tests/fixtures/stripe/disputes/needs_response.json @@ -0,0 +1,98 @@ +{ + "object": "list", + "count": 1, + "data": [ + { + "id": "du_1UBHU8Pjx0CusuMr3hVB1hzt", + "object": "dispute", + "amount": 12345, + "balance_transaction": "txn_1UBHU9Pjx0CusuMrYjmqrfZA", + "balance_transactions": [ + { + "id": "txn_1UBHU9Pjx0CusuMrYjmqrfZA", + "object": "balance_transaction", + "amount": -12345, + "available_on": 1788912000, + "balance_type": "payments", + "created": 1788368284, + "currency": "brl", + "description": "Chargeback withdrawal for ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "exchange_rate": null, + "fee": 3700, + "fee_details": [ + { + "amount": 3700, + "application": null, + "currency": "brl", + "description": "Dispute fee", + "type": "stripe_fee" + } + ], + "net": -16045, + "reporting_category": "dispute", + "source": "du_1UBHU8Pjx0CusuMr3hVB1hzt", + "status": "pending", + "type": "adjustment" + } + ], + "charge": "ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "created": 1788368284, + "currency": "brl", + "enhanced_eligibility_types": [], + "evidence": { + "access_activity_log": null, + "billing_address": null, + "cancellation_policy": null, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_communication": null, + "customer_email_address": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_purchase_ip": null, + "customer_signature": null, + "duplicate_charge_documentation": null, + "duplicate_charge_explanation": null, + "duplicate_charge_id": null, + "enhanced_evidence": [], + "product_description": "Payment for Invoice", + "receipt": null, + "refund_policy": null, + "refund_policy_disclosure": null, + "refund_refusal_explanation": null, + "service_date": null, + "service_documentation": null, + "shipping_address": null, + "shipping_carrier": null, + "shipping_date": null, + "shipping_documentation": null, + "shipping_tracking_number": null, + "uncategorized_file": null, + "uncategorized_text": null + }, + "evidence_details": { + "due_by": 1789084799, + "enhanced_eligibility": [], + "has_evidence": false, + "past_due": false, + "submission_count": 0 + }, + "is_charge_refundable": false, + "livemode": false, + "metadata": [], + "payment_intent": "pi_3UBHU5Pjx0CusuMr1cNe2BIo", + "payment_method_details": { + "card": { + "brand": "visa", + "case_type": "chargeback", + "network": "visa", + "network_reason_code": "10.4" + }, + "type": "card" + }, + "reason": "fraudulent", + "status": "needs_response" + } + ], + "has_more": false, + "url": "/v1/disputes" +} diff --git a/tests/fixtures/stripe/invoices/draft.json b/tests/fixtures/stripe/invoices/draft.json new file mode 100644 index 0000000..857262b --- /dev/null +++ b/tests/fixtures/stripe/invoices/draft.json @@ -0,0 +1,160 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": null, + "ending_balance": null, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": null, + "invoice_pdf": null, + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": null, + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "auto" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "draft", + "status_transitions": { + "finalized_at": null, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_after_declined_attempt.json b/tests/fixtures/stripe/invoices/open_after_declined_attempt.json new file mode 100644 index 0000000..86e2626 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_after_declined_attempt.json @@ -0,0 +1,305 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2OQ0200FbEr9ZkZ?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2OQ0200FbEr9ZkZ/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": { + "advice_code": "try_again_later", + "charge": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "network_decline_code": "01", + "payment_method": { + "id": "pm_1UBHTrPjx0CusuMrw9Pshrt0", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "sryrMZCxUxxxIzqq", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788368267, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_partially_paid.json b/tests/fixtures/stripe/invoices/open_partially_paid.json new file mode 100644 index 0000000..a0cfd8c --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_partially_paid.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 5000, + "amount_remaining": 7345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": 5000, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_processing.json b/tests/fixtures/stripe/invoices/open_processing.json new file mode 100644 index 0000000..1b29e3f --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_processing.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "processing", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_requires_action.json b/tests/fixtures/stripe/invoices/open_requires_action.json new file mode 100644 index 0000000..f19a605 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_requires_action.json @@ -0,0 +1,263 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA3Mg02002PvIsoNX?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA3Mg02002PvIsoNX/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": { + "type": "use_stripe_sdk", + "use_stripe_sdk": { + "directory_server_encryption": { + "algorithm": "RSA", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIGAzCCA+ugAwIBAgIQNyg9v/wemJMz4m18kdvt6zANBgkqhkiG9w0BAQsFADB2\nMQswCQYDVQQGEwJVUzENMAsGA1UECgwEVklTQTEvMC0GA1UECwwmVmlzYSBJbnRl\ncm5hdGlvbmFsIFNlcnZpY2UgQXNzb2NpYXRpb24xJzAlBgNVBAMMHlZpc2EgZUNv\nbW1lcmNlIElzc3VpbmcgQ0EgLSBHMjAeFw0yNDAyMjcyMjQ0MDNaFw0yNzAyMjYy\nMjQ0MDJaMIGhMRgwFgYDVQQHDA9IaWdobGFuZHMgUmFuY2gxETAPBgNVBAgMCENv\nbG9yYWRvMQswCQYDVQQGEwJVUzENMAsGA1UECgwEVklTQTEvMC0GA1UECwwmVmlz\nYSBJbnRlcm5hdGlvbmFsIFNlcnZpY2UgQXNzb2NpYXRpb24xJTAjBgNVBAMMHDNk\nczIucnNhLmVuY3J5cHRpb24udmlzYS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IB\nDwAwggEKAoIBAQCSABLI5aAnf8Cypn/sKETE+U3e3YruYzUkqNpEMH0sPMpCAW1V\n33xiklm5R0S3ZFOEzzlmL22tRyjXaB6/WJUa66ajRz2DiY8+7x5CcMJNVlwqS6OG\nTlmvXGOxPXIVz6hxCsAb7mKS7cnpGpkjVD/Oe4u8ZmeUrcPWKdeH+e5BfeWp2Iel\nh89pU4tpJ84PlPTlLjZ3TLa2OutFLsMBExcr4ipWnEcbpkzvAPBRABBVZbkWTA9i\nVM9v5MXIJGzXDsQVJEOgxm/kyQXeO3JtNytYOaQ6sQi+z9gF32plP3hsrUQ2jJ3H\nCnZmOyrHl8tQWxhAIMZ/WHogpvEGufi14eyBAgMBAAGjggFfMIIBWzAMBgNVHRMB\nAf8EAjAAMB8GA1UdIwQYMBaAFL0nYyikrlS3yCO3wTVCF+nGeF+FMGcGCCsGAQUF\nBwEBBFswWTAwBggrBgEFBQcwAoYkaHR0cDovL2Vucm9sbC52aXNhY2EuY29tL2VD\nb21tRzIuY3J0MCUGCCsGAQUFBzABhhlodHRwOi8vb2NzcC52aXNhLmNvbS9vY3Nw\nMEYGA1UdIAQ/MD0wMQYIKwYBBQUHAgEwJTAjBggrBgEFBQcCARYXaHR0cDovL3d3\ndy52aXNhLmNvbS9wa2kwCAYGZ4EDAQEBMBMGA1UdJQQMMAoGCCsGAQUFBwMCMDUG\nA1UdHwQuMCwwKqAooCaGJGh0dHA6Ly9lbnJvbGwudmlzYWNhLmNvbS9lQ29tbUcy\nLmNybDAdBgNVHQ4EFgQU6gD/Z5m2gw4n676qt4jXOWr8ynowDgYDVR0PAQH/BAQD\nAgSwMA0GCSqGSIb3DQEBCwUAA4ICAQBumWXGS/uaaajte2gI3xCpsuewmihQKvKq\nOhaOc+wgdfwIkEAXdJIc33n2j/iaT5Lusi09ezZpBB80C6KQVr5Fs10SX0hlBCvT\n9MGYFwFtYUgi5OenqYsXc8BdmiE0bFiSXZ7XfxWuvYjis4LOubMr48yVyyEynWWb\nSGr7rTJztraOFjsykK16x946YXi/LswEP9RtDiOBNJ9cBVcOP+u8Q+dEzA5A63Ay\nA2Z/5m/pyCIWMSYGMtM0RANUp9gp7cEXaMpwKWfrW6ZanF60IoGlmRHilGU4rvm7\ntd4yJTfwxtkmh4w/IPiqI/OaXj5iG8hCo8a9FTnGahIcij8c1UWzQ3/A7o99AUwy\n1Xi9JnNiXYVM1bfQalK0DKsLqRiS6rzPfwMUxj2+ALnhcrnSdPnZgVxiQB7kvfi3\nHFXHoGhmMHHj8tlURqUQzpJcCz1QOy5c9SCc/M8Xu+eRsCnJrtvvDxriLWfDDM9M\n8/8JsiBP+DF6omCb6J4ryEulcVPnVAAZNlNtaUadPYnCJfB0p67jWkHa5NvQdZGW\naT1tvo9DqjI7zLf9Km0qqkK51gwYDnsy14pYvfeEwL9O9aepFa7x08bM3272xqNK\neGHXFK/zUVxYY5uxukcqYAdhAvx7f6d0J6Nr8jPxo6UNXKiFH9leGPPzx9oNugts\nz6qck4YdAQ==\n-----END CERTIFICATE-----\n", + "directory_server_id": "A000000003", + "root_certificate_authorities": [ + "-----BEGIN CERTIFICATE-----\nMIIFqTCCA5GgAwIBAgIPUT6WAAAA20Qn7qzgvuFIMA0GCSqGSIb3DQEBCwUAMG8x\nCzAJBgNVBAYTAlVTMQ0wCwYDVQQKDARWSVNBMS8wLQYDVQQLDCZWaXNhIEludGVy\nbmF0aW9uYWwgU2VydmljZSBBc3NvY2lhdGlvbjEgMB4GA1UEAwwXVmlzYSBQdWJs\naWMgUlNBIFJvb3QgQ0EwHhcNMjEwMzE2MDAwMDAwWhcNNDEwMzE1MDAwMDAwWjBv\nMQswCQYDVQQGEwJVUzENMAsGA1UECgwEVklTQTEvMC0GA1UECwwmVmlzYSBJbnRl\ncm5hdGlvbmFsIFNlcnZpY2UgQXNzb2NpYXRpb24xIDAeBgNVBAMMF1Zpc2EgUHVi\nbGljIFJTQSBSb290IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA\n2WEbXLS3gI6LOY93bP7Kz6EO9L1QXlr8l+fTkJWZldJ6QuwZ1cv4369tfjeJ8O5w\nSJiDcVw7eNdOP73LfAtwHlTnUnb0e9ILTTipc5bkNnAevocrJACsrpiQ8jBI9ttp\ncqKUeJgzW4Ie25ypirKroVD42b4E0iICK2cZ5QfD4BSzUnftp4Bqh8AfpGvG1lre\nCaD53qrsy5SUadY/NaeUGOkqdPvDSNoDIdrbExwnZaSFUmjQT1svKwMqGo2GFrgJ\n4cULEp4NNj5rga8YTTZ7Xo5MblHrLpSPOmJev30KWi/BcbvtCNYNWBTg7UMzP3cK\nMQ1pGLvG2PgvFTZSRvH3QzngJRgrDYYOJ6kj9ave+6yOOFqj80ZCuH0Nugt2mMS3\nc3+Nksaw+6H3cQPsE/Gv5zjfsKleRhEFtE1gyrdUg1DMgu8o/YhKM7FAqkXUn74z\nwoRFgx3Mi5OaGTQbg+NlwJgR4sVHXCV4s9b8PjneLhzWMn353SFARF9dnO7LDBqq\ntT6WltJu1z9x2Ze0UVNZvxKGcyCkLody29O8j9/MGZ8SOSUu4U6NHrebKuuf9Fht\nn6PqQ4ppkhy6sReXeV5NVGfVpDYY5ZAKEWqTYgMULWpQ2Py4BGpFzBe07jXkyulR\npoKvz14iXeA0oq16c94DrFYX0jmrWLeU4a/TCZQLFIsCAwEAAaNCMEAwHQYDVR0O\nBBYEFEtNpg77oBHorQvi8PMKAC+sixb7MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P\nAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQC5BU9qQSZYPcgCp2x0Juq59kMm\nXuBly094DaEnPqvtCgwwAirkv8x8/QSOxiWWiu+nveyuR+j6Gz/fJaV4u+J5QEDy\ncfk605Mw3HIcJOeZvDgk1eyOmQwUP6Z/BdQTNJmZ92Z8dcG5yWCxLBrqPH7ro3Ss\njhYq9duIJU7jfizCJCN4W8tp0D2pWBe1/CYNswP4GMs5jQ5+ZQKN/L5JFdwVTu7X\nPt8b5zfgbmmQpVmUn0oFwm3OI++Z6gEpNmW5bd/2oUIZoG96Qff2fauVMAYiWQvN\nnL3y1gkRguTOSMVUCCiGfdvwu5ygowillvV2nHb7+YibQ9N5Z2spP0o9Zlfzoat2\n7WFpyK47TiUdu/4toarLKGZP+hbA/F4xlnM/8EfZkE1DeTTI0lhN3O8yEsHrtRl1\nOuQZ/IexHO8UGU6jvn4TWo10HYeXzrGckL7oIXfGTrjPzfY62T5HDW/BAEZS+9Tk\nijz25YM0fPPz7IdlEG+k4q4YwZ82j73Y9kDEM5423mrWorq/Bq7I5Y8v0LTY9GWH\nYrpElYf0WdOXAbsfwQiT6qnRio+p82VyqlY8Jt6VVA6CDy/iHKwcj1ELEnDQfVv9\nhedoxmnQ6xe/nK8czclu9hQJRv5Lh9gk9Q8DKK2nmgzZ8SSQ+lr3mSSeY8JOMRlE\n+RKdOQIChWthTJKh7w==\n-----END CERTIFICATE-----\n" + ] + }, + "directory_server_name": "visa", + "merchant": "acct_1TzKkTPjx0CusuMr", + "one_click_authn": null, + "server_transaction_id": "e7752c64-4c90-465e-8118-cfd955765525", + "three_d_secure_2_source": "payatt_3UBHTpPjx0CusuMr1aSm67j9", + "three_ds_frontend_optimizations": "eyJtZXRob2RfdGltZW91dCI6MTAsInNraXBfbWV0aG9kIjpmYWxzZSwic2FuZGJveF9tZXRob2QiOnRydWUsInNhbmRib3hfY2hhbGxlbmdlIjpmYWxzZSwicmVjb3JkX2ZpbmFsX2NyZXMiOnRydWUsImZ1bGxzY3JlZW5faG9zdGVkX2NoYWxsZW5nZSI6ZmFsc2UsInNob3dfbG9hZGluZ19iYXIiOnRydWUsInNob3dfY2hhbGxlbmdlX2ZyYW1lX2R1cmluZ19maW5nZXJwcmludGluZyI6dHJ1ZSwid2Fybl9vbl9hYmFuZG9uIjp0cnVlfQ==", + "three_ds_method_url": "", + "type": "stripe_3ds2_fingerprint" + } + }, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": "pm_1UBHTuPjx0CusuMr2Gfp5NBs", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_action", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_requires_capture.json b/tests/fixtures/stripe/invoices/open_requires_capture.json new file mode 100644 index 0000000..1b85fa8 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_requires_capture.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_capture", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_requires_confirmation.json b/tests/fixtures/stripe/invoices/open_requires_confirmation.json new file mode 100644 index 0000000..72052a5 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_requires_confirmation.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_confirmation", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_requires_payment_method.json b/tests/fixtures/stripe/invoices/open_requires_payment_method.json new file mode 100644 index 0000000..a3dca14 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_requires_payment_method.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_without_payment_intent.json b/tests/fixtures/stripe/invoices/open_without_payment_intent.json new file mode 100644 index 0000000..e23d5c3 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_without_payment_intent.json @@ -0,0 +1,160 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/paid.json b/tests/fixtures/stripe/invoices/paid.json new file mode 100644 index 0000000..06ca9c6 --- /dev/null +++ b/tests/fixtures/stripe/invoices/paid.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 12345, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA3NQ0200GZP1D7F9?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA3NQ0200GZP1D7F9/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": 12345, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 12345, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "paid", + "status_transitions": { + "canceled_at": null, + "paid_at": 1788368273 + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": 1788368273, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/paid_disputed.json b/tests/fixtures/stripe/invoices/paid_disputed.json new file mode 100644 index 0000000..a0eda0b --- /dev/null +++ b/tests/fixtures/stripe/invoices/paid_disputed.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHU4Pjx0CusuMrOTB0POaR", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 12345, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368280, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368281, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVub0s5Yjl5dURDd1FrMUQwMUVYRVMxQWhodmd3LDE3ODkwOTA4Ng0200C4tYbPDy?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVub0s5Yjl5dURDd1FrMUQwMUVYRVMxQWhodmd3LDE3ODkwOTA4Ng0200C4tYbPDy/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHU4Pjx0CusuMros25pT4P", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHU4Pjx0CusuMrOTB0POaR", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHU4Pjx0CusuMr5PrOUkIb", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368280, + "start": 1788368280 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHU4Pjx0CusuMrOTB0POaR/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0002", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHU5Pjx0CusuMrA42qQGru", + "object": "invoice_payment", + "amount_paid": 12345, + "amount_requested": 12345, + "created": 1788368281, + "currency": "brl", + "invoice": "in_1UBHU4Pjx0CusuMrOTB0POaR", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHU5Pjx0CusuMr1cNe2BIo", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 12345, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHU5Pjx0CusuMr1cNe2BIo_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368281, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": "ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHU4Pjx0CusuMrOTB0POaR" + }, + "payment_method": "pm_1UBHU6Pjx0CusuMrlJ3aMuAK", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "paid", + "status_transitions": { + "canceled_at": null, + "paid_at": 1788368283 + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368280, + "period_start": 1788368280, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788368281, + "marked_uncollectible_at": null, + "paid_at": 1788368283, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368280 +} diff --git a/tests/fixtures/stripe/invoices/paid_out_of_band.json b/tests/fixtures/stripe/invoices/paid_out_of_band.json new file mode 100644 index 0000000..073247a --- /dev/null +++ b/tests/fixtures/stripe/invoices/paid_out_of_band.json @@ -0,0 +1,263 @@ +{ + "id": "in_1UBHUHPjx0CusuMrD81KgsNd", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 12345, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368293, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368294, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuSEFJSDNTVzVsU3I2VFYxOHB2Zm5PYVFCbjM0LDE3ODkwOTA5OA0200FNURwqxW?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuSEFJSDNTVzVsU3I2VFYxOHB2Zm5PYVFCbjM0LDE3ODkwOTA5OA0200FNURwqxW/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHUIPjx0CusuMruYYLM4mN", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHUHPjx0CusuMrD81KgsNd", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHUIPjx0CusuMrRqKbhPtU", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368294, + "start": 1788368294 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHUHPjx0CusuMrD81KgsNd/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0005", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHUKPjx0CusuMr88FVgCVO", + "object": "invoice_payment", + "amount_paid": 12345, + "amount_requested": 12345, + "created": 1788368296, + "currency": "brl", + "invoice": "in_1UBHUHPjx0CusuMrD81KgsNd", + "is_default": false, + "livemode": false, + "payment": { + "payment_record": "pr_test_65VKfRg6G9qn6oUaOkJ41Pjx0CusuMrUdM", + "type": "payment_record" + }, + "status": "paid", + "status_transitions": { + "canceled_at": null, + "paid_at": 1788368296 + } + }, + { + "id": "inpay_1UBHUJPjx0CusuMrjY5vdZwl", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368295, + "currency": "brl", + "invoice": "in_1UBHUHPjx0CusuMrD81KgsNd", + "is_default": false, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHUJPjx0CusuMr1g8brq49", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": 1788368296, + "cancellation_reason": "duplicate", + "capture_method": "automatic", + "client_secret": "pi_3UBHUJPjx0CusuMr1g8brq49_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368295, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHUHPjx0CusuMrD81KgsNd" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "canceled", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "canceled", + "status_transitions": { + "canceled_at": 1788368297, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368293, + "period_start": 1788368293, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788368294, + "marked_uncollectible_at": null, + "paid_at": 1788368296, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368293 +} diff --git a/tests/fixtures/stripe/invoices/paid_zero_amount_due.json b/tests/fixtures/stripe/invoices/paid_zero_amount_due.json new file mode 100644 index 0000000..a66a2ab --- /dev/null +++ b/tests/fixtures/stripe/invoices/paid_zero_amount_due.json @@ -0,0 +1,160 @@ +{ + "id": "in_1UBHUNPjx0CusuMrEG6etDb8", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 0, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368299, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368300, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuM3VLbkNZS2tVc0ppOWZmSEoxcDltdEtNSFlNLDE3ODkwOTEwMA0200daC6w6MF?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuM3VLbkNZS2tVc0ppOWZmSEoxcDltdEtNSFlNLDE3ODkwOTEwMA0200daC6w6MF/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHUNPjx0CusuMr4FkbSzmo", + "object": "line_item", + "amount": 0, + "currency": "brl", + "description": "Período de teste", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHUNPjx0CusuMrEG6etDb8", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHUNPjx0CusuMrbRF91HQA", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368299, + "start": 1788368299 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHUNPjx0CusuMrbGQ5kEqP", + "product": "prod_VBenoD0uH86i0j" + }, + "type": "price_details", + "unit_amount_decimal": "0" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 0, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHUNPjx0CusuMrEG6etDb8/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0006", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368299, + "period_start": 1788368299, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788368300, + "marked_uncollectible_at": null, + "paid_at": 1788368300, + "voided_at": null + }, + "subtotal": 0, + "subtotal_excluding_tax": 0, + "test_clock": null, + "total": 0, + "total_discount_amounts": [], + "total_excluding_tax": 0, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368299 +} diff --git a/tests/fixtures/stripe/invoices/uncollectible.json b/tests/fixtures/stripe/invoices/uncollectible.json new file mode 100644 index 0000000..227290f --- /dev/null +++ b/tests/fixtures/stripe/invoices/uncollectible.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHUEPjx0CusuMrnkQe23fT", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368290, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368291, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVudlk1SUJjWmRMTjFGdkZiakQ3MzM5NEJJTVV3LDE3ODkwOTA5Mw0200zEaqVcJo?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVudlk1SUJjWmRMTjFGdkZiakQ3MzM5NEJJTVV3LDE3ODkwOTA5Mw0200zEaqVcJo/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHUFPjx0CusuMrfR4xvtyu", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHUEPjx0CusuMrnkQe23fT", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHUFPjx0CusuMrWuNACPFV", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368291, + "start": 1788368291 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHUEPjx0CusuMrnkQe23fT/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0004", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHUGPjx0CusuMreZ1I63mR", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368292, + "currency": "brl", + "invoice": "in_1UBHUEPjx0CusuMrnkQe23fT", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHUGPjx0CusuMr1qD1a6qP", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHUGPjx0CusuMr1qD1a6qP_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368292, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHUEPjx0CusuMrnkQe23fT" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368290, + "period_start": 1788368290, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "uncollectible", + "status_transitions": { + "finalized_at": 1788368291, + "marked_uncollectible_at": 1788368293, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368290 +} diff --git a/tests/fixtures/stripe/invoices/void.json b/tests/fixtures/stripe/invoices/void.json new file mode 100644 index 0000000..e39112a --- /dev/null +++ b/tests/fixtures/stripe/invoices/void.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHUBPjx0CusuMrdZ8CWaQW", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368287, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368288, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVucWRHYXpJMFZVMHFHNUZJdWhCYWp4ZFRZSzQyLDE3ODkwOTA5MA0200LXmagdLN?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVucWRHYXpJMFZVMHFHNUZJdWhCYWp4ZFRZSzQyLDE3ODkwOTA5MA0200LXmagdLN/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHUCPjx0CusuMr3G5FJ0KK", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHUBPjx0CusuMrdZ8CWaQW", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHUCPjx0CusuMr9hFzgHt0", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368288, + "start": 1788368288 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHUBPjx0CusuMrdZ8CWaQW/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0003", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHUDPjx0CusuMre4zedBKF", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368289, + "currency": "brl", + "invoice": "in_1UBHUBPjx0CusuMrdZ8CWaQW", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHUDPjx0CusuMr1qYLdJ5A", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": 1788368290, + "cancellation_reason": "void_invoice", + "capture_method": "automatic", + "client_secret": "pi_3UBHUDPjx0CusuMr1qYLdJ5A_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368289, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHUBPjx0CusuMrdZ8CWaQW" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "canceled", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368287, + "period_start": 1788368287, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "void", + "status_transitions": { + "finalized_at": 1788368288, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": 1788368290 + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368287 +} diff --git a/tests/fixtures/stripe/payment_intents/after_declined_attempt.json b/tests/fixtures/stripe/payment_intents/after_declined_attempt.json new file mode 100644 index 0000000..60c73f3 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/after_declined_attempt.json @@ -0,0 +1,244 @@ +{ + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": { + "advice_code": "try_again_later", + "charge": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "network_decline_code": "01", + "payment_method": { + "id": "pm_1UBHTrPjx0CusuMrw9Pshrt0", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "sryrMZCxUxxxIzqq", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788368267, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": { + "id": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "object": "charge", + "amount": 12345, + "amount_captured": 0, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": null, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": false, + "created": 1788368268, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "description": "Payment for Invoice", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": "card_declined", + "failure_message": "Your card was declined.", + "fraud_details": [], + "livemode": false, + "metadata": [], + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": "try_again_later", + "network_advice_code": null, + "network_decline_code": "01", + "network_status": "declined_by_network", + "reason": "generic_decline", + "risk_level": "normal", + "risk_score": 36, + "seller_message": "The bank did not return any further details with this decline.", + "type": "issuer_declined" + }, + "paid": false, + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTrPjx0CusuMrw9Pshrt0", + "payment_method_details": { + "card": { + "amount_authorized": null, + "authorization_code": "319799", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "sryrMZCxUxxxIzqq", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "0341", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "115114121114779", + "overcapture": { + "maximum_amount_capturable": 12345, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": [], + "receipt_email": null, + "receipt_number": null, + "receipt_url": null, + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3UBHTpPjx0CusuMr1KQeiDo0/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": null, + "transfer_group": null + }, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/payment_intents/disputed.json b/tests/fixtures/stripe/payment_intents/disputed.json new file mode 100644 index 0000000..92079d9 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/disputed.json @@ -0,0 +1,207 @@ +{ + "id": "pi_3UBHU5Pjx0CusuMr1cNe2BIo", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 12345, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHU5Pjx0CusuMr1cNe2BIo_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368281, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": { + "id": "ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "object": "charge", + "amount": 12345, + "amount_captured": 12345, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": { + "id": "txn_3UBHU5Pjx0CusuMr1yiBKYnC", + "object": "balance_transaction", + "amount": 12345, + "available_on": 1788912000, + "balance_type": "payments", + "created": 1788368283, + "currency": "brl", + "description": "Payment for Invoice", + "exchange_rate": null, + "fee": 520, + "fee_details": [ + { + "amount": 520, + "application": null, + "currency": "brl", + "description": "Stripe processing fees", + "type": "stripe_fee" + } + ], + "net": 11825, + "reporting_category": "charge", + "source": "ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "status": "pending", + "type": "charge" + }, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": true, + "created": 1788368283, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "description": "Payment for Invoice", + "destination": null, + "dispute": "du_1UBHU8Pjx0CusuMr3hVB1hzt", + "disputed": true, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": [], + "livemode": false, + "metadata": [], + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 39, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3UBHU5Pjx0CusuMr1cNe2BIo", + "payment_method": "pm_1UBHU6Pjx0CusuMrlJ3aMuAK", + "payment_method_details": { + "card": { + "amount_authorized": 12345, + "authorization_code": "074695", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "Da65OXs5lv5nviqQ", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "0259", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "689754537988115", + "overcapture": { + "maximum_amount_capturable": 12345, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": [], + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xVHpLa1RQangwQ3VzdU1yKJ6r4dQGMga9wj0Esb86LBYxivT4OZHNfyO80HaHx5vdfQq19mo0-CYfIav6s49SDxNTn0r8JkNZ-gfh?s=ap", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3UBHU5Pjx0CusuMr1q2mLg5W/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHU4Pjx0CusuMrOTB0POaR" + }, + "payment_method": "pm_1UBHU6Pjx0CusuMrlJ3aMuAK", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/payment_intents/paid.json b/tests/fixtures/stripe/payment_intents/paid.json new file mode 100644 index 0000000..8eaf024 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/paid.json @@ -0,0 +1,207 @@ +{ + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 12345, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": { + "id": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "object": "charge", + "amount": 12345, + "amount_captured": 12345, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": { + "id": "txn_3UBHTpPjx0CusuMr1GJ9M7S6", + "object": "balance_transaction", + "amount": 12345, + "available_on": 1788912000, + "balance_type": "payments", + "created": 1788368273, + "currency": "brl", + "description": "Payment for Invoice", + "exchange_rate": null, + "fee": 520, + "fee_details": [ + { + "amount": 520, + "application": null, + "currency": "brl", + "description": "Stripe processing fees", + "type": "stripe_fee" + } + ], + "net": 11825, + "reporting_category": "charge", + "source": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "status": "pending", + "type": "charge" + }, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": true, + "created": 1788368273, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "description": "Payment for Invoice", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": [], + "livemode": false, + "metadata": [], + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 37, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_details": { + "card": { + "amount_authorized": 12345, + "authorization_code": "154318", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "4242", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "666611471566978", + "overcapture": { + "maximum_amount_capturable": 12345, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": [], + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xVHpLa1RQangwQ3VzdU1yKJOr4dQGMgb970rw0jA6LBY8dgTEI9dke67Ta1MOOepBietwOe6MF35SjmzCBteD7Dt8a7-NvpVej--m?s=ap", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3UBHTpPjx0CusuMr1Grz2MK4/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/payment_intents/partially_refunded.json b/tests/fixtures/stripe/payment_intents/partially_refunded.json new file mode 100644 index 0000000..f3a5092 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/partially_refunded.json @@ -0,0 +1,236 @@ +{ + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 12345, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": { + "id": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "object": "charge", + "amount": 12345, + "amount_captured": 12345, + "amount_refunded": 2345, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": { + "id": "txn_3UBHTpPjx0CusuMr1GJ9M7S6", + "object": "balance_transaction", + "amount": 12345, + "available_on": 1788912000, + "balance_type": "payments", + "created": 1788368273, + "currency": "brl", + "description": "Payment for Invoice", + "exchange_rate": null, + "fee": 520, + "fee_details": [ + { + "amount": 520, + "application": null, + "currency": "brl", + "description": "Stripe processing fees", + "type": "stripe_fee" + } + ], + "net": 11825, + "reporting_category": "charge", + "source": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "status": "pending", + "type": "charge" + }, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": true, + "created": 1788368273, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "description": "Payment for Invoice", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": [], + "livemode": false, + "metadata": [], + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 37, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_details": { + "card": { + "amount_authorized": 12345, + "authorization_code": "154318", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "4242", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "666611471566978", + "overcapture": { + "maximum_amount_capturable": 12345, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": [], + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xVHpLa1RQangwQ3VzdU1yKJar4dQGMgYjxOjKU1s6LBYM7nuexXpIYp3K07uo_IvpOwEXkO-kwFO9YQ7Z9xEFrgu8z_vqzperYXQC?s=ap", + "refunded": false, + "refunds": { + "object": "list", + "data": [ + { + "id": "re_3UBHTpPjx0CusuMr17mvyNgb", + "object": "refund", + "amount": 2345, + "balance_transaction": "txn_3UBHTpPjx0CusuMr1Z5mxfYY", + "charge": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "created": 1788368276, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "destination_details": { + "card": { + "reference": "1007725422320003", + "reference_status": "available", + "reference_type": "acquirer_reference_number", + "type": "refund" + }, + "type": "card" + }, + "metadata": [], + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "reason": null, + "receipt_number": null, + "source_transfer_reversal": null, + "status": "succeeded", + "transfer_reversal": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges/ch_3UBHTpPjx0CusuMr1Grz2MK4/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/payment_intents/refunded.json b/tests/fixtures/stripe/payment_intents/refunded.json new file mode 100644 index 0000000..22ea889 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/refunded.json @@ -0,0 +1,264 @@ +{ + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 12345, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": { + "id": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "object": "charge", + "amount": 12345, + "amount_captured": 12345, + "amount_refunded": 12345, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": { + "id": "txn_3UBHTpPjx0CusuMr1GJ9M7S6", + "object": "balance_transaction", + "amount": 12345, + "available_on": 1788912000, + "balance_type": "payments", + "created": 1788368273, + "currency": "brl", + "description": "Payment for Invoice", + "exchange_rate": null, + "fee": 520, + "fee_details": [ + { + "amount": 520, + "application": null, + "currency": "brl", + "description": "Stripe processing fees", + "type": "stripe_fee" + } + ], + "net": 11825, + "reporting_category": "charge", + "source": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "status": "pending", + "type": "charge" + }, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": true, + "created": 1788368273, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "description": "Payment for Invoice", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": [], + "livemode": false, + "metadata": [], + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 37, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_details": { + "card": { + "amount_authorized": 12345, + "authorization_code": "154318", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "4242", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "666611471566978", + "overcapture": { + "maximum_amount_capturable": 12345, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": [], + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xVHpLa1RQangwQ3VzdU1yKJer4dQGMgZFicsMwqw6LBbR8m3PpRYxpnupZ9qHkSmG83n4LuAhmnEIO9EF0IwUxQQfkTBNSjyQfULV?s=ap", + "refunded": true, + "refunds": { + "object": "list", + "data": [ + { + "id": "re_3UBHTpPjx0CusuMr1X9KHYad", + "object": "refund", + "amount": 10000, + "balance_transaction": "txn_3UBHTpPjx0CusuMr1D0cp94k", + "charge": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "created": 1788368278, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "destination_details": { + "card": { + "reference": "3456132009380231", + "reference_status": "available", + "reference_type": "acquirer_reference_number", + "type": "refund" + }, + "type": "card" + }, + "metadata": [], + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "reason": null, + "receipt_number": null, + "source_transfer_reversal": null, + "status": "succeeded", + "transfer_reversal": null + }, + { + "id": "re_3UBHTpPjx0CusuMr17mvyNgb", + "object": "refund", + "amount": 2345, + "balance_transaction": "txn_3UBHTpPjx0CusuMr1Z5mxfYY", + "charge": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "created": 1788368276, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "destination_details": { + "card": { + "reference": "1007725422320003", + "reference_status": "available", + "reference_type": "acquirer_reference_number", + "type": "refund" + }, + "type": "card" + }, + "metadata": [], + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "reason": null, + "receipt_number": null, + "source_transfer_reversal": null, + "status": "succeeded", + "transfer_reversal": null + } + ], + "has_more": false, + "total_count": 2, + "url": "/v1/charges/ch_3UBHTpPjx0CusuMr1Grz2MK4/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/payment_intents/requires_action.json b/tests/fixtures/stripe/payment_intents/requires_action.json new file mode 100644 index 0000000..ea8a833 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/requires_action.json @@ -0,0 +1,250 @@ +{ + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": { + "advice_code": "try_again_later", + "charge": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "network_decline_code": "01", + "payment_method": { + "id": "pm_1UBHTrPjx0CusuMrw9Pshrt0", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "sryrMZCxUxxxIzqq", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788368267, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": { + "id": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "object": "charge", + "amount": 12345, + "amount_captured": 0, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": null, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": false, + "created": 1788368268, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "description": "Payment for Invoice", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": "card_declined", + "failure_message": "Your card was declined.", + "fraud_details": [], + "livemode": false, + "metadata": [], + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": "try_again_later", + "network_advice_code": null, + "network_decline_code": "01", + "network_status": "declined_by_network", + "reason": "generic_decline", + "risk_level": "normal", + "risk_score": 36, + "seller_message": "The bank did not return any further details with this decline.", + "type": "issuer_declined" + }, + "paid": false, + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTrPjx0CusuMrw9Pshrt0", + "payment_method_details": { + "card": { + "amount_authorized": null, + "authorization_code": "319799", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "sryrMZCxUxxxIzqq", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "0341", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "115114121114779", + "overcapture": { + "maximum_amount_capturable": 12345, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": [], + "receipt_email": null, + "receipt_number": null, + "receipt_url": null, + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3UBHTpPjx0CusuMr1KQeiDo0/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": null, + "transfer_group": null + }, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": { + "type": "use_stripe_sdk", + "use_stripe_sdk": { + "type": "three_d_secure_redirect", + "stripe_js": "https://hooks.stripe.com/redirect/authenticate/src_REDACTED" + } + }, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_action", + "transfer_data": null, + "transfer_group": null +} From fb8947a8a84cdc7d4b0da2f071293d3aacf0bfa4 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 16:17:17 -0300 Subject: [PATCH 23/32] feat(subscription): introduz SubscriptionStatus com helpers e corrige isTerminal() para fatura expirada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscription::$status passa a ser o enum SubscriptionStatus (nove estados, com isActive(), isRecoverable() e isEnded()), pelo mesmo mecanismo de ENUM_CASTS e AcceptsUnknownValue da fatura; as constantes STATUS_* continuam com o mesmo valor, deprecadas. Na Iugu, cancelSubscription() suspende e grava a marca mp_canceled_at em custom_variables numa segunda requisição; suspended com a marca lê como CANCELED, resumeSubscription() remove a marca, e active falso com expires_at no passado sem fatura em aberto lê como EXPIRED. O mapa de status da Stripe fica em Gateways\Stripe\SubscriptionStatuses, com uma fixture por status. InvoiceStatus::isTerminal() deixa de incluir EXPIRED, que continua pagável nos dois gateways, e isPayable() passa a responder essa pergunta. Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk --- README.md | 148 +++++-- src/Enums/InvoiceStatus.php | 25 +- src/Enums/SubscriptionStatus.php | 113 +++++ src/Gateways/IuguGateway.php | 238 +++++++++-- src/Gateways/Stripe/SubscriptionStatuses.php | 55 +++ src/Models/Subscription.php | 31 +- tests/Integration/SubscriptionTest.php | 31 +- tests/Unit/Enums/InvoiceStatusTest.php | 47 +- tests/Unit/Enums/SubscriptionStatusTest.php | 139 ++++++ .../Gateways/IuguGatewayIdempotencyTest.php | 21 +- .../Gateways/IuguGatewayInvoiceStatusTest.php | 3 +- .../Gateways/IuguGatewaySubscriptionTest.php | 402 ++++++++++++++++-- .../Stripe/SubscriptionStatusesTest.php | 122 ++++++ tests/Unit/ModelEnumCastTest.php | 38 ++ tests/Unit/SubscriptionTest.php | 4 +- tests/fixtures/stripe/README.md | 11 + .../fixtures/stripe/subscriptions/active.json | 138 ++++++ .../active_pause_collection.json | 141 ++++++ .../stripe/subscriptions/canceled.json | 138 ++++++ .../stripe/subscriptions/incomplete.json | 138 ++++++ .../subscriptions/incomplete_expired.json | 138 ++++++ .../stripe/subscriptions/past_due.json | 138 ++++++ .../fixtures/stripe/subscriptions/paused.json | 138 ++++++ .../stripe/subscriptions/trialing.json | 138 ++++++ .../fixtures/stripe/subscriptions/unpaid.json | 138 ++++++ 25 files changed, 2523 insertions(+), 150 deletions(-) create mode 100644 src/Enums/SubscriptionStatus.php create mode 100644 src/Gateways/Stripe/SubscriptionStatuses.php create mode 100644 tests/Unit/Enums/SubscriptionStatusTest.php create mode 100644 tests/Unit/Gateways/Stripe/SubscriptionStatusesTest.php create mode 100644 tests/fixtures/stripe/subscriptions/active.json create mode 100644 tests/fixtures/stripe/subscriptions/active_pause_collection.json create mode 100644 tests/fixtures/stripe/subscriptions/canceled.json create mode 100644 tests/fixtures/stripe/subscriptions/incomplete.json create mode 100644 tests/fixtures/stripe/subscriptions/incomplete_expired.json create mode 100644 tests/fixtures/stripe/subscriptions/past_due.json create mode 100644 tests/fixtures/stripe/subscriptions/paused.json create mode 100644 tests/fixtures/stripe/subscriptions/trialing.json create mode 100644 tests/fixtures/stripe/subscriptions/unpaid.json diff --git a/README.md b/README.md index d94e3eb..aeb02b4 100644 --- a/README.md +++ b/README.md @@ -185,18 +185,18 @@ pacote; o status específico de cada gateway fica em `original`. Os treze estado | `InvoiceStatus` | Significado | Iugu | Stripe | Helper que responde | |---|---|---|---|---| -| `PENDING` | Aguardando pagamento | `pending`, `draft` | PaymentIntent em `requires_payment_method`, `requires_action`, `requires_confirmation`; Invoice `draft` ou `open` sem pagamento em curso | `isOpen()` | -| `AUTHORIZED` | Valor reservado no cartão, aguardando captura ou análise | `in_analysis`, `authorized` | PaymentIntent `requires_capture` | `isOpen()` | +| `PENDING` | Aguardando pagamento | `pending`, `draft` | PaymentIntent em `requires_payment_method`, `requires_action`, `requires_confirmation`; Invoice `draft` ou `open` sem pagamento em curso | `isOpen()` e `isPayable()` | +| `AUTHORIZED` | Valor reservado no cartão, aguardando captura ou análise | `in_analysis`, `authorized` | PaymentIntent `requires_capture` | `isOpen()` e `isPayable()` | | `PROCESSING` | Pagamento em processamento no gateway | (não emite) | PaymentIntent `processing` | `isOpen()` | | `PAID` | Valor recebido | `paid` | PaymentIntent `succeeded` sem estorno nem contestação; Invoice `paid` quitado sem cobrança | `isSettled()` | -| `PARTIALLY_PAID` | Parte do valor recebida, restante em aberto | `partially_paid` | Invoice `open` com parte do `total` em `amount_paid` (não emite em venda avulsa) | `isSettled()` e `isOpen()` | +| `PARTIALLY_PAID` | Parte do valor recebida, restante em aberto | `partially_paid` | Invoice `open` com parte do `total` em `amount_paid` (não emite em venda avulsa) | `isSettled()`, `isOpen()` e `isPayable()` | | `EXTERNALLY_PAID` | Quitada fora do gateway, por baixa manual | `externally_paid` | Invoice `paid` com pagamento registrado fora da Stripe (não emite em venda avulsa) | `isSettled()` | | `PARTIALLY_REFUNDED` | Estorno voluntário, parcial | `partially_refunded` | charge com `amount_refunded` menor que o total | `isSettled()` | | `REFUNDED` | Estorno voluntário, integral | `refunded` | charge com `refunded = true` | `isTerminal()` | | `DISPUTED` | Contestação aberta sobre fatura paga, resolução pendente | `in_protest` | charge `disputed` com dispute em `warning_needs_response`, `warning_under_review`, `needs_response` ou `under_review` | `isContested()` | | `CHARGEBACK` | Contestação perdida: valor devolvido ao cliente pelo gateway | `chargeback` | dispute em `lost` | `isContested()` e `isTerminal()` | | `CANCELED` | Cancelada antes do pagamento | `canceled` | PaymentIntent `canceled`; Invoice `void` | `isTerminal()` | -| `EXPIRED` | Venceu sem pagamento | `expired` | Invoice `uncollectible` (não emite em venda avulsa) | `isTerminal()` | +| `EXPIRED` | Venceu sem pagamento; continua pagável | `expired` | Invoice `uncollectible` (não emite em venda avulsa) | `isPayable()` | | `UNKNOWN` | Status que a lib não reconhece | qualquer outro | qualquer outro | nenhum responde verdadeiro | Dispute ganha (`won`), encerrada sem virar chargeback (`warning_closed`) ou prevenida @@ -213,9 +213,10 @@ Os helpers do enum respondem às perguntas de negócio sem comparar status um a use Potelo\MultiPayment\Enums\InvoiceStatus; $invoice->status->isSettled(); // recebi dinheiro? PAID, PARTIALLY_PAID, EXTERNALLY_PAID, PARTIALLY_REFUNDED -$invoice->status->isOpen(); // ainda pode receber pagamento? PENDING, AUTHORIZED, PROCESSING, PARTIALLY_PAID +$invoice->status->isOpen(); // pagamento ainda por resolver? PENDING, AUTHORIZED, PROCESSING, PARTIALLY_PAID +$invoice->status->isPayable(); // aceita um pagamento agora? PENDING, AUTHORIZED, PARTIALLY_PAID, EXPIRED $invoice->status->isContested(); // tem briga? DISPUTED, CHARGEBACK -$invoice->status->isTerminal(); // acabou? REFUNDED, CHARGEBACK, CANCELED, EXPIRED +$invoice->status->isTerminal(); // acabou? REFUNDED, CHARGEBACK, CANCELED match ($invoice->status) { InvoiceStatus::DISPUTED => $this->openDisputeTicket($invoice), @@ -226,9 +227,20 @@ match ($invoice->status) { ``` `PARTIALLY_PAID` responde verdadeiro a `isSettled()` e a `isOpen()` ao mesmo tempo: parte do -dinheiro entrou e o restante segue cobrável. Os helpers estáticos `Invoice::isSettled()` e -`Invoice::isContested()` continuam existindo, delegam ao enum e estão obsoletos (emitem -`E_USER_DEPRECATED`). +dinheiro entrou e o restante segue cobrável. `EXPIRED` responde verdadeiro só a `isPayable()`: +a fatura vencida continua pagável nos dois gateways (na Iugu ela segue devida até ser paga ou +cancelada, e na Stripe `uncollectible` pode voltar a `paid`), então ela fica fora de +`isTerminal()` e fora de `isOpen()`, que descreve a fatura com pagamento em curso. `PROCESSING` +responde só a `isOpen()`: há um pagamento em curso, e a fatura fica fora de `isPayable()`. Quem +decide se para de cobrar deve olhar `isTerminal()`; quem decide se oferece um novo Pix ou boleto +deve olhar `isPayable()`. +Os helpers estáticos `Invoice::isSettled()` e `Invoice::isContested()` continuam existindo, +delegam ao enum e estão obsoletos (emitem `E_USER_DEPRECATED`). + +> **Mudança de comportamento (versão 5.0.0).** `InvoiceStatus::EXPIRED->isTerminal()` passou a +> responder falso. Quem usava `isTerminal()` para parar de cobrar parava cedo demais na Iugu, +> onde a fatura vencida segue pagável. Se a aplicação tratava a fatura vencida como encerrada, +> trate `EXPIRED` explicitamente, ou use `isPayable()` para decidir se ainda cabe pagamento. > **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, fatura Iugu em `in_protest` lia como > `paid` e fatura em `chargeback` lia como `refunded`; no Stripe, charge contestado lia como @@ -243,20 +255,75 @@ dinheiro entrou e o restante segue cobrável. Os helpers estáticos `Invoice::is > `UNKNOWN` com log. Se a aplicação precisava do comportamento antigo, use `isSettled()` para > "pago", `isOpen()` para "ainda cobrável" e trate `DISPUTED` e `CHARGEBACK` explicitamente. -### Migração das constantes para enum +### Status da assinatura + +`Subscription::$status` é o enum `Potelo\MultiPayment\Enums\SubscriptionStatus`, no mesmo +desenho do status da fatura; as flags ou o status específico do gateway ficam em `original`. +Os nove estados: + +| `SubscriptionStatus` | Significado | Iugu | Stripe | Helper que responde | +|---|---|---|---|---| +| `PENDING` | Criada e ainda sem cobrança confirmada | `active` falso com `expires_at` futuro ou ausente | `incomplete` | `isRecoverable()` | +| `TRIALING` | Em período de teste | `in_trial` | `trialing` | `isActive()` | +| `ACTIVE` | Em dia | `active` | `active` | `isActive()` | +| `PAST_DUE` | Cobrança vencida sem pagamento | derivado: `expires_at` no passado com alguma fatura de `recent_invoices` em aberto | `past_due`, `unpaid` | `isRecoverable()` | +| `PAUSED` | Cobrança pausada pelo gateway | (não emite) | `paused`; qualquer status não encerrado com `pause_collection` preenchido | `isRecoverable()` | +| `SUSPENDED` | Cobrança interrompida pela aplicação | `suspended` | (não emite; `suspend()` usa `pause_collection`, que lê como `PAUSED`) | `isRecoverable()` | +| `CANCELED` | Encerrada | `suspended` com a marca `mp_canceled_at` em `custom_variables`, gravada por `cancel()` | `canceled` | `isEnded()` | +| `EXPIRED` | Ciclo terminou sem renovação | `active` falso com `expires_at` no passado e nenhuma fatura em aberto | `incomplete_expired` | `isEnded()` | +| `UNKNOWN` | Status que a lib não reconhece | (não emite: a Iugu não tem campo de status) | qualquer outro | nenhum responde verdadeiro | + +A precedência na Iugu é a ordem em que o driver testa as flags: `suspended` (com ou sem a +marca) vence `in_trial`, que vence a derivação de `past_due`, que vence `active`. A regra de +`EXPIRED` na Iugu segue o painel do gateway (assinatura "Expirada") e o webhook +`subscription.expired`. O driver Stripe ainda não lê +assinatura (planejado para uma versão futura); o mapa acima é o que ele vai aplicar. Um status +fora do mapa vira `UNKNOWN`, com um aviso no log da aplicação (nível `warning`) contendo o valor +original e o gateway. + +```php +use Potelo\MultiPayment\Enums\SubscriptionStatus; + +if ($subscription->status->isActive()) { // TRIALING, ACTIVE + $account->grantAccess(); +} elseif ($subscription->status->isRecoverable()) { // PENDING, PAST_DUE, PAUSED, SUSPENDED + $dunning->start($subscription); +} elseif ($subscription->status->isEnded()) { // CANCELED, EXPIRED + $account->revokeAccess(); +} + +$subscription->status === SubscriptionStatus::PAST_DUE; +$subscription->status->value; // 'past_due', para gravar no banco +``` -Status da fatura, método de pagamento e intervalo do plano são enums do namespace -`Potelo\MultiPayment\Enums`: `InvoiceStatus`, `PaymentMethod` (`CREDIT_CARD`, `BANK_SLIP`, -`PIX`, `AUTOMATIC_PIX`) e `PlanInterval` (`DAY`, `WEEK`, `MONTH`, `YEAR`). As propriedades -`Invoice::$status`, `Invoice::$paymentMethod`, `Invoice::$availablePaymentMethods`, -`Subscription::$paymentMethod`, `Subscription::$availablePaymentMethods` e `Plan::$interval` -devolvem o enum na leitura e aceitam, na escrita, tanto o caso do enum quanto a string do valor -(as constantes antigas). O mesmo vale para `fill()` e para os builders. +Cada estado responde verdadeiro a exatamente um dos três helpers (`UNKNOWN` a nenhum), então +os três `if` acima cobrem tudo que a lib produz. + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, `Subscription::$status` era uma +> string e `cancel()` na Iugu devolvia `suspended`, o mesmo estado de `suspend()`. Agora a +> propriedade devolve `SubscriptionStatus` (compará-la com `Subscription::STATUS_*` é sempre +> falso; use o caso do enum ou `->value`), e `cancel()` na Iugu devolve `CANCELED`: além de +> suspender, o driver grava a data em `custom_variables` (`mp_canceled_at`), numa segunda +> requisição, e é essa marca que distingue os dois estados na leitura. `resume()` remove a +> marca. Assinaturas canceladas antes desta versão continuam lendo como `SUSPENDED`, porque não +> têm a marca. + +### Migração das constantes para enum -As constantes antigas (`Invoice::STATUS_*`, `Invoice::PAYMENT_METHOD_*`, `Plan::INTERVAL_*`) -continuam existindo, com os mesmos valores de string dos enums, e estão marcadas como -`@deprecated`. O que muda é a **comparação**: a propriedade agora devolve um enum, então -compará-la diretamente com a string antiga é sempre falso. +Status da fatura, status da assinatura, método de pagamento e intervalo do plano são enums do +namespace `Potelo\MultiPayment\Enums`: `InvoiceStatus`, `SubscriptionStatus`, `PaymentMethod` +(`CREDIT_CARD`, `BANK_SLIP`, `PIX`, `AUTOMATIC_PIX`) e `PlanInterval` (`DAY`, `WEEK`, `MONTH`, +`YEAR`). As propriedades `Invoice::$status`, `Invoice::$paymentMethod`, +`Invoice::$availablePaymentMethods`, `Subscription::$status`, `Subscription::$paymentMethod`, +`Subscription::$availablePaymentMethods` e `Plan::$interval` devolvem o enum na leitura e +aceitam, na escrita, tanto o caso do enum quanto a string do valor (as constantes antigas). O +mesmo vale para `fill()` e para os builders. + +As constantes antigas (`Invoice::STATUS_*`, `Subscription::STATUS_*`, +`Invoice::PAYMENT_METHOD_*`, `Plan::INTERVAL_*`) continuam existindo, com os mesmos valores de +string dos enums, e estão marcadas como `@deprecated`. O que muda é a **comparação**: a +propriedade agora devolve um enum, então compará-la diretamente com a string antiga é sempre +falso. ```php use Potelo\MultiPayment\Enums\InvoiceStatus; @@ -286,7 +353,8 @@ Onde a string vai para fora do PHP (banco, JSON, log, comparação com valor vin use `->value`. `toArray()` já emite o valor de string, e `json_encode($invoice)` também. Valor de string fora do enum tem dois tratamentos: em `status`, vira `InvoiceStatus::UNKNOWN` -com aviso no log; em `paymentMethod`, `availablePaymentMethods` e `interval`, lança +ou `SubscriptionStatus::UNKNOWN` com aviso no log; em `paymentMethod`, +`availablePaymentMethods` e `interval`, lança `ModelAttributeValidationException` na escrita, com a lista de valores aceitos. Em `availablePaymentMethods` só entram `CREDIT_CARD`, `BANK_SLIP` e `PIX`; `AUTOMATIC_PIX` é recusado na validação, porque a fatura com Pix Automático é criada com `PIX` e o objeto @@ -422,9 +490,10 @@ cancela uma fatura `open`. A tabela completa: Duas ressalvas para quem trata status como definitivo: -- **`uncollectible` lê como `EXPIRED`, mas na Stripe não é terminal**: a fatura pode voltar a +- **`uncollectible` lê como `EXPIRED` e continua reversível na Stripe**: a fatura pode voltar a `paid` ou ir a `void` depois. Uma fatura `EXPIRED` de origem `INVOICE` pode, portanto, ler - como `PAID` numa releitura, embora `isTerminal()` responda verdadeiro para `EXPIRED`. + como `PAID` numa releitura; `isTerminal()` responde falso e `isPayable()` verdadeiro para + `EXPIRED` por isso. - **`open` com PaymentIntent `succeeded` fica em `UNKNOWN` de propósito**: a Stripe atualiza o Invoice no mesmo instante em que confirma o pagamento, então essa combinação é uma leitura no meio da transição ou um pagamento fora do padrão que não quitou a fatura. Se aparecer no @@ -914,7 +983,7 @@ $subscription = (new \Potelo\MultiPayment\MultiPayment('iugu')) ->setAvailablePaymentMethods(['pix']) ->create(); -echo $subscription->status; // na Iugu: trialing, active, suspended, pending ou past_due +$subscription->status; // SubscriptionStatus; na Iugu: TRIALING, ACTIVE, SUSPENDED, PENDING, PAST_DUE, CANCELED ou EXPIRED ``` Operações sobre a assinatura: @@ -922,7 +991,7 @@ Operações sobre a assinatura: ```php $subscription->suspend(); $subscription->resume(); -$subscription->cancel(); // na Iugu, cancelar é suspender +$subscription->cancel(); // CANCELED; na Iugu, suspende e grava a marca de cancelamento $subscription->changePlan('plano_anual'); // aplica a troca e gera cobrança imediata $subscription->changePlan('plano_anual', charge: false); $preview = $subscription->previewPlanChange('plano_anual'); // simula, não aplica @@ -940,9 +1009,20 @@ $planos = (new \Potelo\MultiPayment\MultiPayment('iugu'))->listPlans(); Particularidades da Iugu: -- **Cancelar é suspender.** `cancel(atPeriodEnd: true)` lança `UnsupportedOperationException` +- **Cancelar é suspender com uma marca.** A Iugu só suspende, então `cancel()` faz duas + requisições: `POST /suspend` e um `PUT` que grava `mp_canceled_at` (data e hora, ISO 8601) + em `custom_variables`. A assinatura lê como `CANCELED` enquanto estiver suspensa com a marca, + `canceledAt` é preenchido a partir dela, e ela também aparece em `metadata`. `resume()` de + uma assinatura cancelada reativa e remove a marca (`PUT` com `_destroy`), voltando a + `ACTIVE`. Chamar `cancel()` de novo numa assinatura já cancelada só repete a suspensão e + mantém a data original. Se a segunda requisição de `cancel()` ou de `resume()` falhar, a + exceção sobe com a assinatura no estado intermediário (suspensa sem marca, ou ativa com a + marca); repita a chamada, de preferência com a mesma chave de idempotência, que a Iugu aceita + `suspend` e `activate` repetidos. O prefixo `mp_` em `custom_variables` é reservado à lib: não + use chaves com esse prefixo em `metadata`; uma marca `mp_canceled_at` que não seja uma data lê + como ausente, com aviso no log. `cancel(atPeriodEnd: true)` lança `UnsupportedOperationException` (`CANCEL_AT_PERIOD_END`, `gateway_limitation`); para encerrar ao fim do período, suspenda na - data. + data (a emulação está planejada para uma versão futura). - **Desconto é sempre valor fixo.** `percentOff` e `cycles` maior que `1` lançam `UnsupportedOperationException` (`NATIVE_COUPONS`, `gateway_limitation`); `cycles` aceita `1` (uma fatura) ou `null` (até ser removido). @@ -975,13 +1055,13 @@ Particularidades da Iugu: subitens na mesma chamada, então o pacote lê a assinatura, envia a remoção sozinha e só depois a atualização — até três requisições. Entre a remoção e a atualização a assinatura fica sem os itens removidos, e se a segunda falhar eles não voltam sozinhos. -- **`paymentMethod`, `cancelAtPeriodEnd` e `canceledAt` não são mapeados** na Iugu, nas duas - direções. +- **`paymentMethod` e `cancelAtPeriodEnd` não são mapeados** na Iugu, nas duas direções. + `canceledAt` vem da marca `mp_canceled_at` gravada por `cancel()`. - **Reativar exige data de cobrança.** Assinatura criada sem `nextBillingAt` fica sem data no gateway e, por isso, não volta com `resume()`: a Iugu responde sem erro e sem mudar nada, e o - `status` devolvido segue `suspended`. + `status` devolvido segue `SUSPENDED`. - **`active` e `suspended` são flags independentes.** Assinatura suspensa pode continuar com - `active: true` na Iugu; o pacote dá precedência a `suspended` e reporta `suspended`. + `active: true` na Iugu; o pacote dá precedência a `suspended` e reporta `SUSPENDED`. - **A simulação de troca não traz linhas.** `previewPlanChange()` preenche só `amount` e `effectiveAt`; `items` fica `null` e o resto (`discount`, `cycles`, `old_plan`, `new_plan`) está em `original`. @@ -994,8 +1074,8 @@ Particularidades da Iugu: mudar preço ou intervalo, crie outro plano e troque as assinaturas com `changePlan()`. - **Fatura vencida lê como `EXPIRED` e continua sendo dívida.** A Iugu chama de `expired` a fatura que venceu sem pagamento; ela conta como fatura em aberto na derivação de `past_due` - da assinatura, embora `InvoiceStatus::EXPIRED->isOpen()` seja falso (na Iugu a fatura vencida - ainda pode ser paga; em outros gateways, vencida é terminal). + da assinatura, e `InvoiceStatus::EXPIRED->isPayable()` responde verdadeiro (`isOpen()` + responde falso, porque não há pagamento em curso). - **A assinatura lida traz o cliente resumido.** `Subscription::get()` preenche `customer` com id, nome e e-mail — documento, endereço e telefone não vêm da Iugu. Eles sobrevivem se o `customer` local já tiver o mesmo id; se o id for outro, ou o local não tiver id, o pacote diff --git a/src/Enums/InvoiceStatus.php b/src/Enums/InvoiceStatus.php index ec7b8a7..815cd6f 100644 --- a/src/Enums/InvoiceStatus.php +++ b/src/Enums/InvoiceStatus.php @@ -44,7 +44,7 @@ enum InvoiceStatus: string implements AcceptsUnknownValue /** Cancelada antes do pagamento. Terminal. */ case CANCELED = 'canceled'; - /** Venceu sem pagamento. Terminal. */ + /** Venceu sem pagamento. Continua pagável (ver `isPayable()`). */ case EXPIRED = 'expired'; /** @@ -83,21 +83,21 @@ public function isContested(): bool /** * Diz se a fatura chegou a um estado final, do qual o gateway não a tira: `REFUNDED`, - * `CHARGEBACK`, `CANCELED` e `EXPIRED`. + * `CHARGEBACK` e `CANCELED`. `EXPIRED` fica de fora (ver `isPayable()`). * * @return bool */ public function isTerminal(): bool { return match ($this) { - self::REFUNDED, self::CHARGEBACK, self::CANCELED, self::EXPIRED => true, + self::REFUNDED, self::CHARGEBACK, self::CANCELED => true, default => false, }; } /** - * Diz se a fatura ainda pode receber pagamento: `PENDING`, `AUTHORIZED`, `PROCESSING` e - * `PARTIALLY_PAID`. + * Diz se a fatura está em aberto, com o pagamento ainda por resolver: `PENDING`, + * `AUTHORIZED`, `PROCESSING` e `PARTIALLY_PAID`. * * @return bool */ @@ -109,6 +109,21 @@ public function isOpen(): bool }; } + /** + * Diz se a fatura aceita um pagamento agora: `PENDING`, `AUTHORIZED`, `PARTIALLY_PAID` e + * `EXPIRED` (a vencida segue pagável nos dois gateways). `PROCESSING` fica de fora: já há um + * pagamento em curso (ver `isOpen()`). + * + * @return bool + */ + public function isPayable(): bool + { + return match ($this) { + self::PENDING, self::AUTHORIZED, self::PARTIALLY_PAID, self::EXPIRED => true, + default => false, + }; + } + /** * Converte o valor de string no caso correspondente. Valor fora do enum devolve `UNKNOWN` * e registra um aviso no log com o valor original e o gateway. diff --git a/src/Enums/SubscriptionStatus.php b/src/Enums/SubscriptionStatus.php new file mode 100644 index 0000000..c54c7af --- /dev/null +++ b/src/Enums/SubscriptionStatus.php @@ -0,0 +1,113 @@ + true, + default => false, + }; + } + + /** + * Diz se a assinatura ainda pode voltar a `ACTIVE` sem ser recriada: `PENDING`, + * `PAST_DUE`, `PAUSED` e `SUSPENDED`. + * + * @return bool + */ + public function isRecoverable(): bool + { + return match ($this) { + self::PENDING, self::PAST_DUE, self::PAUSED, self::SUSPENDED => true, + default => false, + }; + } + + /** + * Diz se a assinatura terminou: `CANCELED` e `EXPIRED`. + * + * @return bool + */ + public function isEnded(): bool + { + return match ($this) { + self::CANCELED, self::EXPIRED => true, + default => false, + }; + } + + /** + * Converte o valor de string no caso correspondente. Valor fora do enum devolve `UNKNOWN` + * e registra um aviso no log com o valor original e o gateway. + * + * @param string $value + * @param string|null $gateway + * @return static + */ + public static function fromValue(string $value, ?string $gateway = null): static + { + return self::tryFrom($value) ?? self::unknown($value, $gateway); + } + + /** + * Devolve `UNKNOWN` e registra um aviso no log com o valor original e o gateway. + * + * @param string $value + * @param string|null $gateway + * @return static + */ + public static function unknown(string $value, ?string $gateway = null): static + { + LogHelper::warning( + "Status de assinatura desconhecido [{$value}] no gateway [" . ($gateway ?? 'desconhecido') . '], lido como unknown', + ['status' => $value, 'gateway' => $gateway] + ); + + return self::UNKNOWN; + } +} diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index 6220f4b..210b277 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -29,6 +29,7 @@ use Potelo\MultiPayment\Enums\InvoiceOriginType; use Potelo\MultiPayment\Enums\RefundStatus; use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Enums\SubscriptionStatus; use Potelo\MultiPayment\Enums\PlanInterval; use Potelo\MultiPayment\Enums\DeclineCode; use Potelo\MultiPayment\Helpers\LogHelper; @@ -72,6 +73,13 @@ class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract private const STATUS_CHARGEBACK = 'chargeback'; private const STATUS_AUTHORIZED = 'authorized'; + /** + * Variável de `custom_variables` da assinatura em que a lib grava a data do cancelamento. + * A Iugu só suspende, então é essa marca que distingue `CANCELED` de `SUSPENDED`. O + * prefixo `mp_` é reservado à lib. + */ + private const CANCELED_AT_VARIABLE = 'mp_canceled_at'; + /** Faixa de `interval` aceita pela Iugu na criação de plano. */ private const PLAN_INTERVAL_MIN = 1; private const PLAN_INTERVAL_MAX = 599; @@ -1889,14 +1897,9 @@ public function updateSubscription(Subscription $subscription, ?string $idempote */ public function suspendSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription { - if (empty($subscription->id)) { - throw ModelAttributeValidationException::required('Subscription', 'id'); - } - - $response = $this->iuguIdempotentRequest( - 'POST', - $this->subscriptionUrl($subscription->id) . '/suspend', - [], + $response = $this->iuguSubscriptionAction( + $subscription, + 'suspend', 'suspending subscription', $this->idempotencyKeyFor($idempotencyKey, $subscription) ); @@ -1907,27 +1910,43 @@ public function suspendSubscription(Subscription $subscription, ?string $idempot /** * @inheritDoc * - * A chave de idempotência passa pela `IdempotencyStore`. + * Reativa também uma assinatura cancelada por `cancelSubscription()`, que na Iugu é uma + * assinatura suspensa com a marca `mp_canceled_at`: a marca é removida de + * `custom_variables` numa segunda requisição (`PUT` com `_destroy`, chave derivada + * `{chave}:uncancel`), para a assinatura voltar a ler como `ACTIVE`. A chave de + * idempotência passa pela `IdempotencyStore`. */ public function resumeSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription { - if (empty($subscription->id)) { - throw ModelAttributeValidationException::required('Subscription', 'id'); + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $response = $this->iuguSubscriptionAction($subscription, 'activate', 'resuming subscription', $idempotencyKey); + $resumed = $this->parseIuguSubscription($response, $subscription); + + if (is_null($resumed->canceledAt)) { + return $resumed; } $response = $this->iuguIdempotentRequest( - 'POST', - $this->subscriptionUrl($subscription->id) . '/activate', - [], - 'resuming subscription', - $this->idempotencyKeyFor($idempotencyKey, $subscription) + 'PUT', + $this->subscriptionUrl($subscription->id), + ['custom_variables' => [['name' => self::CANCELED_AT_VARIABLE, '_destroy' => true]]], + 'clearing the subscription cancellation', + self::derivedIdempotencyKey($idempotencyKey, 'uncancel') ); - return $this->parseIuguSubscription($response, $subscription); + return $this->parseIuguSubscription($response, $resumed); } /** * @inheritDoc + * + * Na Iugu o cancelamento é uma suspensão com marca: a assinatura é suspensa e recebe a + * data do cancelamento em `custom_variables` (`mp_canceled_at`), numa segunda requisição + * (`PUT`, chave derivada `{chave}:cancel`); é essa marca que faz a leitura devolver + * `CANCELED`. Assinatura que já tem a marca é só suspensa de novo, e a data original fica. + * `resumeSubscription()` desfaz as duas coisas. A chave de idempotência passa pela + * `IdempotencyStore`. */ public function cancelSubscription( Subscription $subscription, @@ -1937,8 +1956,57 @@ public function cancelSubscription( if ($atPeriodEnd) { $this->assertSupports(Capability::CANCEL_AT_PERIOD_END, 'Suspenda a assinatura na data desejada.'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $response = $this->iuguSubscriptionAction($subscription, 'suspend', 'suspending subscription', $idempotencyKey); + $suspended = $this->parseIuguSubscription($response, $subscription); - return $this->suspendSubscription($subscription, $idempotencyKey); + if (!is_null($suspended->canceledAt)) { + return $suspended; + } + + $response = $this->iuguIdempotentRequest( + 'PUT', + $this->subscriptionUrl($subscription->id), + ['custom_variables' => [[ + 'name' => self::CANCELED_AT_VARIABLE, + 'value' => Carbon::now()->toIso8601String(), + ]]], + 'marking the subscription as canceled', + self::derivedIdempotencyKey($idempotencyKey, 'cancel') + ); + + return $this->parseIuguSubscription($response, $suspended); + } + + /** + * Faz o `POST` de uma ação da assinatura (`suspend`, `activate`) e devolve a resposta crua. + * + * @param Subscription $subscription + * @param string $action + * @param string $operation + * @param string|null $idempotencyKey chave já resolvida; passa pela `IdempotencyStore` + * + * @return object|array + * @throws ModelAttributeValidationException + */ + private function iuguSubscriptionAction( + Subscription $subscription, + string $action, + string $operation, + ?string $idempotencyKey + ): object|array { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + return $this->iuguIdempotentRequest( + 'POST', + $this->subscriptionUrl($subscription->id) . '/' . $action, + [], + $operation, + $idempotencyKey + ); } /** @@ -2441,15 +2509,10 @@ private function parseIuguSubscription($iuguSubscription, ?Subscription $subscri ); } - if (!empty($iuguSubscription->custom_variables)) { - $metadata = []; - foreach ((array) $iuguSubscription->custom_variables as $variable) { - $variable = (object) $variable; - if (isset($variable->name)) { - $metadata[$variable->name] = $variable->value ?? null; - } - } - $subscription->metadata = $metadata; + // lista vazia também conta: é o que a Iugu devolve depois de remover a última variável + if (isset($iuguSubscription->custom_variables)) { + $subscription->metadata = $this->iuguCustomVariables($iuguSubscription); + $subscription->canceledAt = $this->iuguCanceledAt($iuguSubscription); } $subscription->gateway = 'iugu'; @@ -2458,6 +2521,72 @@ private function parseIuguSubscription($iuguSubscription, ?Subscription $subscri return $subscription; } + /** + * Lê `custom_variables` da assinatura como um mapa nome para valor. + * + * @param object $iuguSubscription + * + * @return array + */ + private function iuguCustomVariables(object $iuguSubscription): array + { + $variables = []; + + foreach ((array) ($iuguSubscription->custom_variables ?? []) as $variable) { + $variable = (object) $variable; + if (isset($variable->name)) { + $variables[$variable->name] = $variable->value ?? null; + } + } + + return $variables; + } + + /** + * Data do cancelamento gravada pela lib em `custom_variables` (`mp_canceled_at`). Nulo + * quando a marca não existe ou não é uma data legível; neste último caso registra um aviso + * no log e a marca é tratada como ausente. + * + * @param object $iuguSubscription + * + * @return Carbon|null + */ + private function iuguCanceledAt(object $iuguSubscription): ?Carbon + { + $value = $this->iuguCustomVariable($iuguSubscription, self::CANCELED_AT_VARIABLE); + + if (is_null($value)) { + return null; + } + + try { + return new Carbon($value); + } catch (\Throwable) { + LogHelper::warning( + 'Marca de cancelamento [' . self::CANCELED_AT_VARIABLE . "] ilegível [{$value}] na assinatura [" + . ($iuguSubscription->id ?? '?') . '] da Iugu, tratada como ausente', + ['subscription' => $iuguSubscription->id ?? null, 'value' => $value, 'gateway' => 'iugu'] + ); + + return null; + } + } + + /** + * Valor de uma variável de `custom_variables` da assinatura; nulo quando ausente ou vazia. + * + * @param object $iuguSubscription + * @param string $name + * + * @return string|null + */ + private function iuguCustomVariable(object $iuguSubscription, string $name): ?string + { + $value = $this->iuguCustomVariables($iuguSubscription)[$name] ?? null; + + return is_scalar($value) && (string) $value !== '' ? (string) $value : null; + } + /** * Converte um subitem de valor não negativo da Iugu num item de assinatura. * @@ -2498,34 +2627,59 @@ private function parseIuguSubscriptionDiscount(object $iuguSubitem): Subscriptio /** * Converte as flags de estado da assinatura da Iugu no status do MultiPayment. * - * A Iugu não tem estado de inadimplência: assinatura com fatura vencida em aberto continua - * `active` com `expires_at` no passado. PAST_DUE é derivado dessa combinação. + * A Iugu descreve a assinatura por flags, sem campo de status; a regra, na ordem: + * `suspended` com a marca `mp_canceled_at` legível em `custom_variables` é `CANCELED`; + * `suspended` sem a marca é `SUSPENDED`; `in_trial` é `TRIALING`; `expires_at` no passado com alguma + * fatura de `recent_invoices` em aberto é `PAST_DUE` (a Iugu não tem inadimplência: a + * assinatura segue `active` com a data vencida); `active` é `ACTIVE`; sem `active`, + * `expires_at` no passado é `EXPIRED` (o ciclo terminou sem renovação e sem fatura a + * receber) e `expires_at` futuro ou ausente é `PENDING` (criada e ainda não ativada). + * Resposta sem a flag `active` devolve nulo e o status anterior do model é mantido. * * @param object $iuguSubscription * - * @return string|null + * @return SubscriptionStatus|null */ - private function iuguToMultiPaymentSubscriptionStatus(object $iuguSubscription): ?string + private function iuguToMultiPaymentSubscriptionStatus(object $iuguSubscription): ?SubscriptionStatus { if (!empty($iuguSubscription->suspended)) { - return Subscription::STATUS_SUSPENDED; + return is_null($this->iuguCanceledAt($iuguSubscription)) + ? SubscriptionStatus::SUSPENDED + : SubscriptionStatus::CANCELED; } if (!empty($iuguSubscription->in_trial)) { - return Subscription::STATUS_TRIALING; + return SubscriptionStatus::TRIALING; } if ($this->iuguSubscriptionIsPastDue($iuguSubscription)) { - return Subscription::STATUS_PAST_DUE; + return SubscriptionStatus::PAST_DUE; } - if (isset($iuguSubscription->active)) { - return $iuguSubscription->active - ? Subscription::STATUS_ACTIVE - : Subscription::STATUS_PENDING; + if (!isset($iuguSubscription->active)) { + return null; } - return null; + if ($iuguSubscription->active) { + return SubscriptionStatus::ACTIVE; + } + + return $this->iuguSubscriptionExpiresAtHasPassed($iuguSubscription) + ? SubscriptionStatus::EXPIRED + : SubscriptionStatus::PENDING; + } + + /** + * Diz se a data da próxima cobrança da assinatura já passou (fim do dia de `expires_at`). + * + * @param object $iuguSubscription + * + * @return bool + */ + private function iuguSubscriptionExpiresAtHasPassed(object $iuguSubscription): bool + { + return !empty($iuguSubscription->expires_at) + && (new Carbon($iuguSubscription->expires_at))->endOfDay()->isPast(); } /** @@ -2558,11 +2712,7 @@ private function iuguInvoiceIsOpen(object $iuguInvoice): bool */ private function iuguSubscriptionIsPastDue(object $iuguSubscription): bool { - if (empty($iuguSubscription->expires_at)) { - return false; - } - - if (!(new Carbon($iuguSubscription->expires_at))->endOfDay()->isPast()) { + if (!$this->iuguSubscriptionExpiresAtHasPassed($iuguSubscription)) { return false; } diff --git a/src/Gateways/Stripe/SubscriptionStatuses.php b/src/Gateways/Stripe/SubscriptionStatuses.php new file mode 100644 index 0000000..578b1e5 --- /dev/null +++ b/src/Gateways/Stripe/SubscriptionStatuses.php @@ -0,0 +1,55 @@ + */ + private const MAP = [ + 'incomplete' => SubscriptionStatus::PENDING, + 'incomplete_expired' => SubscriptionStatus::EXPIRED, + 'trialing' => SubscriptionStatus::TRIALING, + 'active' => SubscriptionStatus::ACTIVE, + 'past_due' => SubscriptionStatus::PAST_DUE, + 'unpaid' => SubscriptionStatus::PAST_DUE, + 'canceled' => SubscriptionStatus::CANCELED, + 'paused' => SubscriptionStatus::PAUSED, + ]; + + /** + * Status genérico de uma Subscription da Stripe, a partir de `status` e de + * `pause_collection`. `pause_collection` preenchido devolve `PAUSED` a menos que a + * assinatura já tenha terminado (`canceled`, `incomplete_expired`). Status fora do mapa + * devolve `UNKNOWN` com aviso no log. + * + * @param object $stripeSubscription `\Stripe\Subscription` ou objeto com os mesmos campos + * @return SubscriptionStatus + */ + public static function toSubscriptionStatus(object $stripeSubscription): SubscriptionStatus + { + $status = (string) ($stripeSubscription->status ?? ''); + $mapped = self::MAP[$status] ?? SubscriptionStatus::unknown($status, 'stripe'); + + if ( + $mapped !== SubscriptionStatus::UNKNOWN + && !$mapped->isEnded() + && !empty($stripeSubscription->pause_collection) + ) { + return SubscriptionStatus::PAUSED; + } + + return $mapped; + } +} diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index 175b997..8bb60d9 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Enums\SubscriptionStatus; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Contracts\SubscriptionContract; @@ -16,23 +17,38 @@ /** * Assinatura recorrente de um cliente a um plano. * - * As duas propriedades abaixo são enums: aceitam na escrita a string do valor ou o caso do + * As três propriedades abaixo são enums: aceitam na escrita a string do valor ou o caso do * enum e devolvem sempre o enum (ver `Model::ENUM_CASTS`). * + * @property SubscriptionStatus|null $status Status genérico; `UNKNOWN` para status que a lib não reconhece. * @property PaymentMethod|null $paymentMethod Método de pagamento da assinatura. * @property PaymentMethod[]|null $availablePaymentMethods Métodos aceitos pela assinatura. */ class Subscription extends Model { + /** @deprecated desde 2026-09-02, use `SubscriptionStatus::TRIALING`. */ public const STATUS_TRIALING = 'trialing'; + + /** @deprecated desde 2026-09-02, use `SubscriptionStatus::ACTIVE`. */ public const STATUS_ACTIVE = 'active'; + + /** @deprecated desde 2026-09-02, use `SubscriptionStatus::SUSPENDED`. */ public const STATUS_SUSPENDED = 'suspended'; + + /** @deprecated desde 2026-09-02, use `SubscriptionStatus::PENDING`. */ public const STATUS_PENDING = 'pending'; + + /** @deprecated desde 2026-09-02, use `SubscriptionStatus::PAST_DUE`. */ public const STATUS_PAST_DUE = 'past_due'; + + /** @deprecated desde 2026-09-02, use `SubscriptionStatus::EXPIRED`. */ public const STATUS_EXPIRED = 'expired'; + + /** @deprecated desde 2026-09-02, use `SubscriptionStatus::CANCELED`. */ public const STATUS_CANCELED = 'canceled'; protected const ENUM_CASTS = [ + 'status' => SubscriptionStatus::class, 'paymentMethod' => PaymentMethod::class, 'availablePaymentMethods' => [PaymentMethod::class], ]; @@ -68,9 +84,9 @@ public function requiredCapabilities(): array public ?string $id = null; /** - * @var string|null + * @var SubscriptionStatus|null */ - public ?string $status = null; + protected ?SubscriptionStatus $status = null; /** * @var Customer|null @@ -124,11 +140,17 @@ public function requiredCapabilities(): array public ?Carbon $nextBillingAt = null; /** + * Diz se há cancelamento agendado para o fim do período corrente. Preenchido na leitura + * por gateway que oferece o recurso; na Iugu fica nulo. + * * @var bool|null */ public ?bool $cancelAtPeriodEnd = null; /** + * Momento em que a assinatura foi cancelada. Na Iugu vem da marca `mp_canceled_at` que + * `cancel()` grava em `custom_variables`. + * * @var Carbon|null */ public ?Carbon $canceledAt = null; @@ -422,7 +444,8 @@ public function suspend(GatewayContract|string|null $gateway = null, ?string $id } /** - * Volta a cobrar uma assinatura suspensa. + * Volta a cobrar uma assinatura suspensa; na Iugu, também uma cancelada por `cancel()` (a + * marca de cancelamento é removida). * * @param GatewayContract|string|null $gateway * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica diff --git a/tests/Integration/SubscriptionTest.php b/tests/Integration/SubscriptionTest.php index 64cec75..cc7cb53 100644 --- a/tests/Integration/SubscriptionTest.php +++ b/tests/Integration/SubscriptionTest.php @@ -15,6 +15,7 @@ use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\PlanInterval; +use Potelo\MultiPayment\Enums\SubscriptionStatus; /** * Cobre o que só a sandbox prova: a serialização do SDK, os endpoints de plano e assinatura e @@ -177,7 +178,7 @@ public function testShouldRunTheSubscriptionLifecycle(): void $nextBillingAt->format('Y-m-d'), $subscription->nextBillingAt->format('Y-m-d') ); - $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); $lida = new Subscription(); $lida->id = $subscription->id; @@ -186,13 +187,29 @@ public function testShouldRunTheSubscriptionLifecycle(): void $this->assertSame($subscription->planId, $lida->planId); $suspensa = $lida->suspend(self::GATEWAY); - $this->assertSame(Subscription::STATUS_SUSPENDED, $suspensa->status); + $this->assertSame(SubscriptionStatus::SUSPENDED, $suspensa->status); $reativada = $suspensa->resume(self::GATEWAY); - $this->assertSame(Subscription::STATUS_ACTIVE, $reativada->status); + $this->assertSame(SubscriptionStatus::ACTIVE, $reativada->status); $cancelada = $reativada->cancel(false, self::GATEWAY); - $this->assertSame(Subscription::STATUS_SUSPENDED, $cancelada->status); + $this->assertSame(SubscriptionStatus::CANCELED, $cancelada->status); + $this->assertNotNull($cancelada->canceledAt); + $this->assertLessThan(5, abs(now()->diffInMinutes($cancelada->canceledAt))); + $this->assertSame( + $cancelada->canceledAt->toIso8601String(), + $cancelada->metadata['mp_canceled_at'] ?? null + ); + + $relida = new Subscription(); + $relida->id = $subscription->id; + $relida = $relida->get(self::GATEWAY); + $this->assertSame(SubscriptionStatus::CANCELED, $relida->status); + + $descancelada = $relida->resume(self::GATEWAY); + $this->assertSame(SubscriptionStatus::ACTIVE, $descancelada->status); + $this->assertNull($descancelada->canceledAt); + $this->assertArrayNotHasKey('mp_canceled_at', $descancelada->metadata ?? []); $doCliente = MultiPayment::setGateway(self::GATEWAY) ->listSubscriptions($subscription->customer->id); @@ -214,10 +231,10 @@ public function testShouldNotResumeASubscriptionWithoutABillingDate(): void $this->assertNull($subscription->nextBillingAt); $suspensa = $subscription->suspend(self::GATEWAY); - $this->assertSame(Subscription::STATUS_SUSPENDED, $suspensa->status); + $this->assertSame(SubscriptionStatus::SUSPENDED, $suspensa->status); $reativada = $suspensa->resume(self::GATEWAY); - $this->assertSame(Subscription::STATUS_SUSPENDED, $reativada->status); + $this->assertSame(SubscriptionStatus::SUSPENDED, $reativada->status); } /** @@ -315,7 +332,7 @@ public function testShouldChangePlanGeneratingTheCharge(): void $this->assertSame($planoNovo->identifier, $trocada->planId); $this->assertSame(30000, $trocada->amount); - $this->assertSame(Subscription::STATUS_ACTIVE, $trocada->status); + $this->assertSame(SubscriptionStatus::ACTIVE, $trocada->status); $this->assertNotNull($trocada->latestInvoice); $this->faturasCriadas[] = $trocada->latestInvoice->id; diff --git a/tests/Unit/Enums/InvoiceStatusTest.php b/tests/Unit/Enums/InvoiceStatusTest.php index 275a8f5..2fb55c5 100644 --- a/tests/Unit/Enums/InvoiceStatusTest.php +++ b/tests/Unit/Enums/InvoiceStatusTest.php @@ -39,27 +39,27 @@ public function testHasTheThirteenStatesOfTheNormalizedVocabulary(): void } /** - * Tabela verdade completa dos quatro helpers, um caso por linha. + * Tabela verdade completa dos cinco helpers, um caso por linha. * - * @return array + * @return array */ public static function helperTruthTableProvider(): array { - // [status, isSettled, isContested, isTerminal, isOpen] + // [status, isSettled, isContested, isTerminal, isOpen, isPayable] return [ - 'pending' => [InvoiceStatus::PENDING, false, false, false, true], - 'authorized' => [InvoiceStatus::AUTHORIZED, false, false, false, true], - 'processing' => [InvoiceStatus::PROCESSING, false, false, false, true], - 'paid' => [InvoiceStatus::PAID, true, false, false, false], - 'partially_paid' => [InvoiceStatus::PARTIALLY_PAID, true, false, false, true], - 'externally_paid' => [InvoiceStatus::EXTERNALLY_PAID, true, false, false, false], - 'partially_refunded' => [InvoiceStatus::PARTIALLY_REFUNDED, true, false, false, false], - 'refunded' => [InvoiceStatus::REFUNDED, false, false, true, false], - 'disputed' => [InvoiceStatus::DISPUTED, false, true, false, false], - 'chargeback' => [InvoiceStatus::CHARGEBACK, false, true, true, false], - 'canceled' => [InvoiceStatus::CANCELED, false, false, true, false], - 'expired' => [InvoiceStatus::EXPIRED, false, false, true, false], - 'unknown' => [InvoiceStatus::UNKNOWN, false, false, false, false], + 'pending' => [InvoiceStatus::PENDING, false, false, false, true, true], + 'authorized' => [InvoiceStatus::AUTHORIZED, false, false, false, true, true], + 'processing' => [InvoiceStatus::PROCESSING, false, false, false, true, false], + 'paid' => [InvoiceStatus::PAID, true, false, false, false, false], + 'partially_paid' => [InvoiceStatus::PARTIALLY_PAID, true, false, false, true, true], + 'externally_paid' => [InvoiceStatus::EXTERNALLY_PAID, true, false, false, false, false], + 'partially_refunded' => [InvoiceStatus::PARTIALLY_REFUNDED, true, false, false, false, false], + 'refunded' => [InvoiceStatus::REFUNDED, false, false, true, false, false], + 'disputed' => [InvoiceStatus::DISPUTED, false, true, false, false, false], + 'chargeback' => [InvoiceStatus::CHARGEBACK, false, true, true, false, false], + 'canceled' => [InvoiceStatus::CANCELED, false, false, true, false, false], + 'expired' => [InvoiceStatus::EXPIRED, false, false, false, false, true], + 'unknown' => [InvoiceStatus::UNKNOWN, false, false, false, false, false], ]; } @@ -69,12 +69,25 @@ public function testHelpersAnswerEachBusinessQuestion( bool $settled, bool $contested, bool $terminal, - bool $open + bool $open, + bool $payable ): void { $this->assertSame($settled, $status->isSettled(), 'isSettled'); $this->assertSame($contested, $status->isContested(), 'isContested'); $this->assertSame($terminal, $status->isTerminal(), 'isTerminal'); $this->assertSame($open, $status->isOpen(), 'isOpen'); + $this->assertSame($payable, $status->isPayable(), 'isPayable'); + $this->assertFalse($terminal && $payable, 'nenhum status é terminal e pagável ao mesmo tempo'); + } + + /** + * `EXPIRED` responde verdadeiro a `isPayable()` e falso a `isTerminal()` e a `isOpen()`. + */ + public function testExpiredIsPayableAndNotTerminal(): void + { + $this->assertFalse(InvoiceStatus::EXPIRED->isTerminal()); + $this->assertTrue(InvoiceStatus::EXPIRED->isPayable()); + $this->assertFalse(InvoiceStatus::EXPIRED->isOpen()); } public function testFromValueReturnsTheMatchingCaseWithoutLogging(): void diff --git a/tests/Unit/Enums/SubscriptionStatusTest.php b/tests/Unit/Enums/SubscriptionStatusTest.php new file mode 100644 index 0000000..36875d5 --- /dev/null +++ b/tests/Unit/Enums/SubscriptionStatusTest.php @@ -0,0 +1,139 @@ +assertSame([ + 'pending', + 'trialing', + 'active', + 'past_due', + 'paused', + 'suspended', + 'canceled', + 'expired', + 'unknown', + ], array_column(SubscriptionStatus::cases(), 'value')); + } + + /** + * Tabela verdade completa dos três helpers, um caso por linha. + * + * @return array + */ + public static function helperTruthTableProvider(): array + { + // [status, isActive, isRecoverable, isEnded] + return [ + 'pending' => [SubscriptionStatus::PENDING, false, true, false], + 'trialing' => [SubscriptionStatus::TRIALING, true, false, false], + 'active' => [SubscriptionStatus::ACTIVE, true, false, false], + 'past_due' => [SubscriptionStatus::PAST_DUE, false, true, false], + 'paused' => [SubscriptionStatus::PAUSED, false, true, false], + 'suspended' => [SubscriptionStatus::SUSPENDED, false, true, false], + 'canceled' => [SubscriptionStatus::CANCELED, false, false, true], + 'expired' => [SubscriptionStatus::EXPIRED, false, false, true], + 'unknown' => [SubscriptionStatus::UNKNOWN, false, false, false], + ]; + } + + #[DataProvider('helperTruthTableProvider')] + public function testHelpersAnswerEachBusinessQuestion( + SubscriptionStatus $status, + bool $active, + bool $recoverable, + bool $ended + ): void { + $this->assertSame($active, $status->isActive(), 'isActive'); + $this->assertSame($recoverable, $status->isRecoverable(), 'isRecoverable'); + $this->assertSame($ended, $status->isEnded(), 'isEnded'); + } + + /** + * Cada estado responde verdadeiro a exatamente um helper, exceto `UNKNOWN`, que não + * responde a nenhum. + */ + #[DataProvider('helperTruthTableProvider')] + public function testHelpersPartitionTheKnownStates( + SubscriptionStatus $status, + bool $active, + bool $recoverable, + bool $ended + ): void { + $expected = $status === SubscriptionStatus::UNKNOWN ? 0 : 1; + + $this->assertSame($expected, (int) $active + (int) $recoverable + (int) $ended); + } + + public function testTheOldConstantsKeepTheEnumValues(): void + { + $this->assertSame(SubscriptionStatus::TRIALING->value, Subscription::STATUS_TRIALING); + $this->assertSame(SubscriptionStatus::ACTIVE->value, Subscription::STATUS_ACTIVE); + $this->assertSame(SubscriptionStatus::SUSPENDED->value, Subscription::STATUS_SUSPENDED); + $this->assertSame(SubscriptionStatus::PENDING->value, Subscription::STATUS_PENDING); + $this->assertSame(SubscriptionStatus::PAST_DUE->value, Subscription::STATUS_PAST_DUE); + $this->assertSame(SubscriptionStatus::EXPIRED->value, Subscription::STATUS_EXPIRED); + $this->assertSame(SubscriptionStatus::CANCELED->value, Subscription::STATUS_CANCELED); + } + + public function testFromValueReturnsTheMatchingCaseWithoutLogging(): void + { + $logger = $this->bindLogger(); + + $this->assertSame(SubscriptionStatus::ACTIVE, SubscriptionStatus::fromValue('active', 'iugu')); + $this->assertSame(SubscriptionStatus::PAST_DUE, SubscriptionStatus::fromValue('past_due')); + $this->assertSame([], $logger->records); + } + + public function testFromValueTurnsAnUnknownStringIntoUnknownWithAWarning(): void + { + $logger = $this->bindLogger(); + + $status = SubscriptionStatus::fromValue('status_novo', 'stripe'); + + $this->assertSame(SubscriptionStatus::UNKNOWN, $status); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertStringContainsString('assinatura', $logger->records[0]['message']); + $this->assertStringContainsString('status_novo', $logger->records[0]['message']); + $this->assertStringContainsString('stripe', $logger->records[0]['message']); + $this->assertSame(['status' => 'status_novo', 'gateway' => 'stripe'], $logger->records[0]['context']); + } + + public function testUnknownLogsAGatewaylessValue(): void + { + $logger = $this->bindLogger(); + + $this->assertSame(SubscriptionStatus::UNKNOWN, SubscriptionStatus::unknown('x')); + $this->assertSame(['status' => 'x', 'gateway' => null], $logger->records[0]['context']); + $this->assertStringContainsString('desconhecido', $logger->records[0]['message']); + } + + private function bindLogger(): RecordingLogger + { + $app = new Container(); + $app->instance('log', $logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + + return $logger; + } +} diff --git a/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php index e7c243b..95dc71d 100644 --- a/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php +++ b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php @@ -261,11 +261,28 @@ function (IuguGateway $g, string $key) { [self::subscriptionResponse()], 'POST', '/subscriptions/sub_1/activate', ], - 'cancelSubscription (POST /suspend)' => [ + 'cancelSubscription (POST /suspend, mais o PUT da marca de cancelamento)' => [ fn (IuguGateway $g, string $key) => $g->cancelSubscription(self::subscriptionWithId(), false, $key), - [self::subscriptionResponse(['suspended' => true])], + [ + self::subscriptionResponse(['suspended' => true]), + self::subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-02T10:00:00-03:00']], + ]), + ], 'POST', '/subscriptions/sub_1/suspend', ], + 'cancelSubscription (PUT da marca de cancelamento, chave derivada)' => [ + fn (IuguGateway $g, string $key) => $g->cancelSubscription(self::subscriptionWithId(), false, $key), + [ + self::subscriptionResponse(['suspended' => true]), + self::subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-02T10:00:00-03:00']], + ]), + ], + 'PUT', '/subscriptions/sub_1', + ], 'changeSubscriptionPlan com cobrança (POST /change_plan, com a releitura repetida)' => [ fn (IuguGateway $g, string $key) => $g->changeSubscriptionPlan(self::subscriptionWithId(), 'plano_anual', true, $key), [(object) ['success' => true], self::subscriptionResponse(['plan_identifier' => 'plano_anual'])], diff --git a/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php b/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php index 1131dee..391edda 100644 --- a/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php +++ b/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php @@ -13,6 +13,7 @@ use Potelo\MultiPayment\Enums\InvoiceOriginType; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Tests\Unit\RecordingLogger; +use Potelo\MultiPayment\Enums\SubscriptionStatus; use PHPUnit\Framework\Attributes\DataProvider; class IuguGatewayInvoiceStatusTest extends TestCase @@ -231,7 +232,7 @@ public function testContestedInvoicesDoNotMakeTheSubscriptionPastDue(string $iug { $subscription = $this->readSubscriptionWithLatestInvoiceStatus($iuguStatus, '2026-08-01'); - $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); } private function mapStatus(string $iuguStatus): InvoiceStatus diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index e4c5571..707e31a 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -24,6 +24,7 @@ use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\PlanInterval; use Potelo\MultiPayment\Tests\Unit\RecordingLogger; +use Potelo\MultiPayment\Enums\SubscriptionStatus; class IuguGatewaySubscriptionTest extends TestCase { @@ -40,6 +41,7 @@ protected function setUp(): void protected function tearDown(): void { + Carbon::setTestNow(); Facade::clearResolvedInstances(); Facade::setFacadeApplication(null); @@ -179,9 +181,14 @@ public function testParseReadsNextBillingFromExpiresAt(): void $this->assertSame('iugu', $subscription->gateway); } + /** + * Derivação do status a partir das flags da Iugu, um caso por estado genérico que o driver + * produz, com a data de hoje fixada em 2026-09-15 (a resposta padrão vence em 2026-10-01). + */ #[DataProvider('statusProvider')] - public function testParseMapsIuguFlagsToGenericStatus(array $flags, ?string $expected): void + public function testParseMapsIuguFlagsToGenericStatus(array $flags, ?SubscriptionStatus $expected): void { + Carbon::setTestNow('2026-09-15 12:00:00'); $api = new QueuedIuguApiRequest([$this->subscriptionResponse($flags)]); $subscription = new Subscription(); @@ -193,19 +200,110 @@ public function testParseMapsIuguFlagsToGenericStatus(array $flags, ?string $exp public static function statusProvider(): array { + $canceledMark = [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-10T10:00:00-03:00']]; + $openInvoice = [(object) ['id' => 'inv_1', 'status' => 'pending', 'due_date' => '2026-09-01']]; + return [ - 'suspensa' => [['suspended' => true, 'active' => false], Subscription::STATUS_SUSPENDED], + 'suspensa' => [['suspended' => true, 'active' => false], SubscriptionStatus::SUSPENDED], 'suspensa tem precedencia sobre trial' => [ ['suspended' => true, 'in_trial' => true], - Subscription::STATUS_SUSPENDED, + SubscriptionStatus::SUSPENDED, + ], + 'suspensa com a marca de cancelamento' => [ + ['suspended' => true, 'active' => false, 'custom_variables' => $canceledMark], + SubscriptionStatus::CANCELED, + ], + 'ativa com a marca de cancelamento continua ativa' => [ + ['suspended' => false, 'active' => true, 'custom_variables' => $canceledMark], + SubscriptionStatus::ACTIVE, + ], + 'suspensa com a marca vazia' => [ + ['suspended' => true, 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '']]], + SubscriptionStatus::SUSPENDED, + ], + 'em trial' => [['in_trial' => true], SubscriptionStatus::TRIALING], + 'ativa' => [['active' => true], SubscriptionStatus::ACTIVE], + 'ativa com cobranca vencida e fatura em aberto' => [ + ['active' => true, 'expires_at' => '2026-09-01', 'recent_invoices' => $openInvoice], + SubscriptionStatus::PAST_DUE, + ], + 'ativa com cobranca vencida e sem fatura em aberto' => [ + ['active' => true, 'expires_at' => '2026-09-01', 'recent_invoices' => []], + SubscriptionStatus::ACTIVE, + ], + 'inativa com cobranca futura' => [['active' => false], SubscriptionStatus::PENDING], + 'inativa sem data de cobranca' => [['active' => false, 'expires_at' => null], SubscriptionStatus::PENDING], + 'inativa com cobranca vencida no dia' => [ + ['active' => false, 'expires_at' => '2026-09-15'], + SubscriptionStatus::PENDING, + ], + 'inativa com cobranca vencida e sem fatura em aberto' => [ + ['active' => false, 'expires_at' => '2026-09-01', 'recent_invoices' => []], + SubscriptionStatus::EXPIRED, + ], + 'inativa com cobranca vencida e fatura em aberto' => [ + ['active' => false, 'expires_at' => '2026-09-01', 'recent_invoices' => $openInvoice], + SubscriptionStatus::PAST_DUE, ], - 'em trial' => [['in_trial' => true], Subscription::STATUS_TRIALING], - 'ativa' => [['active' => true], Subscription::STATUS_ACTIVE], - 'inativa' => [['active' => false], Subscription::STATUS_PENDING], 'sem flag nenhuma' => [['active' => null, 'suspended' => null, 'in_trial' => null], null], ]; } + public function testParseReadsTheCancellationMarkIntoCanceledAtAndKeepsItInMetadata(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [ + (object) ['name' => 'origem', 'value' => 'teste'], + (object) ['name' => 'mp_canceled_at', 'value' => '2026-09-10T10:00:00-03:00'], + ], + ])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::CANCELED, $subscription->status); + $this->assertSame('2026-09-10T10:00:00-03:00', $subscription->canceledAt->toIso8601String()); + $this->assertSame(['origem' => 'teste', 'mp_canceled_at' => '2026-09-10T10:00:00-03:00'], $subscription->metadata); + $this->assertNull($subscription->cancelAtPeriodEnd); + } + + /** + * `custom_variables` vazia na resposta zera `canceledAt` e `metadata`: é o que a Iugu + * devolve depois de remover a última variável. + */ + public function testParseClearsCanceledAtAndMetadataWhenTheResponseHasNoVariablesLeft(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['custom_variables' => []])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->canceledAt = Carbon::parse('2026-09-10T10:00:00-03:00'); + $subscription->metadata = ['mp_canceled_at' => '2026-09-10T10:00:00-03:00']; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertNull($subscription->canceledAt); + $this->assertSame([], $subscription->metadata); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + } + + public function testParseKeepsCanceledAtAndMetadataWhenTheResponseOmitsCustomVariables(): void + { + $response = $this->subscriptionResponse(['suspended' => true]); + unset($response->custom_variables); + $api = new QueuedIuguApiRequest([$response]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->canceledAt = Carbon::parse('2026-09-10T10:00:00-03:00'); + $subscription->metadata = ['origem' => 'teste']; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('2026-09-10T10:00:00-03:00', $subscription->canceledAt->toIso8601String()); + $this->assertSame(['origem' => 'teste'], $subscription->metadata); + } + public function testParseFillsTrialEndsAtWhileInTrial(): void { $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['in_trial' => true])]); @@ -308,23 +406,239 @@ public function testSuspendAndResumeHitTheirOwnEndpoints(): void $suspended = $gateway->suspendSubscription($subscription); $this->assertStringEndsWith('/subscriptions/sub_1/suspend', $api->calls[0]['url']); - $this->assertSame(Subscription::STATUS_SUSPENDED, $suspended->status); + $this->assertSame(SubscriptionStatus::SUSPENDED, $suspended->status); $resumed = $gateway->resumeSubscription($subscription); $this->assertStringEndsWith('/subscriptions/sub_1/activate', $api->calls[1]['url']); - $this->assertSame(Subscription::STATUS_ACTIVE, $resumed->status); + $this->assertSame(SubscriptionStatus::ACTIVE, $resumed->status); } - public function testCancelWithoutPeriodEndSuspendsTheSubscription(): void + /** + * Cancelar na Iugu é suspender e gravar `mp_canceled_at` em `custom_variables`, nesta + * ordem; a leitura da resposta do `PUT` devolve `CANCELED` com `canceledAt` preenchido. + */ + public function testCancelWithoutPeriodEndSuspendsAndMarksTheSubscriptionAsCanceled(): void { - $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['suspended' => true])]); + Carbon::setTestNow(Carbon::parse('2026-09-02T10:00:00-03:00')); + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['suspended' => true]), + $this->subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-02T10:00:00-03:00']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $canceled = (new IuguGateway($api))->cancelSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1/suspend', $api->calls[0]['url']); + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[1]['url']); + $this->assertSame( + ['custom_variables' => [['name' => 'mp_canceled_at', 'value' => Carbon::now()->toIso8601String()]]], + $api->calls[1]['data'], + 'a marca de cancelamento leva o instante da chamada em ISO 8601' + ); + + $this->assertSame($subscription, $canceled); + $this->assertSame(SubscriptionStatus::CANCELED, $canceled->status); + $this->assertSame('2026-09-02T10:00:00-03:00', $canceled->canceledAt->toIso8601String()); + } + + /** + * Segundo `cancel()` numa assinatura já marcada só repete a suspensão: a data original da + * marca fica, e o `PUT` não sai. + */ + public function testCancelOfAnAlreadyCanceledSubscriptionKeepsTheOriginalDate(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-01T10:00:00-03:00']], + ]), + ]); $subscription = new Subscription(); $subscription->id = 'sub_1'; - (new IuguGateway($api))->cancelSubscription($subscription); + $canceled = (new IuguGateway($api))->cancelSubscription($subscription); + $this->assertCount(1, $api->calls); $this->assertStringEndsWith('/subscriptions/sub_1/suspend', $api->calls[0]['url']); + $this->assertSame(SubscriptionStatus::CANCELED, $canceled->status); + $this->assertSame('2026-09-01T10:00:00-03:00', $canceled->canceledAt->toIso8601String()); + } + + /** + * Falha no `PUT` da marca deixa o model com a resposta da suspensão: `SUSPENDED`, sem + * `canceledAt`, e a exceção sobe. + */ + public function testCancelLeavesTheSubscriptionSuspendedWhenTheMarkFails(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['suspended' => true]), + new \IuguObjectNotFound('not found'), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + try { + (new IuguGateway($api))->cancelSubscription($subscription); + $this->fail('esperava NotFoundException'); + } catch (\Potelo\MultiPayment\Exceptions\NotFoundException) { + } + + $this->assertCount(2, $api->calls); + $this->assertSame(SubscriptionStatus::SUSPENDED, $subscription->status); + $this->assertNull($subscription->canceledAt); + } + + /** + * Marca `mp_canceled_at` que não é uma data lê como ausente: a assinatura suspensa fica + * `SUSPENDED`, `canceledAt` nulo, e um aviso vai para o log. + */ + public function testAnUnreadableCancellationMarkIsIgnoredWithAWarning(): void + { + $app = \Illuminate\Support\Facades\Facade::getFacadeApplication(); + $app->instance('log', $logger = new RecordingLogger()); + + $api = new QueuedIuguApiRequest([$this->subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => 'sim']], + ])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::SUSPENDED, $subscription->status); + $this->assertNull($subscription->canceledAt); + $this->assertSame(['mp_canceled_at' => 'sim'], $subscription->metadata); + $this->assertCount(2, $logger->records, 'um aviso por leitura da marca (status e canceledAt)'); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertStringContainsString('mp_canceled_at', $logger->records[0]['message']); + $this->assertSame(['subscription' => 'sub_1', 'value' => 'sim', 'gateway' => 'iugu'], $logger->records[0]['context']); + } + + public function testCancelDoesNotMarkTheSubscriptionWhenTheSuspensionFails(): void + { + $api = new QueuedIuguApiRequest([new \IuguObjectNotFound('not found')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + try { + (new IuguGateway($api))->cancelSubscription($subscription); + $this->fail('esperava NotFoundException'); + } catch (\Potelo\MultiPayment\Exceptions\NotFoundException) { + } + + $this->assertCount(1, $api->calls); + $this->assertNull($subscription->canceledAt); + } + + /** + * Reativar uma assinatura cancelada remove a marca `mp_canceled_at` (`PUT` com `_destroy`) + * depois do `activate`, e o status volta a `ACTIVE`. + */ + public function testResumeClearsTheCancellationMark(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-02T10:00:00-03:00']], + ]), + $this->subscriptionResponse(['custom_variables' => []]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $resumed = (new IuguGateway($api))->resumeSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertStringEndsWith('/subscriptions/sub_1/activate', $api->calls[0]['url']); + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[1]['url']); + $this->assertSame( + ['custom_variables' => [['name' => 'mp_canceled_at', '_destroy' => true]]], + $api->calls[1]['data'] + ); + $this->assertSame(SubscriptionStatus::ACTIVE, $resumed->status); + $this->assertNull($resumed->canceledAt); + } + + /** + * Com chave, o `PUT` que remove a marca recebe `{chave}:uncancel`, e o retry inteiro sai + * da store sem nova requisição. + */ + public function testResumeStoresTheActivationAndTheUnmarkUnderTheirOwnKeys(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-02T10:00:00-03:00']], + ]), + $this->subscriptionResponse(['custom_variables' => []]), + ]); + $store = new \Potelo\MultiPayment\Idempotency\InMemoryIdempotencyStore(); + $gateway = new IuguGateway($api, $store); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $gateway->resumeSubscription($subscription, 'chave-1'); + $this->assertTrue($store->has('iugu:chave-1')); + $this->assertTrue($store->has('iugu:chave-1:uncancel')); + $this->assertSame([], $api->calls[1]['headers']); + + $again = new Subscription(); + $again->id = 'sub_1'; + $retried = $gateway->resumeSubscription($again, 'chave-1'); + + $this->assertCount(2, $api->calls); + $this->assertSame(SubscriptionStatus::ACTIVE, $retried->status); + $this->assertNull($retried->canceledAt); + } + + /** + * Quando a resposta do `activate` não traz `custom_variables`, a marca conhecida pelo + * model decide o `PUT` de remoção. + */ + public function testResumeUsesTheMarkKnownByTheModelWhenTheResponseOmitsCustomVariables(): void + { + $activate = $this->subscriptionResponse(); + unset($activate->custom_variables); + $api = new QueuedIuguApiRequest([$activate, $this->subscriptionResponse(['custom_variables' => []])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->canceledAt = Carbon::parse('2026-09-02T10:00:00-03:00'); + + $resumed = (new IuguGateway($api))->resumeSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertNull($resumed->canceledAt); + } + + public function testResumeOfASuspendedSubscriptionDoesNotTouchCustomVariables(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['custom_variables' => [ + (object) ['name' => 'origem', 'value' => 'teste'], + ]])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $resumed = (new IuguGateway($api))->resumeSubscription($subscription); + + $this->assertCount(1, $api->calls); + $this->assertSame(SubscriptionStatus::ACTIVE, $resumed->status); + $this->assertSame(['origem' => 'teste'], $resumed->metadata); } public function testCancelAtPeriodEndIsRejected(): void @@ -817,7 +1131,7 @@ public function testDerivesPastDueFromOverdueDateAndUnpaidInvoice(): void $subscription->id = 'sub_1'; $subscription = (new IuguGateway($api))->getSubscription($subscription); - $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); $this->assertInstanceOf(Invoice::class, $subscription->latestInvoice); $this->assertSame('inv_1', $subscription->latestInvoice->id); $this->assertSame('https://iugu/inv_1', $subscription->latestInvoice->url); @@ -858,7 +1172,7 @@ public function testExpiredInvoiceAlsoCountsAsPastDue(): void $subscription->id = 'sub_1'; $subscription = (new IuguGateway($api))->getSubscription($subscription); - $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); $this->assertSame(InvoiceStatus::EXPIRED, $subscription->latestInvoice->status); } @@ -875,7 +1189,7 @@ public function testPaidInvoiceWithOverdueDateIsNotPastDue(): void $subscription->id = 'sub_1'; $this->assertSame( - Subscription::STATUS_ACTIVE, + SubscriptionStatus::ACTIVE, (new IuguGateway($api))->getSubscription($subscription)->status ); } @@ -893,7 +1207,7 @@ public function testUnpaidInvoiceWithFutureBillingDateIsNotPastDue(): void $subscription->id = 'sub_1'; $this->assertSame( - Subscription::STATUS_ACTIVE, + SubscriptionStatus::ACTIVE, (new IuguGateway($api))->getSubscription($subscription)->status ); } @@ -912,7 +1226,7 @@ public function testSuspendedTakesPrecedenceOverPastDue(): void $subscription->id = 'sub_1'; $this->assertSame( - Subscription::STATUS_SUSPENDED, + SubscriptionStatus::SUSPENDED, (new IuguGateway($api))->getSubscription($subscription)->status ); } @@ -934,7 +1248,7 @@ public function testUnknownInvoiceStatusDoesNotBreakTheSubscriptionRead(): void $subscription->id = 'sub_1'; $subscription = (new IuguGateway($api))->getSubscription($subscription); - $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); $this->assertSame('inv_1', $subscription->latestInvoice->id); $this->assertSame(InvoiceStatus::UNKNOWN, $subscription->latestInvoice->status); $this->assertSame('status_novo_da_iugu', $subscription->latestInvoice->original->status); @@ -1275,17 +1589,35 @@ public static function invalidPaginationProvider(): array ]; } - public function testCancelReturnsTheSuspendedSubscription(): void + /** + * Com chave de idempotência, o `PUT` da marca de cancelamento recebe a chave derivada + * `{chave}:cancel`, e o retry inteiro sai da store sem nova requisição. + */ + public function testCancelStoresTheSuspensionAndTheMarkUnderTheirOwnKeys(): void { - $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['suspended' => true])]); + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['suspended' => true]), + $this->subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-02T10:00:00-03:00']], + ]), + ]); + $store = new \Potelo\MultiPayment\Idempotency\InMemoryIdempotencyStore(); + $gateway = new IuguGateway($api, $store); $subscription = new Subscription(); $subscription->id = 'sub_1'; - $canceled = (new IuguGateway($api))->cancelSubscription($subscription); + $gateway->cancelSubscription($subscription, false, 'chave-1'); + $this->assertTrue($store->has('iugu:chave-1')); + $this->assertTrue($store->has('iugu:chave-1:cancel')); - $this->assertCount(1, $api->calls); - $this->assertSame(Subscription::STATUS_SUSPENDED, $canceled->status); + $again = new Subscription(); + $again->id = 'sub_1'; + $retried = $gateway->cancelSubscription($again, false, 'chave-1'); + + $this->assertCount(2, $api->calls); + $this->assertSame(SubscriptionStatus::CANCELED, $retried->status); } public function testListSubscriptionsAcceptsAPlainArrayResponse(): void @@ -1516,11 +1848,11 @@ public function testStatusKeepsThePreviousValueWhenTheResponseHasNoFlags(): void $subscription = new Subscription(); $subscription->id = 'sub_1'; $subscription = $gateway->getSubscription($subscription); - $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); $subscription = $gateway->updateSubscription($subscription); - $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); $this->assertSame('inv_1', $subscription->latestInvoice->id); } @@ -1555,7 +1887,7 @@ public function testLatestInvoiceDoesNotDependOnTheOrderIuguReturns(array $recen $subscription = (new IuguGateway($api))->getSubscription($subscription); $this->assertSame('inv_recente', $subscription->latestInvoice->id); - $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); } public static function ordemProvider(): array @@ -1587,7 +1919,7 @@ public function testSameDueDateIsBrokenByTheSmallestId(array $recentInvoices): v $subscription = (new IuguGateway($api))->getSubscription($subscription); $this->assertSame('inv_a_paga', $subscription->latestInvoice->id); - $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); } public static function tieProvider(): array @@ -1722,7 +2054,7 @@ public function testRecentInvoiceWithoutIdIsNotSelectableButStillCountsAsDebt(): $subscription = (new IuguGateway($api))->getSubscription($subscription); $this->assertNull($subscription->latestInvoice); - $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); } /** @@ -1742,7 +2074,7 @@ public function testSubscriptionDueTodayWithAnOpenInvoiceIsNotPastDueYet(): void $subscription->id = 'sub_1'; $this->assertSame( - Subscription::STATUS_ACTIVE, + SubscriptionStatus::ACTIVE, (new IuguGateway($api))->getSubscription($subscription)->status ); } @@ -1763,7 +2095,7 @@ public function testCanceledInvoiceDoesNotMakeTheSubscriptionPastDue(): void $subscription->id = 'sub_1'; $this->assertSame( - Subscription::STATUS_ACTIVE, + SubscriptionStatus::ACTIVE, (new IuguGateway($api))->getSubscription($subscription)->status ); } @@ -1807,7 +2139,7 @@ public function testPastDueLooksAtEveryInvoiceNotOnlyTheChosenOne(): void $subscription = (new IuguGateway($api))->getSubscription($subscription); $this->assertSame('inv_cancelada', $subscription->latestInvoice->id); - $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); } /** @@ -1830,7 +2162,7 @@ public function testPastDueCanPointAtAnInvoiceThatIsAlreadyPaid(): void $subscription->id = 'sub_1'; $subscription = (new IuguGateway($api))->getSubscription($subscription); - $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); $this->assertSame('inv_paga', $subscription->latestInvoice->id); $this->assertSame(InvoiceStatus::PAID, $subscription->latestInvoice->status); } @@ -1877,7 +2209,7 @@ public function testPartiallyPaidInvoiceCountsAsOpen(): void $subscription = (new IuguGateway($api))->getSubscription($subscription); $this->assertSame(InvoiceStatus::PARTIALLY_PAID, $subscription->latestInvoice->status); - $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); } /** @@ -1900,7 +2232,7 @@ public function testRecentInvoiceWithoutIdDoesNotHijackTheChoice(): void $subscription = (new IuguGateway($api))->getSubscription($subscription); $this->assertSame('inv_1', $subscription->latestInvoice->id); - $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); } /** * Entrada sem vencimento perde para qualquer uma com data, em qualquer ordem. @@ -1945,7 +2277,7 @@ public function testEntryWithoutStatusIsNotOpen(): void $subscription->id = 'sub_1'; $subscription = (new IuguGateway($api))->getSubscription($subscription); - $this->assertSame(Subscription::STATUS_ACTIVE, $subscription->status); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); $this->assertNull($subscription->latestInvoice->status); } @@ -1965,7 +2297,7 @@ public function testRecentInvoicesFromTheGatewayAreAcceptedAsAssociativeArrays() $subscription = (new IuguGateway($api))->getSubscription($subscription); $this->assertSame('inv_1', $subscription->latestInvoice->id); - $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); } /** diff --git a/tests/Unit/Gateways/Stripe/SubscriptionStatusesTest.php b/tests/Unit/Gateways/Stripe/SubscriptionStatusesTest.php new file mode 100644 index 0000000..2c90df8 --- /dev/null +++ b/tests/Unit/Gateways/Stripe/SubscriptionStatusesTest.php @@ -0,0 +1,122 @@ +instance('log', $this->logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + #[DataProvider('fixtureProvider')] + public function testEachStripeStatusIsTranslatedFromItsFixture(string $fixture, SubscriptionStatus $expected): void + { + $subscription = \Stripe\Subscription::constructFrom(self::fixture($fixture)); + + $this->assertSame($expected, SubscriptionStatuses::toSubscriptionStatus($subscription)); + $this->assertSame([], $this->logger->records); + } + + public static function fixtureProvider(): array + { + return [ + 'incomplete' => ['incomplete', SubscriptionStatus::PENDING], + 'incomplete_expired' => ['incomplete_expired', SubscriptionStatus::EXPIRED], + 'trialing' => ['trialing', SubscriptionStatus::TRIALING], + 'active' => ['active', SubscriptionStatus::ACTIVE], + 'past_due' => ['past_due', SubscriptionStatus::PAST_DUE], + 'unpaid' => ['unpaid', SubscriptionStatus::PAST_DUE], + 'canceled' => ['canceled', SubscriptionStatus::CANCELED], + 'paused' => ['paused', SubscriptionStatus::PAUSED], + 'active com pause_collection' => ['active_pause_collection', SubscriptionStatus::PAUSED], + ]; + } + + /** + * Toda string da máquina de estados documentada tem uma linha no mapa. + */ + public function testEveryDocumentedStripeStatusIsMapped(): void + { + foreach (['incomplete', 'incomplete_expired', 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'paused'] as $status) { + $this->assertNotSame( + SubscriptionStatus::UNKNOWN, + SubscriptionStatuses::toSubscriptionStatus((object) ['status' => $status]), + $status + ); + } + + $this->assertSame([], $this->logger->records); + } + + public function testPauseCollectionDoesNotReviveAnEndedSubscription(): void + { + $paused = ['behavior' => 'void', 'resumes_at' => null]; + + $this->assertSame( + SubscriptionStatus::CANCELED, + SubscriptionStatuses::toSubscriptionStatus((object) ['status' => 'canceled', 'pause_collection' => $paused]) + ); + $this->assertSame( + SubscriptionStatus::EXPIRED, + SubscriptionStatuses::toSubscriptionStatus((object) ['status' => 'incomplete_expired', 'pause_collection' => $paused]) + ); + $this->assertSame( + SubscriptionStatus::PAUSED, + SubscriptionStatuses::toSubscriptionStatus((object) ['status' => 'past_due', 'pause_collection' => $paused]) + ); + } + + public function testAnUnknownStatusReadsAsUnknownWithAWarningNamingStripe(): void + { + $status = SubscriptionStatuses::toSubscriptionStatus((object) [ + 'status' => 'status_novo', + 'pause_collection' => ['behavior' => 'void'], + ]); + + $this->assertSame(SubscriptionStatus::UNKNOWN, $status); + $this->assertCount(1, $this->logger->records); + $this->assertSame('warning', $this->logger->records[0]['level']); + $this->assertSame(['status' => 'status_novo', 'gateway' => 'stripe'], $this->logger->records[0]['context']); + } + + public function testAMissingStatusReadsAsUnknownWithAWarning(): void + { + $this->assertSame(SubscriptionStatus::UNKNOWN, SubscriptionStatuses::toSubscriptionStatus((object) [])); + $this->assertSame(['status' => '', 'gateway' => 'stripe'], $this->logger->records[0]['context']); + } + + private static function fixture(string $name): array + { + return json_decode( + file_get_contents(__DIR__ . '/../../../fixtures/stripe/subscriptions/' . $name . '.json'), + true + ); + } +} diff --git a/tests/Unit/ModelEnumCastTest.php b/tests/Unit/ModelEnumCastTest.php index 8b3b431..8ee6607 100644 --- a/tests/Unit/ModelEnumCastTest.php +++ b/tests/Unit/ModelEnumCastTest.php @@ -11,6 +11,7 @@ use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\PlanInterval; +use Potelo\MultiPayment\Enums\SubscriptionStatus; use Potelo\MultiPayment\Builders\InvoiceBuilder; use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -76,6 +77,43 @@ public function testUnknownStatusStringBecomesUnknownWithAWarningNamingTheGatewa $this->assertSame(['status' => 'status_inventado', 'gateway' => 'iugu'], $this->logger->records[0]['context']); } + public function testSubscriptionStatusAcceptsTheOldStringAndReadsAsTheEnum(): void + { + $subscription = new Subscription(); + $subscription->status = Subscription::STATUS_PAST_DUE; + + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); + $this->assertTrue($subscription->status->isRecoverable()); + $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status->value); + $this->assertSame('past_due', $subscription->toArray()['status']); + $this->assertSame('past_due', json_decode(json_encode($subscription), true)['status']); + $this->assertTrue(isset($subscription->status)); + $this->assertContains('status', Subscription::fillableKeys()); + + $subscription->fill(['status' => 'trialing']); + $this->assertSame(SubscriptionStatus::TRIALING, $subscription->status); + + $subscription->status = SubscriptionStatus::CANCELED; + $this->assertSame(SubscriptionStatus::CANCELED, $subscription->status); + + $subscription->status = null; + $this->assertNull($subscription->status); + $this->assertFalse(isset($subscription->status)); + } + + public function testUnknownSubscriptionStatusStringBecomesUnknownWithAWarningNamingTheGateway(): void + { + $subscription = new Subscription(); + $subscription->gateway = 'stripe'; + $subscription->fill(['status' => 'status_inventado']); + + $this->assertSame(SubscriptionStatus::UNKNOWN, $subscription->status); + $this->assertCount(1, $this->logger->records); + $this->assertSame('warning', $this->logger->records[0]['level']); + $this->assertStringContainsString('assinatura', $this->logger->records[0]['message']); + $this->assertSame(['status' => 'status_inventado', 'gateway' => 'stripe'], $this->logger->records[0]['context']); + } + public function testFillConvertsStatusPaymentMethodAndAvailablePaymentMethods(): void { $invoice = new Invoice(); diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php index e696c0b..a60899e 100644 --- a/tests/Unit/SubscriptionTest.php +++ b/tests/Unit/SubscriptionTest.php @@ -24,6 +24,7 @@ use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\PlanInterval; +use Potelo\MultiPayment\Enums\SubscriptionStatus; class SubscriptionTest extends TestCase { @@ -103,9 +104,10 @@ public function testFillWithoutItemsDoesNotClearTheExistingOnes(): void $subscription = new Subscription(); $subscription->items = [new SubscriptionItem()]; - $subscription->fill(['status' => Subscription::STATUS_ACTIVE]); + $subscription->fill(['status' => SubscriptionStatus::ACTIVE]); $this->assertCount(1, $subscription->items); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); } public function testToArrayFlattensItemsDiscountsAndCustomer(): void diff --git a/tests/fixtures/stripe/README.md b/tests/fixtures/stripe/README.md index 3af1800..50c4f7a 100644 --- a/tests/fixtures/stripe/README.md +++ b/tests/fixtures/stripe/README.md @@ -42,3 +42,14 @@ Montadas sobre `open_requires_payment_method.json`, porque a sandbox não produz `needs_response.json` é o GET de `/v1/disputes?charge=` do charge disputado; `lost.json` é o mesmo com o status trocado. + +## `subscriptions/` + +Montadas a partir do objeto Subscription documentado para a API `2026-07-29.dahlia` (a +sessão de sandbox não criou assinaturas): `active.json` é a base, com um item de preço +recorrente mensal, e as demais trocam `status` e os campos que acompanham cada estado +(`trial_start`/`trial_end` em `trialing` e `paused`, `canceled_at`/`ended_at` em `canceled`, +`ended_at` em `incomplete_expired`). `active_pause_collection.json` é a base com +`pause_collection` preenchido. Servem ao mapa de status +(`Gateways\Stripe\SubscriptionStatuses`); quando o driver ler assinatura, regravar a partir da +sandbox. diff --git a/tests/fixtures/stripe/subscriptions/active.json b/tests/fixtures/stripe/subscriptions/active.json new file mode 100644 index 0000000..6cec291 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/active.json @@ -0,0 +1,138 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor_config": null, + "billing_mode": { + "type": "flexible", + "updated_at": 1788368400 + }, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788368400, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "days_until_due": null, + "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788368400, + "current_period_end": 1790960400, + "current_period_start": 1788368400, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "schedule": null, + "start_date": 1788368400, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/active_pause_collection.json b/tests/fixtures/stripe/subscriptions/active_pause_collection.json new file mode 100644 index 0000000..d851e6b --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/active_pause_collection.json @@ -0,0 +1,141 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor_config": null, + "billing_mode": { + "type": "flexible", + "updated_at": 1788368400 + }, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788368400, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "days_until_due": null, + "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788368400, + "current_period_end": 1790960400, + "current_period_start": 1788368400, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": { + "behavior": "void", + "resumes_at": null + }, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "schedule": null, + "start_date": 1788368400, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/canceled.json b/tests/fixtures/stripe/subscriptions/canceled.json new file mode 100644 index 0000000..6933e85 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/canceled.json @@ -0,0 +1,138 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor_config": null, + "billing_mode": { + "type": "flexible", + "updated_at": 1788368400 + }, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": 1789059600, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": "cancellation_requested" + }, + "collection_method": "charge_automatically", + "created": 1788368400, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": 1789059600, + "invoice_settings": { + "account_tax_ids": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788368400, + "current_period_end": 1790960400, + "current_period_start": 1788368400, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "schedule": null, + "start_date": 1788368400, + "status": "canceled", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/incomplete.json b/tests/fixtures/stripe/subscriptions/incomplete.json new file mode 100644 index 0000000..b5724e2 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/incomplete.json @@ -0,0 +1,138 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor_config": null, + "billing_mode": { + "type": "flexible", + "updated_at": 1788368400 + }, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788368400, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "days_until_due": null, + "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788368400, + "current_period_end": 1790960400, + "current_period_start": 1788368400, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "schedule": null, + "start_date": 1788368400, + "status": "incomplete", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/incomplete_expired.json b/tests/fixtures/stripe/subscriptions/incomplete_expired.json new file mode 100644 index 0000000..8d0e549 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/incomplete_expired.json @@ -0,0 +1,138 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor_config": null, + "billing_mode": { + "type": "flexible", + "updated_at": 1788368400 + }, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788368400, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "days_until_due": null, + "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": 1788454800, + "invoice_settings": { + "account_tax_ids": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788368400, + "current_period_end": 1790960400, + "current_period_start": 1788368400, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "schedule": null, + "start_date": 1788368400, + "status": "incomplete_expired", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/past_due.json b/tests/fixtures/stripe/subscriptions/past_due.json new file mode 100644 index 0000000..69c044c --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/past_due.json @@ -0,0 +1,138 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor_config": null, + "billing_mode": { + "type": "flexible", + "updated_at": 1788368400 + }, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788368400, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "days_until_due": null, + "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788368400, + "current_period_end": 1790960400, + "current_period_start": 1788368400, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "schedule": null, + "start_date": 1788368400, + "status": "past_due", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/paused.json b/tests/fixtures/stripe/subscriptions/paused.json new file mode 100644 index 0000000..08131d2 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/paused.json @@ -0,0 +1,138 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor_config": null, + "billing_mode": { + "type": "flexible", + "updated_at": 1788368400 + }, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788368400, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788368400, + "current_period_end": 1790960400, + "current_period_start": 1788368400, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "schedule": null, + "start_date": 1788368400, + "status": "paused", + "test_clock": null, + "transfer_data": null, + "trial_end": 1788973200, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "pause" + } + }, + "trial_start": 1788368400 +} diff --git a/tests/fixtures/stripe/subscriptions/trialing.json b/tests/fixtures/stripe/subscriptions/trialing.json new file mode 100644 index 0000000..6c2ad82 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/trialing.json @@ -0,0 +1,138 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788973200, + "billing_cycle_anchor_config": null, + "billing_mode": { + "type": "flexible", + "updated_at": 1788368400 + }, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788368400, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "days_until_due": null, + "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788368400, + "current_period_end": 1790960400, + "current_period_start": 1788368400, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "schedule": null, + "start_date": 1788368400, + "status": "trialing", + "test_clock": null, + "transfer_data": null, + "trial_end": 1788973200, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": 1788368400 +} diff --git a/tests/fixtures/stripe/subscriptions/unpaid.json b/tests/fixtures/stripe/subscriptions/unpaid.json new file mode 100644 index 0000000..18cdd1e --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/unpaid.json @@ -0,0 +1,138 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor_config": null, + "billing_mode": { + "type": "flexible", + "updated_at": 1788368400 + }, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788368400, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "days_until_due": null, + "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788368400, + "current_period_end": 1790960400, + "current_period_start": 1788368400, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788368399, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": null, + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "schedule": null, + "start_date": 1788368400, + "status": "unpaid", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} From 62594f3768e391c6c0c408bb9764e8b367052278 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 17:00:37 -0300 Subject: [PATCH 24/32] =?UTF-8?q?feat(invoice,subscription):=20honra=20pay?= =?UTF-8?q?mentMethod=20na=20escrita,=20separa=20dueDate=20de=20pixExpires?= =?UTF-8?q?At=20e=20adiciona=20cart=C3=A3o=20e=20trial=20em=20dias=20na=20?= =?UTF-8?q?assinatura?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk --- README.md | 304 +++++++++++------ src/Builders/InvoiceBuilder.php | 75 +++- src/Builders/SubscriptionBuilder.php | 54 +++ src/Gateways/IuguGateway.php | 152 +++++++-- src/Gateways/StripeGateway.php | 86 +++-- src/Models/Invoice.php | 249 +++++++++++++- src/Models/Subscription.php | 154 ++++++++- src/MultiPayment.php | 12 +- .../Builders/InvoiceBuilderTest.php | 28 +- tests/Integration/IdempotencyTest.php | 6 +- tests/Integration/MultiPaymentTest.php | 32 +- tests/Integration/StripeGatewayTest.php | 39 ++- tests/Integration/SubscriptionTest.php | 49 ++- tests/Unit/CapabilityGuardsTest.php | 51 ++- .../Gateways/IuguGatewayIdempotencyTest.php | 2 +- .../Unit/Gateways/IuguGatewayInvoiceTest.php | 193 ++++++++++- .../Gateways/IuguGatewaySubscriptionTest.php | 319 +++++++++++++++++- .../Gateways/StripeGatewayInvoiceTest.php | 122 ++++++- .../StripeGatewayStripeInvoiceTest.php | 18 +- tests/Unit/InvoiceTest.php | 233 ++++++++++++- tests/Unit/ModelFillTest.php | 17 +- tests/Unit/MultiPaymentChargeTest.php | 178 ++++++++++ tests/Unit/SubscriptionTest.php | 151 +++++++++ 23 files changed, 2270 insertions(+), 254 deletions(-) create mode 100644 tests/Unit/MultiPaymentChargeTest.php diff --git a/README.md b/README.md index aeb02b4..9ccc810 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [Idempotência](#idempotência) - [Utilizando](#utilizando) - [MultiPayment](#multipayment) - - [InvoiceBuilder](#invoicebuilder) + - [Criar e cobrar uma fatura (InvoiceBuilder)](#criar-e-cobrar-uma-fatura-invoicebuilder) + - [Datas da fatura](#datas-da-fatura) - [Pix Automático](#pix-automático) - [Pix Automático: quem agenda a cobrança](#pix-automático-quem-agenda-a-cobrança) - [Assinaturas e planos](#assinaturas-e-planos) @@ -23,13 +24,14 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [getInvoice](#getinvoice) - [Outras operações de fatura](#outras-operações-de-fatura) - [Estorno](#estorno) - - [charge](#charge) + - [charge (alternativa por array)](#charge-alternativa-por-array) - [Models](#models) - [Customer](#customer) - [Invoice](#invoice) - [Refund](#refund) - [Subscription](#subscription) - [Plan](#plan) +- [Apêndice: chaves do array de `charge()`](#apêndice-chaves-do-array-de-charge) ## Requisitos - PHP 8.3+ @@ -389,9 +391,12 @@ recusado na validação, porque a fatura com Pix Automático é criada com `PIX` código pede ação do pagador (autenticar o cartão ou informar outro); o gateway respondeu normalmente e não cabe fallback. Autenticar no momento de salvar (SetupIntent) está planejado para uma versão futura. - **Pix exige `tax_document` do cliente** (CPF/CNPJ vai nos billing details do pagamento). -- **`expires_at` do pix é opcional** (default do Stripe: 4 horas) e, quando informado, deve - ficar entre 10 segundos e 14 dias no futuro — diferente da Iugu, onde `expires_at` é a - data de vencimento e é obrigatório para pix/boleto. +- **A expiração do QR Code do Pix é `pixExpiresAt`** (opcional; default do Stripe: 4 horas) e, + quando informada, deve ficar entre 10 segundos e 14 dias no futuro. Sem ela, `dueDate` faz o + QR Code expirar no fim do dia do vencimento, dentro da mesma janela (ver + [Datas da fatura](#datas-da-fatura)). Só um método por fatura: `paymentMethod`, + `availablePaymentMethods` com um único método ou só o cartão (`addCreditCardId()`, + `addCreditCardToken()`); sem nenhum dos três, `ModelAttributeValidationException` antes da rede. - **Pix expirado continua pendente e re-cobrável.** Na Iugu, fatura expirada vira `canceled`; no Stripe ela volta a aguardar pagamento (`pending`) e pode ser paga com cartão via `chargeInvoiceWithCreditCard` ou duplicada com `duplicateInvoice` (nova expiração; @@ -449,7 +454,8 @@ O que muda na fatura de origem `INVOICE`: itens); na venda avulsa continuam sendo reconstruídos do `metadata` do PaymentIntent. - **`url`** é a página hospedada da fatura (`hosted_invoice_url`), com o QR Code do Pix quando for o caso; `pix` continua trazendo o QR Code quando o PaymentIntent tem um. -- **`expiresAt`** é o `due_date` da fatura, quando ela tem um, ou a expiração do QR Code do Pix. +- **`dueDate`** é o `due_date` da fatura, quando ela tem um, e **`pixExpiresAt`** a expiração + do QR Code do Pix, quando o PaymentIntent tem um. - **`paidAt`** é o instante em que a Stripe marcou a fatura como paga. - **Uma requisição a mais** quando a fatura já teve tentativa de pagamento: o charge do PaymentIntent fica além do limite de `expand` da Stripe e é lido num GET à parte. @@ -544,7 +550,7 @@ Duas exceções à regra: chave com prefixo `gateway_` (ou `gateway` em `camelCa sendo ignorada em silêncio, e o conteúdo de `gateway_options` é livre (vai inteiro ao gateway). As chaves aceitas de cada model estão em `Model::fillableKeys()`, em `snake_case`. Use sempre `snake_case` nos arrays: uma chave escalar em `camelCase` (`taxDocument`) também é aceita, mas -as chaves que viram objeto ou data (`customer`, `items`, `credit_card`, `expires_at`...) só são +as chaves que viram objeto ou data (`customer`, `items`, `credit_card`, `due_date`...) só são convertidas na forma em `snake_case`. Para desligar temporariamente durante uma migração, use a configuração @@ -589,9 +595,10 @@ só depois de uma falha em que a resposta não chegou ou o processo caiu. Duas regras de payload que valem para a chave: a Stripe compara o payload e recusa a mesma chave com conteúdo diferente (`IdempotencyConflictException`), então um campo derivado do -instante da chamada, como um `expiresAt` calculado de `now()`, precisa ser gravado junto da +instante da chamada, como um `pixExpiresAt` calculado de `now()`, precisa ser gravado junto da chave e reenviado igual; a Iugu não compara e responde com o recurso original mesmo que o -payload tenha mudado. +payload tenha mudado (por isso o `expires_at` que `setTrialDays()` calcula a cada tentativa não +conflita com a chave na Iugu). Retry seguro: @@ -853,19 +860,82 @@ $payment = new \Potelo\MultiPayment\MultiPayment('iugu'); $payment = new \Potelo\MultiPayment\MultiPayment(); $payment->setGateway('iugu'); ``` -#### InvoiceBuilder +#### Criar e cobrar uma fatura (InvoiceBuilder) + +O builder é o caminho principal: método de pagamento por enum, datas por `Carbon` e validação +antes de qualquer requisição. O mesmo código cobra o cartão nos dois gateways: + ```php use Potelo\MultiPayment\Enums\PaymentMethod; -$multiPayment = new \Potelo\MultiPayment\MultiPayment('iugu'); -$invoiceBuilder = $multiPayment->newInvoice(); -$invoice = $invoiceBuilder->addAvailablePaymentMethod(PaymentMethod::PIX) // ou a string 'pix' - ->addCustomer('name', 'email', 'tax_document', 'phone_area', 'phone_number') - ->addCustomerAddress('zip_code', 'street', 'number') - ->addItem('description', 'price', 'quantity') +$payment = new \Potelo\MultiPayment\MultiPayment('iugu'); // ou 'stripe' + +$invoice = $payment->newInvoice() + ->setPaymentMethod(PaymentMethod::CREDIT_CARD) + ->addCustomer('Nome do cliente', 'email@example.com', '20176996915', null, '71', '999999999') + ->addItem('Produto 1', 10000, 1) + ->addItem('Produto 2', 5000, 2) + ->addCreditCardId($card->id) // cartão salvo; ou addCreditCardToken('pm_...') + ->withIdempotencyKey($order->uuid) ->create(); + +$invoice->status; // InvoiceStatus::PAID (ou CardDeclinedException) +$invoice->paymentMethod; // PaymentMethod::CREDIT_CARD ``` -Confira `src/MultiPayment/Builders/InvoiceBuilder.php` para saber quais métodos estão disponíveis. + +Pix e boleto abrem a fatura e devolvem o QR Code ou a linha digitável; as duas datas da fatura +têm um sentido só em todos os gateways (ver [Datas da fatura](#datas-da-fatura)): + +```php +$pix = $payment->newInvoice() + ->setPaymentMethod(PaymentMethod::PIX) + ->addCustomer('Nome do cliente', 'email@example.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->setPixExpiresAt(now()->addHours(4)) // expiração do QR Code + ->create(); +$pix->pix->qrCodeText; + +$boleto = $payment->newInvoice() + ->setPaymentMethod(PaymentMethod::BANK_SLIP) // no Stripe: UnsupportedOperationException, antes da rede + ->addCustomer('Nome do cliente', 'email@example.com', '20176996915') + ->addCustomerAddress('41820330', 'Rua', '123', null, 'Bairro', 'Salvador', 'BA') + ->addItem('Mensalidade', 10000, 1) + ->setDueDate(today()->addDays(3)) // vencimento + ->create(); +$boleto->bankSlip->number; +``` + +`setPaymentMethod()` decide como a fatura é criada quando `availablePaymentMethods` fica vazia; +`setAvailablePaymentMethods([...])` (ou `addAvailablePaymentMethod()`) abre a fatura a mais de +um método na Iugu e tem precedência sobre `setPaymentMethod()`, que então precisa constar da +lista. Cartão informado sem método algum também cobra o cartão; cartão junto de um método ou de +uma lista sem `credit_card` é recusado com `ModelAttributeValidationException`, em vez de ser +ignorado. `amount` junto de `items` só é aceito quando é a soma deles. +Confira `src/Builders/InvoiceBuilder.php` para saber quais métodos estão disponíveis; a +alternativa por array está em [charge](#charge-alternativa-por-array). + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0 a escrita ignorava +> `Invoice::$paymentMethod` (`payment_method` no array): na Iugu, uma fatura com +> `payment_method` `credit_card` e `credit_card` preenchido, sem `available_payment_methods`, +> nascia pendente aberta a todos os métodos da conta e o cartão não era cobrado, enquanto no +> Stripe o mesmo array cobrava o cartão. Agora `paymentMethod` (ou só o cartão) vale nos dois +> gateways, e o mesmo array produz o mesmo resultado financeiro. Quem dependia da fatura +> pendente deve deixar de informar o cartão: `credit_card` junto de uma lista (ou de um +> `payment_method`) sem cartão, que antes era ignorado, passou a lançar +> `ModelAttributeValidationException`. + +#### Datas da fatura + +| Propriedade | Array / builder | Iugu | Stripe | +|---|---|---|---| +| `dueDate` | `due_date` / `setDueDate()` | `due_date` (o dia; a fatura vencida continua pagável) | `due_date` da fatura de assinatura; na venda avulsa por Pix sem `pixExpiresAt`, o fim desse dia vira a expiração do QR Code | +| `pixExpiresAt` | `pix_expires_at` / `setPixExpiresAt()` | `pix_qr_code_expires_at` (ISO 8601); sem `dueDate`, o vencimento é o dia em que o QR Code expira; a leitura só o preenche quando a fatura o devolve | `payment_method_options.pix.expires_at`, entre 10 segundos e 14 dias no futuro (sem ele, a Stripe usa 4 horas); volta na leitura | + +As duas aceitam `Carbon` (ou `CarbonImmutable`) e, no array, string em `Y-m-d` ou ISO 8601 com +hora. `expiresAt` (`expires_at`, `setExpiresAt()`) continua funcionando como alias de `dueDate`, +com aviso `E_USER_DEPRECATED`, e sai na próxima versão maior: até a 4.1.0 era o vencimento na +Iugu e a expiração do QR Code no Stripe, então `'expires_at' => hoje` valia na Iugu e falhava no +Stripe. `toArray()` passa a emitir `due_date` e `pix_expires_at`. #### Pix Automático @@ -977,15 +1047,43 @@ $subscription = (new \Potelo\MultiPayment\MultiPayment('iugu')) ->newSubscription() ->setPlanId('plano_mensal') ->setCustomerId($customer->id) - ->setNextBillingAt('2026-10-01') + ->setCreditCard($card->id) // cartão salvo (ou um CreditCard com token); implica o método cartão + ->setTrialDays(7) // a primeira cobrança acontece no fim do teste ->addItem('Consultas extras', 2500, 2) // item recorrente, valor em centavos ->addAmountDiscount('Promo', 500, cycles: 1) // desconto só na próxima fatura - ->setAvailablePaymentMethods(['pix']) + ->withIdempotencyKey("sub-{$order->uuid}") ->create(); -$subscription->status; // SubscriptionStatus; na Iugu: TRIALING, ACTIVE, SUSPENDED, PENDING, PAST_DUE, CANCELED ou EXPIRED +$subscription->status; // SubscriptionStatus; na Iugu: TRIALING, ACTIVE, SUSPENDED, PENDING, PAST_DUE, CANCELED ou EXPIRED +$subscription->paymentMethod; // PaymentMethod::CREDIT_CARD, lido do gateway +$subscription->trialEndsAt; // calculado pela lib na criação + +$porPix = (new \Potelo\MultiPayment\MultiPayment('iugu')) + ->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomerId($customer->id) + ->setPaymentMethod(PaymentMethod::PIX) // ou setAvailablePaymentMethods(['pix', 'bank_slip']), que tem precedência + ->setNextBillingAt('2026-10-01') + ->create(); ``` +Meio de pagamento e trial são conceitos da assinatura: + +- **`setPaymentMethod()`** define com que método a assinatura é cobrada; com + `availablePaymentMethods` vazia, o driver deriva a lista dele, e com ela preenchida o método + precisa constar da lista. **`setCreditCard()`** aceita o id de um cartão salvo ou um + `CreditCard` (token, ou dados crus na Iugu), implica o método cartão, e um cartão sem id é + salvo no cliente ao criar a assinatura; cartão com uma lista sem `credit_card` é recusado com + `ModelAttributeValidationException`. Na leitura, `paymentMethod` volta preenchido quando a + assinatura aceita um único método, e a lista vem preenchida: para trocar o método de uma + assinatura lida, troque `availablePaymentMethods` (ou a zere) antes de `save()`. +- **`setTrialDays()`** conta o período de teste a partir do momento em que a assinatura é criada: + o driver calcula `trialEndsAt` na hora da requisição, o deixa no model e zera `trialDays` + (o model devolvido pode ser salvo de novo). Prefira + `setTrialDays()` a `setTrialEndsAt(now()->addDays(7))`: a segunda fixa a data no instante em + que o model foi montado, e um model guardado para retry carrega uma data velha. Os dois não + podem ser informados juntos. + Operações sobre a assinatura: ```php @@ -1036,9 +1134,21 @@ Particularidades da Iugu: chamar a API. - **Planos não são desativáveis.** `deactivatePlan` lança `UnsupportedOperationException` (`PLAN_DEACTIVATION`, `gateway_limitation`). -- **`nextBillingAt` e `trialEndsAt` são o mesmo campo** (`expires_at`); informar os dois com - datas diferentes lança `GatewayException`. Ao prorrogar um trial lido do gateway, zere - `nextBillingAt` antes, porque a leitura preenche os dois. +- **`nextBillingAt` e `trialEndsAt` são o mesmo campo** (`expires_at`), e o que os distingue é + a cobrança do primeiro ciclo: por padrão a Iugu cobra o cartão padrão na criação, mesmo com + `expires_at` no futuro (observado na sandbox), então um trial (`setTrialDays()` ou + `setTrialEndsAt()`) vai com `only_charge_on_due_date`, e a primeira cobrança acontece no fim + do teste; `setNextBillingAt()` sozinho vai só como `expires_at`, com a cobrança imediata da + Iugu (`gateway_options['only_charge_on_due_date']` sobrepõe os dois). Informar `trialEndsAt` + (ou `trialDays`) e `nextBillingAt` com datas diferentes lança `GatewayException`. Ao prorrogar + um trial lido do gateway, zere `nextBillingAt` antes, porque a leitura preenche os dois. + `in_trial` (lido como `TRIALING`) só aparece em assinatura que a própria Iugu põe em teste; a + assinatura criada com trial pela lib lê como `ACTIVE`, com `nextBillingAt` no fim do teste. +- **O cartão da assinatura é o cartão padrão do cliente.** A Iugu não guarda cartão por + assinatura, então `setCreditCard()` torna o cartão informado o padrão do cliente (um `PUT` no + cliente, ou o `set_as_default` ao salvar um cartão novo) antes de criar a assinatura, o que + vale para as outras assinaturas do mesmo cliente. Só o método (`setPaymentMethod(CREDIT_CARD)`) + cobra o cartão padrão que o cliente já tiver. A leitura não preenche `creditCard`. - **`past_due` é derivado.** A Iugu não tem esse estado: o pacote o reporta quando a data da próxima cobrança já passou e **alguma** fatura de `recent_invoices` continua em aberto — pendente, vencida (`expired`) ou parcialmente paga. Olha todas, e não só a que virou @@ -1055,8 +1165,9 @@ Particularidades da Iugu: subitens na mesma chamada, então o pacote lê a assinatura, envia a remoção sozinha e só depois a atualização — até três requisições. Entre a remoção e a atualização a assinatura fica sem os itens removidos, e se a segunda falhar eles não voltam sozinhos. -- **`paymentMethod` e `cancelAtPeriodEnd` não são mapeados** na Iugu, nas duas direções. - `canceledAt` vem da marca `mp_canceled_at` gravada por `cancel()`. +- **`cancelAtPeriodEnd` não é mapeado** na Iugu, nas duas direções. `canceledAt` vem da marca + `mp_canceled_at` gravada por `cancel()`. `paymentMethod` vai como `payable_with` e volta + quando a assinatura aceita um único método (`all` e listas com mais de um leem como nulo). - **Reativar exige data de cobrança.** Assinatura criada sem `nextBillingAt` fica sem data no gateway e, por isso, não volta com `resume()`: a Iugu responde sem erro e sem mudar nada, e o `status` devolvido segue `SUSPENDED`. @@ -1082,7 +1193,13 @@ Particularidades da Iugu: troca o objeto para não misturar dados de dois clientes. No update, data de cobrança e métodos de pagamento só são enviados quando mudaram em relação -ao que veio na leitura — um `save()` que mexeu só nos itens não altera a data de cobrança. +ao que veio na leitura — um `save()` que mexeu só nos itens não altera a data de cobrança. Um +`creditCard` informado no update vira o padrão do cliente antes do `PUT` da assinatura. + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, `trialEndsAt` ia só como +> `expires_at`, e com cartão padrão a Iugu cobrava o primeiro ciclo na criação; agora o trial vai +> com `only_charge_on_due_date`. `Subscription::$paymentMethod` passou a ser escrito +> (`payable_with`) e lido; até a 4.1.0 era ignorado nas duas direções. Confira `src/Builders/SubscriptionBuilder.php` para saber quais métodos estão disponíveis. @@ -1241,89 +1358,33 @@ Essa leitura não altera o model do chamador: ele só muda quando o estorno acon > `MultiPaymentException`), fora da árvore de `GatewayException`: um `catch (GatewayException $e)` > sozinho deixa de capturar esses casos. -#### charge +#### charge (alternativa por array) + +`charge(array)` monta a mesma fatura a partir de um array em `snake_case`, para integrações que +recebem os dados prontos (o `MultiPaymentTrait` usa este caminho). As chaves espelham as +propriedades do model e seguem as mesmas regras do builder; `customer` é obrigatório e é +conferido antes de qualquer conversão. O exemplo abaixo vale para os dois gateways: -```php -$options = [ - 'amount' => 10000, +```php +$invoice = (new \Potelo\MultiPayment\MultiPayment('iugu'))->charge([ 'customer' => [ 'name' => 'Nome do cliente', 'email' => 'email@example.com', - 'tax_document' => '12345678901', + 'tax_document' => '20176996915', 'phone_area' => '71', 'phone_number' => '999999999', - 'address' => [ - 'street' => 'Rua do cliente', - 'number' => '123', - 'complement' => 'Apto. 123', - 'district' => 'Bairro do cliente', - 'city' => 'Cidade do cliente', - 'state' => 'SP', - 'zip_code' => '12345678', - ], + 'address' => ['street' => 'Rua', 'number' => '123', 'district' => 'Bairro', 'city' => 'Salvador', 'state' => 'BA', 'zip_code' => '41820330'], ], 'items' => [ - [ - 'description' => 'Produto 1', - 'quantity' => 1, - 'price' => 10000, - ], - [ - 'description' => 'Produto 2', - 'quantity' => 2, - 'price' => 5000, - ], + ['description' => 'Produto 1', 'quantity' => 1, 'price' => 10000], + ['description' => 'Produto 2', 'quantity' => 2, 'price' => 5000], ], 'payment_method' => 'credit_card', - 'credit_card' => [ - 'number' => '1234567890123456', - 'month' => '12', - 'year' => '2022', - 'cvv' => '123', - 'first_name' => 'João', - 'last_name' => 'Maria' - ], -]; - -$payment = new \Potelo\MultiPayment\MultiPayment(); -$payment->setGateway('iugu')->charge($options); + 'credit_card' => ['token' => $token], // token do gateway; na Iugu também number, month, year, cvv, first_name, last_name +], idempotencyKey: $order->uuid); ``` -| atributo | obrigatório | tipo | descrição | exemplo | -|-------------------------------|---------------------------------------------------------------------|--------------------------------|-------------------------------------------|---------------------------------------| -| `amount` | **obrigatório** caso `items` não seja informado | int | valor em centavos | `10000` | -| `customer` | **obrigatório** | array | array com os dados do cliente | `['name' => 'Nome do cliente'...]` | -| `customer.name` | **obrigatório** | string | nome do cliente | `'Nome do cliente'` | -| `customer.email` | **obrigatório** | string | email do cliente | `'joaomaria@email.com'` | -| `customer.tax_document` | **obrigatório** no Stripe para faturas pix | string | cpf ou cnpj do cliente | `'12345678901'` | -| `customer.birth_date` | | string formato `yyyy-mm-dd` | data de nascimento | `'1990-01-01'` | -| `customer.phone_number` | | string | telefone | `'999999999'` | -| `customer.phone_area` | | string | DDD | `'999999999'` | -| `customer.address` | **obrigatório** para o método de pagamento `bank_slip` | array | array com os dados do endereço do cliente | `['street' => 'Rua do cliente'...]` | -| `customer.address.street` | **obrigatório** | string | nome da rua | `'Nome da rua'` | -| `customer.address.number` | **obrigatório** | string | número da casa | `'123'` | -| `customer.address.district` | **obrigatório** | string | bairro | `'Bairro do cliente'` | -| `customer.address.city` | **obrigatório** | string | cidade | `'Salvador'` | -| `customer.address.state` | **obrigatório** | string | estado | `'Bahia'` | -| `customer.address.complement` | **obrigatório** | string | complemento | `'Apto. 123'` | -| `customer.address.zip_code` | **obrigatório** | string | cep | `'12345678'` | -| `items` | **obrigatório** caso `amount` não tenha sido informado | array | array com os itens da compra | `[['description' => 'Produto 1',...` | -| `items.description` | **obrigatório** | string | descrição do item | `'Produto 1'` | -| `items.quantity` | **obrigatório** | int | quantidade do item | `1` | -| `items.price` | **obrigatório** | int | valor do item | `10000` | -| `payment_method` | | `PaymentMethod` ou a string `'credit_card'`, `'bank_slip'`, `'pix'` | método de pagamento | `'credit_card'` | -| `available_payment_methods` | **obrigatório** no Stripe (exatamente um método) quando não há `credit_card` | array de `PaymentMethod` ou de strings | métodos aceitos pela fatura | `['pix']` | -| `expires_at` | **obrigatório** na Iugu caso `payment_method` seja `'bank_slip'` ou `'pix'`; opcional no Stripe (pix — a data precisa cair na janela de 10 segundos a 14 dias no futuro) | string no formato `yyyy-mm-dd` | data de expiração da fatura | `2021-10-10` | -| `credit_card` | **obrigatório** caso `payment_method` seja `'credit_card'` | array | array com os dados do cartão de crédito | `['number' => '1234567890123456',...` | -| `credit_card.token` | | string | token do cartão para o gateway escolhido | `'abc123...'` (Iugu) / `'pm_...'` (Stripe) | -| `credit_card.number` | **obrigatório** caso `token` não tenha sido informado (somente Iugu — o Stripe é token-only) | string | número do cartão de crédito | `'1234567890123456'` | -| `credit_card.month` | **obrigatório** caso `token` não tenha sido informado (somente Iugu) | string | mês de expiração do cartão de crédito | `'12'` | -| `credit_card.year` | **obrigatório** caso `token` não tenha sido informado (somente Iugu) | string | ano de expiração do cartão de crédito | `'2022'` | -| `credit_card.cvv` | **obrigatório** caso `token` não tenha sido informado (somente Iugu) | string | código de segurança do cartão de crédito | `'123'` | -| `credit_card.first_name` | | string | primeiro nome no cartão de crédito | `'João'` | -| `credit_card.last_name` | | string | último nome no cartão de crédito | `'Maria'` | -| `bank_slip` | | array | array com os dados do boleto | `['expires_at' => '2022-12-31',...` | -| `gateway_options` | | array | opções específicas do gateway mescladas ao payload (ver [Opções extras do gateway](#opções-extras-do-gateway)) | `['expires_in' => 3]` | +A lista completa de chaves está no [apêndice](#apêndice-chaves-do-array-de-charge). ### Models #### Customer @@ -1346,7 +1407,7 @@ $item->description = 'Teste'; $item->price = 10000; $item->quantity = 1; $invoice->items[] = $item; -$invoice->paymentMethod = PaymentMethod::CREDIT_CARD; // a string 'credit_card' também é aceita +$invoice->paymentMethod = PaymentMethod::CREDIT_CARD; // a string 'credit_card' também é aceita; decide como a fatura é criada $invoice->creditCard = new CreditCard(); $invoice->creditCard->number = '4111111111111111'; $invoice->creditCard->firstName = 'João'; @@ -1355,9 +1416,10 @@ $invoice->creditCard->month = '11'; $invoice->creditCard->year = '2022'; $invoice->creditCard->cvv = '123'; $invoice->creditCard->customer = $customer; -$invoice->save('iugu'); +$invoice->save('iugu'); // cobra o cartão echo $invoice->id; // CB1FA9B5BD1C42B287F4AC7F6259E45D $invoice->originType; // InvoiceOriginType::INVOICE (na Iugu sempre; no Stripe, PAYMENT_INTENT ou INVOICE) +$invoice->dueDate; // vencimento; $invoice->pixExpiresAt é a expiração do QR Code do Pix ``` #### Refund ```php @@ -1373,6 +1435,8 @@ $invoice->refunds; // Refund[] (ver "Estorno") $subscription = new Subscription(); $subscription->planId = 'plano_mensal'; $subscription->customer = $customer; +$subscription->creditCard = $card; // ou $subscription->paymentMethod = PaymentMethod::PIX +$subscription->trialDays = 7; $subscription->save('iugu'); echo $subscription->id; ``` @@ -1387,3 +1451,45 @@ $plan->save('iugu'); echo $plan->id; ``` +## Apêndice: chaves do array de `charge()` + +Chaves aceitas por `charge(array)` (e por `Invoice::fill()`), em `snake_case`; ver +[charge (alternativa por array)](#charge-alternativa-por-array). + +| atributo | obrigatório | tipo | descrição | exemplo | +|-------------------------------|---------------------------------------------------------------------|--------------------------------|-------------------------------------------|---------------------------------------| +| `amount` | **obrigatório** caso `items` não seja informado | int | valor em centavos; junto de `items`, precisa ser a soma deles | `10000` | +| `customer` | **obrigatório** | array | array com os dados do cliente | `['name' => 'Nome do cliente'...]` | +| `customer.name` | **obrigatório** | string | nome do cliente | `'Nome do cliente'` | +| `customer.email` | **obrigatório** | string | email do cliente | `'joaomaria@email.com'` | +| `customer.tax_document` | **obrigatório** no Stripe para faturas pix | string | cpf ou cnpj do cliente | `'12345678901'` | +| `customer.birth_date` | | string formato `yyyy-mm-dd` | data de nascimento | `'1990-01-01'` | +| `customer.phone_number` | | string | telefone | `'999999999'` | +| `customer.phone_area` | | string | DDD | `'999999999'` | +| `customer.address` | **obrigatório** para o método de pagamento `bank_slip` | array | array com os dados do endereço do cliente | `['street' => 'Rua do cliente'...]` | +| `customer.address.street` | **obrigatório** | string | nome da rua | `'Nome da rua'` | +| `customer.address.number` | **obrigatório** | string | número da casa | `'123'` | +| `customer.address.district` | **obrigatório** | string | bairro | `'Bairro do cliente'` | +| `customer.address.city` | **obrigatório** | string | cidade | `'Salvador'` | +| `customer.address.state` | **obrigatório** | string | estado | `'Bahia'` | +| `customer.address.complement` | **obrigatório** | string | complemento | `'Apto. 123'` | +| `customer.address.zip_code` | **obrigatório** | string | cep | `'12345678'` | +| `items` | **obrigatório** caso `amount` não tenha sido informado | array | array com os itens da compra | `[['description' => 'Produto 1',...` | +| `items.description` | **obrigatório** | string | descrição do item | `'Produto 1'` | +| `items.quantity` | **obrigatório** | int | quantidade do item | `1` | +| `items.price` | **obrigatório** | int | valor do item | `10000` | +| `payment_method` | **obrigatório** no Stripe quando não há `available_payment_methods` nem `credit_card` | `PaymentMethod` ou a string `'credit_card'`, `'bank_slip'`, `'pix'` | método com que a fatura é criada quando `available_payment_methods` está vazia | `'credit_card'` | +| `available_payment_methods` | | array de `PaymentMethod` ou de strings | métodos aceitos pela fatura (mais de um só na Iugu); tem precedência sobre `payment_method` | `['pix']` | +| `due_date` | | string em `yyyy-mm-dd` ou ISO 8601 | vencimento (ver [Datas da fatura](#datas-da-fatura)); na Iugu, hoje quando omitido | `'2026-10-10'` | +| `pix_expires_at` | | string em ISO 8601 | expiração do QR Code do Pix (Stripe: entre 10 segundos e 14 dias no futuro) | `'2026-10-10T18:00:00-03:00'` | +| `expires_at` | obsoleto desde 2026-09-02 | string em `yyyy-mm-dd` | alias de `due_date`, com aviso `E_USER_DEPRECATED` | `'2026-10-10'` | +| `credit_card` | **obrigatório** caso `payment_method` seja `'credit_card'`; sozinho, implica cartão | array | array com os dados do cartão de crédito | `['token' => 'pm_...']` | +| `credit_card.token` | | string | token do cartão para o gateway escolhido | `'abc123...'` (Iugu) / `'pm_...'` (Stripe) | +| `credit_card.number` | **obrigatório** caso `token` não tenha sido informado (somente Iugu — o Stripe é token-only) | string | número do cartão de crédito | `'1234567890123456'` | +| `credit_card.month` | **obrigatório** caso `token` não tenha sido informado (somente Iugu) | string | mês de expiração do cartão de crédito | `'12'` | +| `credit_card.year` | **obrigatório** caso `token` não tenha sido informado (somente Iugu) | string | ano de expiração do cartão de crédito | `'2022'` | +| `credit_card.cvv` | **obrigatório** caso `token` não tenha sido informado (somente Iugu) | string | código de segurança do cartão de crédito | `'123'` | +| `credit_card.first_name` | | string | primeiro nome no cartão de crédito | `'João'` | +| `credit_card.last_name` | | string | último nome no cartão de crédito | `'Maria'` | +| `bank_slip` | | array | dados do boleto devolvidos na leitura (`url`, `number`, `barcode_data`, `barcode_image`) | `['number' => '...', 'url' => '...']` | +| `gateway_options` | | array | opções específicas do gateway mescladas ao payload (ver [Opções extras do gateway](#opções-extras-do-gateway)) | `['expires_in' => 3]` | diff --git a/src/Builders/InvoiceBuilder.php b/src/Builders/InvoiceBuilder.php index feed63f..aad7119 100644 --- a/src/Builders/InvoiceBuilder.php +++ b/src/Builders/InvoiceBuilder.php @@ -3,6 +3,7 @@ namespace Potelo\MultiPayment\Builders; use Carbon\Carbon; +use Carbon\CarbonInterface; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Address; use Potelo\MultiPayment\Models\Customer; @@ -75,19 +76,81 @@ public function addAvailablePaymentMethod(PaymentMethod|string $paymentMethod): } /** - * set invoice expiresAt + * Define o método de pagamento da fatura. Quando `availablePaymentMethods` fica vazia, os + * drivers criam a fatura com este método (ver `Invoice::resolvedPaymentMethods()`). * - * @param Carbon|string $expiresAt Carbon or string in Y-m-d format + * @param PaymentMethod|string $paymentMethod + * + * @return InvoiceBuilder + */ + public function setPaymentMethod(PaymentMethod|string $paymentMethod): InvoiceBuilder + { + $this->model->paymentMethod = $paymentMethod; + + return $this; + } + + /** + * Define a data de vencimento da fatura (ver `Invoice::$dueDate`). + * + * @param CarbonInterface|string $dueDate data, ou string em `Y-m-d` ou ISO 8601 + * + * @return InvoiceBuilder + */ + public function setDueDate(CarbonInterface|string $dueDate): InvoiceBuilder + { + $this->model->dueDate = self::toCarbon($dueDate); + + return $this; + } + + /** + * Define o instante em que o QR Code do Pix expira (ver `Invoice::$pixExpiresAt`). + * + * @param CarbonInterface|string $pixExpiresAt data e hora, ou string em ISO 8601 + * + * @return InvoiceBuilder + */ + public function setPixExpiresAt(CarbonInterface|string $pixExpiresAt): InvoiceBuilder + { + $this->model->pixExpiresAt = self::toCarbon($pixExpiresAt); + + return $this; + } + + /** + * Define a data de vencimento. Nome antigo de setDueDate(). + * + * @deprecated desde 2026-09-02, use setDueDate() (vencimento) ou setPixExpiresAt() (expiração do QR Code) + * + * @param CarbonInterface|string $expiresAt * * @return InvoiceBuilder */ public function setExpiresAt($expiresAt): InvoiceBuilder { - if (is_string($expiresAt)) { - $expiresAt = Carbon::parse($expiresAt); + trigger_error( + 'InvoiceBuilder::setExpiresAt() está obsoleto desde 2026-09-02; use setDueDate() ou setPixExpiresAt()', + E_USER_DEPRECATED + ); + + return $this->setDueDate($expiresAt); + } + + /** + * Converte data ou string numa instância de `Carbon`. + * + * @param CarbonInterface|string $date + * + * @return Carbon + */ + private static function toCarbon(CarbonInterface|string $date): Carbon + { + if ($date instanceof Carbon) { + return $date; } - $this->model->expiresAt = $expiresAt; - return $this; + + return $date instanceof CarbonInterface ? Carbon::instance($date) : Carbon::parse($date); } /** diff --git a/src/Builders/SubscriptionBuilder.php b/src/Builders/SubscriptionBuilder.php index ef105c8..73c29af 100644 --- a/src/Builders/SubscriptionBuilder.php +++ b/src/Builders/SubscriptionBuilder.php @@ -4,6 +4,8 @@ use Carbon\Carbon; use Potelo\MultiPayment\Models\Customer; +use Potelo\MultiPayment\Models\CreditCard; +use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Models\SubscriptionItem; use Potelo\MultiPayment\Contracts\GatewayContract; @@ -113,6 +115,58 @@ public function setTrialEndsAt(Carbon|string $trialEndsAt): SubscriptionBuilder return $this; } + /** + * Define a duração do período de teste em dias, contada do momento em que a assinatura é + * criada; o driver calcula a data de fim na hora da requisição (ver + * `Subscription::$trialDays`). + * + * @param int $trialDays + * + * @return $this + */ + public function setTrialDays(int $trialDays): SubscriptionBuilder + { + $this->model->trialDays = $trialDays; + + return $this; + } + + /** + * Define o método de pagamento da assinatura (ver `Subscription::$paymentMethod`). + * + * @param PaymentMethod|string $paymentMethod + * + * @return $this + */ + public function setPaymentMethod(PaymentMethod|string $paymentMethod): SubscriptionBuilder + { + $this->model->paymentMethod = $paymentMethod; + + return $this; + } + + /** + * Define o cartão que a assinatura cobra, pelo model ou pelo id de um cartão já salvo no + * cliente, e o método de pagamento como cartão (ver `Subscription::$creditCard`). + * + * @param CreditCard|string $creditCard + * + * @return $this + */ + public function setCreditCard(CreditCard|string $creditCard): SubscriptionBuilder + { + if (is_string($creditCard)) { + $id = $creditCard; + $creditCard = new CreditCard(); + $creditCard->id = $id; + } + + $this->model->creditCard = $creditCard; + $this->model->paymentMethod = PaymentMethod::CREDIT_CARD; + + return $this; + } + /** * Define os métodos de pagamento aceitos pela assinatura. * diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index 210b277..3c55499 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -144,10 +144,13 @@ public function notYetImplemented(): array /** * @inheritDoc * - * A chave de idempotência vai no cabeçalho `Idempotency-Key` de `POST /invoices` ou de - * `POST /charge` (fatura com cartão); o cartão salvo antes da cobrança usa a chave derivada - * `{chave}:card` pela `IdempotencyStore`. Na reutilização da chave, a Iugu responde 409 com - * o id da fatura original, que o driver lê e devolve. + * Os métodos da fatura vêm de `Invoice::resolvedPaymentMethods()` (`payable_with`); com + * cartão entre eles e `creditCard` preenchido, a fatura é cobrada por `POST /charge`. + * `dueDate` vai em `due_date` (sem ele, o dia de `pixExpiresAt`, ou hoje) e `pixExpiresAt` + * em `pix_qr_code_expires_at`. A chave de idempotência vai no cabeçalho `Idempotency-Key` de `POST /invoices` ou de `POST /charge`; + * o cartão salvo antes da cobrança usa a chave derivada `{chave}:card` pela + * `IdempotencyStore`. Na reutilização da chave, a Iugu responde 409 com o id da fatura + * original, que o driver lê e devolve. * * @throws ModelAttributeValidationException|ChargingException|UnsupportedOperationException */ @@ -171,10 +174,12 @@ public function createInvoice(Invoice $invoice, ?string $idempotencyKey = null): 'price_cents' => $item->price, ]; } - $iuguInvoiceData['due_date'] = !empty($invoice->expiresAt) - ? $invoice->expiresAt->format('Y-m-d') - : Carbon::now()->format('Y-m-d'); + $dueDate = $invoice->dueDate ?? $invoice->pixExpiresAt ?? Carbon::now(); + $iuguInvoiceData['due_date'] = $dueDate->format('Y-m-d'); $iuguInvoiceData['expires_in'] = 0; + if (!empty($invoice->pixExpiresAt)) { + $iuguInvoiceData['pix_qr_code_expires_at'] = $invoice->pixExpiresAt->toIso8601String(); + } if (!empty($invoice->customer->address)) { $iuguInvoiceData['payer']['address'] = $invoice->customer->address->toArray(); @@ -183,10 +188,7 @@ public function createInvoice(Invoice $invoice, ?string $idempotencyKey = null): } } - // normaliza antes de ler: uma string apensada por `[]=` entra no array sem conversão - $payableWith = !empty($invoice->availablePaymentMethods) - ? PaymentMethod::normalizeSelectable($invoice->availablePaymentMethods, 'Invoice') - : []; + $payableWith = $invoice->resolvedPaymentMethods(); if (!empty($payableWith)) { $iuguInvoiceData['payable_with'] = self::paymentMethodsToIuguPayableWith($payableWith); @@ -1319,11 +1321,17 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice $invoice->paidAmount = $iuguInvoice->paid_cents ?? null; $invoice->refundedAmount = $iuguInvoice->refunded_cents ?? null; $invoice->refunds = $this->parseRefunds($invoice); - $invoice->expiresAt = !empty($iuguInvoice->due_date) ? new Carbon($iuguInvoice->due_date) : null; - - if (empty($invoice->paymentMethod)) { - $invoice->paymentMethod = $this->iuguToMultiPaymentPaymentMethod($iuguInvoice->payment_method ?? null); - } + $invoice->dueDate = !empty($iuguInvoice->due_date) ? new Carbon($iuguInvoice->due_date) : null; + // a Iugu não documenta a expiração do QR Code na fatura; quando vier, ela vale, senão + // fica o que o model já tinha + $invoice->pixExpiresAt = !empty($iuguInvoice->pix_qr_code_expires_at) + ? new Carbon($iuguInvoice->pix_qr_code_expires_at) + : $invoice->pixExpiresAt; + + // a resposta manda: o método pedido na escrita só fica enquanto a Iugu não informa o + // método com que a fatura foi paga + $invoice->paymentMethod = $this->iuguToMultiPaymentPaymentMethod($iuguInvoice->payment_method ?? null) + ?? $invoice->paymentMethod; if (!empty($iuguInvoice->payable_with)) { $invoice->availablePaymentMethods = $this->iuguPayableWithToPaymentMethods($iuguInvoice->payable_with); @@ -1792,29 +1800,83 @@ private function parseIuguCard(mixed $iuguCreditCard, ?CreditCard $creditCard = /** * @inheritDoc * - * A chave de idempotência vai no cabeçalho `Idempotency-Key` de `POST /subscriptions`. Na - * reutilização da chave a Iugu responde 409 sem o id da assinatura original - * (`resource_id: processing`), que chega como `IdempotencyConflictException`. + * `payable_with` vem de `availablePaymentMethods` ou, com ela vazia, de `paymentMethod` + * (cartão quando só `creditCard` foi informado); o cartão informado vira o padrão do + * cliente antes da criação, porque a assinatura da Iugu cobra o cartão padrão. `trialDays` + * vira `trialEndsAt` contado de hoje, e um trial (`trialEndsAt`) vai como `expires_at` com + * `only_charge_on_due_date`, para o primeiro ciclo só ser cobrado no fim do teste; + * `nextBillingAt` sozinho vai só como `expires_at`. A chave de idempotência vai no cabeçalho + * `Idempotency-Key` de `POST /subscriptions`. Na reutilização da chave a Iugu responde 409 + * sem o id da assinatura original (`resource_id: processing`), que chega como + * `IdempotencyConflictException`. */ public function createSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription { + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + $data = array_merge( $this->subscriptionToIuguData($subscription), self::withoutIdempotencyKey($subscription->gatewayOptions) ); + $this->applySubscriptionCreditCard($subscription, $idempotencyKey); + $response = $this->iuguIdempotentRequest( 'POST', Iugu::getBaseURI() . '/subscriptions', $data, 'creating subscription', - $this->idempotencyKeyFor($idempotencyKey, $subscription), + $idempotencyKey, true ); return $this->parseIuguSubscription($response, $subscription); } + /** + * Torna o cartão de `Subscription::$creditCard` o cartão padrão do cliente, que é o que a + * Iugu cobra numa assinatura paga com cartão. Cartão sem `id` (token ou dados crus) é salvo + * no cliente já como padrão (`{chave}:card`); cartão com `id` é marcado como padrão por um + * `PUT` no cliente (`{chave}:default`). Sem cartão no model, nada é feito. + * + * @param Subscription $subscription + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return void + * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException + */ + private function applySubscriptionCreditCard(Subscription $subscription, ?string $idempotencyKey): void + { + $creditCard = $subscription->creditCard; + if (empty($creditCard)) { + return; + } + if (empty($subscription->customer) || empty($subscription->customer->id)) { + throw ModelAttributeValidationException::required('Subscription', 'customer'); + } + + if (empty($creditCard->id)) { + if (empty($creditCard->customer)) { + $creditCard->customer = $subscription->customer; + } + $creditCard->default = true; + $subscription->creditCard = $this->createCreditCard( + $creditCard, + self::derivedIdempotencyKey($idempotencyKey, 'card') + ); + + return; + } + + $this->iuguIdempotentRequest( + 'PUT', + Iugu::getBaseURI() . '/customers/' . rawurlencode($subscription->customer->id), + ['default_payment_method_id' => $creditCard->id], + 'setting the subscription card as the customer default', + self::derivedIdempotencyKey($idempotencyKey, 'default') + ); + $creditCard->default = true; + } + /** * @inheritDoc */ @@ -1838,7 +1900,8 @@ public function getSubscription(Subscription $subscription): Subscription * @inheritDoc * * A chave de idempotência passa pela `IdempotencyStore`: a informada no `PUT` da - * atualização e `{chave}:remove` na remoção de subitens que a antecede. + * atualização, `{chave}:remove` na remoção de subitens que a antecede e `{chave}:card` ou + * `{chave}:default` no cartão que passa a ser o padrão do cliente. */ public function updateSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription { @@ -1854,6 +1917,8 @@ public function updateSubscription(Subscription $subscription, ?string $idempote $subitems = $data['subitems'] ?? null; unset($data['subitems']); + $this->applySubscriptionCreditCard($subscription, $idempotencyKey); + if (!is_null($subitems)) { // a Iugu recusa remover e adicionar subitens na mesma requisição, então a remoção // vai sozinha e antes; entre as duas a assinatura fica sem os itens removidos @@ -2223,30 +2288,44 @@ private function subscriptionToIuguData(Subscription $subscription, bool $creati $data['plan_identifier'] = $subscription->planId; } + // o fim do trial em dias é calculado agora, no momento da requisição, e substitui os + // dias no model, para o próximo save() não os recontar + if (empty($subscription->trialEndsAt) && !empty($subscription->trialDays)) { + $subscription->trialEndsAt = Carbon::now()->addDays($subscription->trialDays); + $subscription->trialDays = null; + } + $trialEndsAt = $subscription->trialEndsAt; + if ( !empty($subscription->nextBillingAt) - && !empty($subscription->trialEndsAt) - && !$subscription->nextBillingAt->isSameDay($subscription->trialEndsAt) + && !empty($trialEndsAt) + && !$subscription->nextBillingAt->isSameDay($trialEndsAt) ) { throw new GatewayException( 'Iugu stores the trial end and the next billing date in the same field, so ' - . 'nextBillingAt and trialEndsAt cannot hold different dates.' + . 'nextBillingAt and trialEndsAt (or trialDays) cannot hold different dates.' ); } - $expiresAt = $subscription->nextBillingAt ?? $subscription->trialEndsAt; + $expiresAt = $subscription->nextBillingAt ?? $trialEndsAt; if (!empty($expiresAt) && ($creating || !$this->isOriginalExpiresAt($subscription, $expiresAt))) { $data['expires_at'] = $expiresAt->format('Y-m-d'); } + // por padrão a Iugu cobra o primeiro ciclo na criação, mesmo com `expires_at` no + // futuro; num trial a primeira cobrança só pode acontecer no fim dele + if ($creating && !empty($trialEndsAt)) { + $data['only_charge_on_due_date'] = true; + } + + // lista vazia: a Iugu usa os métodos habilitados na conta + $payableWith = $subscription->resolvedPaymentMethods(); if ( - !empty($subscription->availablePaymentMethods) - && ($creating || !$this->isOriginalPayableWith($subscription)) + !empty($payableWith) + && ($creating || !$this->isOriginalPayableWith($subscription, $payableWith)) ) { - $data['payable_with'] = self::paymentMethodsToIuguPayableWith( - PaymentMethod::normalizeSelectable($subscription->availablePaymentMethods, 'Subscription') - ); + $data['payable_with'] = self::paymentMethodsToIuguPayableWith($payableWith); } if (!empty($subscription->metadata)) { @@ -2296,10 +2375,11 @@ private function isOriginalExpiresAt(Subscription $subscription, Carbon $expires * dos três métodos. * * @param Subscription $subscription + * @param PaymentMethod[] $payableWith * * @return bool */ - private function isOriginalPayableWith(Subscription $subscription): bool + private function isOriginalPayableWith(Subscription $subscription, array $payableWith): bool { $original = $subscription->original->payable_with ?? null; @@ -2307,8 +2387,7 @@ private function isOriginalPayableWith(Subscription $subscription): bool return false; } - return $this->iuguPayableWithToPaymentMethods($original) - === array_values($subscription->availablePaymentMethods); + return $this->iuguPayableWithToPaymentMethods($original) === array_values($payableWith); } /** @@ -2507,6 +2586,11 @@ private function parseIuguSubscription($iuguSubscription, ?Subscription $subscri $subscription->availablePaymentMethods = $this->iuguPayableWithToPaymentMethods( $iuguSubscription->payable_with ); + // a Iugu não diz com qual método a assinatura é cobrada: só há um quando ela + // aceita um único método + $subscription->paymentMethod = count($subscription->availablePaymentMethods) === 1 + ? $subscription->availablePaymentMethods[0] + : null; } // lista vazia também conta: é o que a Iugu devolve depois de remover a última variável @@ -2814,7 +2898,7 @@ private function parseIuguRecentInvoice(object $iuguSubscription): ?Invoice ? self::iuguStatusToMultiPayment($iuguInvoice->status) : null; - $invoice->expiresAt = !empty($iuguInvoice->due_date) + $invoice->dueDate = !empty($iuguInvoice->due_date) ? new Carbon($iuguInvoice->due_date) : null; $invoice->url = $iuguInvoice->secure_url ?? null; diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index d9332f1..9a4d2cb 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -728,26 +728,21 @@ public function createInvoice(Invoice $invoice, ?string $idempotencyKey = null): */ private function invoicePaymentMethod(Invoice $invoice): PaymentMethod { - if (!empty($invoice->availablePaymentMethods)) { - if (count($invoice->availablePaymentMethods) > 1) { - throw UnsupportedOperationException::forGateway( - $this, - Capability::MULTIPLE_PAYMENT_METHODS, - 'Informe exatamente um método em availablePaymentMethods.' - ); - } - - // normaliza antes de ler: uma string apensada por `[]=` entra no array sem conversão - $methods = PaymentMethod::normalizeSelectable($invoice->availablePaymentMethods, 'Invoice'); + $methods = $invoice->resolvedPaymentMethods(); - return reset($methods); + if (count($methods) > 1) { + throw UnsupportedOperationException::forGateway( + $this, + Capability::MULTIPLE_PAYMENT_METHODS, + 'Informe exatamente um método em availablePaymentMethods.' + ); } - if (!empty($invoice->creditCard)) { - return PaymentMethod::CREDIT_CARD; + if (!empty($methods)) { + return reset($methods); } - throw ModelAttributeValidationException::required('Invoice', 'availablePaymentMethods'); + throw ModelAttributeValidationException::required('Invoice', 'paymentMethod or availablePaymentMethods'); } /** @@ -820,19 +815,9 @@ private function createPixInvoice(Invoice $invoice, ?string $idempotencyKey): In ]), ]; $stripePaymentIntentData['confirm'] = true; - if (!empty($invoice->expiresAt)) { - // janela aceita pela Stripe: mais de 10 segundos e menos de 14 dias no futuro. - // Na Iugu expires_at é due_date (date-only, "vence hoje" é válido) — falhar cedo - // evita o erro obscuro de parâmetro da API para quem vem dessa semântica - if ($invoice->expiresAt->lessThan(Carbon::now()->addSeconds(10)) - || $invoice->expiresAt->greaterThan(Carbon::now()->addDays(14))) { - throw ModelAttributeValidationException::invalid( - 'Invoice', - 'expiresAt', - 'expiresAt must be more than 10 seconds and less than 14 days in the future for pix invoices on the stripe gateway' - ); - } - $stripePaymentIntentData['payment_method_options']['pix']['expires_at'] = $invoice->expiresAt->getTimestamp(); + $pixExpiresAt = $this->pixExpiresAt($invoice); + if (!empty($pixExpiresAt)) { + $stripePaymentIntentData['payment_method_options']['pix']['expires_at'] = $pixExpiresAt->getTimestamp(); } $stripePaymentIntentData = $this->mergeGatewayOptions($stripePaymentIntentData, $invoice); @@ -846,6 +831,37 @@ private function createPixInvoice(Invoice $invoice, ?string $idempotencyKey): In return $this->parseInvoice($stripePaymentIntent, $invoice); } + /** + * Instante em que o QR Code do Pix expira: `pixExpiresAt` ou, na falta dele, o fim do dia + * de `dueDate` (o vencimento vale o dia inteiro, como na Iugu). Nulo quando a fatura não + * informa nenhum dos dois (a Stripe usa o padrão de 4 horas). A janela aceita pela Stripe + * (mais de 10 segundos e menos de 14 dias no futuro) é validada antes da requisição. + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @return Carbon|null + * @throws ModelAttributeValidationException + */ + private function pixExpiresAt(Invoice $invoice): ?Carbon + { + $attribute = !empty($invoice->pixExpiresAt) ? 'pixExpiresAt' : 'dueDate'; + $expiresAt = $invoice->pixExpiresAt ?? $invoice->dueDate?->copy()->endOfDay(); + if (empty($expiresAt)) { + return null; + } + + if ($expiresAt->lessThan(Carbon::now()->addSeconds(10)) + || $expiresAt->greaterThan(Carbon::now()->addDays(14))) { + throw ModelAttributeValidationException::invalid( + 'Invoice', + $attribute, + "{$attribute} must be more than 10 seconds and less than 14 days in the future for pix invoices on the stripe gateway" + . ($attribute === 'dueDate' ? ' (the QR Code expires at the end of the due date)' : '') + ); + } + + return $expiresAt; + } + /** * Recupera o CPF/CNPJ dos billing_details do PaymentMethod de um PaymentIntent pix — * o PaymentMethod pode já ter sido consumido (pix expirado), sobrando só a cópia @@ -1286,8 +1302,8 @@ private function parseFromPaymentIntent(StripePaymentIntent $stripePaymentIntent * MultiPayment (origem `INVOICE`). O PaymentIntent da fatura vem de `payments` * (`invoicePaymentIntent()`), e o charge dele alimenta valores, estornos e contestação como * na cobrança avulsa. Os line items vêm de `lines.data` (a primeira página, de até dez - * itens); `url` é a página hospedada da fatura; `expiresAt` é o `due_date`, quando a fatura - * tem um, ou a expiração do QR Code do Pix. + * itens); `url` é a página hospedada da fatura; `dueDate` é o `due_date`, quando a fatura + * tem um, e `pixExpiresAt` a expiração do QR Code do Pix. * * @param \Stripe\Invoice $stripeInvoice * @param \Potelo\MultiPayment\Models\Invoice|null $invoice @@ -1318,7 +1334,7 @@ private function parseFromStripeInvoice(StripeInvoice $stripeInvoice, ?Invoice $ $invoice->paidAt = !empty($paidAt) ? Carbon::createFromTimestamp($paidAt) : null; $invoice->fee = self::chargeFee($paidCharge); $invoice->createdAt = Carbon::createFromTimestamp($stripeInvoice->created); - $invoice->expiresAt = !empty($stripeInvoice->due_date) + $invoice->dueDate = !empty($stripeInvoice->due_date) ? Carbon::createFromTimestamp($stripeInvoice->due_date) : null; $invoice->url = $stripeInvoice->hosted_invoice_url ?? null; @@ -1528,7 +1544,7 @@ private function parseCardDetails(Invoice $invoice, ?object $stripeCharge): void } /** - * Preenche `pix` (QR Code) e `expiresAt` a partir de `next_action.pix_display_qr_code` do + * Preenche `pix` (QR Code) e `pixExpiresAt` a partir de `next_action.pix_display_qr_code` do * PaymentIntent e devolve a página hospedada de instruções. Sem QR Code, limpa `pix` e * devolve nulo. * @@ -1553,9 +1569,9 @@ private function parsePixDisplay(Invoice $invoice, ?StripePaymentIntent $stripeP } $invoice->pix->qrCodeText = $qrCode->data ?? null; $invoice->pix->qrCodeImageUrl = $qrCode->image_url_png ?? null; - $invoice->expiresAt = !empty($qrCode->expires_at) + $invoice->pixExpiresAt = !empty($qrCode->expires_at) ? Carbon::createFromTimestamp($qrCode->expires_at) - : $invoice->expiresAt; + : $invoice->pixExpiresAt; return $qrCode->hosted_instructions_url ?? null; } @@ -1932,7 +1948,7 @@ public function duplicateInvoice( $duplicated->amount = $parsedOriginal->amount; $duplicated->items = $parsedOriginal->items; $duplicated->availablePaymentMethods = [PaymentMethod::PIX]; - $duplicated->expiresAt = $expiresAt; + $duplicated->pixExpiresAt = $expiresAt; // preserva o metadata da original (inclusive chaves custom do consumidor); // as gatewayOptions do chamador vêm por último e podem sobrescrever $originalMetadata = !empty($original->metadata) ? $original->metadata->toArray() : []; diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 3c75020..fffdb5b 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -22,6 +22,7 @@ * @property PaymentMethod|null $paymentMethod Método com que a fatura foi (ou será) paga. * @property PaymentMethod[]|null $availablePaymentMethods Métodos aceitos pela fatura. * @property InvoiceOriginType|null $originType Objeto do gateway de onde a fatura foi lida (`PAYMENT_INTENT` ou `INVOICE`); `original` guarda esse objeto. + * @property Carbon|null $expiresAt Obsoleto desde 2026-09-02, use `$dueDate`. Alias que lê e escreve a mesma data, com aviso de deprecação. */ class Invoice extends Model { @@ -155,9 +156,23 @@ class Invoice extends Model public ?AutomaticPixCharge $automaticPixCharge = null; /** + * Data de vencimento da fatura. Na Iugu é o `due_date` (o dia; a fatura vencida continua + * pagável); no Stripe é o `due_date` da fatura de assinatura e, na venda avulsa por Pix sem + * `pixExpiresAt`, o fim desse dia vira a expiração do QR Code. + * * @var Carbon|null */ - public ?Carbon $expiresAt = null; + public ?Carbon $dueDate = null; + + /** + * Instante em que o QR Code do Pix deixa de aceitar pagamento. No Stripe vai em + * `payment_method_options.pix.expires_at` (entre 10 segundos e 14 dias no futuro) e volta + * na leitura; na Iugu vai em `pix_qr_code_expires_at` e a leitura só o preenche quando a + * fatura o devolve. + * + * @var Carbon|null + */ + public ?Carbon $pixExpiresAt = null; /** * @var int|null @@ -221,10 +236,20 @@ public function fill(array $data): void } if (!empty($data['expires_at'])) { - $this->expiresAt = Carbon::createFromFormat('Y-m-d', $data['expires_at']); + self::warnExpiresAtDeprecated(); + $data['due_date'] = $data['due_date'] ?? $data['expires_at']; unset($data['expires_at']); } + foreach (['due_date' => 'dueDate', 'pix_expires_at' => 'pixExpiresAt'] as $key => $attribute) { + if (!empty($data[$key])) { + $this->{$attribute} = $data[$key] instanceof Carbon + ? $data[$key] + : Carbon::parse($data[$key]); + unset($data[$key]); + } + } + if (!empty($data['credit_card']) && is_array($data['credit_card'])) { $this->creditCard = new CreditCard(); $this->creditCard->fill($data['credit_card']); @@ -260,6 +285,61 @@ public function attributesExtraValidation($attributes): void if (in_array('amount', $attributes) && in_array('items', $attributes) && empty($this->amount) && empty($this->items)) { throw ModelAttributeValidationException::required($model, 'amount or items'); } + + if (in_array('paymentMethod', $attributes) && in_array('creditCard', $attributes)) { + $this->resolvedPaymentMethods(); + } + + if ( + in_array('amount', $attributes) + && in_array('items', $attributes) + && !empty($this->amount) + && !empty($this->items) + && $this->amount !== $this->itemsTotal() + ) { + throw ModelAttributeValidationException::invalid( + $model, + 'amount', + "amount [{$this->amount}] must equal the sum of the items [{$this->itemsTotal()}]; omit it when items are given" + ); + } + } + + /** + * Soma dos itens (`price` vezes `quantity`, com quantidade 1 quando ausente), em centavos. + * + * @return int + */ + public function itemsTotal(): int + { + $total = 0; + foreach ($this->items ?? [] as $item) { + if ($item instanceof InvoiceItem) { + $total += (int) $item->price * (int) ($item->quantity ?? 1); + } + } + + return $total; + } + + /** + * Na escrita, `paymentMethod` precisa ser um método selecionável + * (`PaymentMethod::selectable()`); Pix Automático entra pela lista com `PIX` e por + * `automaticPix`. + * + * @return void + * @throws ModelAttributeValidationException + */ + public function validatePaymentMethodAttribute(): void + { + if (!in_array($this->paymentMethod, PaymentMethod::selectable(), true)) { + $accepted = implode(', ', array_column(PaymentMethod::selectable(), 'value')); + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'paymentMethod', + "paymentMethod must be one of: {$accepted}" + ); + } } /** @@ -341,16 +421,93 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate parent::save($gateway, false, $idempotencyKey); } + /** + * Métodos de pagamento com que a fatura será criada, na ordem de precedência que os + * drivers seguem: `availablePaymentMethods` quando preenchida (normalizada, porque uma + * string apensada por `[]=` entra no array sem conversão); senão `paymentMethod`; senão + * cartão, quando só `creditCard` foi informado. Lista vazia quando nada foi informado (a + * Iugu abre a fatura a todos os métodos da conta; o Stripe exige um). Lança + * `ModelAttributeValidationException` para valor fora de `PaymentMethod::selectable()`, + * para `paymentMethod` fora da lista informada e para `creditCard` sem cartão entre os + * métodos resultantes. + * + * @return PaymentMethod[] + * @throws ModelAttributeValidationException + */ + public function resolvedPaymentMethods(): array + { + if (!is_null($this->paymentMethod)) { + $this->validatePaymentMethodAttribute(); + } + + if (!empty($this->availablePaymentMethods)) { + $methods = array_values(array_unique( + PaymentMethod::normalizeSelectable($this->availablePaymentMethods, $this->getClassName()), + SORT_REGULAR + )); + self::assertPaymentMethodIsListed($this->getClassName(), $this->paymentMethod, $methods); + } elseif (!is_null($this->paymentMethod)) { + $methods = [$this->paymentMethod]; + } else { + $methods = !empty($this->creditCard) ? [PaymentMethod::CREDIT_CARD] : []; + } + + self::assertCreditCardIsPayable($this->getClassName(), $this->creditCard, $methods); + + return $methods; + } + + /** + * Lança `ModelAttributeValidationException` quando `paymentMethod` está preenchido e não + * consta da lista de métodos informada. + * + * @param string $model + * @param PaymentMethod|null $paymentMethod + * @param PaymentMethod[] $methods + * @return void + * @throws ModelAttributeValidationException + */ + public static function assertPaymentMethodIsListed(string $model, ?PaymentMethod $paymentMethod, array $methods): void + { + if (!is_null($paymentMethod) && !in_array($paymentMethod, $methods, true)) { + throw ModelAttributeValidationException::invalid( + $model, + 'paymentMethod', + "paymentMethod [{$paymentMethod->value}] must be one of availablePaymentMethods; change the list or leave it empty" + ); + } + } + + /** + * Lança `ModelAttributeValidationException` quando há cartão informado e cartão não está + * entre os métodos com que a fatura ou a assinatura será criada. + * + * @param string $model + * @param CreditCard|null $creditCard + * @param PaymentMethod[] $methods + * @return void + * @throws ModelAttributeValidationException + */ + public static function assertCreditCardIsPayable(string $model, ?CreditCard $creditCard, array $methods): void + { + if (!empty($creditCard) && !in_array(PaymentMethod::CREDIT_CARD, $methods, true)) { + throw ModelAttributeValidationException::invalid( + $model, + 'creditCard', + 'creditCard was given but credit_card is not among the payment methods; add it or remove the card' + ); + } + } + /** * Na criação, além do que o `Model` exige, a fatura precisa da capability de cada método - * selecionável em `availablePaymentMethods` (ou de cartão, quando só `creditCard` foi - * informado), de `MULTIPLE_PAYMENT_METHODS` quando há mais de um método, de + * de `resolvedPaymentMethods()`, de `MULTIPLE_PAYMENT_METHODS` quando há mais de um, de * `AUTOMATIC_PIX` quando `automaticPix` está preenchido e de `RAW_CARD_DATA` quando o - * cartão vem com os dados crus (sem `id` nem `token`). Valor fora de - * `PaymentMethod::selectable()` fica para a validação. Com `id` preenchido, só o que o + * cartão vem com os dados crus (sem `id` nem `token`). Com `id` preenchido, só o que o * `Model` exige. * * @return Capability[] + * @throws ModelAttributeValidationException método de pagamento fora de `PaymentMethod::selectable()` */ public function requiredCapabilities(): array { @@ -359,17 +516,7 @@ public function requiredCapabilities(): array return $capabilities; } - $methods = []; - foreach ((array) ($this->availablePaymentMethods ?? []) as $method) { - $case = $method instanceof PaymentMethod ? $method : (is_string($method) ? PaymentMethod::tryFrom($method) : null); - if (!is_null($case) && in_array($case, PaymentMethod::selectable(), true)) { - $methods[] = $case; - } - } - - if (empty($methods) && !empty($this->creditCard)) { - $methods[] = PaymentMethod::CREDIT_CARD; - } + $methods = $this->resolvedPaymentMethods(); foreach ($methods as $method) { $capabilities[] = Capability::forPaymentMethod($method); @@ -423,6 +570,74 @@ public static function isContested(InvoiceStatus|string $status): bool return self::statusFromHelperArgument($status)?->isContested() ?? false; } + /** + * Resolve a leitura do nome antigo `expiresAt` para `dueDate`, com aviso de deprecação; + * os demais nomes seguem o `Model`. + * + * @param string $name + * @return mixed + */ + public function &__get(string $name): mixed + { + if ($name === 'expiresAt') { + self::warnExpiresAtDeprecated(); + + return $this->dueDate; + } + + $value = &parent::__get($name); + + return $value; + } + + /** + * Resolve a escrita no nome antigo `expiresAt` para `dueDate`, com aviso de deprecação; os + * demais nomes seguem o `Model`. + * + * @param string $name + * @param mixed $value + * @return void + */ + public function __set(string $name, mixed $value): void + { + if ($name === 'expiresAt') { + self::warnExpiresAtDeprecated(); + $this->dueDate = $value; + + return; + } + + parent::__set($name, $value); + } + + /** + * Mantém `isset()` e `empty()` funcionando sobre o nome antigo `expiresAt`. + * + * @param string $name + * @return bool + */ + public function __isset(string $name): bool + { + if ($name === 'expiresAt') { + return isset($this->dueDate); + } + + return parent::__isset($name); + } + + /** + * Emite o aviso de deprecação do nome antigo `expiresAt`. + * + * @return void + */ + private static function warnExpiresAtDeprecated(): void + { + trigger_error( + 'Invoice::$expiresAt está obsoleto desde 2026-09-02; use $dueDate (vencimento) ou $pixExpiresAt (expiração do QR Code)', + E_USER_DEPRECATED + ); + } + /** * Copia também os objetos aninhados que os drivers preenchem na leitura (`customer` e seu * `address`, `creditCard`, `bankSlip`, `pix`, `automaticPix`, `automaticPixCharge`), para diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index 8bb60d9..7b13f99 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -56,15 +56,29 @@ class Subscription extends Model protected const REQUIRED_CAPABILITY = Capability::SUBSCRIPTIONS; /** - * Além de `SUBSCRIPTIONS`, a assinatura precisa de `NATIVE_COUPONS` quando algum desconto é - * percentual ou limitado a mais de um ciclo. + * Além de `SUBSCRIPTIONS`, a assinatura precisa da capability de cada método de + * `resolvedPaymentMethods()` (e de `MULTIPLE_PAYMENT_METHODS` quando há mais de um), de + * `RAW_CARD_DATA` quando o cartão vem com os dados crus (sem `id` nem `token`) e de + * `NATIVE_COUPONS` quando algum desconto é percentual ou limitado a mais de um ciclo. * * @return Capability[] + * @throws ModelAttributeValidationException método de pagamento fora de `PaymentMethod::selectable()` */ public function requiredCapabilities(): array { $capabilities = parent::requiredCapabilities(); + $methods = $this->resolvedPaymentMethods(); + foreach ($methods as $method) { + $capabilities[] = Capability::forPaymentMethod($method); + } + if (count($methods) > 1) { + $capabilities[] = Capability::MULTIPLE_PAYMENT_METHODS; + } + if (!empty($this->creditCard) && empty($this->creditCard->id) && empty($this->creditCard->token)) { + $capabilities[] = Capability::RAW_CARD_DATA; + } + foreach ($this->discounts ?? [] as $discount) { if ( $discount instanceof SubscriptionDiscount @@ -117,6 +131,10 @@ public function requiredCapabilities(): array public ?int $amount = null; /** + * Método com que a assinatura é cobrada. Na escrita, quando `availablePaymentMethods` fica + * vazia, o driver a deriva dele; na leitura é preenchido quando a assinatura aceita um + * único método. + * * @var PaymentMethod|null */ protected ?PaymentMethod $paymentMethod = null; @@ -126,6 +144,25 @@ public function requiredCapabilities(): array */ protected ?array $availablePaymentMethods = null; + /** + * Cartão que a assinatura cobra. Informar o cartão implica `paymentMethod` de cartão. + * Cartão sem `id` (token ou dados crus) é salvo no cliente ao criar a assinatura. Na Iugu + * a assinatura cobra o cartão padrão do cliente, então o cartão informado passa a ser o + * padrão; a leitura não o preenche. + * + * @var CreditCard|null + */ + public ?CreditCard $creditCard = null; + + /** + * Duração do período de teste em dias, contada do momento da requisição. O driver a + * converte em `trialEndsAt` e a zera, então o model devolvido traz a data; incompatível + * com `trialEndsAt` preenchido. + * + * @var int|null + */ + public ?int $trialDays = null; + /** * @var Carbon|null */ @@ -213,6 +250,12 @@ public function fill(array $data): void $data['customer'] = $customer; } + if (!empty($data['credit_card']) && is_array($data['credit_card'])) { + $creditCard = new CreditCard(); + $creditCard->fill($data['credit_card']); + $data['credit_card'] = $creditCard; + } + if (!empty($data['latest_invoice']) && is_array($data['latest_invoice'])) { $invoice = new Invoice(); $invoice->fill($data['latest_invoice']); @@ -272,7 +315,7 @@ public function toArray(): array } } - foreach (['customer', 'latest_invoice'] as $key) { + foreach (['customer', 'credit_card', 'latest_invoice'] as $key) { if (!empty($array[$key])) { $array[$key] = $array[$key]->toArray(); } @@ -281,6 +324,84 @@ public function toArray(): array return $array; } + /** + * Método de pagamento com que a assinatura será criada quando `availablePaymentMethods` + * está vazia: `paymentMethod` quando informado; senão cartão, quando `creditCard` foi + * informado; senão nulo (o gateway usa o padrão dele). + * + * @return PaymentMethod|null + */ + public function resolvedPaymentMethod(): ?PaymentMethod + { + if (!is_null($this->paymentMethod)) { + return $this->paymentMethod; + } + + return !empty($this->creditCard) ? PaymentMethod::CREDIT_CARD : null; + } + + /** + * Métodos de pagamento com que a assinatura será criada, na ordem de precedência que os + * drivers seguem: `availablePaymentMethods` quando preenchida (normalizada, porque uma + * string apensada por `[]=` entra no array sem conversão); senão o método de + * `resolvedPaymentMethod()`; senão lista vazia. Lança `ModelAttributeValidationException` + * para valor fora de `PaymentMethod::selectable()`, para `paymentMethod` fora da lista + * informada e para `creditCard` sem cartão entre os métodos resultantes (num model lido do + * gateway a lista vem preenchida: para trocar o método, troque a lista ou a zere). + * + * @return PaymentMethod[] + * @throws ModelAttributeValidationException + */ + public function resolvedPaymentMethods(): array + { + if (!is_null($this->paymentMethod)) { + $this->validatePaymentMethodAttribute(); + } + + if (!empty($this->availablePaymentMethods)) { + $methods = array_values(array_unique( + PaymentMethod::normalizeSelectable($this->availablePaymentMethods, $this->getClassName()), + SORT_REGULAR + )); + Invoice::assertPaymentMethodIsListed($this->getClassName(), $this->paymentMethod, $methods); + } else { + $method = $this->resolvedPaymentMethod(); + $methods = is_null($method) ? [] : [$method]; + } + + Invoice::assertCreditCardIsPayable($this->getClassName(), $this->creditCard, $methods); + + return $methods; + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateCreditCardAttribute(): void + { + $this->creditCard->validate(); + } + + /** + * Na escrita, `paymentMethod` precisa ser um método selecionável + * (`PaymentMethod::selectable()`). + * + * @return void + * @throws ModelAttributeValidationException + */ + protected function validatePaymentMethodAttribute(): void + { + if (!in_array($this->paymentMethod, PaymentMethod::selectable(), true)) { + $accepted = implode(', ', array_column(PaymentMethod::selectable(), 'value')); + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'paymentMethod', + "paymentMethod must be one of: {$accepted}" + ); + } + } + /** * @return void * @throws ModelAttributeValidationException @@ -349,14 +470,37 @@ protected function validateAvailablePaymentMethodsAttribute(): void */ protected function attributesExtraValidation(array $attributes): void { + $model = $this->getClassName(); + + // zero é vazio para o validate() do Model, então o mínimo não pode depender de + // validateTrialDaysAttribute() + if (in_array('trialDays', $attributes) && !is_null($this->trialDays) && $this->trialDays < 1) { + throw ModelAttributeValidationException::invalid($model, 'trialDays', 'trialDays must be at least 1'); + } + + if ( + in_array('trialDays', $attributes) + && in_array('trialEndsAt', $attributes) + && !is_null($this->trialDays) + && !empty($this->trialEndsAt) + ) { + throw ModelAttributeValidationException::invalid( + $model, + 'trialDays', + 'trialDays and trialEndsAt are mutually exclusive' + ); + } + + if (in_array('paymentMethod', $attributes) && in_array('creditCard', $attributes)) { + $this->resolvedPaymentMethods(); + } + // com id preenchido é update, que aceita atributo parcial: cliente e plano não vão no // payload e não são exigidos if (!empty($this->id)) { return; } - $model = $this->getClassName(); - if (in_array('customer', $attributes) && empty($this->customer)) { throw ModelAttributeValidationException::required($model, 'customer'); } diff --git a/src/MultiPayment.php b/src/MultiPayment.php index 1830637..a98ba51 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -106,20 +106,24 @@ public function notYetImplemented($gateway = null): array } /** - * Charge a customer + * Cria e cobra uma fatura a partir de um array em `snake_case` (as chaves aceitas estão no + * README, no apêndice "Chaves do array de charge()"). `customer` é obrigatório e é + * conferido antes de qualquer conversão. * * @param array $attributes - * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Invoice * @throws GatewayException|ModelAttributeValidationException|GatewayNotAvailableException */ public function charge(array $attributes, ?string $idempotencyKey = null): Invoice { + if (empty($attributes['customer'])) { + throw ModelAttributeValidationException::required('Invoice', 'customer'); + } + $invoice = new Invoice(); $invoice->fill($attributes); - $invoice->customer = new Customer(); - $invoice->customer->fill($attributes['customer']); $invoice->save($this->gateway, true, $idempotencyKey); return $invoice; diff --git a/tests/Integration/Builders/InvoiceBuilderTest.php b/tests/Integration/Builders/InvoiceBuilderTest.php index 3831e63..27a0f4f 100644 --- a/tests/Integration/Builders/InvoiceBuilderTest.php +++ b/tests/Integration/Builders/InvoiceBuilderTest.php @@ -39,7 +39,7 @@ public function testShouldCreateAutomaticPixInvoice(): void '982345678' ) ->addItem('Automatic Pix sandbox test', 100, 1) - ->setExpiresAt(Carbon::now()->addDays(2)) + ->setDueDate(Carbon::now()->addDays(2)) ->addAutomaticPix( AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT, AutomaticPix::FREQUENCY_MONTHLY, @@ -94,8 +94,8 @@ private function createInvoice(string $gateway, array $data): Invoice foreach ($data['items'] as $item) { $invoiceBuilder->addItem($item['description'], $item['price'], $item['quantity']); } - if (isset($data['expiresAt'])) { - $invoiceBuilder->setExpiresAt($data['expiresAt']); + if (isset($data['dueDate'])) { + $invoiceBuilder->setDueDate($data['dueDate']); } if (isset($data['availablePaymentMethods'])) { $invoiceBuilder->setAvailablePaymentMethods($data['availablePaymentMethods']); @@ -165,8 +165,8 @@ public function testShouldCreateInvoice(string $gateway, array $data): void $this->assertEquals($item['quantity'], $invoice->items[$key]->quantity); } - if (isset($data['expiresAt'])) { - $this->assertEquals($data['expiresAt'], $invoice->expiresAt->format('Y-m-d')); + if (isset($data['dueDate'])) { + $this->assertEquals($data['dueDate'], $invoice->dueDate->format('Y-m-d')); } if (isset($data['paymentMethod'])) { @@ -235,8 +235,8 @@ public function testShouldCreateInvoice(string $gateway, array $data): void $this->assertEquals($item['quantity'], $invoice->items[$key]->quantity); } - if (isset($data['expiresAt'])) { - $this->assertEquals($data['expiresAt'], $invoice->expiresAt->format('Y-m-d')); + if (isset($data['dueDate'])) { + $this->assertEquals($data['dueDate'], $invoice->dueDate->format('Y-m-d')); } if (isset($data['paymentMethod']) && $invoice->status === InvoiceStatus::PAID) { @@ -268,7 +268,7 @@ public static function shouldCreateInvoiceDataProvider(): array 'iugu - without payment method' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithAddress(), ] @@ -276,7 +276,7 @@ public static function shouldCreateInvoiceDataProvider(): array 'iugu - without payment method - with adicional options' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithAddress(), 'gatewayOptions' => [ @@ -287,7 +287,7 @@ public static function shouldCreateInvoiceDataProvider(): array 'iugu - without payment method - with payable_with' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithAddress(), 'gatewayOptions' => [ @@ -298,7 +298,7 @@ public static function shouldCreateInvoiceDataProvider(): array 'iugu - company with address without payment method' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::companyWithAddress(), ] @@ -324,7 +324,7 @@ public static function shouldCreateInvoiceDataProvider(): array 'iugu - bank slip with address' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithAddress(), 'availablePaymentMethods' => ['bank_slip'], @@ -333,7 +333,7 @@ public static function shouldCreateInvoiceDataProvider(): array 'iugu - pix with address' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithAddress(), 'availablePaymentMethods' => ['pix'], @@ -342,7 +342,7 @@ public static function shouldCreateInvoiceDataProvider(): array 'iugu - pix without address' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithoutAddress(), 'availablePaymentMethods' => ['pix'], diff --git a/tests/Integration/IdempotencyTest.php b/tests/Integration/IdempotencyTest.php index 964889d..b245722 100644 --- a/tests/Integration/IdempotencyTest.php +++ b/tests/Integration/IdempotencyTest.php @@ -123,9 +123,9 @@ private function pixInvoiceBuilder(string $gateway, string $customerId, int $amo ->addAvailablePaymentMethod(PaymentMethod::PIX) ->setCustomer($this->customerWithId($gateway, $customerId)) ->addItem('Idempotency sandbox test', $amount, 1) - // data fixa: um expires_at derivado do instante da chamada mudaria o payload entre as - // tentativas, e a Stripe recusa a mesma chave com payload diferente - ->setExpiresAt(now()->addDays(2)->startOfDay()); + // data fixa: uma expiração derivada do instante da chamada mudaria o payload entre + // as tentativas, e a Stripe recusa a mesma chave com payload diferente + ->setDueDate(now()->addDays(2)->startOfDay()); } private function customerWithId(string $gateway, string $customerId): \Potelo\MultiPayment\Models\Customer diff --git a/tests/Integration/MultiPaymentTest.php b/tests/Integration/MultiPaymentTest.php index 0ca5597..e161f24 100644 --- a/tests/Integration/MultiPaymentTest.php +++ b/tests/Integration/MultiPaymentTest.php @@ -41,7 +41,7 @@ public function testShouldGetAutomaticPixInvoice(): void '982345678' ) ->addItem('Automatic Pix sandbox test', 100, 1) - ->setExpiresAt(now()->addDays(2)) + ->setDueDate(now()->addDays(2)) ->addAutomaticPix( AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT, AutomaticPix::FREQUENCY_MONTHLY, @@ -280,7 +280,7 @@ public function testShouldDuplicateInvoice() $new = $multiPayment->duplicateInvoice($invoice->id, now()->addDays(7)); $this->assertNotEquals($new->id, $invoice->id); $this->assertEquals($new->status, InvoiceStatus::PENDING); - $this->assertTrue($new->expiresAt->isSameDay((now()->addDays(7)))); + $this->assertTrue($new->dueDate->isSameDay((now()->addDays(7)))); } @@ -498,4 +498,32 @@ public static function shouldChargeInvoiceWithCreditCard(): array ], ]; } + + /** + * `pixExpiresAt` vai em `pix_qr_code_expires_at` e a Iugu aceita a fatura; `dueDate` + * ausente vira o dia em que o QR Code expira. A Iugu não devolve o campo na fatura, então + * a releitura só confirma o vencimento. + * + * @return void + */ + public function testShouldCreateAPixInvoiceWithItsOwnQrCodeExpiryOnIugu(): void + { + $pixExpiresAt = now()->addDays(2)->setTime(18, 0); + $invoice = MultiPayment::setGateway('iugu')->newInvoice() + ->setPaymentMethod(PaymentMethod::PIX) + ->addCustomer('Fake Customer', 'email@exemplo.com', '20176996915') + ->addItem('teste', 1000, 1) + ->setPixExpiresAt($pixExpiresAt) + ->create(); + + $this->assertSame(InvoiceStatus::PENDING, $invoice->status); + $this->assertSame([PaymentMethod::PIX], $invoice->availablePaymentMethods); + $this->assertNotEmpty($invoice->pix->qrCodeText); + $this->assertSame($pixExpiresAt->format('Y-m-d'), $invoice->dueDate->format('Y-m-d')); + + $relida = MultiPayment::setGateway('iugu')->getInvoice($invoice->id); + $this->assertSame($pixExpiresAt->format('Y-m-d'), $relida->dueDate->format('Y-m-d')); + + MultiPayment::setGateway('iugu')->cancelInvoice($invoice->id); + } } diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index 19dcc53..8a9ac06 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -205,7 +205,7 @@ public function testShouldCreatePixInvoiceAndReceiveMagicPayment($gateway) ) ->addItem('Assinatura mensal', 12345, 1) ->setAvailablePaymentMethods([PaymentMethod::PIX]) - ->setExpiresAt(\Carbon\Carbon::now()->addHour()) + ->setPixExpiresAt(\Carbon\Carbon::now()->addHour()) ->create(); $this->assertNotNull($invoice->id); @@ -215,7 +215,8 @@ public function testShouldCreatePixInvoiceAndReceiveMagicPayment($gateway) $this->assertNotNull($invoice->pix->qrCodeText); $this->assertNotNull($invoice->pix->qrCodeImageUrl); $this->assertNotNull($invoice->url); - $this->assertNotNull($invoice->expiresAt); + $this->assertNotNull($invoice->pixExpiresAt); + $this->assertNull($invoice->dueDate); // além do pagamento mágico, espera a balance transaction (fee) materializar $invoiceFetched = $this->waitForInvoiceCondition($gateway, $invoice->id, function (Invoice $fetched) { @@ -384,7 +385,7 @@ public function testShouldDuplicatePendingPixInvoice($gateway) ->addCustomer($customerData['name'], $customerData['email'], $customerData['taxDocument']) ->addItem('Assinatura mensal', 5000, 1) ->setAvailablePaymentMethods([PaymentMethod::PIX]) - ->setExpiresAt(\Carbon\Carbon::now()->addHour()) + ->setPixExpiresAt(\Carbon\Carbon::now()->addHour()) ->create(); $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); @@ -399,7 +400,7 @@ public function testShouldDuplicatePendingPixInvoice($gateway) $this->assertNotNull($invoiceDuplicated->pix->qrCodeText); $this->assertEqualsWithDelta( $newExpiresAt->getTimestamp(), - $invoiceDuplicated->expiresAt->getTimestamp(), + $invoiceDuplicated->pixExpiresAt->getTimestamp(), 60 ); $this->assertEquals($invoice->customer->id, $invoiceDuplicated->customer->id); @@ -539,4 +540,34 @@ public function testShouldRejectBankSlipInvoice($gateway) $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); } } + + /** + * Sem `pixExpiresAt`, o QR Code expira no fim do dia de `dueDate`, e a Stripe aceita um + * vencimento de hoje. + * + * @param string $gateway + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCreateAPixInvoiceExpiringAtTheEndOfTheDueDate(string $gateway): void + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer($customerData['name'], $customerData['email'], $customerData['taxDocument']) + ->addItem('Assinatura mensal', 5000, 1) + ->setPaymentMethod(PaymentMethod::PIX) + ->setDueDate(\Carbon\Carbon::today()) + ->create(); + + $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); + $this->assertEquals(PaymentMethod::PIX, $invoice->paymentMethod); + $this->assertEqualsWithDelta( + \Carbon\Carbon::today()->endOfDay()->getTimestamp(), + $invoice->pixExpiresAt->getTimestamp(), + 60 + ); + $this->assertSame(\Carbon\Carbon::today()->format('Y-m-d'), $invoice->dueDate->format('Y-m-d')); + + MultiPayment::setGateway($gateway)->cancelInvoice($invoice->id); + } } diff --git a/tests/Integration/SubscriptionTest.php b/tests/Integration/SubscriptionTest.php index cc7cb53..4a661a8 100644 --- a/tests/Integration/SubscriptionTest.php +++ b/tests/Integration/SubscriptionTest.php @@ -8,6 +8,7 @@ use Potelo\MultiPayment\Models\Plan; use Potelo\MultiPayment\Tests\TestCase; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Models\SubscriptionItem; use Potelo\MultiPayment\Facades\MultiPayment; @@ -340,7 +341,7 @@ public function testShouldChangePlanGeneratingTheCharge(): void $this->assertSame(InvoiceStatus::PENDING, $trocada->latestInvoice->status); // a cobrança é imediata, e não a do próximo ciclo; comparar contra now() traria o fuso // do gateway para dentro do teste - $this->assertTrue($trocada->latestInvoice->expiresAt->lessThan($proximaCobranca)); + $this->assertTrue($trocada->latestInvoice->dueDate->lessThan($proximaCobranca)); // o resumo de recent_invoices traz o valor formatado, sem centavos, e sem secure_url $this->assertSame('R$ 300,00', $trocada->latestInvoice->original->total); $this->assertNull($trocada->latestInvoice->amount); @@ -365,4 +366,50 @@ private function discount(string $description, int $amountOff): SubscriptionDisc return $discount; } + + /** + * Assinatura com cartão e trial em dias: o cartão informado sem id é salvo no cliente como + * padrão, `payable_with` fica só com cartão, `paymentMethod` volta preenchido e o trial + * termina no dia calculado pela lib. + * + * @return void + */ + public function testShouldCreateASubscriptionWithACardAndTrialDays(): void + { + $plan = $this->createPlan(10000, 'cartao'); + $customer = $this->createCustomer(self::GATEWAY, $this->customerWithoutAddress()); + $data = $this->creditCard(); + + $card = new CreditCard(); + $card->number = $data['number']; + $card->month = $data['month']; + $card->year = $data['year']; + $card->cvv = $data['cvv']; + $card->firstName = $data['firstName']; + $card->lastName = $data['lastName']; + + $subscription = MultiPayment::setGateway(self::GATEWAY)->newSubscription() + ->setPlanId($plan->identifier) + ->setCustomerId($customer->id) + ->setCreditCard($card) + ->setTrialDays(7) + ->withIdempotencyKey('multipayment-teste-cartao-' . now()->format('YmdHisu')) + ->create(); + $this->criados['subscriptions'][] = $subscription->id; + + $this->assertNotEmpty($subscription->creditCard->id); + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->paymentMethod); + $this->assertSame([PaymentMethod::CREDIT_CARD], $subscription->availablePaymentMethods); + $this->assertTrue($subscription->status->isActive()); + $this->assertSame(now()->addDays(7)->format('Y-m-d'), $subscription->trialEndsAt->format('Y-m-d')); + $this->assertSame(now()->addDays(7)->format('Y-m-d'), $subscription->nextBillingAt->format('Y-m-d')); + // com only_charge_on_due_date a Iugu não cobra o cartão na criação: a fatura do primeiro + // ciclo, quando já existe, fica em aberto + if (!is_null($subscription->latestInvoice)) { + $this->assertFalse($subscription->latestInvoice->status->isSettled()); + } + + $relido = MultiPayment::setGateway(self::GATEWAY)->getCustomer($customer->id); + $this->assertSame($subscription->creditCard->id, $relido->defaultCard->id); + } } diff --git a/tests/Unit/CapabilityGuardsTest.php b/tests/Unit/CapabilityGuardsTest.php index 5ba16ea..d940305 100644 --- a/tests/Unit/CapabilityGuardsTest.php +++ b/tests/Unit/CapabilityGuardsTest.php @@ -19,6 +19,7 @@ use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; +use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use Potelo\MultiPayment\Tests\Unit\Gateways\QueuedIuguApiRequest; use Potelo\MultiPayment\Tests\Unit\Gateways\RecordingStripeHttpClient; @@ -218,15 +219,57 @@ public function testTheFirstMissingCapabilityIsThePaymentMethod(): void } /** - * `requiredCapabilities()` ignora método fora de `PaymentMethod::selectable()`, como - * `AUTOMATIC_PIX` em `availablePaymentMethods`. + * `requiredCapabilities()` recusa método fora de `PaymentMethod::selectable()`, como + * `AUTOMATIC_PIX` em `availablePaymentMethods` ou em `paymentMethod`, com + * `ModelAttributeValidationException`. */ - public function testInvoiceRequiredCapabilitiesIgnoreNonSelectableMethods(): void + public function testInvoiceRequiredCapabilitiesRejectNonSelectableMethods(): void { $invoice = new Invoice(); $invoice->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX]; - $this->assertSame([], $invoice->requiredCapabilities()); + try { + $invoice->requiredCapabilities(); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('availablePaymentMethods must be one of', $e->getMessage()); + } + + $byMethod = new Invoice(); + $byMethod->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + try { + $byMethod->requiredCapabilities(); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('paymentMethod must be one of', $e->getMessage()); + } + } + + /** + * Com a lista vazia, `paymentMethod` decide a capability exigida, e a fatura de boleto no + * Stripe falha pelo array de `charge()` antes de criar o cliente. + */ + public function testInvoiceRequiredCapabilitiesDeriveFromPaymentMethodWhenTheListIsEmpty(): void + { + $invoice = new Invoice(); + $invoice->paymentMethod = PaymentMethod::BANK_SLIP; + $this->assertSame([Capability::BANK_SLIP], $invoice->requiredCapabilities()); + + // a lista tem precedência sobre o método, que precisa constar dela + $invoice->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::BANK_SLIP]; + $this->assertSame( + [Capability::PIX, Capability::BANK_SLIP, Capability::MULTIPLE_PAYMENT_METHODS], + $invoice->requiredCapabilities() + ); + + $multiPayment = new MultiPayment('stripe'); + $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $multiPayment->charge([ + 'items' => [['description' => 'Mensalidade', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'bank_slip', + 'customer' => ['name' => 'Fulano', 'email' => 'fulano@exemplo.com'], + ])); + $this->assertSame([], $this->stripeHttp->calls); } public function testInvoiceRequiredCapabilitiesDeriveFromTheAttributes(): void diff --git a/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php index 95dc71d..af48bef 100644 --- a/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php +++ b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php @@ -842,7 +842,7 @@ private static function pixInvoiceModel(): Invoice $invoice = new Invoice(); $invoice->customer = self::customerWithId(); $invoice->availablePaymentMethods = [PaymentMethod::PIX]; - $invoice->expiresAt = Carbon::parse('2026-10-01'); + $invoice->dueDate = Carbon::parse('2026-10-01'); $item = new InvoiceItem(); $item->description = 'Item'; $item->price = 10000; diff --git a/tests/Unit/Gateways/IuguGatewayInvoiceTest.php b/tests/Unit/Gateways/IuguGatewayInvoiceTest.php index bc33787..e9f45f0 100644 --- a/tests/Unit/Gateways/IuguGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/IuguGatewayInvoiceTest.php @@ -9,6 +9,8 @@ use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Enums\PaymentMethod; +use Carbon\Carbon; +use PHPUnit\Framework\Attributes\DataProvider; class IuguGatewayInvoiceTest extends TestCase { @@ -25,6 +27,7 @@ protected function setUp(): void protected function tearDown(): void { + Carbon::setTestNow(); QueuedIuguApiRequest::restoreSdkRequester(); Facade::clearResolvedInstances(); Facade::setFacadeApplication(null); @@ -44,7 +47,7 @@ public function testCreateInvoiceMergesGatewayOptionsIntoIuguPayload(): void $invoice->fill([ 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], - 'expires_at' => '2026-10-01', + 'due_date' => '2026-10-01', 'gateway_options' => ['expires_in' => 5, 'payable_with' => ['bank_slip', 'pix']], ]); @@ -73,7 +76,7 @@ public function testCreateInvoiceSendsAvailablePaymentMethodsAsIuguStrings(): vo $invoice->fill([ 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], - 'expires_at' => '2026-10-01', + 'due_date' => '2026-10-01', ]); $invoice->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; $invoice->availablePaymentMethods[] = 'pix'; @@ -120,7 +123,7 @@ public function testCreateInvoiceWithoutGatewayOptionsKeepsTheDefaultExpiresIn() $invoice->fill([ 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], - 'expires_at' => '2026-10-01', + 'due_date' => '2026-10-01', ]); (new IuguGateway($api))->createInvoice($invoice); @@ -161,4 +164,188 @@ private function pendingInvoiceResponse(): object 'variables' => [], ]; } + + /** + * `paymentMethod` de cartão com `availablePaymentMethods` vazia cobra o cartão por + * `POST /charge`, com `payable_with` só de cartão. + */ + public function testPaymentMethodAloneWithASavedCardChargesTheCard(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => true, 'invoice_id' => 'inv_1'], + $this->pendingInvoiceResponse(), + ]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'credit_card', + 'credit_card' => ['id' => 'pm_1'], + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertStringEndsWith('/charge', $api->calls[0]['url']); + $this->assertSame('pm_1', $api->calls[0]['data']['customer_payment_method_id']); + $this->assertSame(['credit_card'], $api->calls[0]['data']['payable_with']); + } + + public function testACardAloneChargesTheCard(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => true, 'invoice_id' => 'inv_1'], + $this->pendingInvoiceResponse(), + ]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'credit_card' => ['id' => 'pm_1'], + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertStringEndsWith('/charge', $api->calls[0]['url']); + $this->assertSame(['credit_card'], $api->calls[0]['data']['payable_with']); + } + + /** + * Cartão como método sem `creditCard` abre a fatura só a cartão por `POST /invoices`, sem + * cobrança. + */ + public function testPaymentMethodCardWithoutACardOpensTheInvoiceWithoutCharging(): void + { + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'credit_card', + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertCount(1, $api->calls); + $this->assertStringEndsWith('/invoices', $api->calls[0]['url']); + $this->assertSame(['credit_card'], $api->calls[0]['data']['payable_with']); + } + + public function testPaymentMethodAloneWithPixOpensThePixInvoice(): void + { + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => PaymentMethod::PIX, + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertStringEndsWith('/invoices', $api->calls[0]['url']); + $this->assertSame(['pix'], $api->calls[0]['data']['payable_with']); + } + + /** + * `dueDate` vai em `due_date` (só o dia) e `pixExpiresAt` em `pix_qr_code_expires_at` + * (ISO 8601 com hora); sem `dueDate`, o vencimento é o dia em que o QR Code expira. + */ + #[DataProvider('datesProvider')] + public function testDueDateAndPixExpiresAtGoToTheirOwnIuguFields(array $data, string $dueDate, ?string $pixExpiresAt): void + { + Carbon::setTestNow('2026-09-02 10:00:00'); + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill(array_merge([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + ], $data)); + + (new IuguGateway($api))->createInvoice($invoice); + + $payload = $api->calls[0]['data']; + $this->assertSame($dueDate, $payload['due_date']); + $this->assertSame($pixExpiresAt, $payload['pix_qr_code_expires_at'] ?? null); + } + + public static function datesProvider(): array + { + return [ + 'so vencimento' => [['due_date' => '2026-10-01'], '2026-10-01', null], + 'vencimento e expiracao do QR' => [ + ['due_date' => '2026-10-01', 'pix_expires_at' => '2026-09-30T18:00:00-03:00'], + '2026-10-01', + '2026-09-30T18:00:00-03:00', + ], + 'so expiracao do QR' => [ + ['pix_expires_at' => '2026-09-30T18:00:00-03:00'], + '2026-09-30', + '2026-09-30T18:00:00-03:00', + ], + 'nenhuma' => [[], '2026-09-02', null], + ]; + } + + /** + * O método pedido na escrita fica no model só enquanto a Iugu não informa o método com que + * a fatura foi paga. + */ + public function testParseOverwritesTheRequestedPaymentMethodWithTheOneTheInvoiceWasPaidWith(): void + { + $paid = $this->pendingInvoiceResponse(); + $paid->status = 'paid'; + $paid->payment_method = 'iugu_credit_card'; + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse(), $paid]))->installAsSdkRequester(); + $gateway = new IuguGateway($api); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'pix', + 'available_payment_methods' => ['pix', 'credit_card'], + ]); + + $gateway->createInvoice($invoice); + $this->assertSame(PaymentMethod::PIX, $invoice->paymentMethod); + + $gateway->getInvoice($invoice); + $this->assertSame(PaymentMethod::CREDIT_CARD, $invoice->paymentMethod); + } + + public function testParseReadsDueDateAndKeepsThePixExpiryTheModelHad(): void + { + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'pix_expires_at' => '2026-09-30T18:00:00-03:00', + ]); + + $result = (new IuguGateway($api))->createInvoice($invoice); + + $this->assertSame('2026-10-01', $result->dueDate->format('Y-m-d')); + $this->assertSame('2026-09-30T18:00:00-03:00', $result->pixExpiresAt->toIso8601String()); + } + + public function testParseReadsThePixExpiryWhenTheInvoiceBringsIt(): void + { + $response = $this->pendingInvoiceResponse(); + $response->pix_qr_code_expires_at = '2026-10-01T12:00:00-03:00'; + $api = (new QueuedIuguApiRequest([$response]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + $result = (new IuguGateway($api))->getInvoice($invoice); + + $this->assertSame('2026-10-01T12:00:00-03:00', $result->pixExpiresAt->toIso8601String()); + $this->assertSame('2026-10-01', $result->dueDate->format('Y-m-d')); + } } diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index 707e31a..7096c12 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -9,6 +9,9 @@ use Illuminate\Support\Facades\Facade; use Potelo\MultiPayment\Models\Plan; use Potelo\MultiPayment\Models\Customer; +use Potelo\MultiPayment\Models\CreditCard; +use Potelo\MultiPayment\Builders\SubscriptionBuilder; +use Potelo\MultiPayment\Idempotency\InMemoryIdempotencyStore; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Gateways\IuguGateway; @@ -34,7 +37,10 @@ protected function setUp(): void $app = new Container(); $app->instance('config', new Repository([ - 'multi-payment.gateways.iugu.api_key' => 'test-api-key', + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => ['iugu' => ['api_key' => 'test-api-key', 'class' => IuguGateway::class]], + ], ])); Facade::setFacadeApplication($app); } @@ -42,6 +48,7 @@ protected function setUp(): void protected function tearDown(): void { Carbon::setTestNow(); + QueuedIuguApiRequest::restoreSdkRequester(); Facade::clearResolvedInstances(); Facade::setFacadeApplication(null); @@ -2297,6 +2304,7 @@ public function testRecentInvoicesFromTheGatewayAreAcceptedAsAssociativeArrays() $subscription = (new IuguGateway($api))->getSubscription($subscription); $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertSame('2026-08-01', $subscription->latestInvoice->dueDate->format('Y-m-d')); $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); } @@ -2323,4 +2331,313 @@ public function testAResponseListingNoUsableInvoiceClearsTheStoredOne(): void $this->assertNull($gateway->getSubscription($subscription)->latestInvoice); } + + private function cardResponse(string $id = 'pm_1'): object + { + return (object) [ + 'id' => $id, + 'description' => 'CREDIT CARD', + 'created_at_iso' => '2026-09-02T09:00:00-03:00', + 'data' => (object) ['brand' => 'VISA', 'display_number' => 'XXXX-XXXX-XXXX-4242', 'month' => 12, 'year' => 2030, 'holder_name' => 'Cliente'], + ]; + } + + /** + * A assinatura da Iugu cobra o cartão padrão do cliente: o cartão salvo informado vira o + * padrão por um `PUT` no cliente antes de `POST /subscriptions`, e `payable_with` sai só + * com cartão. + */ + public function testCreateSubscriptionWithASavedCardMakesItTheCustomerDefaultAndPaysWithCard(): void + { + $api = new QueuedIuguApiRequest([(object) ['id' => 'cus_1'], $this->subscriptionResponse(['payable_with' => 'credit_card'])]); + + $subscription = (new SubscriptionBuilder(new IuguGateway($api))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setCreditCard('pm_1') + ->create(); + + $this->assertCount(2, $api->calls); + $this->assertSame('PUT', $api->calls[0]['method']); + $this->assertStringEndsWith('/customers/cus_1', $api->calls[0]['url']); + $this->assertSame(['default_payment_method_id' => 'pm_1'], $api->calls[0]['data']); + $this->assertSame('POST', $api->calls[1]['method']); + $this->assertStringEndsWith('/subscriptions', $api->calls[1]['url']); + $this->assertSame(['credit_card'], $api->calls[1]['data']['payable_with']); + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->paymentMethod); + $this->assertSame('pm_1', $subscription->creditCard->id); + $this->assertTrue($subscription->creditCard->default); + } + + /** + * Cartão sem id é salvo no cliente já como padrão (`set_as_default`) antes da assinatura. + */ + public function testCreateSubscriptionWithATokenizedCardSavesItAsTheDefaultFirst(): void + { + $api = new QueuedIuguApiRequest([$this->cardResponse('pm_novo'), $this->subscriptionResponse()]); + + $card = new CreditCard(); + $card->token = 'tok_1'; + $subscription = (new SubscriptionBuilder(new IuguGateway($api))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setCreditCard($card) + ->create(); + + $this->assertCount(2, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith('/customers/cus_1/payment_methods', $api->calls[0]['url']); + $this->assertSame('tok_1', $api->calls[0]['data']['token']); + $this->assertTrue($api->calls[0]['data']['set_as_default']); + $this->assertSame(['credit_card'], $api->calls[1]['data']['payable_with']); + $this->assertSame('pm_novo', $subscription->creditCard->id); + } + + public function testCreateSubscriptionWithATokenizedCardUsesTheDerivedCardKey(): void + { + $api = new QueuedIuguApiRequest([$this->cardResponse('pm_novo'), $this->subscriptionResponse()]); + $store = new InMemoryIdempotencyStore(); + + $card = new CreditCard(); + $card->token = 'tok_1'; + (new SubscriptionBuilder(new IuguGateway($api, $store))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setCreditCard($card) + ->withIdempotencyKey('sub-1') + ->create(); + + $this->assertTrue($store->has('iugu:sub-1:card')); + $this->assertSame([], $api->calls[0]['headers']); + $this->assertSame(['Idempotency-Key: sub-1'], $api->calls[1]['headers']); + } + + public function testCreateSubscriptionWithOnlyAPaymentMethodDerivesPayableWithWithoutTouchingTheCustomer(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['payable_with' => 'pix'])]); + + $subscription = (new SubscriptionBuilder(new IuguGateway($api))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setPaymentMethod(PaymentMethod::PIX) + ->create(); + + $this->assertCount(1, $api->calls); + $this->assertSame(['pix'], $api->calls[0]['data']['payable_with']); + $this->assertSame(PaymentMethod::PIX, $subscription->paymentMethod); + } + + public function testAvailablePaymentMethodsTakePrecedenceOverPaymentMethod(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1'], 'payment_method' => 'pix']); + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP, PaymentMethod::PIX]; + + (new IuguGateway($api))->createSubscription($subscription); + + $this->assertSame(['bank_slip', 'pix'], $api->calls[0]['data']['payable_with']); + } + + /** + * `trialDays` vira `expires_at` contado do momento da requisição, com + * `only_charge_on_due_date` para a Iugu não cobrar o cartão na criação, e a chave de + * idempotência vai intacta no cabeçalho de `POST /subscriptions`; o cartão padrão usa a + * chave derivada `{chave}:default` pela store. + */ + public function testTrialDaysBecomesExpiresAtCountedFromNowWithoutChangingTheIdempotencyKey(): void + { + Carbon::setTestNow('2026-09-15 12:00:00'); + $api = new QueuedIuguApiRequest([(object) ['id' => 'cus_1'], $this->subscriptionResponse(['in_trial' => true, 'expires_at' => '2026-09-22'])]); + $store = new InMemoryIdempotencyStore(); + + $subscription = (new SubscriptionBuilder(new IuguGateway($api, $store))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setCreditCard('pm_1') + ->setTrialDays(7) + ->withIdempotencyKey('sub-1') + ->create(); + + $this->assertSame('2026-09-22', $api->calls[1]['data']['expires_at']); + $this->assertTrue($api->calls[1]['data']['only_charge_on_due_date']); + $this->assertSame(['Idempotency-Key: sub-1'], $api->calls[1]['headers']); + $this->assertSame([], $api->calls[0]['headers']); + $this->assertTrue($store->has('iugu:sub-1:default')); + $this->assertSame('2026-09-22', $subscription->trialEndsAt->format('Y-m-d')); + $this->assertNull($subscription->trialDays); + } + + /** + * Um model criado com `trialDays` pode ser salvo de novo: os dias viraram a data. + */ + public function testAModelCreatedWithTrialDaysCanBeSavedAgain(): void + { + Carbon::setTestNow('2026-09-15 12:00:00'); + // o save() seguinte resolve o gateway pelo nome gravado no model, então o fake vai no + // requester compartilhado do SDK + $api = (new QueuedIuguApiRequest([ + $this->subscriptionResponse(['in_trial' => true, 'expires_at' => '2026-09-22']), + $this->subscriptionResponse(['in_trial' => true, 'expires_at' => '2026-09-22']), + ]))->installAsSdkRequester(); + + $subscription = (new SubscriptionBuilder(new IuguGateway($api))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setTrialDays(7) + ->create(); + $subscription->metadata = ['origem' => 'teste']; + $subscription->save(); + + $this->assertCount(2, $api->calls); + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertSame(['custom_variables' => [['name' => 'origem', 'value' => 'teste']]], $api->calls[1]['data']); + } + + /** + * Num model lido do gateway a lista de métodos vem preenchida, então trocar só + * `paymentMethod` ou informar um cartão fora dela é recusado antes de qualquer requisição. + */ + public function testChangingThePaymentMethodOfAReadModelRequiresChangingTheList(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['payable_with' => 'pix'])]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + $this->assertSame([PaymentMethod::PIX], $subscription->availablePaymentMethods); + + $subscription->paymentMethod = PaymentMethod::CREDIT_CARD; + try { + $subscription->save($gateway); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('paymentMethod [credit_card] must be one of availablePaymentMethods', $e->getMessage()); + } + + $subscription->paymentMethod = null; + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->id = 'pm_9'; + try { + $subscription->save($gateway); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('creditCard was given but credit_card is not among the payment methods', $e->getMessage()); + } + $this->assertCount(1, $api->calls); + } + + public function testTrialEndsAtAlsoPostponesTheFirstChargeWhileNextBillingAtAloneDoesNot(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(), $this->subscriptionResponse(), $this->subscriptionResponse()]); + $gateway = new IuguGateway($api); + + $trial = new Subscription(); + $trial->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1'], 'trial_ends_at' => '2026-10-01']); + $gateway->createSubscription($trial); + $this->assertSame('2026-10-01', $api->calls[0]['data']['expires_at']); + $this->assertTrue($api->calls[0]['data']['only_charge_on_due_date']); + + $billing = new Subscription(); + $billing->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1'], 'next_billing_at' => '2026-10-01']); + $gateway->createSubscription($billing); + $this->assertSame('2026-10-01', $api->calls[1]['data']['expires_at']); + $this->assertArrayNotHasKey('only_charge_on_due_date', $api->calls[1]['data']); + + $overridden = new Subscription(); + $overridden->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1'], 'trial_days' => 7]); + $overridden->gatewayOptions = ['only_charge_on_due_date' => false]; + $gateway->createSubscription($overridden); + $this->assertFalse($api->calls[2]['data']['only_charge_on_due_date']); + } + + public function testTrialDaysConflictingWithNextBillingAtIsRejectedBeforeTheNetwork(): void + { + Carbon::setTestNow('2026-09-15 12:00:00'); + $api = new QueuedIuguApiRequest([]); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1'], 'trial_days' => 7, 'next_billing_at' => '2026-10-01']); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/trialEndsAt \(or trialDays\)/'); + + (new IuguGateway($api))->createSubscription($subscription); + } + + public function testUpdateSubscriptionWithACardMakesItTheCustomerDefaultBeforeTheUpdate(): void + { + $api = new QueuedIuguApiRequest([(object) ['id' => 'cus_1'], $this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'customer' => ['id' => 'cus_1'], 'credit_card' => ['id' => 'pm_2']]); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertStringEndsWith('/customers/cus_1', $api->calls[0]['url']); + $this->assertSame(['default_payment_method_id' => 'pm_2'], $api->calls[0]['data']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[1]['url']); + $this->assertSame(['payable_with' => ['credit_card']], $api->calls[1]['data']); + } + + public function testUpdateSubscriptionWithATokenizedCardSavesItAsTheDefaultBeforeTheUpdate(): void + { + $api = new QueuedIuguApiRequest([$this->cardResponse('pm_novo'), $this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'customer' => ['id' => 'cus_1'], 'credit_card' => ['token' => 'tok_1']]); + + $subscription = (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith('/customers/cus_1/payment_methods', $api->calls[0]['url']); + $this->assertTrue($api->calls[0]['data']['set_as_default']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[1]['url']); + $this->assertSame(['payable_with' => ['credit_card']], $api->calls[1]['data']); + $this->assertSame('pm_novo', $subscription->creditCard->id); + } + + public function testCardWithoutACustomerIdIsRejectedBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'credit_card' => ['id' => 'pm_2']]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`customer` attribute is required/'); + + (new IuguGateway($api))->updateSubscription($subscription); + } + + /** + * A Iugu não informa com qual método a assinatura é cobrada; o driver só preenche + * `paymentMethod` quando ela aceita um único método. + */ + #[DataProvider('parsedPaymentMethodProvider')] + public function testParseFillsPaymentMethodOnlyWhenTheSubscriptionAcceptsASingleMethod(mixed $payableWith, ?PaymentMethod $expected): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['payable_with' => $payableWith])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::CREDIT_CARD; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame($expected, $subscription->paymentMethod); + } + + public static function parsedPaymentMethodProvider(): array + { + return [ + 'string unica' => ['pix', PaymentMethod::PIX], + 'lista de um' => [['credit_card'], PaymentMethod::CREDIT_CARD], + 'lista de dois' => [['pix', 'bank_slip'], null], + 'all' => ['all', null], + ]; + } } diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index 1fd65cf..d6bcf45 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -47,6 +47,7 @@ protected function setUp(): void protected function tearDown(): void { + Carbon::setTestNow(); ApiRequestor::setHttpClient(null); Facade::clearResolvedInstances(); Facade::setFacadeApplication(null); @@ -138,6 +139,7 @@ public function testRejectsBankSlipInvoiceAttributingTheLimitationToTheLibrary() { $httpClient = RecordingStripeHttpClient::withResponses([]); $invoice = $this->creditCardInvoiceModel(); + $invoice->creditCard = null; $invoice->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; try { @@ -204,8 +206,8 @@ public function testCreatesPixInvoiceFullyServerSideAndParsesQrCode(): void $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); $invoice = $this->pixInvoiceModel(); - // o parse sobrescreve expiresAt com o valor devolvido pela Stripe — captura antes - $requestedExpiresAt = $invoice->expiresAt->getTimestamp(); + // o parse sobrescreve pixExpiresAt com o valor devolvido pela Stripe: captura antes + $requestedExpiresAt = $invoice->pixExpiresAt->getTimestamp(); $result = (new StripeGateway())->createInvoice($invoice); $this->assertCount(1, $httpClient->calls); @@ -240,7 +242,8 @@ public function testCreatesPixInvoiceFullyServerSideAndParsesQrCode(): void $this->assertSame('00020126pixcopiaecola', $result->pix->qrCodeText); $this->assertSame('https://qr.stripe.com/test.png', $result->pix->qrCodeImageUrl); $this->assertSame('https://payments.stripe.com/qr/instructions/test', $result->url); - $this->assertSame(1786800000, $result->expiresAt->getTimestamp()); + $this->assertSame(1786800000, $result->pixExpiresAt->getTimestamp()); + $this->assertNull($result->dueDate); $this->assertNull($result->paidAmount); } @@ -282,28 +285,129 @@ public function testPixInvoiceRequiresCustomer(): void (new StripeGateway())->createInvoice($invoice); } - public function testPixInvoiceWithoutExpiresAtOmitsPaymentMethodOptions(): void + public function testPixInvoiceWithoutPixExpiresAtOrDueDateOmitsPaymentMethodOptions(): void { $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); $invoice = $this->pixInvoiceModel(); - $invoice->expiresAt = null; + $invoice->pixExpiresAt = null; (new StripeGateway())->createInvoice($invoice); $this->assertArrayNotHasKey('payment_method_options', $httpClient->calls[0][2]); } - public function testPixInvoiceRejectsExpiresAtOutsideStripeWindow(): void + public function testPixInvoiceRejectsPixExpiresAtOutsideStripeWindow(): void + { + $invoice = $this->pixInvoiceModel(); + $invoice->pixExpiresAt = Carbon::now()->subMinute(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('pixExpiresAt must be more than 10 seconds and less than 14 days'); + + (new StripeGateway())->createInvoice($invoice); + } + + /** + * Sem `pixExpiresAt`, o QR Code expira no fim do dia de `dueDate`: um vencimento de hoje + * cabe na janela e `dueDate` permanece no model. + */ + public function testPixInvoiceDerivesTheQrCodeExpiryFromTheEndOfTheDueDate(): void + { + Carbon::setTestNow('2026-09-02 10:00:00'); + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + $invoice->pixExpiresAt = null; + $invoice->dueDate = Carbon::parse('2026-09-02'); + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame( + Carbon::parse('2026-09-02 23:59:59')->getTimestamp(), + $httpClient->calls[0][2]['payment_method_options']['pix']['expires_at'] + ); + $this->assertSame('2026-09-02', $invoice->dueDate->format('Y-m-d')); + } + + public function testPixInvoiceGivesPixExpiresAtPrecedenceOverDueDate(): void + { + Carbon::setTestNow('2026-09-02 10:00:00'); + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + $invoice->dueDate = Carbon::parse('2026-09-05'); + $invoice->pixExpiresAt = Carbon::parse('2026-09-02 14:00:00'); + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame( + Carbon::parse('2026-09-02 14:00:00')->getTimestamp(), + $httpClient->calls[0][2]['payment_method_options']['pix']['expires_at'] + ); + } + + #[DataProvider('dueDateOutsideWindowProvider')] + public function testPixInvoiceRejectsDueDateWhoseEndOfDayIsOutsideStripeWindow(string $dueDate): void { + Carbon::setTestNow('2026-09-02 10:00:00'); $invoice = $this->pixInvoiceModel(); - $invoice->expiresAt = Carbon::now()->subMinute(); + $invoice->pixExpiresAt = null; + $invoice->dueDate = Carbon::parse($dueDate); $this->expectException(ModelAttributeValidationException::class); - $this->expectExceptionMessage('more than 10 seconds and less than 14 days'); + $this->expectExceptionMessage('dueDate must be more than 10 seconds and less than 14 days'); (new StripeGateway())->createInvoice($invoice); } + public static function dueDateOutsideWindowProvider(): array + { + return [ + 'vencida ontem' => ['2026-09-01'], + 'fim do dia alem de 14 dias' => ['2026-09-16'], + ]; + } + + /** + * `paymentMethod` com a lista vazia escolhe o método da fatura, como a lista faria. + */ + #[DataProvider('paymentMethodOnlyProvider')] + public function testPaymentMethodAloneSelectsTheStripePaymentMethodType(PaymentMethod $method, string $stripeType, bool $withCard): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $withCard ? $this->paidCardPaymentIntentResponse() : $this->pendingPixPaymentIntentResponse(), + ]); + $invoice = $withCard ? $this->creditCardInvoiceModel() : $this->pixInvoiceModel(); + $invoice->availablePaymentMethods = null; + $invoice->paymentMethod = $method; + + $result = (new StripeGateway())->createInvoice($invoice); + + $this->assertSame([$stripeType], $httpClient->calls[0][2]['payment_method_types']); + $this->assertSame($method, $result->paymentMethod); + } + + public static function paymentMethodOnlyProvider(): array + { + return [ + 'cartao' => [PaymentMethod::CREDIT_CARD, 'card', true], + 'pix' => [PaymentMethod::PIX, 'pix', false], + ]; + } + + public function testInvoiceWithoutAnyPaymentMethodIsRejectedBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->pixInvoiceModel(); + $invoice->availablePaymentMethods = null; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Fatura sem método deveria lançar ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('paymentMethod or availablePaymentMethods', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + public function testPixInvoiceBillingDetailsOmitsMissingNameAndEmail(): void { $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); @@ -1714,7 +1818,7 @@ private function pixInvoiceModel(): Invoice $invoice->customer->name = 'Fake Customer'; $invoice->customer->email = 'email@exemplo.com'; $invoice->customer->taxDocument = '20176996915'; - $invoice->expiresAt = Carbon::now()->addHour(); + $invoice->pixExpiresAt = Carbon::now()->addHour(); return $invoice; } diff --git a/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php index 6d182c7..f6999af 100644 --- a/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php @@ -79,7 +79,8 @@ public function testGetInvoiceWithAnInvoiceIdReadsTheStripeInvoiceWithItsPayment $this->assertNull($result->paidAt); $this->assertNull($result->fee); $this->assertSame(1788368263, $result->createdAt->getTimestamp()); - $this->assertNull($result->expiresAt); + $this->assertNull($result->dueDate); + $this->assertNull($result->pixExpiresAt); $this->assertStringStartsWith('https://invoice.stripe.com/i/', $result->url); $this->assertSame('cus_VBen1v8T4Qa6XX', $result->customer->id); $this->assertSame(PaymentMethod::CREDIT_CARD, $result->paymentMethod); @@ -122,7 +123,7 @@ public function testLineItemPriceIsTheUnitAmount(): void $this->assertSame([1000, 3], [$items[1]->price, $items[1]->quantity]); } - public function testDueDateBecomesExpiresAt(): void + public function testDueDateBecomesDueDate(): void { $response = self::fixture('invoices/open_requires_payment_method'); $response['due_date'] = 1789000000; @@ -130,8 +131,9 @@ public function testDueDateBecomesExpiresAt(): void $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); - $this->assertInstanceOf(Carbon::class, $result->expiresAt); - $this->assertSame(1789000000, $result->expiresAt->getTimestamp()); + $this->assertInstanceOf(Carbon::class, $result->dueDate); + $this->assertSame(1789000000, $result->dueDate->getTimestamp()); + $this->assertNull($result->pixExpiresAt); } public function testGetInvoiceWithAPaymentIntentIdKeepsThePaymentIntentOrigin(): void @@ -580,12 +582,13 @@ public function testPaymentIntentGivenAsIdIsRead(): void } /** - * Fatura de assinatura paga por Pix: o QR Code vem do PaymentIntent e `url` continua sendo - * a página hospedada da fatura. + * Fatura de assinatura paga por Pix: o QR Code e a expiração dele vêm do PaymentIntent, o + * vencimento vem da fatura e `url` continua sendo a página hospedada da fatura. */ public function testPixQrCodeComesFromThePaymentIntentAndUrlStaysTheHostedInvoicePage(): void { $response = self::fixture('invoices/open_requires_payment_method'); + $response['due_date'] = 1789000000; $paymentIntent = &$response['payments']['data'][0]['payment']['payment_intent']; $paymentIntent['status'] = 'requires_action'; $paymentIntent['payment_method_types'] = ['pix']; @@ -607,7 +610,8 @@ public function testPixQrCodeComesFromThePaymentIntentAndUrlStaysTheHostedInvoic $this->assertSame(PaymentMethod::PIX, $result->paymentMethod); $this->assertSame('00020126pixcopiaecola', $result->pix->qrCodeText); $this->assertSame('https://qr.stripe.com/test.png', $result->pix->qrCodeImageUrl); - $this->assertSame(1788400000, $result->expiresAt->getTimestamp()); + $this->assertSame(1788400000, $result->pixExpiresAt->getTimestamp()); + $this->assertSame(1789000000, $result->dueDate->getTimestamp()); $this->assertStringStartsWith('https://invoice.stripe.com/i/', $result->url); } diff --git a/tests/Unit/InvoiceTest.php b/tests/Unit/InvoiceTest.php index 2f1519c..164e474 100644 --- a/tests/Unit/InvoiceTest.php +++ b/tests/Unit/InvoiceTest.php @@ -2,18 +2,36 @@ namespace Potelo\MultiPayment\Tests\Unit; +use Mockery; +use Carbon\Carbon; +use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\IgnoreDeprecations; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\Customer; +use Potelo\MultiPayment\Models\CreditCard; +use Potelo\MultiPayment\Models\InvoiceItem; +use Potelo\MultiPayment\Builders\InvoiceBuilder; +use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; /** - * Cobre os helpers estáticos obsoletos de `Invoice`, que delegam ao enum e aceitam tanto o - * caso do enum quanto a string antiga. + * Cobre os helpers estáticos obsoletos de `Invoice`, as datas `dueDate` e `pixExpiresAt` com + * o alias deprecado `expiresAt`, a derivação do método de pagamento e a validação de `amount` + * contra os itens. */ class InvoiceTest extends TestCase { + protected function tearDown(): void + { + Mockery::close(); + + parent::tearDown(); + } + public static function settledProvider(): array { return [ @@ -118,4 +136,215 @@ public function testOldStatusConstantsKeepTheEnumValue(string $constant, Invoice $this->assertSame($constant, $invoice->status->value); $this->assertTrue($invoice->status->value === $constant); } + + #[IgnoreDeprecations] + public function testExpiresAtIsADeprecatedAliasOfDueDate(): void + { + $this->expectUserDeprecationMessage('Invoice::$expiresAt está obsoleto desde 2026-09-02; use $dueDate (vencimento) ou $pixExpiresAt (expiração do QR Code)'); + + $invoice = new Invoice(); + $invoice->expiresAt = Carbon::parse('2026-10-01'); + + $this->assertSame('2026-10-01', $invoice->dueDate->format('Y-m-d')); + $this->assertSame($invoice->dueDate, $invoice->expiresAt); + $this->assertTrue(isset($invoice->expiresAt)); + $this->assertArrayHasKey('due_date', $invoice->toArray()); + $this->assertArrayNotHasKey('expires_at', $invoice->toArray()); + } + + public function testIssetOnExpiresAtDoesNotWarnAndFollowsDueDate(): void + { + $invoice = new Invoice(); + + $this->assertFalse(isset($invoice->expiresAt)); + $this->assertTrue(empty($invoice->expiresAt)); + } + + #[IgnoreDeprecations] + public function testFillAcceptsExpiresAtAsAnAliasWithDueDateTakingPrecedence(): void + { + $this->expectUserDeprecationMessage('Invoice::$expiresAt está obsoleto desde 2026-09-02; use $dueDate (vencimento) ou $pixExpiresAt (expiração do QR Code)'); + + $invoice = new Invoice(); + $invoice->fill(['expires_at' => '2026-10-01']); + $this->assertSame('2026-10-01', $invoice->dueDate->format('Y-m-d')); + + $both = new Invoice(); + $both->fill(['expires_at' => '2026-10-01', 'due_date' => '2026-10-05']); + $this->assertSame('2026-10-05', $both->dueDate->format('Y-m-d')); + } + + #[DataProvider('dateInputProvider')] + public function testFillAcceptsDateOnlyIso8601AndCarbonInBothDates(mixed $value, string $expected): void + { + $invoice = new Invoice(); + $invoice->fill(['due_date' => $value, 'pix_expires_at' => $value]); + + $this->assertSame($expected, $invoice->dueDate->toIso8601String()); + $this->assertSame($expected, $invoice->pixExpiresAt->toIso8601String()); + $this->assertSame(['due_date' => $invoice->dueDate, 'pix_expires_at' => $invoice->pixExpiresAt], array_intersect_key($invoice->toArray(), ['due_date' => 1, 'pix_expires_at' => 1])); + } + + public static function dateInputProvider(): array + { + return [ + 'so data' => ['2026-10-01', Carbon::parse('2026-10-01')->toIso8601String()], + 'ISO 8601 com hora' => ['2026-10-01T18:30:00-03:00', '2026-10-01T18:30:00-03:00'], + 'Carbon' => [Carbon::parse('2026-10-01 18:30:00', 'America/Bahia'), '2026-10-01T18:30:00-03:00'], + ]; + } + + public function testResolvedPaymentMethodsFollowThePrecedenceListMethodCard(): void + { + $invoice = new Invoice(); + $this->assertSame([], $invoice->resolvedPaymentMethods()); + + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_1'; + $this->assertSame([PaymentMethod::CREDIT_CARD], $invoice->resolvedPaymentMethods()); + + $invoice->paymentMethod = 'credit_card'; + $this->assertSame([PaymentMethod::CREDIT_CARD], $invoice->resolvedPaymentMethods()); + + $invoice->creditCard = null; + $invoice->paymentMethod = 'pix'; + $this->assertSame([PaymentMethod::PIX], $invoice->resolvedPaymentMethods()); + + $invoice->paymentMethod = null; + $invoice->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $invoice->availablePaymentMethods[] = 'bank_slip'; + $this->assertSame([PaymentMethod::BANK_SLIP], $invoice->resolvedPaymentMethods()); + } + + #[DataProvider('conflictingPaymentProvider')] + public function testValidationRejectsAPaymentMethodOutsideTheListAndACardWithoutCardAmongTheMethods(callable $mutate, string $message): void + { + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['name' => 'Ana', 'email' => 'ana@example.com'], + 'amount' => 10000, + ]); + $mutate($invoice); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches($message); + + $invoice->validate(); + } + + public static function conflictingPaymentProvider(): array + { + $card = function (Invoice $i) { + $i->creditCard = new CreditCard(); + $i->creditCard->id = 'pm_1'; + }; + + return [ + 'metodo fora da lista' => [ + function (Invoice $i) { + $i->paymentMethod = PaymentMethod::CREDIT_CARD; + $i->availablePaymentMethods = [PaymentMethod::PIX]; + }, + '/paymentMethod \[credit_card\] must be one of availablePaymentMethods/', + ], + 'cartao com metodo pix' => [ + function (Invoice $i) use ($card) { + $card($i); + $i->paymentMethod = PaymentMethod::PIX; + }, + '/creditCard was given but credit_card is not among the payment methods/', + ], + 'cartao com lista sem cartao' => [ + function (Invoice $i) use ($card) { + $card($i); + $i->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::BANK_SLIP]; + }, + '/creditCard was given but credit_card is not among the payment methods/', + ], + ]; + } + + public function testValidationRejectsANonSelectablePaymentMethod(): void + { + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['name' => 'Ana', 'email' => 'ana@example.com'], + 'amount' => 10000, + ]); + $invoice->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/paymentMethod must be one of: credit_card, bank_slip, pix/'); + + $invoice->validate(); + } + + /** + * `amount` junto de `items` só é aceito quando é a soma deles: `itemsTotal()` devolve a soma + * e `validate()` lança `ModelAttributeValidationException` citando os dois valores. + */ + public function testValidationRejectsAnAmountDifferentFromTheItemsTotal(): void + { + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['name' => 'Ana', 'email' => 'ana@example.com'], + 'amount' => 10000, + 'items' => [ + ['description' => 'Produto 1', 'price' => 10000, 'quantity' => 1], + ['description' => 'Produto 2', 'price' => 5000, 'quantity' => 2], + ], + ]); + $this->assertSame(20000, $invoice->itemsTotal()); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/amount \[10000\] must equal the sum of the items \[20000\]/'); + + $invoice->validate(); + } + + public function testValidationAcceptsAnAmountEqualToTheItemsTotal(): void + { + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['name' => 'Ana', 'email' => 'ana@example.com'], + 'amount' => 20000, + 'items' => [ + ['description' => 'Produto 1', 'price' => 10000, 'quantity' => 1], + ['description' => 'Produto 2', 'price' => 5000, 'quantity' => 2], + ], + ]); + + $invoice->validate(); + + $this->assertSame(20000, $invoice->amount); + } + + public function testBuilderSetsPaymentMethodAndBothDates(): void + { + $gateway = Mockery::mock(GatewayContract::class); + + $invoice = (new InvoiceBuilder($gateway)) + ->setPaymentMethod('pix') + ->setDueDate(CarbonImmutable::parse('2026-10-01')) + ->setPixExpiresAt('2026-09-30T18:00:00-03:00') + ->get(); + + $this->assertSame(PaymentMethod::PIX, $invoice->paymentMethod); + $this->assertInstanceOf(Carbon::class, $invoice->dueDate); + $this->assertSame('2026-10-01', $invoice->dueDate->format('Y-m-d')); + $this->assertSame('2026-09-30T18:00:00-03:00', $invoice->pixExpiresAt->toIso8601String()); + } + + #[IgnoreDeprecations] + public function testBuilderSetExpiresAtIsADeprecatedAliasOfSetDueDate(): void + { + $this->expectUserDeprecationMessage('InvoiceBuilder::setExpiresAt() está obsoleto desde 2026-09-02; use setDueDate() ou setPixExpiresAt()'); + + $invoice = (new InvoiceBuilder(Mockery::mock(GatewayContract::class))) + ->setExpiresAt('2026-10-01') + ->get(); + + $this->assertSame('2026-10-01', $invoice->dueDate->format('Y-m-d')); + $this->assertNull($invoice->pixExpiresAt); + } } diff --git a/tests/Unit/ModelFillTest.php b/tests/Unit/ModelFillTest.php index 794c1c0..44df2da 100644 --- a/tests/Unit/ModelFillTest.php +++ b/tests/Unit/ModelFillTest.php @@ -131,7 +131,7 @@ public function testNestedModelsAreStrictToo(): void /** * As chaves que os `fill()` especializados consomem antes do `Model` (`items`, `customer`, - * `expires_at`, `credit_card`, datas da assinatura) continuam aceitas. + * `due_date`, `pix_expires_at`, `credit_card`, datas da assinatura) continuam aceitas. */ public function testKeysConsumedBySpecializedFillsAreStillAccepted(): void { @@ -141,24 +141,30 @@ public function testKeysConsumedBySpecializedFillsAreStillAccepted(): void $invoice->fill([ 'items' => [['description' => 'Item', 'price' => 1000, 'quantity' => 1]], 'customer' => ['name' => 'Ana', 'email' => 'ana@example.com', 'address' => ['zip_code' => '41820330']], - 'expires_at' => '2026-10-01', + 'due_date' => '2026-10-01', + 'pix_expires_at' => '2026-10-01T18:00:00-03:00', 'credit_card' => ['token' => 'pm_x', 'first_name' => 'Ana'], 'available_payment_methods' => ['credit_card'], 'origin_type' => 'invoice', ]); $this->assertSame('41820330', $invoice->customer->address->zipCode); $this->assertSame('pm_x', $invoice->creditCard->token); + $this->assertSame('2026-10-01', $invoice->dueDate->format('Y-m-d')); + $this->assertSame('2026-10-01T18:00:00-03:00', $invoice->pixExpiresAt->toIso8601String()); $subscription = new Subscription(); $subscription->fill([ 'plan_id' => 'plano', 'trial_ends_at' => '2026-10-01', 'next_billing_at' => '2026-11-01', + 'trial_days' => null, + 'credit_card' => ['id' => 'pm_1'], 'customer' => ['id' => 'cus_1'], 'latest_invoice' => ['id' => 'inv_1', 'status' => 'paid'], 'items' => [['description' => 'Extra', 'amount' => 500]], ]); $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertSame('pm_1', $subscription->creditCard->id); $card = new CreditCard(); $card->fill(['token' => 'tok_x', 'customer' => ['id' => 'cus_1'], 'default' => true]); @@ -170,9 +176,14 @@ public function testFillableKeysAreTheSnakeCasePropertiesIncludingEnums(): void $this->assertSame(['description', 'price', 'quantity', 'gateway_options'], InvoiceItem::fillableKeys()); $keys = Invoice::fillableKeys(); - foreach (['id', 'status', 'amount', 'payment_method', 'available_payment_methods', 'origin_type', 'credit_card', 'expires_at', 'gateway_options'] as $key) { + foreach (['id', 'status', 'amount', 'payment_method', 'available_payment_methods', 'origin_type', 'credit_card', 'due_date', 'pix_expires_at', 'gateway_options'] as $key) { $this->assertContains($key, $keys); } + // o nome antigo é alias, fora da lista, como `gateway_adicional_options` + $this->assertNotContains('expires_at', $keys); + foreach (['payment_method', 'credit_card', 'trial_days', 'trial_ends_at'] as $key) { + $this->assertContains($key, Subscription::fillableKeys()); + } $this->assertContains('interval', Plan::fillableKeys()); $this->assertContains('status', Refund::fillableKeys()); } diff --git a/tests/Unit/MultiPaymentChargeTest.php b/tests/Unit/MultiPaymentChargeTest.php new file mode 100644 index 0000000..dfd42c0 --- /dev/null +++ b/tests/Unit/MultiPaymentChargeTest.php @@ -0,0 +1,178 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => ['iugu' => ['api_key' => 'iugu-key', 'class' => IuguGateway::class]], + ], + ])); + Facade::setFacadeApplication($app); + + $this->api = (new QueuedIuguApiRequest([]))->installAsSdkRequester(); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + QueuedIuguApiRequest::restoreSdkRequester(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testChargeWithoutCustomerFailsBeforeAnyRequest(): void + { + try { + (new MultiPayment('iugu'))->charge([ + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'pix', + ]); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('`customer` attribute is required', $e->getMessage()); + } + $this->assertSame([], $this->api->calls); + } + + /** + * `payment_method` `credit_card` com `credit_card` preenchido e sem + * `available_payment_methods` cobra o cartão na Iugu: `POST /charge` com o id do cartão e + * `payable_with` só de cartão. + */ + public function testChargeHonorsPaymentMethodOnWrite(): void + { + $this->queue([(object) ['success' => true, 'invoice_id' => 'inv_1'], $this->pendingInvoiceResponse()]); + + $invoice = (new MultiPayment('iugu'))->charge([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'credit_card', + 'credit_card' => ['id' => 'pm_1'], + ]); + + $this->assertStringEndsWith('/charge', $this->api->calls[0]['url']); + $this->assertSame('pm_1', $this->api->calls[0]['data']['customer_payment_method_id']); + $this->assertSame(['credit_card'], $this->api->calls[0]['data']['payable_with']); + $this->assertSame('inv_1', $invoice->id); + } + + public function testChargeAcceptsACustomerInstance(): void + { + $this->queue([$this->pendingInvoiceResponse()]); + $customer = new Customer(); + $customer->id = 'cus_1'; + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + + (new MultiPayment('iugu'))->charge([ + 'customer' => $customer, + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'pix', + ]); + + $this->assertCount(1, $this->api->calls); + $this->assertStringEndsWith('/invoices', $this->api->calls[0]['url']); + $this->assertSame('cus_1', $this->api->calls[0]['data']['customer_id']); + } + + public function testChargeSendsDueDateAndPixExpiresAtSeparately(): void + { + $this->queue([$this->pendingInvoiceResponse()]); + + (new MultiPayment('iugu'))->charge([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'pix', + 'due_date' => '2026-10-01', + 'pix_expires_at' => '2026-09-30T18:00:00-03:00', + ]); + + $payload = $this->api->calls[0]['data']; + $this->assertSame('2026-10-01', $payload['due_date']); + $this->assertSame('2026-09-30T18:00:00-03:00', $payload['pix_qr_code_expires_at']); + } + + #[IgnoreDeprecations] + public function testChargeStillAcceptsExpiresAtAsADeprecatedAliasOfDueDate(): void + { + $this->expectUserDeprecationMessage('Invoice::$expiresAt está obsoleto desde 2026-09-02; use $dueDate (vencimento) ou $pixExpiresAt (expiração do QR Code)'); + $this->queue([$this->pendingInvoiceResponse()]); + + (new MultiPayment('iugu'))->charge([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'expires_at' => '2026-10-01', + ]); + + $this->assertSame('2026-10-01', $this->api->calls[0]['data']['due_date']); + } + + public function testChargeRejectsAnAmountThatDiffersFromTheItemsBeforeAnyRequest(): void + { + try { + (new MultiPayment('iugu'))->charge([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'amount' => 10000, + 'items' => [ + ['description' => 'Produto 1', 'price' => 10000, 'quantity' => 1], + ['description' => 'Produto 2', 'price' => 5000, 'quantity' => 2], + ], + ]); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('amount [10000] must equal the sum of the items [20000]', $e->getMessage()); + } + $this->assertSame([], $this->api->calls); + } + + private function queue(array $responses): void + { + $this->api = (new QueuedIuguApiRequest($responses))->installAsSdkRequester(); + } + + private function pendingInvoiceResponse(): object + { + return (object) [ + 'id' => 'inv_1', + 'status' => 'pending', + 'total_cents' => 10000, + 'paid_cents' => 0, + 'refunded_cents' => 0, + 'due_date' => '2026-10-01', + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'items' => [(object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1]], + ]; + } +} diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php index a60899e..b031f2d 100644 --- a/tests/Unit/SubscriptionTest.php +++ b/tests/Unit/SubscriptionTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Potelo\MultiPayment\Models\Plan; use Potelo\MultiPayment\Models\Customer; +use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\InvoiceItem; use Potelo\MultiPayment\Models\Subscription; @@ -678,4 +679,154 @@ public static function listOperationsProvider(): array 'planos' => ['listPlans', [], Capability::PLANS], ]; } + + public function testBuilderSetsTheCardByIdAndImpliesTheCardPaymentMethod(): void + { + $subscription = (new SubscriptionBuilder(Mockery::mock(GatewayContract::class))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setCreditCard('pm_1') + ->setTrialDays(7) + ->get(); + + $this->assertInstanceOf(CreditCard::class, $subscription->creditCard); + $this->assertSame('pm_1', $subscription->creditCard->id); + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->paymentMethod); + $this->assertSame(7, $subscription->trialDays); + $this->assertNull($subscription->trialEndsAt); + } + + public function testBuilderSetsTheCardByModelAndThePaymentMethodByString(): void + { + $card = new CreditCard(); + $card->token = 'tok_1'; + + $subscription = (new SubscriptionBuilder(Mockery::mock(GatewayContract::class))) + ->setCreditCard($card) + ->get(); + $this->assertSame($card, $subscription->creditCard); + + $pix = (new SubscriptionBuilder(Mockery::mock(GatewayContract::class))) + ->setPaymentMethod('pix') + ->get(); + $this->assertSame(PaymentMethod::PIX, $pix->paymentMethod); + $this->assertNull($pix->creditCard); + } + + public function testResolvedPaymentMethodFallsBackToTheCard(): void + { + $subscription = new Subscription(); + $this->assertNull($subscription->resolvedPaymentMethod()); + + $subscription->creditCard = new CreditCard(); + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->resolvedPaymentMethod()); + + $subscription->paymentMethod = PaymentMethod::CREDIT_CARD; + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->resolvedPaymentMethod()); + } + + public function testFillAndToArrayHandleTheCreditCard(): void + { + $subscription = new Subscription(); + $subscription->fill(['credit_card' => ['id' => 'pm_1'], 'trial_days' => 3]); + + $this->assertSame('pm_1', $subscription->creditCard->id); + $this->assertSame(3, $subscription->trialDays); + $this->assertSame('pm_1', $subscription->toArray()['credit_card']['id']); + $this->assertSame(3, $subscription->toArray()['trial_days']); + } + + #[DataProvider('invalidSubscriptionProvider')] + public function testValidationRejectsConflictingPaymentAndTrialAttributes(callable $mutate, string $message): void + { + $subscription = new Subscription(); + $subscription->fill(['customer' => ['name' => 'Fulano'], 'plan_id' => 'plano']); + $mutate($subscription); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches($message); + + $subscription->validate(); + } + + public static function invalidSubscriptionProvider(): array + { + return [ + 'trialDays zero' => [fn (Subscription $s) => $s->trialDays = 0, '/trialDays must be at least 1/'], + 'trialDays negativo' => [fn (Subscription $s) => $s->trialDays = -1, '/trialDays must be at least 1/'], + 'trialDays com trialEndsAt' => [ + function (Subscription $s) { + $s->trialDays = 7; + $s->trialEndsAt = Carbon::parse('2026-10-01'); + }, + '/trialDays and trialEndsAt are mutually exclusive/', + ], + 'cartao com metodo pix' => [ + function (Subscription $s) { + $s->creditCard = new CreditCard(); + $s->creditCard->id = 'pm_1'; + $s->paymentMethod = PaymentMethod::PIX; + }, + '/creditCard was given but credit_card is not among the payment methods/', + ], + 'cartao com lista sem cartao' => [ + function (Subscription $s) { + $s->creditCard = new CreditCard(); + $s->creditCard->id = 'pm_1'; + $s->availablePaymentMethods = [PaymentMethod::PIX]; + }, + '/creditCard was given but credit_card is not among the payment methods/', + ], + 'metodo fora da lista' => [ + function (Subscription $s) { + $s->paymentMethod = PaymentMethod::CREDIT_CARD; + $s->availablePaymentMethods = [PaymentMethod::PIX]; + }, + '/paymentMethod \[credit_card\] must be one of availablePaymentMethods/', + ], + 'metodo nao selecionavel' => [ + fn (Subscription $s) => $s->paymentMethod = PaymentMethod::AUTOMATIC_PIX, + '/paymentMethod must be one of: credit_card, bank_slip, pix/', + ], + 'cartao invalido' => [ + function (Subscription $s) { + $s->creditCard = new CreditCard(); + $s->creditCard->number = '123'; + }, + '/CreditCard number/', + ], + ]; + } + + public function testRequiredCapabilitiesDeriveFromThePaymentMethodAndTheCard(): void + { + $subscription = new Subscription(); + $this->assertSame([Capability::SUBSCRIPTIONS], $subscription->requiredCapabilities()); + + $subscription->paymentMethod = PaymentMethod::PIX; + $this->assertSame([Capability::SUBSCRIPTIONS, Capability::PIX], $subscription->requiredCapabilities()); + + $rawCard = new Subscription(); + $rawCard->creditCard = new CreditCard(); + $rawCard->creditCard->number = '4111111111111111'; + $this->assertSame( + [Capability::SUBSCRIPTIONS, Capability::CREDIT_CARD, Capability::RAW_CARD_DATA], + $rawCard->requiredCapabilities() + ); + + $tokenCard = new Subscription(); + $tokenCard->creditCard = new CreditCard(); + $tokenCard->creditCard->token = 'tok_1'; + $this->assertSame([Capability::SUBSCRIPTIONS, Capability::CREDIT_CARD], $tokenCard->requiredCapabilities()); + + // a lista tem precedência sobre o método, e mais de um método exige MULTIPLE_PAYMENT_METHODS + $list = new Subscription(); + $list->paymentMethod = PaymentMethod::PIX; + $list->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::BANK_SLIP]; + $this->assertSame( + [Capability::SUBSCRIPTIONS, Capability::PIX, Capability::BANK_SLIP, Capability::MULTIPLE_PAYMENT_METHODS], + $list->requiredCapabilities() + ); + $this->assertSame([PaymentMethod::PIX, PaymentMethod::BANK_SLIP], $list->resolvedPaymentMethods()); + } } From d24cf897de18046aa81589658a5086ddc1063dc4 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 17:14:03 -0300 Subject: [PATCH 25/32] docs(subscription): registra que o trial na Iugu nasce sem fatura com only_charge_on_due_date, confirmado na sandbox Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9ccc810..0b64a36 100644 --- a/README.md +++ b/README.md @@ -1135,10 +1135,11 @@ Particularidades da Iugu: - **Planos não são desativáveis.** `deactivatePlan` lança `UnsupportedOperationException` (`PLAN_DEACTIVATION`, `gateway_limitation`). - **`nextBillingAt` e `trialEndsAt` são o mesmo campo** (`expires_at`), e o que os distingue é - a cobrança do primeiro ciclo: por padrão a Iugu cobra o cartão padrão na criação, mesmo com - `expires_at` no futuro (observado na sandbox), então um trial (`setTrialDays()` ou - `setTrialEndsAt()`) vai com `only_charge_on_due_date`, e a primeira cobrança acontece no fim - do teste; `setNextBillingAt()` sozinho vai só como `expires_at`, com a cobrança imediata da + a cobrança do primeiro ciclo: por padrão a Iugu fatura o primeiro ciclo na criação e cobra o + cartão padrão na hora, mesmo com `expires_at` no futuro (observado na sandbox), então um + trial (`setTrialDays()` ou `setTrialEndsAt()`) vai com `only_charge_on_due_date`, e a + assinatura nasce sem fatura (`latestInvoice` nulo) e sem cobrança até o fim do teste; + `setNextBillingAt()` sozinho vai só como `expires_at`, com a fatura e a cobrança imediatas da Iugu (`gateway_options['only_charge_on_due_date']` sobrepõe os dois). Informar `trialEndsAt` (ou `trialDays`) e `nextBillingAt` com datas diferentes lança `GatewayException`. Ao prorrogar um trial lido do gateway, zere `nextBillingAt` antes, porque a leitura preenche os dois. From 8bfebaf3c0a31e2aa514c27b1364b3de06c97599 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 17:37:30 -0300 Subject: [PATCH 26/32] =?UTF-8?q?feat(subscription):=20nomeia=20a=20pol?= =?UTF-8?q?=C3=ADtica=20de=20pr=C3=B3-rata,=20garante=20linhas=20na=20pr?= =?UTF-8?q?=C3=A9via=20e=20exp=C3=B5e=20getSubscription=20e=20getPlan=20na?= =?UTF-8?q?=20fachada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 101 ++++- src/Contracts/SubscriptionContract.php | 25 +- src/Enums/Capability.php | 2 +- src/Enums/ProrationBehavior.php | 71 ++++ src/Facades/MultiPayment.php | 2 + src/Gateways/IuguGateway.php | 83 ++++- src/Gateways/Stripe/ProrationBehaviors.php | 31 ++ src/Models/Subscription.php | 24 +- src/Models/SubscriptionPlanChange.php | 32 +- src/MultiPayment.php | 45 +++ tests/Integration/SubscriptionTest.php | 20 +- tests/Unit/CapabilityGuardsTest.php | 7 +- tests/Unit/Enums/ProrationBehaviorTest.php | 62 ++++ .../Gateways/IuguGatewayIdempotencyTest.php | 5 +- .../Gateways/IuguGatewaySubscriptionTest.php | 345 +++++++++++++++++- .../Stripe/ProrationBehaviorsTest.php | 36 ++ tests/Unit/IdempotencyKeyPropagationTest.php | 4 +- tests/Unit/MultiPaymentReadTest.php | 160 ++++++++ tests/Unit/SubscriptionTest.php | 82 ++++- 19 files changed, 1081 insertions(+), 56 deletions(-) create mode 100644 src/Enums/ProrationBehavior.php create mode 100644 src/Gateways/Stripe/ProrationBehaviors.php create mode 100644 tests/Unit/Enums/ProrationBehaviorTest.php create mode 100644 tests/Unit/Gateways/Stripe/ProrationBehaviorsTest.php create mode 100644 tests/Unit/MultiPaymentReadTest.php diff --git a/README.md b/README.md index 0b64a36..6d21156 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ o teste `GatewayCapabilitiesTest` falha quando o README fica defasado em relaç | `PLAN_DEACTIVATION` | Desativar um plano sem apagá-lo (`deactivatePlan`). | limitação do gateway | não implementado | | `CANCEL_AT_PERIOD_END` | Cancelar a assinatura só no fim do período já pago (`cancel(atPeriodEnd: true)`). | limitação do gateway | não implementado | | `NATIVE_COUPONS` | Cupom de primeira classe na assinatura: desconto percentual e desconto limitado a vários ciclos. | limitação do gateway | não implementado | -| `PLAN_CHANGE_PRORATION` | Crédito proporcional do período não usado, calculado pelo gateway, ao trocar de plano. | limitação do gateway | não implementado | +| `PLAN_CHANGE_PRORATION` | Crédito proporcional do período não usado, calculado pelo gateway, ao trocar de plano (`changePlan()` com `ProrationBehavior::CREDIT`). | limitação do gateway | não implementado | | `SUBSCRIPTION_CREDITS` | Assinatura com saldo de créditos consumíveis, abatidos a cada uso. | não implementado | limitação do gateway | | `MANAGES_RECURRENCE` | O gateway agenda as cobranças do Pix Automático por conta própria; sem ela, a aplicação é o motor de recorrência e chama as operações de `AutomaticPixContract` na periodicidade certa. | limitação do gateway | não implementado | @@ -179,6 +179,8 @@ Restrições dentro de uma célula "sim": herda de `UnsupportedOperationException` (ver [Estorno](#estorno)). - **`MANAGES_RECURRENCE`** é informativa: diz quem agenda a cobrança do Pix Automático (ver [Pix Automático: quem agenda a cobrança](#pix-automático-quem-agenda-a-cobrança)). +- **`PLAN_CHANGE_PRORATION`** é o que `changePlan()` com `ProrationBehavior::CREDIT` exige; as + outras duas políticas fazem parte de `SUBSCRIPTIONS` (ver [Troca de plano](#troca-de-plano)). ### Status da fatura @@ -1087,12 +1089,14 @@ Meio de pagamento e trial são conceitos da assinatura: Operações sobre a assinatura: ```php +use Potelo\MultiPayment\Enums\ProrationBehavior; + $subscription->suspend(); $subscription->resume(); $subscription->cancel(); // CANCELED; na Iugu, suspende e grava a marca de cancelamento -$subscription->changePlan('plano_anual'); // aplica a troca e gera cobrança imediata -$subscription->changePlan('plano_anual', charge: false); -$preview = $subscription->previewPlanChange('plano_anual'); // simula, não aplica +$subscription->changePlan('plano_anual'); // ProrationBehavior::CHARGE_DIFFERENCE: cobra o plano novo agora +$subscription->changePlan('plano_anual', ProrationBehavior::NONE); // nada é cobrado agora +$preview = $subscription->previewPlanChange('plano_anual'); // simula sem aplicar (ver "Troca de plano") // itens e descontos são declarativos: a lista informada vira o estado da assinatura, e a lista // que ficar em null é preservada como está no gateway @@ -1101,10 +1105,67 @@ $mantido->id = $subscription->items[0]->id; $subscription->items = [$mantido]; $subscription->save(); -$assinaturas = (new \Potelo\MultiPayment\MultiPayment('iugu'))->listSubscriptions($customer->id); -$planos = (new \Potelo\MultiPayment\MultiPayment('iugu'))->listPlans(); +$payment = new \Potelo\MultiPayment\MultiPayment('iugu'); +$subscription = $payment->getSubscription($subscriptionId); // mesma forma de getInvoice() +$plan = $payment->getPlan('plano_mensal'); // identificador ou id do gateway +$assinaturas = $payment->listSubscriptions($customer->id); +$planos = $payment->listPlans(); ``` +##### Troca de plano + +`changePlan()` recebe a política de pró-rata como `Potelo\MultiPayment\Enums\ProrationBehavior`, +e cada gateway a traduz para o próprio parâmetro: + +| `ProrationBehavior` | O que acontece | Iugu | Stripe | +|---|---|---|---| +| `CHARGE_DIFFERENCE` (padrão) | O plano novo é cobrado agora; o gateway decide o que abater do período já pago | `POST change_plan`: fatura emitida na hora, sem crédito do período anterior (a Iugu acrescenta ciclos no downgrade) | `proration_behavior: always_invoice` (planejado para uma versão futura) | +| `NONE` | Nada é cobrado nem creditado agora; o plano novo vale a partir da próxima cobrança do ciclo | `PUT` com `skip_charge`, mantendo a data de cobrança (`nextBillingAt` preenchido vai junto) | `proration_behavior: none` (planejado para uma versão futura) | +| `CREDIT` | O gateway calcula o crédito do período não usado e o aplica na próxima fatura | `UnsupportedOperationException` (`PLAN_CHANGE_PRORATION`, `gateway_limitation`), antes de qualquer requisição | `proration_behavior: create_prorations` (planejado para uma versão futura) | + +Como a política que o gateway não oferece é recusada antes da rede, consulte +`supports(Capability::PLAN_CHANGE_PRORATION)` antes de oferecer a opção de crédito no +checkout. + +```php +$subscription->changePlan('plano_anual', ProrationBehavior::CHARGE_DIFFERENCE, idempotencyKey: "upgrade-{$order->uuid}"); +$subscription->changePlan('basico', ProrationBehavior::NONE); +$subscription->changePlan('plano_anual', ProrationBehavior::CREDIT); // Iugu: UnsupportedOperationException +``` + +`previewPlanChange()` simula a troca sem aplicá-la e devolve um `SubscriptionPlanChange`: + +- **`amount`**: o que a troca cobraria agora, em centavos; quando há linhas, é a soma de `items`. +- **`items`**: as linhas da fatura que a troca geraria, como `InvoiceItem` (crédito com `price` + negativo). A lista nunca é nula: a Iugu não devolve linhas em `change_plan_simulation`, então + a lib monta uma linha de cobrança do plano novo (`Plano `, valendo `cost` mais + `discount`) e, quando `discount` é maior que zero, uma linha negativa de crédito do plano + antigo. O payload cru (`cost`, `discount`, `cycles`, `expires_at`, `old_plan`, `new_plan`) + segue em `original`. +- **`effectiveAt`**: a data em que a próxima cobrança acontece após a troca. +- **`appliesImmediately`**: se o plano novo passa a valer assim que a troca for aplicada. Na + Iugu é verdadeiro quando a assinatura é paga só com cartão (o cartão padrão é cobrado na + hora) e falso quando ela aceita boleto ou Pix, porque a Iugu só efetiva a troca depois do + pagamento da fatura gerada; uma assinatura aberta a mais de um método também lê como falso. + Quando o model não traz os métodos de pagamento (só o id), o driver lê a assinatura antes + da simulação. + +```php +$preview = $subscription->previewPlanChange('plano_anual'); +foreach ($preview->items as $line) { // nunca null + echo "{$line->description}: {$line->price}"; +} +$preview->amount; // 30000 +$preview->effectiveAt; // Carbon: próxima cobrança depois da troca +$preview->appliesImmediately; // false numa assinatura paga por Pix na Iugu +``` + +> **Obsoleto (desde 2026-09-02).** O booleano `$charge` de `changePlan()` continua aceito, na +> mesma posição ou pelo nome (`charge: false`), e é traduzido (`true` para +> `CHARGE_DIFFERENCE`, `false` para `NONE`) com aviso `E_USER_DEPRECATED`; `charge` informado +> prevalece sobre a política. Nos drivers, `changeSubscriptionPlan()` aceita o booleano das +> mesmas duas formas. A remoção fica para uma versão futura. + Particularidades da Iugu: - **Cancelar é suspender com uma marca.** A Iugu só suspende, então `cancel()` faz duas @@ -1174,13 +1235,16 @@ Particularidades da Iugu: `status` devolvido segue `SUSPENDED`. - **`active` e `suspended` são flags independentes.** Assinatura suspensa pode continuar com `active: true` na Iugu; o pacote dá precedência a `suspended` e reporta `SUSPENDED`. -- **A simulação de troca não traz linhas.** `previewPlanChange()` preenche só `amount` e - `effectiveAt`; `items` fica `null` e o resto (`discount`, `cycles`, `old_plan`, `new_plan`) - está em `original`. +- **A simulação de troca traz linhas montadas pela lib.** `change_plan_simulation` devolve só + totais (`cost`, `discount`, `cycles`, `expires_at`, `old_plan`, `new_plan`, todos em + `original`); `previewPlanChange()` monta `items` a partir deles (ver + [Troca de plano](#troca-de-plano)). `cost` é lido como o valor líquido da troca, então + `amount` é `cost` e, quando há `discount`, a linha do plano novo é `cost` mais `discount`. - **Trocar de plano com cobrança gera fatura pendente, não pagamento.** `changePlan()` com - `charge: true` (o padrão) faz a Iugu emitir a fatura na hora, com vencimento imediato e não na - data do próximo ciclo. Ela volta resumida em `latestInvoice`, com status `pending`; use - `getInvoice()` pelo id para o valor em centavos. Com `charge: false` nada é cobrado. + `ProrationBehavior::CHARGE_DIFFERENCE` (o padrão) faz a Iugu emitir a fatura na hora, com + vencimento imediato e sem crédito do período anterior. Ela volta resumida em + `latestInvoice`, com status `pending`; use `getInvoice()` pelo id para o valor em centavos. Com `ProrationBehavior::NONE` nada é cobrado; `ProrationBehavior::CREDIT` é + recusado antes da rede. - **O plano de uma assinatura existente não muda por `save()`**; use `changePlan()`. - **Plano não é atualizável.** `save()` num `Plan` que já tem `id` lança `GatewayException`; para mudar preço ou intervalo, crie outro plano e troque as assinaturas com `changePlan()`. @@ -1229,6 +1293,19 @@ $foundInvoice = (new \Potelo\MultiPayment\MultiPayment('stripe'))->getInvoice('i $foundInvoice->originType; // InvoiceOriginType::INVOICE ``` +#### getSubscription e getPlan +```php +$payment = new \Potelo\MultiPayment\MultiPayment('iugu'); +$subscription = $payment->getSubscription($subscriptionId); // Subscription, com status, latestInvoice e cliente resumido + +// getPlan() aceita o identificador definido na criação ou o id do gateway: busca primeiro pelo +// identificador e, se ele não existir, pelo id (duas requisições nesse caso) +$plan = $payment->getPlan('plano_mensal'); +$plan = $payment->getPlan('7D96C7C932F2427CAF54F042345A13C6'); +``` +Nos dois, gateway sem `SUBSCRIPTIONS` ou `PLANS` lança `UnsupportedOperationException` antes de +qualquer requisição; plano inexistente pelos dois caminhos lança `NotFoundException`. + #### Outras operações de fatura ```php $payment = new \Potelo\MultiPayment\MultiPayment('stripe'); diff --git a/src/Contracts/SubscriptionContract.php b/src/Contracts/SubscriptionContract.php index 1f389d5..087e621 100644 --- a/src/Contracts/SubscriptionContract.php +++ b/src/Contracts/SubscriptionContract.php @@ -3,6 +3,7 @@ namespace Potelo\MultiPayment\Contracts; use Potelo\MultiPayment\Models\Customer; +use Potelo\MultiPayment\Enums\ProrationBehavior; use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Models\SubscriptionPlanChange; use Potelo\MultiPayment\Exceptions\GatewayException; @@ -91,28 +92,38 @@ public function cancelSubscription( ): Subscription; /** - * Troca o plano da assinatura. + * Troca o plano da assinatura com a política de pró-rata informada. * - * Com $charge falso, a troca não gera cobrança imediata. Se `nextBillingAt` estiver - * preenchido na assinatura, a data da próxima cobrança vai na mesma requisição. + * `CHARGE_DIFFERENCE` cobra o plano novo na hora; `NONE` não cobra nem credita nada agora + * e, se `nextBillingAt` estiver preenchido na assinatura, a data da próxima cobrança vai na + * mesma requisição; `CREDIT` pede ao gateway o crédito proporcional do período não usado, e + * gateway sem `Capability::PLAN_CHANGE_PRORATION` lança `UnsupportedOperationException` + * antes de qualquer requisição. O booleano antigo continua aceito no lugar do enum e pelo + * nome `charge` (`true` é `CHARGE_DIFFERENCE`, `false` é `NONE`), com aviso + * `E_USER_DEPRECATED`; `charge` informado prevalece sobre `$proration`. * * @param Subscription $subscription * @param string $planId - * @param bool $charge + * @param ProrationBehavior|bool $proration política de pró-rata; o booleano é o `$charge` antigo, obsoleto * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @param bool|null $charge obsoleto desde 2026-09-02; use `$proration` * * @return Subscription * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException */ public function changeSubscriptionPlan( Subscription $subscription, string $planId, - bool $charge = true, - ?string $idempotencyKey = null + ProrationBehavior|bool $proration = ProrationBehavior::CHARGE_DIFFERENCE, + ?string $idempotencyKey = null, + ?bool $charge = null ): Subscription; /** - * Simula a troca de plano sem aplicá-la, devolvendo o que seria cobrado. + * Simula a troca de plano sem aplicá-la, devolvendo o que seria cobrado. As linhas de + * `SubscriptionPlanChange::$items` nunca faltam: quando o gateway não as devolve, o driver + * as monta a partir dos totais da simulação. * * @param Subscription $subscription * @param string $planId diff --git a/src/Enums/Capability.php b/src/Enums/Capability.php index c3ce6eb..7debc95 100644 --- a/src/Enums/Capability.php +++ b/src/Enums/Capability.php @@ -78,7 +78,7 @@ enum Capability: string /** Cupom de primeira classe na assinatura: desconto percentual e desconto limitado a vários ciclos. */ case NATIVE_COUPONS = 'native_coupons'; - /** Crédito proporcional do período não usado, calculado pelo gateway, ao trocar de plano. */ + /** Crédito proporcional do período não usado, calculado pelo gateway, ao trocar de plano (`changePlan()` com `ProrationBehavior::CREDIT`). */ case PLAN_CHANGE_PRORATION = 'plan_change_proration'; /** Assinatura com saldo de créditos consumíveis, abatidos a cada uso. */ diff --git a/src/Enums/ProrationBehavior.php b/src/Enums/ProrationBehavior.php new file mode 100644 index 0000000..0ec84bf --- /dev/null +++ b/src/Enums/ProrationBehavior.php @@ -0,0 +1,71 @@ +id)) { throw ModelAttributeValidationException::required('Subscription', 'id'); } + + $proration = ProrationBehavior::resolve($charge ?? $proration); + if (!is_null($proration->requiredCapability())) { + $this->assertSupports( + $proration->requiredCapability(), + 'A Iugu não gera crédito do período não usado ao trocar de plano; use' + . ' ProrationBehavior::CHARGE_DIFFERENCE ou ProrationBehavior::NONE.' + ); + } $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); - if ($charge) { + if ($proration === ProrationBehavior::CHARGE_DIFFERENCE) { $this->iuguIdempotentRequest( 'POST', $this->subscriptionUrl($subscription->id) . '/change_plan/' . rawurlencode($planId), @@ -2125,6 +2139,14 @@ public function changeSubscriptionPlan( /** * @inheritDoc + * + * Simula o fluxo de `ProrationBehavior::CHARGE_DIFFERENCE` (`change_plan_simulation`). + * `appliesImmediately` depende de como a assinatura é paga: verdadeiro quando o único + * método é cartão (a Iugu cobra o cartão padrão na hora); falso quando há boleto ou Pix, + * porque a Iugu só efetiva a troca depois do pagamento da fatura gerada. Quando o model não + * traz `paymentMethod` nem `availablePaymentMethods`, o driver lê a assinatura antes da + * simulação (num model à parte, sem tocar no do chamador), o que custa uma requisição a + * mais; `creditCard` sozinho não conta, porque é atributo de escrita. */ public function previewSubscriptionPlanChange( Subscription $subscription, @@ -2134,6 +2156,14 @@ public function previewSubscriptionPlanChange( throw ModelAttributeValidationException::required('Subscription', 'id'); } + $source = $subscription; + if (empty($subscription->availablePaymentMethods) && is_null($subscription->paymentMethod)) { + $source = new Subscription(); + $source->id = $subscription->id; + $source = $this->getSubscription($source); + } + $paymentMethods = $source->resolvedPaymentMethods(); + $response = $this->iuguRequest( 'GET', $this->subscriptionUrl($subscription->id) @@ -2142,7 +2172,10 @@ public function previewSubscriptionPlanChange( 'simulating subscription plan change' ); - return $this->parseIuguPlanChange($response); + $planChange = $this->parseIuguPlanChange($response); + $planChange->appliesImmediately = $paymentMethods === [PaymentMethod::CREDIT_CARD]; + + return $planChange; } /** @@ -2944,6 +2977,10 @@ private function parseIuguPlanChange($response): SubscriptionPlanChange } } + if (empty($planChange->items) && !is_null($planChange->amount)) { + $planChange->items = $this->synthesizeIuguPlanChangeItems($planChange->amount, $response); + } + if (!empty($response->expires_at)) { $planChange->effectiveAt = new Carbon($response->expires_at); } @@ -2954,6 +2991,44 @@ private function parseIuguPlanChange($response): SubscriptionPlanChange return $planChange; } + /** + * Monta as linhas da simulação de troca de plano a partir dos totais, porque a Iugu não + * devolve linhas em `change_plan_simulation`: uma de cobrança do plano novo e, quando + * `discount` é maior que zero, uma negativa de crédito do plano antigo. `cost` é lido como + * o valor líquido da troca, então a linha do plano novo é `cost` mais `discount` e a soma + * das linhas é igual a `cost`. + * + * @param int $amount valor líquido da troca (`cost`) + * @param object $response + * + * @return InvoiceItem[] + */ + private function synthesizeIuguPlanChangeItems(int $amount, object $response): array + { + $discount = isset($response->discount) && is_numeric($response->discount) + ? (int) $response->discount + : 0; + $newPlan = isset($response->new_plan) && is_scalar($response->new_plan) ? (string) $response->new_plan : null; + $oldPlan = isset($response->old_plan) && is_scalar($response->old_plan) ? (string) $response->old_plan : null; + + $charge = new InvoiceItem(); + $charge->description = is_null($newPlan) ? 'Plano novo' : "Plano {$newPlan}"; + $charge->price = $amount + max($discount, 0); + $charge->quantity = 1; + + $items = [$charge]; + + if ($discount > 0) { + $credit = new InvoiceItem(); + $credit->description = is_null($oldPlan) ? 'Crédito do plano anterior' : "Crédito do plano {$oldPlan}"; + $credit->price = -$discount; + $credit->quantity = 1; + $items[] = $credit; + } + + return $items; + } + /** * Monta o payload de plano da Iugu a partir do model. * diff --git a/src/Gateways/Stripe/ProrationBehaviors.php b/src/Gateways/Stripe/ProrationBehaviors.php new file mode 100644 index 0000000..a1f5f6b --- /dev/null +++ b/src/Gateways/Stripe/ProrationBehaviors.php @@ -0,0 +1,31 @@ + 'always_invoice', + ProrationBehavior::NONE => 'none', + ProrationBehavior::CREDIT => 'create_prorations', + }; + } +} diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index 7b13f99..a6af685 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Enums\ProrationBehavior; use Potelo\MultiPayment\Enums\SubscriptionStatus; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Exceptions\GatewayException; @@ -629,15 +630,21 @@ public function cancel( } /** - * Troca o plano da assinatura. + * Troca o plano da assinatura com a política de pró-rata informada. * - * Com $charge, a troca gera a cobrança na hora e a fatura resultante volta em - * `latestInvoice`; sem ele, nada é cobrado. + * Com `ProrationBehavior::CHARGE_DIFFERENCE` (o padrão) a troca gera a cobrança na hora e a + * fatura resultante volta em `latestInvoice`; com `NONE` nada é cobrado nem creditado agora; + * com `CREDIT` o gateway calcula o crédito do período não usado, e gateway sem + * `Capability::PLAN_CHANGE_PRORATION` lança `UnsupportedOperationException` antes de + * qualquer requisição. O booleano antigo continua aceito na mesma posição, e o argumento + * nomeado `charge` também; os dois são traduzidos (`true` é `CHARGE_DIFFERENCE`, `false` é + * `NONE`) com aviso `E_USER_DEPRECATED`, e `charge` informado prevalece sobre `$proration`. * * @param string $planId - * @param bool $charge + * @param ProrationBehavior|bool $proration política de pró-rata; o booleano é o `$charge` antigo, obsoleto * @param GatewayContract|string|null $gateway * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @param bool|null $charge obsoleto desde 2026-09-02; use `$proration` * * @return Subscription * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException @@ -648,12 +655,15 @@ public function cancel( */ public function changePlan( string $planId, - bool $charge = true, + ProrationBehavior|bool $proration = ProrationBehavior::CHARGE_DIFFERENCE, GatewayContract|string|null $gateway = null, - ?string $idempotencyKey = null + ?string $idempotencyKey = null, + ?bool $charge = null ): Subscription { + $proration = ProrationBehavior::resolve($charge ?? $proration); + return $this->resolveSubscriptionGateway($gateway) - ->changeSubscriptionPlan($this, $planId, $charge, $idempotencyKey); + ->changeSubscriptionPlan($this, $planId, $proration, $idempotencyKey); } /** diff --git a/src/Models/SubscriptionPlanChange.php b/src/Models/SubscriptionPlanChange.php index faf3057..09873a3 100644 --- a/src/Models/SubscriptionPlanChange.php +++ b/src/Models/SubscriptionPlanChange.php @@ -10,28 +10,39 @@ class SubscriptionPlanChange extends Model { /** - * Valor da troca segundo o gateway, em centavos. Nem todo gateway o devolve já líquido de - * créditos e descontos — o que sobra fica em `original`. + * Valor que a troca cobraria agora, em centavos, segundo o gateway; quando há linhas, é a + * soma de `items`. * * @var int|null */ public ?int $amount = null; /** - * Linhas da fatura que a troca geraria, quando o gateway as devolve — a Iugu não devolve, e - * lá fica `null`. Crédito de período não usado vem com price negativo. + * Linhas da fatura que a troca geraria. Crédito de período não usado vem com `price` + * negativo. Gateway que não devolve linhas recebe linhas montadas pela lib a partir dos + * totais da simulação (na Iugu: uma de cobrança do plano novo e, quando há crédito, uma + * negativa do plano antigo), então a lista nunca é nula; o payload cru fica em `original`. * - * @var InvoiceItem[]|null + * @var InvoiceItem[] */ - public ?array $items = null; + public array $items = []; /** - * Quando a próxima cobrança aconteceria caso a troca fosse aplicada. + * Data em que a próxima cobrança acontece após a troca. * * @var Carbon|null */ public ?Carbon $effectiveAt = null; + /** + * Diz se o plano novo passa a valer assim que a troca for aplicada. Falso quando o gateway + * só efetiva a troca depois que o pagador quitar a fatura gerada por ela (na Iugu, + * assinatura paga por boleto ou Pix). + * + * @var bool + */ + public bool $appliesImmediately = false; + /** * @var string|null */ @@ -53,6 +64,13 @@ public function fill(array $data): void $data['effective_at'] = Carbon::parse($data['effective_at']); } + // as duas propriedades não aceitam nulo; chave nula mantém o valor atual + foreach (['items', 'applies_immediately'] as $key) { + if (array_key_exists($key, $data) && is_null($data[$key])) { + unset($data[$key]); + } + } + if (!empty($data['items']) && is_array($data['items'])) { $data['items'] = array_map(function ($item) { if ($item instanceof InvoiceItem) { diff --git a/src/MultiPayment.php b/src/MultiPayment.php index a98ba51..28ad7f8 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -23,6 +23,7 @@ use Potelo\MultiPayment\Builders\CreditCardBuilder; use Potelo\MultiPayment\Builders\SubscriptionBuilder; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\NotFoundException; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -248,6 +249,50 @@ public function getInvoice(string $id): Invoice return $invoice->get($this->gateway); } + /** + * Busca a assinatura pelo id no gateway desta instância. + * + * @param string $id + * + * @return Subscription + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws GatewayException|GatewayNotAvailableException|UnsupportedOperationException + */ + public function getSubscription(string $id): Subscription + { + $subscription = new Subscription(); + $subscription->id = $id; + + return $subscription->get($this->gateway); + } + + /** + * Busca o plano pelo identificador definido por quem o criou ou pelo id do gateway. A + * primeira busca usa o valor como `identifier`; se o gateway responder que não existe + * (`NotFoundException`), a segunda usa o valor como `id`. Plano inexistente nos dois + * lança a `NotFoundException` da segunda busca. + * + * @param string $idOrIdentifier + * + * @return Plan + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws GatewayException|GatewayNotAvailableException|UnsupportedOperationException + */ + public function getPlan(string $idOrIdentifier): Plan + { + $plan = new Plan(); + $plan->identifier = $idOrIdentifier; + + try { + return $plan->get($this->gateway); + } catch (NotFoundException) { + $plan = new Plan(); + $plan->id = $idOrIdentifier; + + return $plan->get($this->gateway); + } + } + /** * Duplicate an invoice * diff --git a/tests/Integration/SubscriptionTest.php b/tests/Integration/SubscriptionTest.php index 4a661a8..082ef34 100644 --- a/tests/Integration/SubscriptionTest.php +++ b/tests/Integration/SubscriptionTest.php @@ -16,6 +16,7 @@ use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\PlanInterval; +use Potelo\MultiPayment\Enums\ProrationBehavior; use Potelo\MultiPayment\Enums\SubscriptionStatus; /** @@ -126,6 +127,10 @@ public function testShouldCreateGetAndListPlans(): void $porId->id = $plan->id; $this->assertSame($plan->identifier, $porId->get(self::GATEWAY)->identifier); + // a fachada aceita o identificador (uma requisição) ou o id do gateway (duas) + $this->assertSame($plan->id, MultiPayment::setGateway(self::GATEWAY)->getPlan($plan->identifier)->id); + $this->assertSame($plan->identifier, MultiPayment::setGateway(self::GATEWAY)->getPlan($plan->id)->identifier); + $this->createPlan(500, 'plano2'); $primeira = MultiPayment::setGateway(self::GATEWAY)->listPlans(1, 1); @@ -181,9 +186,7 @@ public function testShouldRunTheSubscriptionLifecycle(): void ); $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); - $lida = new Subscription(); - $lida->id = $subscription->id; - $lida = $lida->get(self::GATEWAY); + $lida = MultiPayment::setGateway(self::GATEWAY)->getSubscription($subscription->id); $this->assertSame($subscription->id, $lida->id); $this->assertSame($subscription->planId, $lida->planId); @@ -304,11 +307,16 @@ public function testShouldChangePlanAndPreviewIt(): void $preview = $subscription->previewPlanChange($planoNovo->identifier, self::GATEWAY); $this->assertSame('iugu', $preview->gateway); $this->assertSame(30000, $preview->amount); - $this->assertNull($preview->items); + // a Iugu não devolve linhas; a lib monta a de cobrança do plano novo a partir de cost + $this->assertCount(1, $preview->items); + $this->assertSame(30000, $preview->items[0]->price); + $this->assertSame("Plano {$planoNovo->identifier}", $preview->items[0]->description); + // assinatura paga por Pix: a troca com cobrança só vale depois do pagamento + $this->assertFalse($preview->appliesImmediately); $this->assertSame($planoNovo->identifier, $preview->original->new_plan); $this->assertSame($plan->identifier, $preview->original->old_plan); - $trocada = $subscription->changePlan($planoNovo->identifier, false, self::GATEWAY); + $trocada = $subscription->changePlan($planoNovo->identifier, ProrationBehavior::NONE, self::GATEWAY); $this->assertSame($planoNovo->identifier, $trocada->planId); $this->assertSame(30000, $trocada->amount); } @@ -329,7 +337,7 @@ public function testShouldChangePlanGeneratingTheCharge(): void // guarda: sem isto a asserção de latestInvoice abaixo passaria com a da leitura anterior $this->assertNull($subscription->latestInvoice); - $trocada = $subscription->changePlan($planoNovo->identifier, true, self::GATEWAY); + $trocada = $subscription->changePlan($planoNovo->identifier, ProrationBehavior::CHARGE_DIFFERENCE, self::GATEWAY); $this->assertSame($planoNovo->identifier, $trocada->planId); $this->assertSame(30000, $trocada->amount); diff --git a/tests/Unit/CapabilityGuardsTest.php b/tests/Unit/CapabilityGuardsTest.php index d940305..45bc827 100644 --- a/tests/Unit/CapabilityGuardsTest.php +++ b/tests/Unit/CapabilityGuardsTest.php @@ -15,6 +15,7 @@ use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\PlanInterval; +use Potelo\MultiPayment\Enums\ProrationBehavior; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Gateways\StripeGateway; @@ -80,8 +81,11 @@ public function testSubscriptionDomainMethodsOnStripeFailBeforeTheNetwork(): voi $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->get('stripe')); $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->suspend('stripe')); - $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->changePlan('plano_anual', true, 'stripe')); + foreach (ProrationBehavior::cases() as $proration) { + $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->changePlan('plano_anual', $proration, 'stripe')); + } $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->previewPlanChange('plano_anual', 'stripe')); + $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => (new MultiPayment('stripe'))->getSubscription('sub_1')); $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->resume('stripe')); $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->cancel(false, 'stripe')); $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => (new MultiPayment('stripe'))->listSubscriptions('cus_1')); @@ -130,6 +134,7 @@ public function testPlanOperationsOnStripeFailBeforeTheNetwork(): void $existing->id = 'plan_1'; $this->assertNotImplemented(Capability::PLANS, fn () => $existing->get('stripe')); $this->assertNotImplemented(Capability::PLANS, fn () => (new MultiPayment('stripe'))->listPlans()); + $this->assertNotImplemented(Capability::PLANS, fn () => (new MultiPayment('stripe'))->getPlan('plano_mensal')); } public function testBankSlipChargeOnStripeFailsBeforeCreatingTheCustomer(): void diff --git a/tests/Unit/Enums/ProrationBehaviorTest.php b/tests/Unit/Enums/ProrationBehaviorTest.php new file mode 100644 index 0000000..70b2670 --- /dev/null +++ b/tests/Unit/Enums/ProrationBehaviorTest.php @@ -0,0 +1,62 @@ +assertSame( + ['charge_difference', 'none', 'credit'], + array_column(ProrationBehavior::cases(), 'value') + ); + } + + #[DataProvider('chargeProvider')] + public function testFromChargeTranslatesTheOldBoolean(bool $charge, ProrationBehavior $expected): void + { + $this->assertSame($expected, ProrationBehavior::fromCharge($charge)); + } + + public static function chargeProvider(): array + { + return [ + 'true cobra a diferença' => [true, ProrationBehavior::CHARGE_DIFFERENCE], + 'false não cobra' => [false, ProrationBehavior::NONE], + ]; + } + + public function testResolveReturnsTheEnumAsIsWithoutDeprecation(): void + { + foreach (ProrationBehavior::cases() as $behavior) { + $this->assertSame($behavior, ProrationBehavior::resolve($behavior)); + } + } + + #[IgnoreDeprecations] + public function testResolveTranslatesTheBooleanWithADeprecationNotice(): void + { + $this->expectUserDeprecationMessage( + 'O booleano $charge de changePlan() está obsoleto desde 2026-09-02; passe' + . ' ProrationBehavior::CHARGE_DIFFERENCE ou ProrationBehavior::NONE' + ); + + $this->assertSame(ProrationBehavior::NONE, ProrationBehavior::resolve(false)); + } + + /** + * Só `CREDIT` depende de uma capability além de `SUBSCRIPTIONS`. + */ + public function testOnlyCreditRequiresACapability(): void + { + $this->assertSame(Capability::PLAN_CHANGE_PRORATION, ProrationBehavior::CREDIT->requiredCapability()); + $this->assertNull(ProrationBehavior::CHARGE_DIFFERENCE->requiredCapability()); + $this->assertNull(ProrationBehavior::NONE->requiredCapability()); + } +} diff --git a/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php index af48bef..b352a69 100644 --- a/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php +++ b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php @@ -22,6 +22,7 @@ use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\PlanInterval; +use Potelo\MultiPayment\Enums\ProrationBehavior; use Potelo\MultiPayment\Contracts\IdempotencyStore; use Potelo\MultiPayment\Idempotency\InMemoryIdempotencyStore; use Potelo\MultiPayment\Exceptions\RateLimitException; @@ -284,13 +285,13 @@ function (IuguGateway $g, string $key) { 'PUT', '/subscriptions/sub_1', ], 'changeSubscriptionPlan com cobrança (POST /change_plan, com a releitura repetida)' => [ - fn (IuguGateway $g, string $key) => $g->changeSubscriptionPlan(self::subscriptionWithId(), 'plano_anual', true, $key), + fn (IuguGateway $g, string $key) => $g->changeSubscriptionPlan(self::subscriptionWithId(), 'plano_anual', ProrationBehavior::CHARGE_DIFFERENCE, $key), [(object) ['success' => true], self::subscriptionResponse(['plan_identifier' => 'plano_anual'])], 'POST', '/subscriptions/sub_1/change_plan/plano_anual', [self::subscriptionResponse(['plan_identifier' => 'plano_anual'])], ], 'changeSubscriptionPlan sem cobrança (PUT /subscriptions/{id})' => [ - fn (IuguGateway $g, string $key) => $g->changeSubscriptionPlan(self::subscriptionWithId(), 'plano_anual', false, $key), + fn (IuguGateway $g, string $key) => $g->changeSubscriptionPlan(self::subscriptionWithId(), 'plano_anual', ProrationBehavior::NONE, $key), [self::subscriptionResponse(['plan_identifier' => 'plano_anual'])], 'PUT', '/subscriptions/sub_1', ], diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index 7096c12..3807ee9 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -13,6 +13,7 @@ use Potelo\MultiPayment\Builders\SubscriptionBuilder; use Potelo\MultiPayment\Idempotency\InMemoryIdempotencyStore; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\InvoiceItem; use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Models\SubscriptionItem; @@ -22,10 +23,12 @@ use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\InvoiceOriginType; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\PlanInterval; +use Potelo\MultiPayment\Enums\ProrationBehavior; use Potelo\MultiPayment\Tests\Unit\RecordingLogger; use Potelo\MultiPayment\Enums\SubscriptionStatus; @@ -675,7 +678,7 @@ public function testChangePlanWithoutChargeSendsSkipChargeAndTheNewBillingDate() $subscription = new Subscription(); $subscription->fill(['id' => 'sub_1', 'next_billing_at' => '2026-12-01']); - (new IuguGateway($api))->changeSubscriptionPlan($subscription, 'plano_anual', false); + (new IuguGateway($api))->changeSubscriptionPlan($subscription, 'plano_anual', ProrationBehavior::NONE); $this->assertCount(1, $api->calls); $this->assertSame('PUT', $api->calls[0]['method']); @@ -694,7 +697,8 @@ public function testChangePlanWithChargeUsesTheChangePlanEndpointThenReloads(): $subscription = new Subscription(); $subscription->id = 'sub_1'; - $changed = (new IuguGateway($api))->changeSubscriptionPlan($subscription, 'plano_anual'); + $changed = (new IuguGateway($api)) + ->changeSubscriptionPlan($subscription, 'plano_anual', ProrationBehavior::CHARGE_DIFFERENCE); $this->assertCount(2, $api->calls); $this->assertSame('POST', $api->calls[0]['method']); @@ -703,6 +707,128 @@ public function testChangePlanWithChargeUsesTheChangePlanEndpointThenReloads(): $this->assertSame('plano_anual', $changed->planId); } + /** + * `CREDIT` é limitação da Iugu: a recusa aponta `PLAN_CHANGE_PRORATION` e nenhuma + * requisição sai. + */ + public function testChangePlanWithCreditIsRefusedBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([]); + $store = new InMemoryIdempotencyStore(); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + try { + (new IuguGateway($api, $store)) + ->changeSubscriptionPlan($subscription, 'plano_anual', ProrationBehavior::CREDIT, 'chave-1'); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::PLAN_CHANGE_PRORATION, $e->capability); + $this->assertSame('iugu', $e->gateway); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('ProrationBehavior::CHARGE_DIFFERENCE', $e->getMessage()); + } + $this->assertCount(0, $api->calls); + // a recusa vem antes da chave entrar na store + $this->assertFalse($store->has('iugu:chave-1')); + } + + /** + * O booleano antigo continua aceito pelo driver: `true` segue para `change_plan` e `false` + * para o `PUT` com `skip_charge`, com aviso de obsolescência. + */ + #[DataProvider('deprecatedChargeProvider')] + #[IgnoreDeprecations] + public function testChangePlanTranslatesTheDeprecatedBoolean(bool $charge, string $method, array $responses): void + { + $api = new QueuedIuguApiRequest($responses); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->expectUserDeprecationMessage( + 'O booleano $charge de changePlan() está obsoleto desde 2026-09-02; passe' + . ' ProrationBehavior::CHARGE_DIFFERENCE ou ProrationBehavior::NONE' + ); + + (new IuguGateway($api))->changeSubscriptionPlan($subscription, 'plano_anual', $charge); + + $this->assertSame($method, $api->calls[0]['method']); + if ($charge) { + $this->assertStringEndsWith('/subscriptions/sub_1/change_plan/plano_anual', $api->calls[0]['url']); + } else { + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[0]['url']); + $this->assertTrue($api->calls[0]['data']['skip_charge']); + } + } + + public static function deprecatedChargeProvider(): array + { + $reloaded = (object) ['id' => 'sub_1', 'active' => true, 'plan_identifier' => 'plano_anual']; + + return [ + 'true cobra pelo change_plan' => [true, 'POST', [(object) ['success' => true], $reloaded]], + 'false troca pelo PUT com skip_charge' => [false, 'PUT', [$reloaded]], + ]; + } + + /** + * Do model ao driver, o booleano antigo dispara um único aviso: o model o traduz e o driver + * recebe o enum. + */ + public function testTheDeprecatedBooleanTriggersASingleNoticeFromTheModelToTheDriver(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'sub_1', 'active' => true, 'plan_identifier' => 'plano_anual'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $notices = []; + set_error_handler(function (int $level, string $message) use (&$notices): bool { + $notices[] = $message; + + return true; + }, E_USER_DEPRECATED); + + try { + $subscription->changePlan('plano_anual', false, new IuguGateway($api)); + } finally { + restore_error_handler(); + } + + $this->assertCount(1, $notices); + $this->assertSame('PUT', $api->calls[0]['method']); + } + + /** + * O argumento nomeado `charge` antigo continua aceito pelo driver e prevalece sobre a + * política, com aviso de obsolescência. + */ + #[IgnoreDeprecations] + public function testChangePlanAcceptsTheDeprecatedNamedChargeArgument(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'sub_1', 'active' => true, 'plan_identifier' => 'plano_anual'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->expectUserDeprecationMessage( + 'O booleano $charge de changePlan() está obsoleto desde 2026-09-02; passe' + . ' ProrationBehavior::CHARGE_DIFFERENCE ou ProrationBehavior::NONE' + ); + + (new IuguGateway($api))->changeSubscriptionPlan($subscription, 'plano_anual', charge: false); + + $this->assertCount(1, $api->calls); + $this->assertSame('PUT', $api->calls[0]['method']); + $this->assertTrue($api->calls[0]['data']['skip_charge']); + } + /** * O plano da assinatura devolvida vem do gateway; o plano pedido só é mantido quando a * resposta não traz `plan_identifier`. @@ -725,11 +851,14 @@ public function testChangePlanKeepsTheRequestedPlanWhenTheReloadOmitsIt(): void /** * Resposta real de `change_plan_simulation` gravada na sandbox, de uma assinatura com * subitem e desconto ativos: só `cost`, `discount`, `cycles`, `expires_at`, `new_plan` e - * `old_plan`, com `discount` em 0 e sem linhas. O parse lê `cost` e deixa `items` nulo. + * `old_plan`, com `discount` em 0 e sem linhas. O parse lê `cost` e monta uma única linha, + * a de cobrança do plano novo; o model só com o id faz o driver ler a assinatura antes da + * simulação, e a assinatura paga por Pix não aplica o plano na hora. */ public function testPreviewPlanChangeReadsTheSimulationResponse(): void { $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['payable_with' => 'pix']), json_decode( file_get_contents(__DIR__ . '/../../fixtures/iugu/change_plan_simulation.json'), flags: JSON_THROW_ON_ERROR @@ -742,17 +871,217 @@ public function testPreviewPlanChangeReadsTheSimulationResponse(): void $planChange = (new IuguGateway($api)) ->previewSubscriptionPlanChange($subscription, 'multipayment-teste-destino'); + $this->assertCount(2, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[0]['url']); $this->assertStringEndsWith( '/subscriptions/sub_1/change_plan_simulation/multipayment-teste-destino', - $api->calls[0]['url'] + $api->calls[1]['url'] ); $this->assertSame(30000, $planChange->amount); $this->assertSame('2026-10-02', $planChange->effectiveAt->format('Y-m-d')); - $this->assertNull($planChange->items); + $this->assertFalse($planChange->appliesImmediately); + $this->assertCount(1, $planChange->items); + $this->assertSame('Plano multipayment-teste-destino', $planChange->items[0]->description); + $this->assertSame(30000, $planChange->items[0]->price); + $this->assertSame(1, $planChange->items[0]->quantity); $this->assertSame(0, $planChange->original->discount); $this->assertSame(1, $planChange->original->cycles); $this->assertSame('multipayment-teste-destino', $planChange->original->new_plan); $this->assertSame('multipayment-teste-origem', $planChange->original->old_plan); + // a leitura prévia não altera o model do chamador + $this->assertNull($subscription->availablePaymentMethods); + } + + /** + * A leitura prévia usa um model à parte: o cliente do model do chamador não é sobrescrito + * com o que veio do gateway. + */ + public function testPreviewReadsTheSubscriptionWithoutTouchingTheCallersCustomer(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'payable_with' => 'pix', + 'customer_name' => 'Nome do gateway', + 'customer_email' => 'gateway@exemplo.com', + ]), + (object) ['cost' => 30000, 'discount' => 0, 'expires_at' => '2026-10-02', 'new_plan' => 'p', 'old_plan' => 'o'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->customer = new Customer(); + $subscription->customer->id = 'cus_1'; + $subscription->customer->name = 'Nome local'; + + (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertCount(2, $api->calls); + $this->assertSame('Nome local', $subscription->customer->name); + $this->assertNull($subscription->customer->email); + } + + /** + * `creditCard` é atributo de escrita e não diz como a assinatura é paga no gateway: com + * ele sozinho o driver ainda lê a assinatura, e `payable_with: all` responde falso. + */ + public function testPreviewReadsTheSubscriptionWhenTheModelOnlyHasACreditCard(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['payable_with' => 'all']), + (object) ['cost' => 30000, 'discount' => 0, 'expires_at' => '2026-10-02', 'new_plan' => 'p', 'old_plan' => 'o'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->id = 'pm_1'; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertCount(2, $api->calls); + $this->assertFalse($planChange->appliesImmediately); + } + + /** + * Com `discount` maior que zero, a lib monta a linha de crédito do plano antigo com valor + * negativo, e a soma das linhas continua igual a `cost`. + */ + public function testPreviewSynthesizesACreditLineWhenTheSimulationHasADiscount(): void + { + $api = new QueuedIuguApiRequest([ + (object) [ + 'cost' => 25000, + 'discount' => 5000, + 'cycles' => 1, + 'expires_at' => '2026-10-02', + 'new_plan' => 'plano_anual', + 'old_plan' => 'plano_mensal', + ], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::PIX; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'plano_anual'); + + $this->assertSame(25000, $planChange->amount); + $this->assertCount(2, $planChange->items); + $this->assertSame('Plano plano_anual', $planChange->items[0]->description); + $this->assertSame(30000, $planChange->items[0]->price); + $this->assertSame('Crédito do plano plano_mensal', $planChange->items[1]->description); + $this->assertSame(-5000, $planChange->items[1]->price); + $this->assertSame(1, $planChange->items[1]->quantity); + $this->assertSame( + $planChange->amount, + array_sum(array_map(fn (InvoiceItem $item) => $item->price * $item->quantity, $planChange->items)) + ); + } + + /** + * Assinatura paga só com cartão aplica o plano novo na hora; o model que já traz o método + * dispensa a leitura prévia. + */ + public function testPreviewAppliesImmediatelyWhenTheSubscriptionIsPaidOnlyByCard(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['cost' => 30000, 'discount' => 0, 'expires_at' => '2026-10-02', 'new_plan' => 'p', 'old_plan' => 'o'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::CREDIT_CARD; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertCount(1, $api->calls); + $this->assertTrue($planChange->appliesImmediately); + } + + /** + * Model lido do gateway com `payable_with: credit_card` já traz a lista: sem leitura extra, + * e o plano novo vale na hora. + */ + public function testPreviewAppliesImmediatelyForASubscriptionReadWithCardOnly(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['payable_with' => 'credit_card']), + (object) ['cost' => 30000, 'discount' => 0, 'expires_at' => '2026-10-02', 'new_plan' => 'p', 'old_plan' => 'o'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertCount(2, $api->calls); + $this->assertTrue($planChange->appliesImmediately); + } + + /** + * As linhas sintetizadas toleram totais fora do esperado: `discount` não numérico ou + * negativo vale zero, e plano sem identificador ganha descrição genérica. + */ + #[DataProvider('unusualSimulationTotalsProvider')] + public function testPreviewSynthesizedLinesTolerateUnusualTotals(object $simulation, array $expectedItems): void + { + $api = new QueuedIuguApiRequest([$simulation]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::PIX; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertSame(30000, $planChange->amount); + $this->assertSame( + $expectedItems, + array_map(fn (InvoiceItem $item) => $item->toArray(), $planChange->items) + ); + } + + public static function unusualSimulationTotalsProvider(): array + { + return [ + 'discount formatado' => [ + (object) ['cost' => 30000, 'discount' => 'R$ 50,00', 'new_plan' => 'p', 'old_plan' => 'o'], + [['description' => 'Plano p', 'price' => 30000, 'quantity' => 1]], + ], + 'discount negativo' => [ + (object) ['cost' => 30000, 'discount' => -5000, 'new_plan' => 'p', 'old_plan' => 'o'], + [['description' => 'Plano p', 'price' => 30000, 'quantity' => 1]], + ], + 'planos sem identificador' => [ + (object) ['cost' => 30000, 'discount' => 5000], + [ + ['description' => 'Plano novo', 'price' => 35000, 'quantity' => 1], + ['description' => 'Crédito do plano anterior', 'price' => -5000, 'quantity' => 1], + ], + ], + ]; + } + + /** + * Assinatura aberta a mais de um método (`payable_with: all`) não aplica na hora: o driver + * não sabe se o cartão padrão será cobrado. + */ + public function testPreviewDoesNotApplyImmediatelyWhenTheSubscriptionAcceptsSeveralMethods(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['payable_with' => 'all']), + (object) ['cost' => 30000, 'discount' => 0, 'expires_at' => '2026-10-02', 'new_plan' => 'p', 'old_plan' => 'o'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertCount(2, $api->calls); + $this->assertFalse($planChange->appliesImmediately); } public function testPreviewPlanChangeFallsBackToPriceCentsAndSubitems(): void @@ -769,12 +1098,14 @@ public function testPreviewPlanChangeFallsBackToPriceCentsAndSubitems(): void $subscription = new Subscription(); $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::PIX; $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'plano_anual'); $this->assertSame(30000, $planChange->amount); $this->assertSame('2026-12-01', $planChange->effectiveAt->format('Y-m-d')); $this->assertCount(1, $planChange->items); + $this->assertSame('Plano anual', $planChange->items[0]->description); $this->assertSame(30000, $planChange->items[0]->price); } @@ -1836,10 +2167,13 @@ public function testPlanChangeIgnoresNonNumericAmountFields(): void $subscription = new Subscription(); $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::PIX; $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); $this->assertNull($planChange->amount); + // sem total não há linha a sintetizar + $this->assertSame([], $planChange->items); } public function testStatusKeepsThePreviousValueWhenTheResponseHasNoFlags(): void @@ -1869,6 +2203,7 @@ public function testPlanChangeFallsBackWhenCostIsNotNumeric(): void $subscription = new Subscription(); $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::PIX; $this->assertSame( 30000, diff --git a/tests/Unit/Gateways/Stripe/ProrationBehaviorsTest.php b/tests/Unit/Gateways/Stripe/ProrationBehaviorsTest.php new file mode 100644 index 0000000..1a9b6e9 --- /dev/null +++ b/tests/Unit/Gateways/Stripe/ProrationBehaviorsTest.php @@ -0,0 +1,36 @@ +assertSame($expected, ProrationBehaviors::toStripe($behavior)); + } + + public static function behaviorProvider(): array + { + return [ + 'charge_difference fatura na hora' => [ProrationBehavior::CHARGE_DIFFERENCE, 'always_invoice'], + 'none não cria pró-rata' => [ProrationBehavior::NONE, 'none'], + 'credit deixa o crédito para a próxima fatura' => [ProrationBehavior::CREDIT, 'create_prorations'], + ]; + } + + public function testEveryPolicyIsMapped(): void + { + foreach (ProrationBehavior::cases() as $behavior) { + $this->assertNotSame('', ProrationBehaviors::toStripe($behavior)); + } + } +} diff --git a/tests/Unit/IdempotencyKeyPropagationTest.php b/tests/Unit/IdempotencyKeyPropagationTest.php index 34176a2..60f0046 100644 --- a/tests/Unit/IdempotencyKeyPropagationTest.php +++ b/tests/Unit/IdempotencyKeyPropagationTest.php @@ -314,13 +314,13 @@ public function testSubscriptionDomainMethodsPassTheKey(): void $gateway->shouldReceive('suspendSubscription')->once()->with($subscription, 'k-suspend')->andReturn($subscription); $gateway->shouldReceive('resumeSubscription')->once()->with($subscription, 'k-resume')->andReturn($subscription); $gateway->shouldReceive('cancelSubscription')->once()->with($subscription, true, 'k-cancel')->andReturn($subscription); - $gateway->shouldReceive('changeSubscriptionPlan')->once()->with($subscription, 'plano_anual', false, 'k-change')->andReturn($subscription); + $gateway->shouldReceive('changeSubscriptionPlan')->once()->with($subscription, 'plano_anual', \Potelo\MultiPayment\Enums\ProrationBehavior::NONE, 'k-change')->andReturn($subscription); $gateway->shouldReceive('updateSubscription')->once()->with($subscription, 'k-update')->andReturn($subscription); $this->assertSame($subscription, $subscription->suspend($gateway, 'k-suspend')); $this->assertSame($subscription, $subscription->resume($gateway, 'k-resume')); $this->assertSame($subscription, $subscription->cancel(true, $gateway, 'k-cancel')); - $this->assertSame($subscription, $subscription->changePlan('plano_anual', false, $gateway, 'k-change')); + $this->assertSame($subscription, $subscription->changePlan('plano_anual', \Potelo\MultiPayment\Enums\ProrationBehavior::NONE, $gateway, 'k-change')); $subscription->save($gateway, true, 'k-update'); $this->assertSame('sub_1', $subscription->id); } diff --git a/tests/Unit/MultiPaymentReadTest.php b/tests/Unit/MultiPaymentReadTest.php new file mode 100644 index 0000000..ad70694 --- /dev/null +++ b/tests/Unit/MultiPaymentReadTest.php @@ -0,0 +1,160 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => ['iugu' => ['api_key' => 'iugu-key', 'class' => IuguGateway::class]], + ], + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + QueuedIuguApiRequest::restoreSdkRequester(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testGetSubscriptionReadsTheSubscriptionById(): void + { + $api = (new QueuedIuguApiRequest([ + (object) [ + 'id' => 'sub_1', + 'customer_id' => 'cus_1', + 'plan_identifier' => 'plano_mensal', + 'price_cents' => 10000, + 'expires_at' => '2026-10-01', + 'active' => true, + 'suspended' => false, + 'in_trial' => false, + ], + ]))->installAsSdkRequester(); + + $subscription = (new MultiPayment('iugu'))->getSubscription('sub_1'); + + $this->assertInstanceOf(Subscription::class, $subscription); + $this->assertCount(1, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[0]['url']); + $this->assertSame('sub_1', $subscription->id); + $this->assertSame('plano_mensal', $subscription->planId); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + $this->assertSame('iugu', $subscription->gateway); + } + + /** + * O valor informado é buscado primeiro como identificador do plano. + */ + public function testGetPlanReadsThePlanByItsIdentifier(): void + { + $api = (new QueuedIuguApiRequest([self::planResponse()]))->installAsSdkRequester(); + + $plan = (new MultiPayment('iugu'))->getPlan('plano_mensal'); + + $this->assertInstanceOf(Plan::class, $plan); + $this->assertCount(1, $api->calls); + $this->assertStringEndsWith('/plans/identifier/plano_mensal', $api->calls[0]['url']); + $this->assertSame('plan_1', $plan->id); + $this->assertSame('plano_mensal', $plan->identifier); + $this->assertSame(10000, $plan->amount); + $this->assertSame(PlanInterval::MONTH, $plan->interval); + } + + /** + * Quando o identificador não existe, o mesmo valor é buscado como id do gateway. + */ + public function testGetPlanFallsBackToTheIdWhenTheIdentifierDoesNotExist(): void + { + $api = (new QueuedIuguApiRequest([ + new \IuguObjectNotFound('plan: not found'), + self::planResponse(), + ]))->installAsSdkRequester(); + + $plan = (new MultiPayment('iugu'))->getPlan('plan_1'); + + $this->assertCount(2, $api->calls); + $this->assertStringEndsWith('/plans/identifier/plan_1', $api->calls[0]['url']); + $this->assertStringEndsWith('/plans/plan_1', $api->calls[1]['url']); + $this->assertSame('plan_1', $plan->id); + } + + /** + * Plano inexistente pelos dois caminhos lança a `NotFoundException` da busca pelo id. + */ + public function testGetPlanThrowsNotFoundWhenNeitherLookupFinds(): void + { + $api = (new QueuedIuguApiRequest([ + new \IuguObjectNotFound('identifier: not found'), + new \IuguObjectNotFound('id: not found'), + ]))->installAsSdkRequester(); + + try { + (new MultiPayment('iugu'))->getPlan('inexistente'); + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertStringContainsString('id: not found', $e->getMessage()); + } + $this->assertCount(2, $api->calls); + $this->assertStringEndsWith('/plans/inexistente', $api->calls[1]['url']); + } + + /** + * Só o 404 da busca pelo identificador leva à busca pelo id; outro erro sobe sem a segunda + * requisição. + */ + public function testGetPlanDoesNotFallBackToTheIdWhenTheIdentifierLookupFailsForAnotherReason(): void + { + $api = (new QueuedIuguApiRequest([ + new \IuguRequestException('Bad Gateway', 502), + ]))->installAsSdkRequester(); + + try { + (new MultiPayment('iugu'))->getPlan('plano_mensal'); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException) { + $this->assertCount(1, $api->calls); + } + } + + private static function planResponse(): object + { + return (object) [ + 'id' => 'plan_1', + 'identifier' => 'plano_mensal', + 'name' => 'Mensal', + 'interval' => 1, + 'interval_type' => 'months', + 'prices' => [(object) ['value_cents' => 10000, 'currency' => 'BRL']], + ]; + } +} diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php index b031f2d..8e188fc 100644 --- a/tests/Unit/SubscriptionTest.php +++ b/tests/Unit/SubscriptionTest.php @@ -17,11 +17,13 @@ use Potelo\MultiPayment\Models\SubscriptionDiscount; use Potelo\MultiPayment\Builders\SubscriptionBuilder; use Potelo\MultiPayment\Models\SubscriptionPlanChange; +use Potelo\MultiPayment\Enums\ProrationBehavior; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\PlanInterval; @@ -376,11 +378,39 @@ public function testPlanChangeParsesInvoiceLinesAndDate(): void 'amount' => 50000, 'items' => [['description' => 'Tempo não utilizado', 'price' => -10000, 'quantity' => 1]], 'effective_at' => '2026-10-01', + 'applies_immediately' => true, ]); $this->assertInstanceOf(InvoiceItem::class, $planChange->items[0]); $this->assertSame(-10000, $planChange->items[0]->price); $this->assertSame('2026-10-01', $planChange->effectiveAt->format('Y-m-d')); + $this->assertTrue($planChange->appliesImmediately); + } + + /** + * Uma prévia recém-criada já tem lista de linhas (vazia) e não assume que o plano vale na + * hora. + */ + public function testPlanChangeStartsWithAnEmptyListOfLines(): void + { + $planChange = new SubscriptionPlanChange(); + + $this->assertSame([], $planChange->items); + $this->assertFalse($planChange->appliesImmediately); + } + + /** + * `items` e `applies_immediately` nulos no `fill()` mantêm o valor atual em vez de lançar + * `TypeError`. + */ + public function testPlanChangeFillIgnoresNullLinesAndFlag(): void + { + $planChange = new SubscriptionPlanChange(); + $planChange->fill(['amount' => 100, 'items' => null, 'applies_immediately' => null]); + + $this->assertSame(100, $planChange->amount); + $this->assertSame([], $planChange->items); + $this->assertFalse($planChange->appliesImmediately); } public function testBuilderAssemblesTheSubscription(): void @@ -474,8 +504,56 @@ public static function lifecycleProvider(): array 'resume' => ['resumeSubscription', [], 'resume', []], 'cancel imediato' => ['cancelSubscription', [false], 'cancel', [false]], 'cancel ao fim do periodo' => ['cancelSubscription', [true], 'cancel', [true]], - 'changePlan cobrando' => ['changeSubscriptionPlan', ['plano_anual', true], 'changePlan', ['plano_anual', true]], - 'changePlan sem cobrar' => ['changeSubscriptionPlan', ['plano_anual', false], 'changePlan', ['plano_anual', false]], + 'changePlan cobrando' => ['changeSubscriptionPlan', ['plano_anual', ProrationBehavior::CHARGE_DIFFERENCE], 'changePlan', ['plano_anual', ProrationBehavior::CHARGE_DIFFERENCE]], + 'changePlan sem cobrar' => ['changeSubscriptionPlan', ['plano_anual', ProrationBehavior::NONE], 'changePlan', ['plano_anual', ProrationBehavior::NONE]], + 'changePlan com crédito' => ['changeSubscriptionPlan', ['plano_anual', ProrationBehavior::CREDIT], 'changePlan', ['plano_anual', ProrationBehavior::CREDIT]], + ]; + } + + /** + * O booleano antigo de `changePlan()`, posicional ou pelo nome `charge`, chega ao gateway + * traduzido para o enum, com aviso de obsolescência. + */ + #[DataProvider('deprecatedChargeProvider')] + #[IgnoreDeprecations] + public function testChangePlanTranslatesTheDeprecatedBoolean(callable $call, ProrationBehavior $expected): void + { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $gateway = self::subscriptionGateway(); + $gateway->shouldReceive('changeSubscriptionPlan') + ->once() + ->with($subscription, 'plano_anual', $expected, null) + ->andReturn($subscription); + + $this->expectUserDeprecationMessage( + 'O booleano $charge de changePlan() está obsoleto desde 2026-09-02; passe' + . ' ProrationBehavior::CHARGE_DIFFERENCE ou ProrationBehavior::NONE' + ); + + $this->assertSame($subscription, $call($subscription, $gateway)); + } + + public static function deprecatedChargeProvider(): array + { + return [ + 'true posicional' => [ + fn (Subscription $s, $g) => $s->changePlan('plano_anual', true, $g), + ProrationBehavior::CHARGE_DIFFERENCE, + ], + 'false posicional' => [ + fn (Subscription $s, $g) => $s->changePlan('plano_anual', false, $g), + ProrationBehavior::NONE, + ], + 'charge nomeado' => [ + fn (Subscription $s, $g) => $s->changePlan('plano_anual', charge: false, gateway: $g), + ProrationBehavior::NONE, + ], + 'charge nomeado prevalece sobre o enum' => [ + fn (Subscription $s, $g) => $s->changePlan('plano_anual', ProrationBehavior::NONE, $g, charge: true), + ProrationBehavior::CHARGE_DIFFERENCE, + ], ]; } From fb72b44a31df8487eb84d4b09309725367c33b44 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 18:27:33 -0300 Subject: [PATCH 27/32] =?UTF-8?q?refactor(exceptions,capabilities):=20tipa?= =?UTF-8?q?=20regras=20locais,=20isola=20RefundNotSupported,=20adiciona=20?= =?UTF-8?q?refundableAmount=20e=20restri=C3=A7=C3=B5es=20consult=C3=A1veis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nenhuma GatewayException nasce mais de regra local: regra de valor (page e limit, plano com id, nextBillingAt diferente de trialEndsAt, PlanInterval::DAY e teto de 599 na Iugu) vira ModelAttributeValidationException; restrição de gateway (cartão de outro cliente, rascunho não anulável e duplicação sem cliente no Stripe) vira UnsupportedOperationException::restricted(); driver que declara capability sem o contract ou sem o método de despacho vira ConfigurationException. Um teste percorre src/ com o tokenizer e falha se GatewayException for criada fora dos classificadores ou de um catch. RefundNotSupportedException passa a herdar direto de MultiPaymentException, com isCapabilityLimitation() separando boleto e Pix parcial (capability preenchida) de fatura já estornada, valor acima do restante e prazo vencido. O valor do estorno vira argumento: Invoice::refund(?int $amount, ?string $key) e refundInvoice(Invoice, ?int $amount, ?string $key) no contract; refundableAmount() na fachada, no model e nos drivers (paid_cents na Iugu, pago menos estornado na Stripe). Invoice::$refundedAmount fica só de leitura, com o caminho antigo de escrever nela aceito com E_USER_DEPRECATED; num model lido do gateway, refund() sem valor estorna o restante. A Stripe deixa de reler a fatura parcialmente estornada no estorno por valor. Capabilities ganham restrições consultáveis: CapabilityRestriction, restrictions(), restriction() e supportsAll() na interface, no trait, nos drivers e na fachada; capability nova INVOICE_CANCELLATION; coluna "Restrições" na tabela gerada; configuração multi-payment.gateways.iugu.max_installments. --- README.md | 249 ++++++++++----- src/Capabilities/CapabilityRestriction.php | 54 ++++ src/Contracts/DeclaresCapabilities.php | 29 +- src/Contracts/InvoiceContract.php | 33 +- src/Enums/Capability.php | 3 + src/Exceptions/ConfigurationException.php | 33 ++ src/Exceptions/GatewayException.php | 2 + .../RefundNotSupportedException.php | 57 +++- .../UnsupportedOperationException.php | 5 +- src/Facades/MultiPayment.php | 4 + src/Gateways/Concerns/ChecksCapabilities.php | 36 ++- src/Gateways/IuguGateway.php | 113 +++++-- src/Gateways/StripeGateway.php | 146 +++++++-- src/Helpers/CapabilitiesTable.php | 37 ++- src/Models/Invoice.php | 138 +++++++- src/Models/Model.php | 60 +++- src/Models/Plan.php | 8 +- src/Models/Subscription.php | 9 +- src/MultiPayment.php | 83 +++-- src/config/multi-payment.php | 2 + tests/Integration/StripeGatewayTest.php | 3 +- .../CapabilityRestrictionTest.php | 50 +++ .../Exceptions/ExceptionHierarchyTest.php | 10 + .../NoLocalGatewayExceptionTest.php | 300 ++++++++++++++++++ .../RefundNotSupportedExceptionTest.php | 88 +++++ .../UnsupportedOperationExceptionTest.php | 59 ---- .../Unit/Gateways/GatewayCapabilitiesTest.php | 114 ++++++- .../Gateways/IuguGatewayIdempotencyTest.php | 40 ++- tests/Unit/Gateways/IuguGatewayRefundTest.php | 190 ++++++++--- .../Gateways/IuguGatewaySubscriptionTest.php | 14 +- .../Gateways/StripeGatewayCreditCardTest.php | 12 +- .../Gateways/StripeGatewayIdempotencyTest.php | 8 +- .../Gateways/StripeGatewayInvoiceTest.php | 244 ++++++++++++-- .../StripeGatewayStripeInvoiceTest.php | 27 +- tests/Unit/IdempotencyKeyPropagationTest.php | 5 +- tests/Unit/InvoiceTest.php | 92 ++++++ tests/Unit/ModelFillTest.php | 2 +- tests/Unit/MultiPaymentReadTest.php | 24 ++ tests/Unit/RefundTest.php | 2 +- tests/Unit/SubscriptionTest.php | 39 ++- 40 files changed, 2038 insertions(+), 386 deletions(-) create mode 100644 src/Capabilities/CapabilityRestriction.php create mode 100644 tests/Unit/Capabilities/CapabilityRestrictionTest.php create mode 100644 tests/Unit/Exceptions/NoLocalGatewayExceptionTest.php diff --git a/README.md b/README.md index 6d21156..4ef9611 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ MULTIPAYMENT_DEFAULT=iugu #iugu IUGU_ID= IUGU_APIKEY= +IUGU_MAX_INSTALLMENTS=12 # opcional; máximo de parcelas habilitado na conta (ver Capabilities) #stripe STRIPE_APIKEY= @@ -112,7 +113,14 @@ Cada driver declara o que suporta em dois níveis, pelo contract `DeclaresCapabi `capabilities()` lista o que o gateway oferece e a lib implementa; `notYetImplemented()` lista o que o gateway oferece mas a lib ainda não construiu (planejado para uma versão futura). O que não aparece em nenhuma das duas listas é limitação do gateway. `supports(Capability $c)` responde -sobre a primeira lista. Os valores são o enum `Potelo\MultiPayment\Enums\Capability`. +sobre a primeira lista e `supportsAll(Capability ...$c)` exige todas de uma vez. Os valores são +o enum `Potelo\MultiPayment\Enums\Capability`. + +Uma capability suportada pode valer só numa parte dos casos. `restriction(Capability $c)` devolve +um `CapabilityRestriction` (`Potelo\MultiPayment\Capabilities\CapabilityRestriction`) com a +`description` da restrição e, quando ela é enumerável, `allowedPaymentMethods`, `allowedBrands` +ou `maxInstallments`; nulo quando a capability vale em todos os casos. `restrictions()` lista +todas, com o valor da capability como chave. Consulte a capability **antes** de montar a interface de checkout ou de escolher o gateway, em vez de capturar a exceção depois: @@ -126,8 +134,17 @@ if (!MultiPayment::gateway('stripe')->supports(Capability::BANK_SLIP)) { } MultiPayment::supports(Capability::INSTALLMENTS, 'iugu'); // true +MultiPayment::supportsAll(Capability::PIX, Capability::INVOICE_DUPLICATION); // no gateway da instância MultiPayment::capabilities('stripe'); // Capability[] que a lib implementa MultiPayment::notYetImplemented('stripe'); // Capability[] planejadas + +// restrições dentro de um "sim", consultáveis antes de tokenizar ou de exibir a opção +$brands = MultiPayment::restriction(Capability::CREDIT_CARD, 'stripe'); +if ($brands && !$brands->allowsBrand($binLookup->brand)) { + $gateway = 'iugu'; // Elo, Hipercard e Amex vão para a Iugu +} +MultiPayment::restriction(Capability::INSTALLMENTS, 'iugu')->maxInstallments; // 12 por padrão +MultiPayment::restriction(Capability::PIX, 'iugu'); // null: vale em todos os casos ``` Toda operação fora das capabilities do gateway lança `UnsupportedOperationException` **antes de @@ -137,46 +154,55 @@ gateway oferece e a lib ainda não implementou; `gateway_limitation` quando o ga oferece). Ver [Tratamento de erros](#tratamento-de-erros). A matriz abaixo é gerada a partir das declarações dos drivers com `composer capabilities:table`; -o teste `GatewayCapabilitiesTest` falha quando o README fica defasado em relação ao código. +o teste `GatewayCapabilitiesTest` falha quando o README fica defasado em relação ao código. A +coluna "Restrições" é o que `restriction()` devolve para cada gateway. -| Capability | Significado | Iugu | Stripe | -|---|---|---|---| -| `CREDIT_CARD` | Fatura paga com cartão de crédito. | sim | sim | -| `PIX` | Fatura paga com Pix avulso, com QR Code de pagamento único. | sim | sim | -| `BANK_SLIP` | Fatura paga com boleto bancário. | sim | não implementado | -| `AUTOMATIC_PIX` | Recorrência de Pix Automático criada junto com a fatura, com reagendamento e cancelamento pela lib. | sim | não implementado | -| `MULTIPLE_PAYMENT_METHODS` | Fatura aberta a mais de um método de pagamento, escolhido pelo pagador na hora de pagar. | sim | não implementado | -| `RAW_CARD_DATA` | Cartão informado com número e CVV pela API; sem ela, o cartão é tokenizado no navegador e só o token chega à lib. | sim | limitação do gateway | -| `INSTALLMENTS` | Parcelamento da cobrança no cartão de crédito. | sim | limitação do gateway | -| `DELAYED_CAPTURE` | Cobrança em duas etapas no cartão: reserva do valor agora e captura depois. | não implementado | não implementado | -| `PARTIAL_REFUND_CARD` | Estorno de parte do valor numa fatura paga com cartão. | sim | sim | -| `PARTIAL_REFUND_PIX` | Estorno de parte do valor numa fatura paga com Pix. | limitação do gateway | sim | -| `REFUND_BANK_SLIP` | Estorno pela API de uma fatura paga com boleto. | limitação do gateway | limitação do gateway | -| `INVOICE_DUPLICATION` | Segunda via de uma fatura pendente com nova data de vencimento (`duplicateInvoice`). | sim | sim | -| `IDEMPOTENCY` | Chave de idempotência (`idempotencyKey`) honrada em toda operação de escrita, pelo gateway ou pela deduplicação da lib (`IdempotencyStore`). | sim | sim | -| `IDEMPOTENCY_ALL_ENDPOINTS` | Chave de idempotência honrada pelo próprio gateway em toda operação de escrita, sem depender da deduplicação da lib. | limitação do gateway | sim | -| `SUBSCRIPTIONS` | Assinatura recorrente: criar, buscar, atualizar, suspender, retomar, cancelar, trocar de plano e listar. | sim | não implementado | -| `PLANS` | Plano de assinatura: criar, buscar e listar. | sim | não implementado | -| `PLAN_DEACTIVATION` | Desativar um plano sem apagá-lo (`deactivatePlan`). | limitação do gateway | não implementado | -| `CANCEL_AT_PERIOD_END` | Cancelar a assinatura só no fim do período já pago (`cancel(atPeriodEnd: true)`). | limitação do gateway | não implementado | -| `NATIVE_COUPONS` | Cupom de primeira classe na assinatura: desconto percentual e desconto limitado a vários ciclos. | limitação do gateway | não implementado | -| `PLAN_CHANGE_PRORATION` | Crédito proporcional do período não usado, calculado pelo gateway, ao trocar de plano (`changePlan()` com `ProrationBehavior::CREDIT`). | limitação do gateway | não implementado | -| `SUBSCRIPTION_CREDITS` | Assinatura com saldo de créditos consumíveis, abatidos a cada uso. | não implementado | limitação do gateway | -| `MANAGES_RECURRENCE` | O gateway agenda as cobranças do Pix Automático por conta própria; sem ela, a aplicação é o motor de recorrência e chama as operações de `AutomaticPixContract` na periodicidade certa. | limitação do gateway | não implementado | - -Restrições dentro de uma célula "sim": - -- **`INVOICE_DUPLICATION` no Stripe** vale só para fatura Pix pendente; cartão ou fatura em outro - estado lança `UnsupportedOperationException` com `gateway_limitation` (ver - [Particularidades do Stripe](#particularidades-do-stripe)). +| Capability | Significado | Iugu | Stripe | Restrições | +|---|---|---|---|---| +| `CREDIT_CARD` | Fatura paga com cartão de crédito. | sim | sim | Stripe: Na conta brasileira só cartão de crédito Visa e Mastercard; outra bandeira é recusada na cobrança com DeclineCode::BRAND_NOT_SUPPORTED. | +| `PIX` | Fatura paga com Pix avulso, com QR Code de pagamento único. | sim | sim | | +| `BANK_SLIP` | Fatura paga com boleto bancário. | sim | não implementado | | +| `AUTOMATIC_PIX` | Recorrência de Pix Automático criada junto com a fatura, com reagendamento e cancelamento pela lib. | sim | não implementado | | +| `MULTIPLE_PAYMENT_METHODS` | Fatura aberta a mais de um método de pagamento, escolhido pelo pagador na hora de pagar. | sim | não implementado | | +| `RAW_CARD_DATA` | Cartão informado com número e CVV pela API; sem ela, o cartão é tokenizado no navegador e só o token chega à lib. | sim | limitação do gateway | | +| `INSTALLMENTS` | Parcelamento da cobrança no cartão de crédito. | sim | limitação do gateway | Iugu: O número de parcelas vai em gatewayOptions['months'], até 12 (máximo da conta, configurável em multi-payment.gateways.iugu.max_installments); a lib não lê as parcelas da fatura paga. | +| `DELAYED_CAPTURE` | Cobrança em duas etapas no cartão: reserva do valor agora e captura depois. | não implementado | não implementado | | +| `PARTIAL_REFUND_CARD` | Estorno de parte do valor numa fatura paga com cartão. | sim | sim | | +| `PARTIAL_REFUND_PIX` | Estorno de parte do valor numa fatura paga com Pix. | limitação do gateway | sim | | +| `REFUND_BANK_SLIP` | Estorno pela API de uma fatura paga com boleto. | limitação do gateway | limitação do gateway | | +| `INVOICE_DUPLICATION` | Segunda via de uma fatura pendente com nova data de vencimento (`duplicateInvoice`). | sim | sim | Stripe: Só fatura Pix pendente de venda avulsa (PaymentIntent); cartão, outro estado ou fatura de assinatura são recusados. | +| `INVOICE_CANCELLATION` | Cancelamento de uma fatura ainda não paga (`cancelInvoice`). | sim | sim | Stripe: A fatura de assinatura (objeto Invoice) só é anulada depois de finalizada pela Stripe; rascunho é recusado. | +| `IDEMPOTENCY` | Chave de idempotência (`idempotencyKey`) honrada em toda operação de escrita, pelo gateway ou pela deduplicação da lib (`IdempotencyStore`). | sim | sim | | +| `IDEMPOTENCY_ALL_ENDPOINTS` | Chave de idempotência honrada pelo próprio gateway em toda operação de escrita, sem depender da deduplicação da lib. | limitação do gateway | sim | | +| `SUBSCRIPTIONS` | Assinatura recorrente: criar, buscar, atualizar, suspender, retomar, cancelar, trocar de plano e listar. | sim | não implementado | | +| `PLANS` | Plano de assinatura: criar, buscar e listar. | sim | não implementado | | +| `PLAN_DEACTIVATION` | Desativar um plano sem apagá-lo (`deactivatePlan`). | limitação do gateway | não implementado | | +| `CANCEL_AT_PERIOD_END` | Cancelar a assinatura só no fim do período já pago (`cancel(atPeriodEnd: true)`). | limitação do gateway | não implementado | | +| `NATIVE_COUPONS` | Cupom de primeira classe na assinatura: desconto percentual e desconto limitado a vários ciclos. | limitação do gateway | não implementado | | +| `PLAN_CHANGE_PRORATION` | Crédito proporcional do período não usado, calculado pelo gateway, ao trocar de plano (`changePlan()` com `ProrationBehavior::CREDIT`). | limitação do gateway | não implementado | | +| `SUBSCRIPTION_CREDITS` | Assinatura com saldo de créditos consumíveis, abatidos a cada uso. | não implementado | limitação do gateway | | +| `MANAGES_RECURRENCE` | O gateway agenda as cobranças do Pix Automático por conta própria; sem ela, a aplicação é o motor de recorrência e chama as operações de `AutomaticPixContract` na periodicidade certa. | limitação do gateway | não implementado | | + +Sobre as restrições e algumas células: + +- **Operação fora da restrição** lança `UnsupportedOperationException::restricted()`, com a + capability, `reason` `gateway_limitation` e a mensagem que descreve a restrição: duplicação + fora de Pix pendente e cancelamento de rascunho de fatura de assinatura no Stripe, cartão que + pertence a outro cliente no Stripe (ver [Particularidades do Stripe](#particularidades-do-stripe)). + A bandeira fora da restrição de `CREDIT_CARD` chega depois, na cobrança, como + `CardDeclinedException` com `DeclineCode::BRAND_NOT_SUPPORTED`; por isso vale consultar + `restriction()->allowsBrand()` antes de tokenizar. - **`INSTALLMENTS` na Iugu** é informado em `gateway_options['months']`; a lib não modela parcelas - nem lê os campos da fatura parcelada. + nem lê os campos da fatura parcelada. O máximo publicado em `maxInstallments` vem de + `multi-payment.gateways.iugu.max_installments` (`IUGU_MAX_INSTALLMENTS`, 12 por padrão) e + deve refletir o parcelamento habilitado na conta. - **`IDEMPOTENCY` na Iugu** é honrada pelo gateway só na criação de fatura, cliente e assinatura e na cobrança com cartão; nas demais operações de escrita a deduplicação é da lib, pela `IdempotencyStore`, que exige o cache do Laravel configurado (ver [Idempotência](#idempotência)). Por isso a Iugu não tem `IDEMPOTENCY_ALL_ENDPOINTS`. -- **`PARTIAL_REFUND_PIX` e `REFUND_BANK_SLIP`** chegam como `RefundNotSupportedException`, que - herda de `UnsupportedOperationException` (ver [Estorno](#estorno)). +- **`PARTIAL_REFUND_PIX` e `REFUND_BANK_SLIP`** chegam como `RefundNotSupportedException`, com + `isCapabilityLimitation()` verdadeiro; a classe fica fora da árvore de + `UnsupportedOperationException` (ver [Estorno](#estorno)). - **`MANAGES_RECURRENCE`** é informativa: diz quem agenda a cobrança do Pix Automático (ver [Pix Automático: quem agenda a cobrança](#pix-automático-quem-agenda-a-cobrança)). - **`PLAN_CHANGE_PRORATION`** é o que `changePlan()` com `ProrationBehavior::CREDIT` exige; as @@ -404,7 +430,11 @@ recusado na validação, porque a fatura com Pix Automático é criada com `PIX` `chargeInvoiceWithCreditCard` ou duplicada com `duplicateInvoice` (nova expiração; a original é cancelada). Só fatura Pix pendente de venda avulsa é duplicável: cartão, fatura em outro estado ou fatura de assinatura lança `UnsupportedOperationException` - (`INVOICE_DUPLICATION`, `gateway_limitation`). + (`INVOICE_DUPLICATION`, `gateway_limitation`); `restriction(Capability::INVOICE_DUPLICATION)` + publica a regra. +- **Cartão pertence a um único cliente.** Cobrar, buscar ou excluir um `pm_` informando outro + cliente lança `UnsupportedOperationException` (`CREDIT_CARD`, `gateway_limitation`) antes da + operação. - **A fatura tem duas origens.** A venda avulsa é um PaymentIntent (`pi_`) e a fatura de assinatura é um objeto Invoice da Stripe (`in_`); `getInvoice()` aceita os dois ids e `Invoice::$originType` diz qual voltou. Ver @@ -462,15 +492,16 @@ O que muda na fatura de origem `INVOICE`: - **Uma requisição a mais** quando a fatura já teve tentativa de pagamento: o charge do PaymentIntent fica além do limite de `expand` da Stripe e é lido num GET à parte. - **`cancelInvoice()`** anula a fatura (`void`); a Stripe cancela sozinha o PaymentIntent - dela. Rascunho (`draft`) não é anulável e lança `GatewayException` orientando a esperar a - finalização; fatura `paid` ou já anulada lança `ValidationException`, como o PaymentIntent - já pago ou cancelado. + dela. Rascunho (`draft`) não é anulável e lança `UnsupportedOperationException` + (`INVOICE_CANCELLATION`, `gateway_limitation`) orientando a esperar a finalização; fatura + `paid` ou já anulada lança `ValidationException`, como o PaymentIntent já pago ou cancelado. - **`duplicateInvoice()`** é recusado com `UnsupportedOperationException` (`INVOICE_DUPLICATION`, `gateway_limitation`): a próxima fatura da assinatura é gerada pela Stripe, e um Pix expirado se resolve com nova tentativa de pagamento da mesma fatura. -- **`refundInvoice()` e `chargeInvoiceWithCreditCard()`** sobre a fatura de assinatura ainda não - estão disponíveis (`UnsupportedOperationException`, `SUBSCRIPTIONS`, `not_implemented`); entram - em uma versão futura junto com a assinatura no Stripe. +- **`refundInvoice()`, `refundableAmount()` e `chargeInvoiceWithCreditCard()`** sobre a fatura de + assinatura ainda não estão disponíveis (`UnsupportedOperationException`, `SUBSCRIPTIONS`, + `not_implemented`, antes de qualquer requisição); entram em uma versão futura junto com a + assinatura no Stripe. **Precedência de status.** O status do Invoice da Stripe manda no ciclo de vida da fatura; o PaymentIntent e o charge só refinam o detalhe de pagamento. Um PaymentIntent `succeeded` não @@ -713,10 +744,10 @@ A árvore, com a indentação marcando a herança: ``` MultiPaymentException - ConfigurationException gateway não configurado, classe inválida ou IdempotencyStore sem cache + ConfigurationException gateway não configurado, classe inválida, driver que declara capability sem o contract ou o método, IdempotencyStore sem cache ModelAttributeValidationException atributo obrigatório ausente ou inválido, antes da requisição - UnsupportedOperationException operação fora das capabilities do gateway, antes da requisição - RefundNotSupportedException estorno recusado pela lib antes da requisição + UnsupportedOperationException operação fora das capabilities do gateway, ou fora da restrição de uma capability, antes da requisição + RefundNotSupportedException estorno recusado pela lib antes da requisição (limitação do gateway ou estado da fatura) AuthenticationException credencial recusada (401, 403) ou não configurada GatewayNotAvailableException 5xx, falha de conexão ou timeout CardDeclinedException cobrança recusada: declineCode, gatewayCode, retryable @@ -738,11 +769,11 @@ MultiPaymentException | `IdempotencyConflictException` | Chave de idempotência reutilizada (409 na Iugu em cliente e assinatura; `idempotency_error` na Stripe quando o payload mudou), a primeira requisição com a chave ainda em andamento, ou lock ocupado na `IdempotencyStore` da lib. `resourceId` traz o id do recurso original quando o gateway o informa | Consultar o resultado da primeira requisição (`resourceId` ou o registro da aplicação) ou usar chave nova; nunca repetir com a mesma chave e outro conteúdo | | `AuthenticationException` | Chave de API inválida, revogada, sem permissão (401 ou 403) ou não configurada | Registrar e alertar. Repetir a chamada ou trocar de gateway não resolve | | `GatewayNotAvailableException` | Erro 5xx, falha de conexão ou timeout | Repetir mais tarde ou tentar outro gateway | -| `UnsupportedOperationException` | Operação fora das capabilities do gateway, antes de qualquer requisição; `capability`, `gateway` e `reason` (`not_implemented` ou `gateway_limitation`) dizem qual e por quê | Rotear para um gateway que declare a capability; melhor ainda, consultar `supports()` antes (ver [Capabilities](#capabilities)) | -| `RefundNotSupportedException` | Estorno recusado pela lib antes de chamar o gateway (boleto, Pix parcial, já estornada, valor acima do restante, prazo vencido); herda de `UnsupportedOperationException` e refina `reason` | Ver [Estorno](#estorno) | -| `ModelAttributeValidationException` | Atributo obrigatório ausente ou inválido, antes de qualquer requisição | Corrigir a chamada | -| `ConfigurationException` | Gateway não configurado ou classe inválida; `IdempotencyStore` sem registro no container ou sobre um cache sem lock | Corrigir a configuração | -| `GatewayException` | Qualquer outra resposta de erro do gateway, e a classe pai das quatro de resposta acima; `getErrors()` traz o corpo de erro | Depende do caso; `httpStatus` e `getErrors()` dizem o que aconteceu | +| `UnsupportedOperationException` | Operação fora das capabilities do gateway, ou fora da restrição de uma capability suportada (`restriction()`), antes de qualquer requisição; `capability`, `gateway` e `reason` (`not_implemented` ou `gateway_limitation`) dizem qual e por quê | Rotear para um gateway que declare a capability; melhor ainda, consultar `supports()` e `restriction()` antes (ver [Capabilities](#capabilities)) | +| `RefundNotSupportedException` | Estorno recusado pela lib antes de chamar o gateway: limitação do gateway (boleto, Pix parcial; `isCapabilityLimitation()` verdadeiro e `capability` preenchida) ou estado da fatura (já estornada, valor acima do restante, prazo vencido). Herda direto de `MultiPaymentException`: `catch (UnsupportedOperationException)` não a captura | Ver [Estorno](#estorno) | +| `ModelAttributeValidationException` | Atributo obrigatório ausente ou inválido, antes de qualquer requisição, inclusive regra de valor que só um gateway impõe (`PlanInterval::DAY` e teto de 599 meses na Iugu, `nextBillingAt` diferente de `trialEndsAt`, `page` e `limit` fora da faixa, plano com `id` em `save()`, valor de estorno zero ou negativo) | Corrigir a chamada | +| `ConfigurationException` | Gateway não configurado ou classe inválida; driver que declara uma capability sem implementar o contract ou sem o método do despacho por convenção; `IdempotencyStore` sem registro no container ou sobre um cache sem lock | Corrigir a configuração ou o driver | +| `GatewayException` | Qualquer outra resposta de erro do gateway, e a classe pai das quatro de resposta acima; `httpStatus` e `getErrors()` sempre preenchidos com a resposta. Nenhuma regra local da lib a lança | Depende do caso; `httpStatus` e `getErrors()` dizem o que aconteceu | Um `catch` por camada. As subclasses vêm antes de `GatewayException`, senão ela captura tudo: @@ -835,6 +866,16 @@ o deixava nulo, traz o valor de `declineCode`. Compare com `declineCode`. > `UnsupportedOperationException`, que herda de `MultiPaymentException`. Um > `catch (GatewayException $e)` sozinho deixa de capturar esses casos. > +> Ainda na 5.0.0, `GatewayException` deixou de ser lançada por regra local, sem requisição: regra +> de valor (`page` e `limit` fora da faixa, plano com `id` em `save()`, `nextBillingAt` diferente +> de `trialEndsAt`, `PlanInterval::DAY` e teto de 599 meses na Iugu) virou +> `ModelAttributeValidationException`; restrição do gateway (cartão de outro cliente, rascunho +> não anulável, fatura sem cliente na duplicação no Stripe) virou +> `UnsupportedOperationException::restricted()`; driver que declara capability sem o contract ou +> sem o método de despacho virou `ConfigurationException`. Toda `GatewayException` que sobrou +> traz `httpStatus` da resposta. `RefundNotSupportedException` deixou de herdar de +> `UnsupportedOperationException` (ver [Estorno](#estorno)). +> > Ainda na 5.0.0, validação (400, 422), 404, 409 e 429 passaram a chegar como > `ValidationException`, `NotFoundException`, `IdempotencyConflictException` e > `RateLimitException`. Todas herdam de `GatewayException`, então `catch (GatewayException $e)` @@ -1187,12 +1228,12 @@ Particularidades da Iugu: (uma fatura) ou `null` (até ser removido). - **Plano anual é 12 meses, e plano diário não existe.** A Iugu só tem intervalos em semanas e meses, então `PlanInterval::YEAR` é enviado como `12 * intervalCount` meses e - `PlanInterval::DAY` lança `GatewayException` antes de chamar a API. Na leitura vale a + `PlanInterval::DAY` lança `ModelAttributeValidationException` antes de chamar a API. Na leitura vale a heurística inversa: todo plano em meses cujo intervalo é múltiplo de 12 volta como `YEAR` com `intervalCount` dividido por 12 (um plano criado direto na Iugu com 24 meses lê como 2 anos). Quem precisar do valor cru lê `original`. A Iugu aceita intervalo de 1 a 599, então um plano - anual vai até `intervalCount` 49; acima disso o driver lança `GatewayException` antes de - chamar a API. + anual vai até `intervalCount` 49; acima disso o driver lança `ModelAttributeValidationException` + antes de chamar a API. - **Planos não são desativáveis.** `deactivatePlan` lança `UnsupportedOperationException` (`PLAN_DEACTIVATION`, `gateway_limitation`). - **`nextBillingAt` e `trialEndsAt` são o mesmo campo** (`expires_at`), e o que os distingue é @@ -1202,7 +1243,7 @@ Particularidades da Iugu: assinatura nasce sem fatura (`latestInvoice` nulo) e sem cobrança até o fim do teste; `setNextBillingAt()` sozinho vai só como `expires_at`, com a fatura e a cobrança imediatas da Iugu (`gateway_options['only_charge_on_due_date']` sobrepõe os dois). Informar `trialEndsAt` - (ou `trialDays`) e `nextBillingAt` com datas diferentes lança `GatewayException`. Ao prorrogar + (ou `trialDays`) e `nextBillingAt` com datas diferentes lança `ModelAttributeValidationException`. Ao prorrogar um trial lido do gateway, zere `nextBillingAt` antes, porque a leitura preenche os dois. `in_trial` (lido como `TRIALING`) só aparece em assinatura que a própria Iugu põe em teste; a assinatura criada com trial pela lib lê como `ACTIVE`, com `nextBillingAt` no fim do teste. @@ -1246,8 +1287,9 @@ Particularidades da Iugu: `latestInvoice`, com status `pending`; use `getInvoice()` pelo id para o valor em centavos. Com `ProrationBehavior::NONE` nada é cobrado; `ProrationBehavior::CREDIT` é recusado antes da rede. - **O plano de uma assinatura existente não muda por `save()`**; use `changePlan()`. -- **Plano não é atualizável.** `save()` num `Plan` que já tem `id` lança `GatewayException`; para - mudar preço ou intervalo, crie outro plano e troque as assinaturas com `changePlan()`. +- **Plano não é atualizável.** `save()` num `Plan` que já tem `id` lança + `ModelAttributeValidationException` antes da requisição; para mudar preço ou intervalo, crie + outro plano e troque as assinaturas com `changePlan()`. - **Fatura vencida lê como `EXPIRED` e continua sendo dívida.** A Iugu chama de `expired` a fatura que venceu sem pagamento; ela conta como fatura em aberto na derivação de `past_due` da assinatura, e `InvoiceStatus::EXPIRED->isPayable()` responde verdadeiro (`isOpen()` @@ -1310,9 +1352,10 @@ qualquer requisição; plano inexistente pelos dois caminhos lança `NotFoundExc ```php $payment = new \Potelo\MultiPayment\MultiPayment('stripe'); -// estorno total ou parcial (valor em centavos); devolve um Refund (seção "Estorno") +// estorno do restante ou parcial (valor em centavos); devolve um Refund (seção "Estorno") $refund = $payment->refundInvoice($invoiceId); $refund = $payment->refundInvoice($invoiceId, 5000); +$payment->refundableAmount($invoiceId); // quanto ainda pode ser estornado, em centavos // cancelamento de fatura pendente (no Stripe, a fatura de assinatura in_ é anulada com void) $payment->cancelInvoice($invoiceId); @@ -1331,9 +1374,9 @@ $payment->cancelInvoice($invoiceId, idempotencyKey: $uuid); #### Estorno -Sem valor, o estorno é integral; com valor em centavos, é parcial. A operação devolve um -`Refund` (`Potelo\MultiPayment\Models\Refund`) com o que o gateway registrou do estorno, e a -fatura relida depois dele fica em `$refund->invoice()`: +Sem valor, o estorno é do restante estornável; com valor em centavos, é parcial. A operação +devolve um `Refund` (`Potelo\MultiPayment\Models\Refund`) com o que o gateway registrou do +estorno, e a fatura relida depois dele fica em `$refund->invoice()`: | Campo | Conteúdo | |---|---| @@ -1351,7 +1394,7 @@ use Potelo\MultiPayment\Enums\RefundStatus; $payment = new \Potelo\MultiPayment\MultiPayment('stripe'); -$refund = $payment->refundInvoice($invoiceId); // integral +$refund = $payment->refundInvoice($invoiceId); // o restante $refund = $payment->refundInvoice($invoiceId, 5000); // parcial $refund->id; // 're_...' (Stripe) ou null (Iugu) @@ -1362,12 +1405,27 @@ $invoice = $refund->invoice(); $invoice->status; // InvoiceStatus::REFUNDED ou InvoiceStatus::PARTIALLY_REFUNDED ``` -`$invoice->refund()` num model faz o mesmo e atualiza a própria instância: depois da chamada -`$invoice->status` já é o novo status, e `$refund->invoice()` é a mesma instância. O valor -pedido viaja em `$invoice->refundedAmount`, que a leitura da fatura preenche com o total já -estornado: num model lido do gateway que já teve estorno parcial, defina `refundedAmount` antes -de chamar `refund()` (o novo valor, ou `null` para estornar o restante), senão o acumulado é -reenviado como um novo estorno parcial. +`$invoice->refund(?int $amount = null, ?string $idempotencyKey = null)` num model faz o mesmo e +atualiza a própria instância: depois da chamada `$invoice->status` já é o novo status, e +`$refund->invoice()` é a mesma instância. `$invoice->refundedAmount` é só de leitura e traz o +total já estornado que o gateway informou; num model lido do gateway que já teve estorno parcial, +`refund()` sem valor estorna o que resta. + +**Quanto ainda pode ser estornado.** `$invoice->refundableAmount()` (ou +`$payment->refundableAmount($id)`) devolve o restante em centavos, calculado pelo driver: na +Iugu é `paidAmount`, porque `paid_cents` já vem líquido do estornado; na Stripe é `paidAmount` +menos `refundedAmount`, porque o valor pago vem bruto. Zero para fatura não paga ou já +integralmente estornada. Um model que traz o valor pago não custa requisição; um model só com o +id lê a fatura. É o teto aritmético do estorno; as guardas de boleto, Pix parcial e prazo +continuam valendo. + +```php +$invoice = $payment->getInvoice($id); // já partially_refunded + +$invoice->refundableAmount(); // 5000 nos dois gateways +$refund = $invoice->refund(amount: 2000, idempotencyKey: $key); +$refund = $invoice->refund(); // o restante +``` **Histórico de estornos.** Toda fatura lida do gateway traz `$invoice->refunds`, uma lista de `Refund` (vazia quando nada foi estornado). Os dois gateways preenchem a lista de formas @@ -1390,7 +1448,12 @@ foreach ($invoice->refunds as $refund) { **Recusa antes da requisição.** O pacote recusa, **antes de chamar o gateway**, o estorno que a regra do gateway já garante que seria negado, e o faz com `RefundNotSupportedException` nos -dois drivers, para a aplicação não precisar interpretar a mensagem da Iugu ou da Stripe. +dois drivers, para a aplicação não precisar interpretar a mensagem da Iugu ou da Stripe. A +classe herda direto de `MultiPaymentException`: `isCapabilityLimitation()` separa a recusa que +é limitação do gateway (boleto, Pix parcial; `capability` preenchida) da recusa por estado da +fatura (já estornada, valor acima do restante, prazo vencido; `capability` nula). Um +`catch (UnsupportedOperationException $e)` usado para rotear a operação para outro gateway não +captura estorno recusado: rotear um estorno de fatura já estornada não faria sentido. ```php use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; @@ -1402,29 +1465,31 @@ try { if ($e->manualRefundRequired) { // boleto ou prazo vencido: o dinheiro só volta por fora do gateway ManualRefund::dispatch($invoiceId, $e->paymentMethod, $e->reason); + } elseif ($e->isCapabilityLimitation()) { + // Pix parcial na Iugu: $e->capability é PARTIAL_REFUND_PIX; repita sem valor } } ``` -| `$e->reason` | Quando | `$e->manualRefundRequired` | -|---|---|---| -| `boleto_no_refund` | Fatura paga com boleto, nos dois gateways | `true` | -| `pix_partial_not_supported` | Iugu: valor pedido diferente do valor pago numa fatura Pix. Repita sem valor para estornar o total | `false` | -| `already_refunded` | Fatura já lida como `refunded` | `false` | -| `amount_exceeds_refundable` | Valor pedido acima do que ainda pode ser estornado (o restante vai na mensagem). Repita com valor até o restante | `false` | -| `refund_window_expired` | Iugu: depois do fim do 90º dia após `paidAt` | `true` | +| `$e->reason` | Quando | `$e->manualRefundRequired` | `$e->isCapabilityLimitation()` | +|---|---|---|---| +| `boleto_no_refund` | Fatura paga com boleto, nos dois gateways | `true` | `true` (`REFUND_BANK_SLIP`) | +| `pix_partial_not_supported` | Iugu: valor pedido diferente do valor pago numa fatura Pix. Repita sem valor para estornar o total | `false` | `true` (`PARTIAL_REFUND_PIX`) | +| `already_refunded` | Fatura já lida como `refunded` | `false` | `false` | +| `amount_exceeds_refundable` | Valor pedido acima de `refundableAmount()` (o restante vai na mensagem). Repita com valor até o restante | `false` | `false` | +| `refund_window_expired` | Iugu: depois do fim do 90º dia após `paidAt` | `true` | `false` | Uma fatura `partially_refunded` aceita novos estornos até zerar o restante; pedir exatamente -o que resta é estorno integral. O restante é `paidAmount` na Iugu (que devolve `paid_cents` -líquido do já estornado) e `paidAmount` menos `refundedAmount` na Stripe. +o que resta é estorno integral. Valor zero ou negativo é recusado com +`ModelAttributeValidationException` antes de qualquer requisição. **Custo da leitura prévia.** As guardas precisam do método de pagamento, do status, na Iugu da -data de pagamento e, no estorno por valor, do quanto ainda pode ser estornado. Chamar `refundInvoice($id)` só com o id -custa **um GET a mais** para ler a fatura antes do estorno, nos dois gateways; chamar -`$invoice->refund()` num model já lido do gateway e pago não paga esse GET. No Stripe, o estorno -por valor sobre uma fatura fora de `PAID` (por exemplo `partially_refunded`) relê a fatura mesmo -com o model preenchido, porque o restante estornável depende do acumulado que o gateway guarda. -Essa leitura não altera o model do chamador: ele só muda quando o estorno acontece. +data de pagamento e, no estorno por valor, do quanto ainda pode ser estornado. Chamar +`refundInvoice($id)` só com o id custa **um GET a mais** para ler a fatura antes do estorno, nos +dois gateways; chamar `$invoice->refund()` num model já lido do gateway não paga esse GET, +inclusive numa fatura `partially_refunded`, porque `refundedAmount` traz o acumulado que o +gateway informou. Essa leitura não altera o model do chamador: ele só muda quando o estorno +acontece. > **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, `refundInvoice()` e > `$invoice->refund()` devolviam a `Invoice` atualizada; agora devolvem o `Refund`, e a fatura @@ -1432,9 +1497,17 @@ Essa leitura não altera o model do chamador: ele só muda quando o estorno acon > campo provisório `Invoice::$lastRefundId` foi removido: o id está em `$refund->id`. Na mesma > versão, estorno de boleto, Pix parcial, fatura já estornada, valor acima do restante e fora do > prazo de 90 dias na Iugu deixaram de ir até a API e voltar como `GatewayException`: lançam -> `RefundNotSupportedException`, que herda de `UnsupportedOperationException` (e por ela de -> `MultiPaymentException`), fora da árvore de `GatewayException`: um `catch (GatewayException $e)` +> `RefundNotSupportedException`, que herda de `MultiPaymentException`, fora da árvore de +> `GatewayException` e fora da de `UnsupportedOperationException`: um `catch (GatewayException $e)` > sozinho deixa de capturar esses casos. +> +> Ainda na 5.0.0, o valor do estorno virou argumento: `$invoice->refund(amount: 5000)` e +> `refundInvoice(Invoice $invoice, ?int $amount, ?string $idempotencyKey)` no contract. +> `Invoice::$refundedAmount` passou a ser só de leitura (o total já estornado, como o gateway +> informa); escrever nela antes de chamar `refund()` continua funcionando como pedido de estorno +> parcial, com aviso `E_USER_DEPRECATED`, e some em uma versão futura. Consequência: num model +> lido do gateway já `partially_refunded`, `refund()` sem valor passou a estornar o restante em +> vez de reenviar o acumulado como novo estorno parcial. #### charge (alternativa por array) @@ -1502,10 +1575,12 @@ $invoice->dueDate; // vencimento; $invoice->pixExpiresAt é a expiração do #### Refund ```php $invoice = $payment->getInvoice($invoiceId); -$invoice->refundedAmount = 5000; // vazio: estorno integral -$refund = $invoice->refund(); // Refund; $invoice já reflete o estado posterior +$invoice->refundableAmount(); // quanto ainda pode ser estornado, em centavos +$refund = $invoice->refund(5000); // Refund; $invoice já reflete o estado posterior +$refund = $invoice->refund(); // sem valor: o restante $refund->amount; // 5000 +$invoice->refundedAmount; // total já estornado, só de leitura $invoice->refunds; // Refund[] (ver "Estorno") ``` #### Subscription diff --git a/src/Capabilities/CapabilityRestriction.php b/src/Capabilities/CapabilityRestriction.php new file mode 100644 index 0000000..1da2977 --- /dev/null +++ b/src/Capabilities/CapabilityRestriction.php @@ -0,0 +1,54 @@ +allowedPaymentMethods) || in_array($paymentMethod, $this->allowedPaymentMethods, true); + } + + /** + * Diz se a bandeira está dentro da restrição, sem diferenciar maiúsculas. Verdadeiro + * quando a restrição não é por bandeira. + * + * @param string $brand + * @return bool + */ + public function allowsBrand(string $brand): bool + { + return is_null($this->allowedBrands) + || in_array(strtolower($brand), array_map('strtolower', $this->allowedBrands), true); + } +} diff --git a/src/Contracts/DeclaresCapabilities.php b/src/Contracts/DeclaresCapabilities.php index 1be9f06..c4ef2b6 100644 --- a/src/Contracts/DeclaresCapabilities.php +++ b/src/Contracts/DeclaresCapabilities.php @@ -3,11 +3,13 @@ namespace Potelo\MultiPayment\Contracts; use Potelo\MultiPayment\Enums\Capability; +use Potelo\MultiPayment\Capabilities\CapabilityRestriction; /** * Declaração do que um gateway suporta, em dois níveis: o que o gateway oferece e a lib * implementa, e o que o gateway oferece mas a lib ainda não construiu. Uma capability fora - * das duas listas é limitação do gateway. + * das duas listas é limitação do gateway. Uma capability suportada pode ainda ter uma + * restrição (`restriction()`), que descreve em que parte dos casos ela vale. */ interface DeclaresCapabilities { @@ -32,4 +34,29 @@ public function notYetImplemented(): array; * @return bool */ public function supports(Capability $capability): bool; + + /** + * Diz se todas as capabilities informadas estão em `capabilities()`. Sem argumento, + * responde verdadeiro. + * + * @param Capability ...$capabilities + * @return bool + */ + public function supportsAll(Capability ...$capabilities): bool; + + /** + * Restrições das capabilities suportadas, com o valor da capability como chave. Uma + * capability sem entrada aqui vale em todos os casos. + * + * @return array + */ + public function restrictions(): array; + + /** + * Restrição da capability, ou nulo quando ela vale em todos os casos (ou não é suportada). + * + * @param Capability $capability + * @return CapabilityRestriction|null + */ + public function restriction(Capability $capability): ?CapabilityRestriction; } diff --git a/src/Contracts/InvoiceContract.php b/src/Contracts/InvoiceContract.php index 37283f5..81b1749 100644 --- a/src/Contracts/InvoiceContract.php +++ b/src/Contracts/InvoiceContract.php @@ -36,22 +36,35 @@ public function createInvoice(Invoice $invoice, ?string $idempotencyKey = null): public function getInvoice(Invoice $invoice): Invoice; /** - * Refund an invoice - * - * Full refund when `refundedAmount` is empty; partial when set. The gateway throws - * `RefundNotSupportedException` before any request when its own rules already guarantee - * the refusal (bank slip, partial Pix on Iugu, invoice already refunded, amount above the - * refundable remainder, window expired). Returns the created `Refund`, with the invoice - * re-read after the refund in `$refund->invoice`; the given model is updated in place. + * Estorna uma fatura: o restante estornável quando `$amount` é nulo, ou o valor informado em + * centavos (zero ou negativo lança `ModelAttributeValidationException`). O driver lança + * `RefundNotSupportedException` antes de qualquer requisição quando a regra do gateway já + * garante a recusa (boleto, Pix parcial na Iugu, fatura já estornada, valor acima do + * restante, prazo vencido). Devolve o `Refund` criado, com a fatura relida em + * `$refund->invoice`; o model recebido é atualizado no lugar. * * @param Invoice $invoice - * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication + * @param int|null $amount valor em centavos; nulo estorna o restante + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Refund - * @throws GatewayException + * @throws GatewayException|GatewayNotAvailableException * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + */ + public function refundInvoice(Invoice $invoice, ?int $amount = null, ?string $idempotencyKey = null): Refund; + + /** + * Valor que ainda pode ser estornado na fatura, em centavos: zero para fatura não paga ou + * já integralmente estornada. Lê a fatura (um GET) quando o model não traz o valor pago. + * + * @param Invoice $invoice + * @return int + * @throws GatewayException|GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException fatura que o driver não estorna + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException `id` ausente */ - public function refundInvoice(Invoice $invoice, ?string $idempotencyKey = null): Refund; + public function refundableAmount(Invoice $invoice): int; /** * Charge an invoice with a credit card diff --git a/src/Enums/Capability.php b/src/Enums/Capability.php index 7debc95..ac644f2 100644 --- a/src/Enums/Capability.php +++ b/src/Enums/Capability.php @@ -48,6 +48,9 @@ enum Capability: string /** Segunda via de uma fatura pendente com nova data de vencimento (`duplicateInvoice`). */ case INVOICE_DUPLICATION = 'invoice_duplication'; + /** Cancelamento de uma fatura ainda não paga (`cancelInvoice`). */ + case INVOICE_CANCELLATION = 'invoice_cancellation'; + /** * Chave de idempotência (`idempotencyKey`) honrada em toda operação de escrita, pelo * gateway ou pela deduplicação da lib (`IdempotencyStore`). diff --git a/src/Exceptions/ConfigurationException.php b/src/Exceptions/ConfigurationException.php index b86841b..9fbdb51 100644 --- a/src/Exceptions/ConfigurationException.php +++ b/src/Exceptions/ConfigurationException.php @@ -2,10 +2,43 @@ namespace Potelo\MultiPayment\Exceptions; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Contracts\GatewayContract; class ConfigurationException extends MultiPaymentException { + /** + * O driver declara a capability em `capabilities()` mas não implementa o contract que a + * operação exige (erro de driver, sem requisição). + * + * @param GatewayContract $gateway + * @param Capability $capability + * @param class-string $contract + * @return self + */ + public static function GatewayMissingContract(GatewayContract $gateway, Capability $capability, string $contract): self + { + $contractName = substr(strrchr($contract, '\\'), 1) ?: $contract; + + return new static( + 'Gateway [' . get_class($gateway) . "] declares the {$capability->value} capability" + . " but does not implement {$contractName}" + ); + } + + /** + * O driver passou na verificação de capability mas não tem o método que o despacho por + * convenção de nome esperava (erro de driver, sem requisição). + * + * @param string $gatewayClass + * @param string $method + * @return self + */ + public static function GatewayMethodNotFound(string $gatewayClass, string $method): self + { + return new static("Gateway [{$gatewayClass}] does not have method [{$method}]"); + } + /** * The gateway does not implement the interface. * diff --git a/src/Exceptions/GatewayException.php b/src/Exceptions/GatewayException.php index 5c40adf..75d98ed 100644 --- a/src/Exceptions/GatewayException.php +++ b/src/Exceptions/GatewayException.php @@ -46,6 +46,8 @@ public function getErrors(): array /** * Dispatch method missing in a gateway that declares the capability (driver error). * + * @deprecated desde 2026-09-02, use `ConfigurationException::GatewayMethodNotFound()`: a falta + * do método é erro de configuração do driver, sem resposta do gateway. * @param string $gatewayClass * @param string $method * diff --git a/src/Exceptions/RefundNotSupportedException.php b/src/Exceptions/RefundNotSupportedException.php index d1d1b7b..5739aec 100644 --- a/src/Exceptions/RefundNotSupportedException.php +++ b/src/Exceptions/RefundNotSupportedException.php @@ -13,10 +13,12 @@ * não estorna de novo, o valor pedido não pode passar do restante estornável e a Iugu fecha a * janela de estorno 90 dias após o pagamento. O motivo fica em `$reason`, no vocabulário do * pacote, para a aplicação ramificar sem ler a mensagem. `$capability` aponta a capability - * recusada quando existe uma (`REFUND_BANK_SLIP`, `PARTIAL_REFUND_PIX`) e fica nula para - * fatura já estornada, valor acima do restante e prazo vencido. + * recusada quando a recusa é limitação do gateway (`REFUND_BANK_SLIP`, `PARTIAL_REFUND_PIX`) e + * fica nula quando é estado da fatura (já estornada, valor acima do restante, prazo vencido); + * `isCapabilityLimitation()` separa os dois casos. Herda direto de `MultiPaymentException`: + * um `catch (UnsupportedOperationException)` não a captura. */ -class RefundNotSupportedException extends UnsupportedOperationException +class RefundNotSupportedException extends MultiPaymentException { /** Boleto não tem estorno pela API do gateway; devolução manual. */ public const REASON_BOLETO_NO_REFUND = 'boleto_no_refund'; @@ -58,6 +60,22 @@ class RefundNotSupportedException extends UnsupportedOperationException */ public bool $manualRefundRequired; + /** + * Nome do gateway, como registrado na configuração; vazio quando a exceção foi criada + * sem ele. + * + * @var string + */ + public string $gateway; + + /** + * Capability que o gateway não oferece (`REFUND_BANK_SLIP`, `PARTIAL_REFUND_PIX`), ou nulo + * quando a recusa vem do estado da fatura. + * + * @var Capability|null + */ + public ?Capability $capability; + /** * Cria a exceção com o motivo da recusa e a flag de devolução manual. * @@ -79,9 +97,40 @@ public function __construct( ?Capability $capability = null ) { $this->paymentMethod = $paymentMethod; + $this->reason = $reason; $this->manualRefundRequired = $manualRefundRequired; + $this->gateway = $gateway; + $this->capability = $capability; + + parent::__construct($message, $previous); + } + + /** + * Diz se a recusa é limitação do gateway, descrita por `$capability` (boleto sem estorno, + * Pix sem estorno parcial). Falso quando a recusa vem do estado da fatura: já estornada, + * valor acima do restante ou prazo vencido. + * + * @return bool + */ + public function isCapabilityLimitation(): bool + { + return !is_null($this->capability); + } + + /** + * Sempre falso: nenhuma recusa de estorno é uma capability que a lib ainda não implementou. + * + * @deprecated desde 2026-09-02, sem substituto; responde sempre falso. + * @return bool + */ + public function isNotImplemented(): bool + { + trigger_error( + 'RefundNotSupportedException::isNotImplemented() está obsoleto desde 2026-09-02 e responde sempre falso', + E_USER_DEPRECATED + ); - parent::__construct($message, $gateway, $capability, $reason, $previous); + return false; } /** diff --git a/src/Exceptions/UnsupportedOperationException.php b/src/Exceptions/UnsupportedOperationException.php index b9a8476..20bdbe5 100644 --- a/src/Exceptions/UnsupportedOperationException.php +++ b/src/Exceptions/UnsupportedOperationException.php @@ -20,7 +20,7 @@ class UnsupportedOperationException extends MultiPaymentException /** * Capability recusada, ou nulo quando a recusa vem de uma regra que nenhuma capability - * descreve (por exemplo, fatura já estornada). + * descreve. * * @var Capability|null */ @@ -34,8 +34,7 @@ class UnsupportedOperationException extends MultiPaymentException public string $gateway; /** - * Motivo da recusa: `gateway_limitation` ou `not_implemented`. Uma subclasse pode - * trazer um motivo mais específico no lugar deles (`RefundNotSupportedException`). + * Motivo da recusa: `gateway_limitation` ou `not_implemented`. * * @var string */ diff --git a/src/Facades/MultiPayment.php b/src/Facades/MultiPayment.php index af85100..ec68427 100644 --- a/src/Facades/MultiPayment.php +++ b/src/Facades/MultiPayment.php @@ -23,6 +23,7 @@ * @method static \Potelo\MultiPayment\Models\Plan getPlan(string $idOrIdentifier) * @method static \Potelo\MultiPayment\Models\Customer getCustomer(string $id) * @method static \Potelo\MultiPayment\Models\Refund refundInvoice(string $id, ?int $partialValueCents = null, ?string $idempotencyKey = null) + * @method static int refundableAmount(string $id) * @method static Invoice duplicateInvoice(Invoice|string $invoice, \Carbon\Carbon $expiresAt, array $gatewayOptions = [], ?string $idempotencyKey = null) * @method static CreditCard getCard(string $customerId, string $creditCardId) * @method static void deleteCard(string $customerId, string $creditCardId, ?string $idempotencyKey = null) @@ -31,6 +32,9 @@ * @method static bool supports(\Potelo\MultiPayment\Enums\Capability $capability, $gateway = null) * @method static \Potelo\MultiPayment\Enums\Capability[] capabilities($gateway = null) * @method static \Potelo\MultiPayment\Enums\Capability[] notYetImplemented($gateway = null) + * @method static bool supportsAll(\Potelo\MultiPayment\Enums\Capability ...$capabilities) + * @method static \Potelo\MultiPayment\Capabilities\CapabilityRestriction|null restriction(\Potelo\MultiPayment\Enums\Capability $capability, $gateway = null) + * @method static array restrictions($gateway = null) * @method static Invoice chargeInvoiceWithCreditCard($invoice, ?string $creditCardToken = null, ?string $creditCardId = null, ?string $idempotencyKey = null) * @method static \Potelo\MultiPayment\Models\Customer setDefaultCard(string $customerId, string $creditCardId, ?string $idempotencyKey = null) * @method static Invoice cancelInvoice(Invoice|string $invoice, ?string $idempotencyKey = null) diff --git a/src/Gateways/Concerns/ChecksCapabilities.php b/src/Gateways/Concerns/ChecksCapabilities.php index 19ff863..5eb6b25 100644 --- a/src/Gateways/Concerns/ChecksCapabilities.php +++ b/src/Gateways/Concerns/ChecksCapabilities.php @@ -3,11 +3,13 @@ namespace Potelo\MultiPayment\Gateways\Concerns; use Potelo\MultiPayment\Enums\Capability; +use Potelo\MultiPayment\Capabilities\CapabilityRestriction; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; /** - * Implementa `supports()` de `DeclaresCapabilities` sobre as listas do driver e oferece as - * guardas que os drivers chamam antes de qualquer requisição. + * Implementa `supports()`, `supportsAll()` e `restriction()` de `DeclaresCapabilities` sobre + * as listas do driver e oferece as guardas que os drivers chamam antes de qualquer requisição. + * `restrictions()` devolve lista vazia; o driver que tem restrição a sobrescreve. */ trait ChecksCapabilities { @@ -29,6 +31,36 @@ public function supports(Capability $capability): bool return in_array($capability, $this->capabilities(), true); } + /** + * @inheritDoc + */ + public function supportsAll(Capability ...$capabilities): bool + { + foreach ($capabilities as $capability) { + if (!$this->supports($capability)) { + return false; + } + } + + return true; + } + + /** + * @inheritDoc + */ + public function restrictions(): array + { + return []; + } + + /** + * @inheritDoc + */ + public function restriction(Capability $capability): ?CapabilityRestriction + { + return $this->restrictions()[$capability->value] ?? null; + } + /** * Lança `UnsupportedOperationException` quando a capability não está em `capabilities()`. * diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index f08e41e..c84d2a1 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -34,6 +34,7 @@ use Potelo\MultiPayment\Enums\PlanInterval; use Potelo\MultiPayment\Enums\DeclineCode; use Potelo\MultiPayment\Helpers\LogHelper; +use Potelo\MultiPayment\Capabilities\CapabilityRestriction; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Contracts\PlanContract; use Potelo\MultiPayment\Contracts\GatewayContract; @@ -88,6 +89,12 @@ class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract /** Prazo, em dias após o pagamento, em que a Iugu ainda aceita estorno pela API. */ private const REFUND_WINDOW_DAYS = 90; + /** + * Máximo de parcelas declarado em `restriction(INSTALLMENTS)` quando a configuração + * `multi-payment.gateways.iugu.max_installments` não informa o da conta; é o teto da Iugu. + */ + private const DEFAULT_MAX_INSTALLMENTS = 12; + /** Prefixo das chaves deste driver na `IdempotencyStore`. */ private const IDEMPOTENCY_STORE_PREFIX = 'iugu:'; @@ -125,6 +132,7 @@ public function capabilities(): array Capability::INSTALLMENTS, Capability::PARTIAL_REFUND_CARD, Capability::INVOICE_DUPLICATION, + Capability::INVOICE_CANCELLATION, Capability::IDEMPOTENCY, Capability::SUBSCRIPTIONS, Capability::PLANS, @@ -142,6 +150,29 @@ public function notYetImplemented(): array ]; } + /** + * @inheritDoc + * + * `INSTALLMENTS`: o número de parcelas vai em `gatewayOptions['months']`, até o máximo da + * conta (`multi-payment.gateways.iugu.max_installments`, 12 por padrão), e a lib não lê as + * parcelas da fatura paga. + */ + public function restrictions(): array + { + // variável de ambiente vazia chega como string vazia; vale o padrão da Iugu + $maxInstallments = (int) Config::get('multi-payment.gateways.iugu.max_installments') + ?: self::DEFAULT_MAX_INSTALLMENTS; + + return [ + Capability::INSTALLMENTS->value => new CapabilityRestriction( + description: "O número de parcelas vai em gatewayOptions['months'], até {$maxInstallments}" + . ' (máximo da conta, configurável em multi-payment.gateways.iugu.max_installments);' + . ' a lib não lê as parcelas da fatura paga.', + maxInstallments: $maxInstallments, + ), + ]; + } + /** * @inheritDoc * @@ -651,39 +682,70 @@ private static function paymentMethodsToIuguPayableWith(array $paymentMethods): * seguinte com a mesma chave devolve o `Refund` da primeira sem reler a fatura, que a essa * altura já estaria estornada e faria a guarda recusar o retry. * + * O valor vem de `$amount`; sem ele, do caminho antigo de escrever `refundedAmount` antes + * de estornar (`Invoice::resolveRefundAmount()`); sem os dois, estorna o restante. + * * @throws ModelAttributeValidationException|RefundNotSupportedException */ - public function refundInvoice(Invoice $invoice, ?string $idempotencyKey = null): Refund + public function refundInvoice(Invoice $invoice, ?int $amount = null, ?string $idempotencyKey = null): Refund { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); } + $requestedAmount = $invoice->resolveRefundAmount($amount); $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); if (is_null($idempotencyKey)) { - return $this->performIuguRefund($invoice); + return $this->performIuguRefund($invoice, $requestedAmount); } return $this->rememberIuguOperation( $idempotencyKey, 'POST ' . Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/refund', - fn () => $this->performIuguRefund($invoice) + fn () => $this->performIuguRefund($invoice, $requestedAmount) ); } + /** + * @inheritDoc + * + * Na Iugu o restante é `paid_cents`, que a API devolve líquido do que já foi estornado + * (`Invoice::$paidAmount`); a fatura é lida quando o model não o traz. + */ + public function refundableAmount(Invoice $invoice): int + { + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + + $current = is_null($invoice->paidAmount) ? $this->getInvoice(clone $invoice) : $invoice; + + return self::iuguRefundableAmount($current); + } + + /** + * Restante estornável de uma fatura já lida: `paid_cents`, líquido do estornado; zero + * quando nada foi pago. + * + * @param Invoice $invoice + * @return int + */ + private static function iuguRefundableAmount(Invoice $invoice): int + { + return max(0, (int) ($invoice->paidAmount ?? 0)); + } + /** * Lê a fatura quando o model não traz o que as guardas precisam, aplica as guardas e faz o * `POST /refund`, devolvendo o `Refund` montado pela lib. * * @param Invoice $invoice + * @param int|null $requestedAmount valor pedido em centavos; nulo é estorno do restante * @return Refund * @throws RefundNotSupportedException */ - private function performIuguRefund(Invoice $invoice): Refund + private function performIuguRefund(Invoice $invoice, ?int $requestedAmount): Refund { - // guardado antes da leitura: parseInvoice() sobrescreve refundedAmount com o já estornado - $requestedAmount = $invoice->refundedAmount ?: null; - $current = $invoice; if ( empty($invoice->paymentMethod) @@ -742,12 +804,12 @@ private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmou throw RefundNotSupportedException::alreadyRefunded('iugu', $invoice->paymentMethod?->value); } - if (!is_null($requestedAmount) && !is_null($invoice->paidAmount) && $requestedAmount > $invoice->paidAmount) { + if (!is_null($requestedAmount) && !is_null($invoice->paidAmount) && $requestedAmount > self::iuguRefundableAmount($invoice)) { throw RefundNotSupportedException::amountExceedsRefundable( 'iugu', $invoice->paymentMethod?->value, $requestedAmount, - $invoice->paidAmount + self::iuguRefundableAmount($invoice) ); } @@ -954,10 +1016,10 @@ public function listAutomaticPixCancellations( throw ModelAttributeValidationException::required('AutomaticPix', 'id'); } if ($page < 1) { - throw new GatewayException('Automatic Pix cancellation page must be at least 1'); + throw ModelAttributeValidationException::invalid('AutomaticPix', 'page', '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'); + throw ModelAttributeValidationException::invalid('AutomaticPix', 'limit', 'Automatic Pix cancellation limit must be between 1 and 100'); } $query = http_build_query(['limit' => $limit, 'page' => $page], '', '&', PHP_QUERY_RFC3986); @@ -1320,7 +1382,7 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice $invoice->original = $iuguInvoice; $invoice->createdAt = !empty($iuguInvoice->created_at_iso) ? new Carbon($iuguInvoice->created_at_iso) : null; $invoice->paidAmount = $iuguInvoice->paid_cents ?? null; - $invoice->refundedAmount = $iuguInvoice->refunded_cents ?? null; + $invoice->setRefundedAmountFromGateway($iuguInvoice->refunded_cents ?? null); $invoice->refunds = $this->parseRefunds($invoice); $invoice->dueDate = !empty($iuguInvoice->due_date) ? new Carbon($iuguInvoice->due_date) : null; // a Iugu não documenta a expiração do QR Code na fatura; quando vier, ela vale, senão @@ -1518,7 +1580,7 @@ private function chargeIuguInvoice(array $iuguInvoiceData, ?string $idempotencyK // a cobrança devolve só o id; a leitura da fatura é outra requisição e falha como tal $invoiceId = $iuguCharge->invoice_id ?? null; if (empty($invoiceId)) { - throw new GatewayException('Error getting charged invoice: the charge response has no invoice_id'); + throw $this->iuguResponseException('Error getting charged invoice: the charge response has no invoice_id', null); } return $this->fetchIuguInvoice((string) $invoiceId, 'getting charged invoice'); @@ -2188,11 +2250,11 @@ public function listSubscriptions(Customer $customer, int $page = 1, int $limit } if ($page < 1) { - throw new GatewayException('Subscription page must be at least 1'); + throw ModelAttributeValidationException::invalid('Subscription', 'page', 'Subscription page must be at least 1'); } if ($limit < 1 || $limit > 100) { - throw new GatewayException('Subscription limit must be between 1 and 100'); + throw ModelAttributeValidationException::invalid('Subscription', 'limit', 'Subscription limit must be between 1 and 100'); } $query = http_build_query([ @@ -2254,11 +2316,11 @@ public function getPlan(Plan $plan): Plan public function listPlans(int $page = 1, int $limit = 100): array { if ($page < 1) { - throw new GatewayException('Plan page must be at least 1'); + throw ModelAttributeValidationException::invalid('Plan', 'page', 'Plan page must be at least 1'); } if ($limit < 1 || $limit > 100) { - throw new GatewayException('Plan limit must be between 1 and 100'); + throw ModelAttributeValidationException::invalid('Plan', 'limit', 'Plan limit must be between 1 and 100'); } $query = http_build_query([ @@ -2334,7 +2396,9 @@ private function subscriptionToIuguData(Subscription $subscription, bool $creati && !empty($trialEndsAt) && !$subscription->nextBillingAt->isSameDay($trialEndsAt) ) { - throw new GatewayException( + throw ModelAttributeValidationException::invalid( + 'Subscription', + 'nextBillingAt', 'Iugu stores the trial end and the next billing date in the same field, so ' . 'nextBillingAt and trialEndsAt (or trialDays) cannot hold different dates.' ); @@ -3059,14 +3123,15 @@ private function planToIuguData(Plan $plan): array * Converte o intervalo genérico no par `interval` e `interval_type` da Iugu. * * A Iugu só tem `weeks` e `months`, então o intervalo anual é enviado como múltiplo de 12 - * meses e o diário lança `GatewayException`. A leitura inversa fica em + * meses; o diário, o intervalo ausente e a contagem fora da faixa da Iugu lançam + * `ModelAttributeValidationException` antes da requisição. A leitura inversa fica em * `iuguIntervalToMultiPayment()`. * * @param PlanInterval|null $interval * @param int $intervalCount * * @return array{interval: int, interval_type: string} - * @throws GatewayException + * @throws ModelAttributeValidationException */ private function intervalToIuguData(?PlanInterval $interval, int $intervalCount): array { @@ -3074,7 +3139,9 @@ private function intervalToIuguData(?PlanInterval $interval, int $intervalCount) PlanInterval::WEEK => ['interval' => $intervalCount, 'interval_type' => 'weeks'], PlanInterval::MONTH => ['interval' => $intervalCount, 'interval_type' => 'months'], PlanInterval::YEAR => ['interval' => 12 * $intervalCount, 'interval_type' => 'months'], - default => throw new GatewayException( + default => throw ModelAttributeValidationException::invalid( + 'Plan', + 'interval', 'Iugu driver does not support the `' . ($interval?->value ?? 'null') . '` plan interval; ' . 'use week, month or year (sent as 12 months).' ), @@ -3082,7 +3149,9 @@ private function intervalToIuguData(?PlanInterval $interval, int $intervalCount) // a Iugu aceita interval de 1 a 599; a tradução de ano pode estourar o teto if ($data['interval'] < self::PLAN_INTERVAL_MIN || $data['interval'] > self::PLAN_INTERVAL_MAX) { - throw new GatewayException( + throw ModelAttributeValidationException::invalid( + 'Plan', + 'intervalCount', "Iugu accepts a plan interval from " . self::PLAN_INTERVAL_MIN . ' to ' . self::PLAN_INTERVAL_MAX . " {$data['interval_type']}, {$data['interval']} given." ); diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 9a4d2cb..31bcb70 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -36,6 +36,7 @@ use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\DeclineCode; use Potelo\MultiPayment\Helpers\LogHelper; +use Potelo\MultiPayment\Capabilities\CapabilityRestriction; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Gateways\Concerns\ChecksCapabilities; use Potelo\MultiPayment\Gateways\Concerns\ResolvesIdempotencyKey; @@ -155,6 +156,7 @@ public function capabilities(): array Capability::PARTIAL_REFUND_CARD, Capability::PARTIAL_REFUND_PIX, Capability::INVOICE_DUPLICATION, + Capability::INVOICE_CANCELLATION, Capability::IDEMPOTENCY, Capability::IDEMPOTENCY_ALL_ENDPOINTS, ]; @@ -180,6 +182,34 @@ public function notYetImplemented(): array ]; } + /** + * @inheritDoc + * + * `CREDIT_CARD`: a conta brasileira só aceita crédito Visa e Mastercard, e outra bandeira + * é recusada na cobrança com `DeclineCode::BRAND_NOT_SUPPORTED`. `INVOICE_DUPLICATION`: + * só fatura Pix pendente de venda avulsa. `INVOICE_CANCELLATION`: a fatura de assinatura + * (`in_`) só é anulada depois de finalizada pela Stripe; rascunho é recusado. + */ + public function restrictions(): array + { + return [ + Capability::CREDIT_CARD->value => new CapabilityRestriction( + description: 'Na conta brasileira só cartão de crédito Visa e Mastercard; outra bandeira é' + . ' recusada na cobrança com DeclineCode::BRAND_NOT_SUPPORTED.', + allowedBrands: ['visa', 'mastercard'], + ), + Capability::INVOICE_DUPLICATION->value => new CapabilityRestriction( + description: 'Só fatura Pix pendente de venda avulsa (PaymentIntent); cartão, outro estado' + . ' ou fatura de assinatura são recusados.', + allowedPaymentMethods: [PaymentMethod::PIX], + ), + Capability::INVOICE_CANCELLATION->value => new CapabilityRestriction( + description: 'A fatura de assinatura (objeto Invoice) só é anulada depois de finalizada' + . ' pela Stripe; rascunho é recusado.', + ), + ]; + } + /** * @inheritDoc * @@ -1036,29 +1066,32 @@ private function assertPaymentIntentOrigin(Invoice $invoice, string $operation): * estorno já feito, então o driver envia o refund e deixa a Stripe repetir a resposta * original; se ela recusar, a recusa da guarda é a que sobe. * + * O valor vem de `$amount`; sem ele, do caminho antigo de escrever `refundedAmount` antes + * de estornar (`Invoice::resolveRefundAmount()`); sem os dois, estorna o restante. No + * estorno por valor a fatura é relida quando o model não traz o valor pago ou quando o + * acumulado estornado que ele traz não é confiável (escrito pelo caminho antigo, ou ausente + * numa fatura fora de `PAID`). + * * @throws ModelAttributeValidationException|RefundNotSupportedException */ - public function refundInvoice(Invoice $invoice, ?string $idempotencyKey = null): Refund + public function refundInvoice(Invoice $invoice, ?int $amount = null, ?string $idempotencyKey = null): Refund { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); } $this->assertPaymentIntentOrigin($invoice, 'refundInvoice'); + $requestedAmount = $invoice->resolveRefundAmount($amount); $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); - // guardado antes da leitura: parseInvoice() sobrescreve refundedAmount com o já estornado - $requestedAmount = $invoice->refundedAmount ?: null; - $current = $invoice; if ( empty($invoice->paymentMethod) || empty($invoice->status) - || (!is_null($requestedAmount) && (is_null($invoice->paidAmount) || $invoice->status !== InvoiceStatus::PAID)) + || (!is_null($requestedAmount) && !self::hasReliableRefundableAmount($invoice)) ) { $current = $this->getInvoice(clone $invoice); } - // mesma semântica da Iugu: refundedAmount preenchido = estorno parcial; vazio = total $stripeRefundData = ['payment_intent' => $invoice->id]; if (!is_null($requestedAmount)) { $stripeRefundData['amount'] = $requestedAmount; @@ -1066,7 +1099,7 @@ public function refundInvoice(Invoice $invoice, ?string $idempotencyKey = null): $stripeRefundData = $this->mergeGatewayOptions($stripeRefundData, $invoice); try { - $this->assertInvoiceIsRefundable($current, $requestedAmount, $current !== $invoice); + $this->assertInvoiceIsRefundable($current, $requestedAmount); $stripeRefund = $this->stripeRequest(function () use ($stripeRefundData, $idempotencyKey) { return $this->client->refunds->create($stripeRefundData, self::stripeOptions($idempotencyKey)); }); @@ -1111,20 +1144,68 @@ private function replayStripeRefund(RefundNotSupportedException $refusal, array } } + /** + * @inheritDoc + * + * Na Stripe o restante é `amount_captured` menos `amount_refunded` do charge + * (`Invoice::$paidAmount` menos `Invoice::$refundedAmount`, porque o valor pago vem bruto); a + * fatura é lida quando o model não traz o valor pago ou o acumulado estornado confiável. A + * fatura de assinatura (`in_`) é recusada como em `refundInvoice()`, antes da leitura. + * + * @throws UnsupportedOperationException + */ + public function refundableAmount(Invoice $invoice): int + { + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + $this->assertPaymentIntentOrigin($invoice, 'refundableAmount'); + + $current = self::hasReliableRefundableAmount($invoice) ? $invoice : $this->getInvoice(clone $invoice); + + return self::stripeRefundableAmount($current); + } + + /** + * Restante estornável de uma fatura já lida: `paidAmount` menos `refundedAmount`; zero + * quando nada foi pago. + * + * @param Invoice $invoice + * @return int + */ + private static function stripeRefundableAmount(Invoice $invoice): int + { + return max(0, (int) ($invoice->paidAmount ?? 0) - (int) ($invoice->refundedAmount ?? 0)); + } + + /** + * Diz se o model traz o que basta para calcular o restante estornável sem reler a fatura: + * valor pago presente e `refundedAmount` sendo o acumulado do gateway, ou seja, sem valor + * pedido pelo caminho antigo e preenchido sempre que a fatura está fora de `PAID`. + * + * @param Invoice $invoice + * @return bool + */ + private static function hasReliableRefundableAmount(Invoice $invoice): bool + { + if (is_null($invoice->paidAmount) || !is_null($invoice->requestedRefundAmount())) { + return false; + } + + return !is_null($invoice->refundedAmount) || $invoice->status === InvoiceStatus::PAID; + } + /** * Lança antes da rede quando a Stripe certamente recusaria o estorno: boleto não tem estorno * pela API, fatura em `refunded` é terminal e o valor pedido não pode passar do que resta * (`amount_captured` menos `amount_refunded` do charge). * - * @param Invoice $invoice - * @param int|null $requestedAmount valor pedido em centavos; nulo é estorno integral - * @param bool $freshlyRead verdadeiro quando `$invoice` acabou de ser lida do gateway e - * `refundedAmount` é o acumulado; falso quando o model é do - * chamador, em `PAID`, sem estorno anterior + * @param Invoice $invoice fatura com `paidAmount` e `refundedAmount` confiáveis + * @param int|null $requestedAmount valor pedido em centavos; nulo é estorno do restante * @return void * @throws RefundNotSupportedException */ - private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmount, bool $freshlyRead): void + private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmount): void { if ($invoice->paymentMethod === PaymentMethod::BANK_SLIP) { throw RefundNotSupportedException::boletoNoRefund('stripe'); @@ -1138,7 +1219,7 @@ private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmou return; } - $refundable = $invoice->paidAmount - ($freshlyRead ? ($invoice->refundedAmount ?? 0) : 0); + $refundable = self::stripeRefundableAmount($invoice); if ($requestedAmount > $refundable) { throw RefundNotSupportedException::amountExceedsRefundable( 'stripe', @@ -1195,8 +1276,11 @@ public function chargeInvoiceWithCreditCard(Invoice $invoice, ?string $idempoten if (!empty($paymentIntentCustomer) && !empty($stripePaymentMethod->customer) && $stripePaymentMethod->customer !== $paymentIntentCustomer) { - throw new GatewayException( - "Credit card [{$paymentMethodId}] does not belong to customer [{$paymentIntentCustomer}]" + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::CREDIT_CARD, + "Credit card [{$paymentMethodId}] does not belong to customer [{$paymentIntentCustomer}];" + . ' the Stripe PaymentMethod is bound to one customer and cannot pay another customer\'s invoice.' ); } @@ -1264,7 +1348,7 @@ private function parseFromPaymentIntent(StripePaymentIntent $stripePaymentIntent $invoice->status = $this->deriveStatus(null, $stripePaymentIntent, $paidCharge); $invoice->amount = $stripePaymentIntent->amount; $invoice->paidAmount = $paidCharge?->amount_captured; - $invoice->refundedAmount = $paidCharge?->amount_refunded; + $invoice->setRefundedAmountFromGateway($paidCharge?->amount_refunded); $invoice->refunds = $this->parseRefunds($paidCharge, $stripePaymentIntent->id); $invoice->paidAt = $paidCharge ? Carbon::createFromTimestamp($paidCharge->created) : null; $invoice->fee = self::chargeFee($paidCharge); @@ -1328,7 +1412,7 @@ private function parseFromStripeInvoice(StripeInvoice $stripeInvoice, ?Invoice $ $amountPaid = $stripeInvoice->amount_paid ?? 0; $invoice->paidAmount = $paidCharge?->amount_captured ?? ($stripeInvoice->status === 'paid' || $amountPaid > 0 ? $amountPaid : null); - $invoice->refundedAmount = $paidCharge?->amount_refunded; + $invoice->setRefundedAmountFromGateway($paidCharge?->amount_refunded); $invoice->refunds = $this->parseRefunds($paidCharge, $stripeInvoice->id); $paidAt = $stripeInvoice->status_transitions->paid_at ?? $paidCharge?->created; $invoice->paidAt = !empty($paidAt) ? Carbon::createFromTimestamp($paidAt) : null; @@ -1928,7 +2012,9 @@ public function duplicateInvoice( ); } if (empty($parsedOriginal->customer) || empty($parsedOriginal->customer->id)) { - throw new GatewayException( + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::INVOICE_DUPLICATION, "Invoice [{$invoice->id}] has no customer on the stripe gateway and cannot be duplicated" ); } @@ -2010,20 +2096,23 @@ public function cancelInvoice(Invoice $invoice, ?string $idempotencyKey = null): } /** - * Anula um Invoice da Stripe. A fatura é lida antes: `draft` lança `GatewayException` - * orientando a esperar a finalização; nos demais estados a Stripe decide, e `paid` ou - * `void` recusam com `ValidationException`, como o PaymentIntent já pago ou cancelado. + * Anula um Invoice da Stripe. A fatura é lida antes: `draft` lança + * `UnsupportedOperationException::restricted()` (`INVOICE_CANCELLATION`) orientando a + * esperar a finalização; nos demais estados a Stripe decide, e `paid` ou `void` recusam + * com `ValidationException`, como o PaymentIntent já pago ou cancelado. * * @param Invoice $invoice * @param string|null $idempotencyKey * @return Invoice - * @throws GatewayException|GatewayNotAvailableException + * @throws GatewayException|GatewayNotAvailableException|UnsupportedOperationException */ private function voidStripeInvoice(Invoice $invoice, ?string $idempotencyKey): Invoice { $current = $this->retrieveStripeInvoice($invoice->id); if ($current->status === 'draft') { - throw new GatewayException( + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::INVOICE_CANCELLATION, "A fatura [{$invoice->id}] ainda é um rascunho na Stripe e não pode ser cancelada;" . ' aguarde a finalização dela pela Stripe.' ); @@ -2163,14 +2252,17 @@ private function resolvePaymentMethodId(string $token, ?string $idempotencyKey = * @param \Stripe\PaymentMethod $stripePaymentMethod * @param \Potelo\MultiPayment\Models\CreditCard $creditCard * @return void - * @throws GatewayException + * @throws UnsupportedOperationException */ private function assertCardBelongsToCustomer(StripePaymentMethod $stripePaymentMethod, CreditCard $creditCard): void { $customerId = $creditCard->customer->id ?? null; if (!empty($customerId) && $stripePaymentMethod->customer !== $customerId) { - throw new GatewayException( - "Credit card [{$stripePaymentMethod->id}] does not belong to customer [{$customerId}]" + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::CREDIT_CARD, + "Credit card [{$stripePaymentMethod->id}] does not belong to customer [{$customerId}];" + . ' the Stripe PaymentMethod is bound to one customer.' ); } } diff --git a/src/Helpers/CapabilitiesTable.php b/src/Helpers/CapabilitiesTable.php index c04649c..20b9a7f 100644 --- a/src/Helpers/CapabilitiesTable.php +++ b/src/Helpers/CapabilitiesTable.php @@ -21,16 +21,19 @@ class CapabilitiesTable public const GATEWAY_LIMITATION = 'limitação do gateway'; /** - * Gera a tabela com uma linha por caso de `Capability` e uma coluna por gateway, na - * ordem informada. + * Gera a tabela com uma linha por caso de `Capability`, uma coluna por gateway, na ordem + * informada, e uma última coluna com as restrições declaradas por cada gateway para a + * capability (`restriction()`), vazia quando nenhum gateway restringe. * * @param array $gateways nome do gateway como chave * @return string */ public static function markdown(array $gateways): string { - $header = '| Capability | Significado | ' . implode(' | ', array_map('ucfirst', array_keys($gateways))) . ' |'; - $separator = '|---|---|' . str_repeat('---|', count($gateways)); + $header = '| Capability | Significado | ' + . implode(' | ', array_map('ucfirst', array_keys($gateways))) + . ' | Restrições |'; + $separator = '|---|---|' . str_repeat('---|', count($gateways)) . '---|'; $rows = []; foreach (Capability::cases() as $capability) { @@ -38,7 +41,9 @@ public static function markdown(array $gateways): string static fn (DeclaresCapabilities $gateway) => self::cell($gateway, $capability), array_values($gateways) ); - $rows[] = "| `{$capability->name}` | {$capability->description()} | " . implode(' | ', $cells) . ' |'; + $rows[] = "| `{$capability->name}` | {$capability->description()} | " + . implode(' | ', $cells) + . ' | ' . self::restrictionsCell($gateways, $capability) . ' |'; } return implode("\n", array_merge([$header, $separator], $rows)) . "\n"; @@ -63,4 +68,26 @@ public static function cell(DeclaresCapabilities $gateway, Capability $capabilit return self::GATEWAY_LIMITATION; } + + /** + * Célula de restrições de uma capability: a descrição declarada por cada gateway, + * prefixada pelo nome dele, separadas por quebra de linha HTML. Vazia quando nenhum + * gateway restringe a capability. + * + * @param array $gateways nome do gateway como chave + * @param Capability $capability + * @return string + */ + public static function restrictionsCell(array $gateways, Capability $capability): string + { + $parts = []; + foreach ($gateways as $name => $gateway) { + $restriction = $gateway->restriction($capability); + if (!is_null($restriction)) { + $parts[] = ucfirst($name) . ': ' . str_replace('|', '\\|', $restriction->description); + } + } + + return implode('
', $parts); + } } diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index fffdb5b..b2995bd 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -22,6 +22,7 @@ * @property PaymentMethod|null $paymentMethod Método com que a fatura foi (ou será) paga. * @property PaymentMethod[]|null $availablePaymentMethods Métodos aceitos pela fatura. * @property InvoiceOriginType|null $originType Objeto do gateway de onde a fatura foi lida (`PAYMENT_INTENT` ou `INVOICE`); `original` guarda esse objeto. + * @property-read int|null $refundedAmount Total já estornado, em centavos, preenchido na leitura. Escrever nela é o caminho antigo de pedir um estorno parcial, obsoleto desde 2026-09-02: use `refund(amount:)`. * @property Carbon|null $expiresAt Obsoleto desde 2026-09-02, use `$dueDate`. Alias que lê e escreve a mesma data, com aviso de deprecação. */ class Invoice extends Model @@ -63,6 +64,8 @@ class Invoice extends Model 'originType' => InvoiceOriginType::class, ]; + protected const MAGIC_PROPERTIES = ['refundedAmount']; + /** * @var string|null */ @@ -89,9 +92,21 @@ class Invoice extends Model public ?int $paidAmount = null; /** + * Total já estornado, em centavos, como o gateway informa na leitura (`refunded_cents` na + * Iugu, `amount_refunded` do charge na Stripe). Só os drivers escrevem aqui, por + * `setRefundedAmountFromGateway()`. + * + * @var int|null + */ + protected ?int $refundedAmount = null; + + /** + * Valor escrito em `refundedAmount` de fora do model pelo caminho antigo de pedir estorno + * parcial, ainda não enviado ao gateway; nulo quando `refundedAmount` só reflete a leitura. + * * @var int|null */ - public ?int $refundedAmount = null; + private ?int $requestedRefundAmount = null; /** * Estornos da fatura, preenchidos na leitura. No Stripe é um `Refund` por estorno feito, @@ -241,6 +256,13 @@ public function fill(array $data): void unset($data['expires_at']); } + foreach (['refunded_amount', 'refundedAmount'] as $key) { + if (array_key_exists($key, $data)) { + $this->__set('refundedAmount', $data[$key]); + unset($data[$key]); + } + } + foreach (['due_date' => 'dueDate', 'pix_expires_at' => 'pixExpiresAt'] as $key => $attribute) { if (!empty($data[$key])) { $this->{$attribute} = $data[$key] instanceof Carbon @@ -585,14 +607,20 @@ public function &__get(string $name): mixed return $this->dueDate; } + if ($name === 'refundedAmount') { + return $this->refundedAmount; + } + $value = &parent::__get($name); return $value; } /** - * Resolve a escrita no nome antigo `expiresAt` para `dueDate`, com aviso de deprecação; os - * demais nomes seguem o `Model`. + * Resolve a escrita no nome antigo `expiresAt` para `dueDate`, com aviso de deprecação, e a + * escrita em `refundedAmount`, que é o caminho antigo de pedir um estorno parcial: o valor + * fica guardado como pedido para o próximo `refund()` sem valor, com aviso de deprecação. + * Os demais nomes seguem o `Model`. * * @param string $name * @param mixed $value @@ -607,11 +635,23 @@ public function __set(string $name, mixed $value): void return; } + if ($name === 'refundedAmount') { + trigger_error( + 'Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)', + E_USER_DEPRECATED + ); + $this->refundedAmount = is_null($value) ? null : (int) $value; + $this->requestedRefundAmount = $this->refundedAmount ?: null; + + return; + } + parent::__set($name, $value); } /** - * Mantém `isset()` e `empty()` funcionando sobre o nome antigo `expiresAt`. + * Mantém `isset()` e `empty()` funcionando sobre o nome antigo `expiresAt` e sobre + * `refundedAmount`. * * @param string $name * @return bool @@ -622,9 +662,66 @@ public function __isset(string $name): bool return isset($this->dueDate); } + if ($name === 'refundedAmount') { + return isset($this->refundedAmount); + } + return parent::__isset($name); } + /** + * Grava o total já estornado informado pelo gateway e apaga o valor pedido pelo caminho + * antigo (`requestedRefundAmount()` volta a nulo). É a escrita que os drivers fazem ao + * parsear a fatura. + * + * @internal usado pelos drivers ao parsear a fatura + * @param int|null $refundedAmount total estornado, em centavos + * @return void + */ + public function setRefundedAmountFromGateway(?int $refundedAmount): void + { + $this->refundedAmount = $refundedAmount; + $this->requestedRefundAmount = null; + } + + /** + * Valor de estorno parcial pedido pelo caminho antigo (escrita em `refundedAmount`) e ainda + * não enviado ao gateway; nulo quando `refundedAmount` só reflete a leitura do gateway. + * Enquanto não é nulo, `refundedAmount` não é o total já estornado. + * + * @internal usado pelos drivers + * @return int|null + */ + public function requestedRefundAmount(): ?int + { + return $this->requestedRefundAmount; + } + + /** + * Resolve o valor de um estorno como os drivers o usam: o argumento, senão o valor pedido + * pelo caminho antigo (`requestedRefundAmount()`), senão nulo, que é estorno do restante. + * Zero ou negativo lança `ModelAttributeValidationException`. + * + * @internal usado pelos drivers + * @param int|null $amount valor em centavos + * @return int|null + * @throws ModelAttributeValidationException + */ + public function resolveRefundAmount(?int $amount): ?int + { + $amount ??= $this->requestedRefundAmount; + + if (!is_null($amount) && $amount <= 0) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'amount', + 'The refund amount must be a positive number of cents; omit it to refund the remainder.' + ); + } + + return $amount; + } + /** * Emite o aviso de deprecação do nome antigo `expiresAt`. * @@ -671,19 +768,42 @@ private static function statusFromHelperArgument(InvoiceStatus|string $status): } /** - * Estorna a fatura: integral quando `refundedAmount` está vazio, parcial quando preenchido - * com o valor em centavos. Devolve o `Refund` criado e atualiza esta instância com o - * estado posterior ao estorno (`$refund->invoice()` é esta instância). + * Estorna a fatura: o restante estornável quando `$amount` é nulo, ou o valor informado em + * centavos. Devolve o `Refund` criado e atualiza esta instância com o estado posterior ao + * estorno (`$refund->invoice()` é esta instância). Zero ou negativo lança + * `ModelAttributeValidationException` antes da requisição. * + * @param int|null $amount valor em centavos; nulo estorna o restante * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * @return Refund * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws ModelAttributeValidationException + */ + public function refund(?int $amount = null, ?string $idempotencyKey = null): Refund + { + $gateway = ConfigurationHelper::resolveGateway($this->gateway); + return $gateway->refundInvoice($this, $amount, $idempotencyKey); + } + + /** + * Valor que ainda pode ser estornado na fatura, em centavos, calculado pelo driver: zero para + * fatura não paga ou já integralmente estornada. Lê a fatura (um GET) quando o model não traz + * o valor pago. É o teto aritmético de `refund()`; as guardas de boleto, Pix parcial e prazo + * continuam valendo. + * + * @return int + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException fatura que o driver não estorna (fatura de assinatura no Stripe) + * @throws ModelAttributeValidationException `id` ausente */ - public function refund(?string $idempotencyKey = null): Refund + public function refundableAmount(): int { $gateway = ConfigurationHelper::resolveGateway($this->gateway); - return $gateway->refundInvoice($this, $idempotencyKey); + return $gateway->refundableAmount($this); } /** diff --git a/src/Models/Model.php b/src/Models/Model.php index 2acbd9c..039d5f2 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -7,6 +7,7 @@ use Potelo\MultiPayment\Contracts\AcceptsUnknownValue; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\ConfigurationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -27,6 +28,14 @@ abstract class Model implements \JsonSerializable */ protected const ENUM_CASTS = []; + /** + * Propriedades `protected` que o model lê e escreve pelos próprios métodos mágicos, fora de + * `ENUM_CASTS`, e que `fill()`, `toArray()` e `fillableKeys()` tratam como públicas. + * + * @var string[] + */ + protected const MAGIC_PROPERTIES = []; + /** * Capability que o gateway precisa declarar para operar este model, ou nulo quando qualquer * gateway serve. `requiredCapabilities()` a devolve junto com as derivadas dos atributos. @@ -246,14 +255,15 @@ public function create(array $data, $gateway = null, ?string $idempotencyKey = n /** * Salva o model no gateway: `create{Model}` sem `id`, `update{Model}` com `id` (sem * validação). A chave de idempotência vai para essa operação; o cliente salvo antes de uma - * fatura ou assinatura recebe a chave derivada `{chave}:customer`. + * fatura ou assinatura recebe a chave derivada `{chave}:customer`. Driver que declara a + * capability sem ter o método de despacho lança `ConfigurationException` antes da rede. * * @param string|GatewayContract|null $gateway * @param bool $validate * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return void - * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException|ConfigurationException * @throws UnsupportedOperationException */ public function save(GatewayContract|string|null $gateway = null, bool $validate = true, ?string $idempotencyKey = null): void @@ -274,7 +284,7 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate $gatewayClass = ConfigurationHelper::resolveGateway($gateway); $this->assertGatewaySupports($gatewayClass); if (!method_exists($gatewayClass, $method)) { - throw GatewayException::methodNotFound(get_class($gatewayClass), $method); + throw ConfigurationException::GatewayMethodNotFound(get_class($gatewayClass), $method); } $gatewayClass->$method($this, $idempotencyKey); } @@ -388,7 +398,7 @@ public function fill(array $data): void self::warnGatewayAdicionalOptionsDeprecated(); $property = 'gatewayOptions'; } - if (!property_exists($this, $property)) { + if (!static::isFillableProperty($property)) { if (!str_starts_with($property, 'gateway') && ConfigurationHelper::strictFill()) { throw ModelAttributeValidationException::unknownAttribute( static::getClassName(), @@ -403,8 +413,8 @@ public function fill(array $data): void } /** - * Chaves que `fill()` aceita, em `snake_case`: as propriedades públicas do model e as de - * enum (ver `ENUM_CASTS`), na ordem de declaração. + * Chaves que `fill()` aceita, em `snake_case`: as propriedades públicas do model, as de + * enum (ver `ENUM_CASTS`) e as de `MAGIC_PROPERTIES`, na ordem de declaração. * * @return string[] */ @@ -414,7 +424,7 @@ public static function fillableKeys(): array $reflect = new \ReflectionClass(static::class); foreach ($reflect->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED) as $prop) { $name = $prop->getName(); - if ($prop->isStatic() || ($prop->isProtected() && !isset(static::ENUM_CASTS[$name]))) { + if ($prop->isStatic() || ($prop->isProtected() && !static::isMagicProperty($name))) { continue; } $keys[] = self::snakeCase($name); @@ -423,6 +433,34 @@ public static function fillableKeys(): array return $keys; } + /** + * Diz se `fill()` pode escrever na propriedade: ela existe no model e é pública ou protegida + * (propriedade privada é estado interno e conta como chave desconhecida). + * + * @param string $property + * @return bool + */ + private static function isFillableProperty(string $property): bool + { + if (!property_exists(static::class, $property)) { + return false; + } + + return !(new \ReflectionProperty(static::class, $property))->isPrivate(); + } + + /** + * Diz se a propriedade `protected` é exposta pelos métodos mágicos do model (enum de + * `ENUM_CASTS` ou nome em `MAGIC_PROPERTIES`). + * + * @param string $name + * @return bool + */ + protected static function isMagicProperty(string $name): bool + { + return isset(static::ENUM_CASTS[$name]) || in_array($name, static::MAGIC_PROPERTIES, true); + } + /** * Converte o nome de uma propriedade em `camelCase` para a chave em `snake_case`. * @@ -436,7 +474,7 @@ private static function snakeCase(string $name): string /** * Convert the model instance to an array. Chave em `snake_case`; propriedade de enum sai - * como o valor de string do enum. + * como o valor de string do enum; propriedade de `MAGIC_PROPERTIES` sai como as públicas. * * @return array */ @@ -447,7 +485,7 @@ public function toArray(): array $props = $reflect->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED); foreach ($props as $prop) { $name = $prop->getName(); - if ($prop->isProtected() && !isset(static::ENUM_CASTS[$name])) { + if ($prop->isProtected() && !static::isMagicProperty($name)) { continue; } if (!empty($this->{$name})) { @@ -495,7 +533,7 @@ public function get(GatewayContract|string|null $gateway = null): static $gateway = ConfigurationHelper::resolveGateway($gateway); $this->assertGatewaySupports($gateway); if (!method_exists($gateway, $method)) { - throw GatewayException::methodNotFound(get_class($gateway), $method); + throw ConfigurationException::GatewayMethodNotFound(get_class($gateway), $method); } return $gateway->$method($this); } @@ -516,7 +554,7 @@ public function delete(GatewayContract|string|null $gateway = null, ?string $ide $gateway = ConfigurationHelper::resolveGateway($gateway); $this->assertGatewaySupports($gateway); if (!method_exists($gateway, $method)) { - throw GatewayException::methodNotFound(get_class($gateway), $method); + throw ConfigurationException::GatewayMethodNotFound(get_class($gateway), $method); } $gateway->$method($this, $idempotencyKey); } diff --git a/src/Models/Plan.php b/src/Models/Plan.php index d19df14..4d0cc16 100644 --- a/src/Models/Plan.php +++ b/src/Models/Plan.php @@ -86,8 +86,8 @@ class Plan extends Model public $original = null; /** - * Cria o plano no gateway. Plano com `id` preenchido lança `GatewayException`: plano não é - * atualizável. + * Cria o plano no gateway. Plano com `id` preenchido lança + * `ModelAttributeValidationException` antes da requisição: plano não é atualizável. * * @param GatewayContract|string|null $gateway * @param bool $validate @@ -100,7 +100,9 @@ class Plan extends Model public function save(GatewayContract|string|null $gateway = null, bool $validate = true, ?string $idempotencyKey = null): void { if (!empty($this->id)) { - throw new GatewayException( + throw ModelAttributeValidationException::invalid( + 'Plan', + 'id', 'A plan cannot be updated. Create a new plan instead.' ); } diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index a6af685..2cbad8f 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -9,6 +9,7 @@ use Potelo\MultiPayment\Enums\SubscriptionStatus; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\ConfigurationException; use Potelo\MultiPayment\Contracts\SubscriptionContract; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Idempotency\IdempotencyKey; @@ -551,9 +552,8 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate * @param GatewayContract|string|null $gateway * * @return GatewayContract&SubscriptionContract - * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws ConfigurationException driver que declara a capability sem implementar o contract * @throws UnsupportedOperationException - * @throws GatewayException */ private function resolveSubscriptionGateway(GatewayContract|string|null $gateway) { @@ -561,10 +561,7 @@ private function resolveSubscriptionGateway(GatewayContract|string|null $gateway $this->assertGatewaySupports($resolved); if (!$resolved instanceof SubscriptionContract) { - throw new GatewayException( - 'Gateway [' . get_class($resolved) . '] declares the subscriptions capability' - . ' but does not implement SubscriptionContract' - ); + throw ConfigurationException::GatewayMissingContract($resolved, Capability::SUBSCRIPTIONS, SubscriptionContract::class); } return $resolved; diff --git a/src/MultiPayment.php b/src/MultiPayment.php index 28ad7f8..ded2199 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -24,6 +24,8 @@ use Potelo\MultiPayment\Builders\SubscriptionBuilder; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\NotFoundException; +use Potelo\MultiPayment\Exceptions\ConfigurationException; +use Potelo\MultiPayment\Capabilities\CapabilityRestriction; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -106,6 +108,45 @@ public function notYetImplemented($gateway = null): array return $this->gateway($gateway)->notYetImplemented(); } + /** + * Diz se o gateway desta instância suporta todas as capabilities informadas. Para outro + * gateway, use `gateway($nome)->supportsAll(...)`. + * + * @param Capability ...$capabilities + * @return bool + */ + public function supportsAll(Capability ...$capabilities): bool + { + return $this->gateway->supportsAll(...$capabilities); + } + + /** + * Restrição que o gateway (o desta instância, por padrão) impõe a uma capability que + * suporta, ou nulo quando ela vale em todos os casos. + * + * @param Capability $capability + * @param GatewayContract|string|null $gateway + * @return CapabilityRestriction|null + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function restriction(Capability $capability, $gateway = null): ?CapabilityRestriction + { + return $this->gateway($gateway)->restriction($capability); + } + + /** + * Restrições do gateway (o desta instância, por padrão), com o valor da capability como + * chave. + * + * @param GatewayContract|string|null $gateway + * @return array + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function restrictions($gateway = null): array + { + return $this->gateway($gateway)->restrictions(); + } + /** * Cria e cobra uma fatura a partir de um array em `snake_case` (as chaves aceitas estão no * README, no apêndice "Chaves do array de charge()"). `customer` é obrigatório e é @@ -214,7 +255,7 @@ public function listPlans(int $page = 1, int $limit = 100): array * * @return GatewayContract * @throws UnsupportedOperationException - * @throws GatewayException + * @throws ConfigurationException driver que declara a capability sem implementar o contract */ private function gatewayImplementing(string $contract, Capability $capability): GatewayContract { @@ -223,11 +264,7 @@ private function gatewayImplementing(string $contract, Capability $capability): } if (!$this->gateway instanceof $contract) { - $contractName = substr(strrchr($contract, '\\'), 1); - throw new GatewayException( - 'Gateway [' . get_class($this->gateway) . "] declares the {$capability->value} capability" - . " but does not implement {$contractName}" - ); + throw ConfigurationException::GatewayMissingContract($this->gateway, $capability, $contract); } return $this->gateway; @@ -342,37 +379,41 @@ public function getCustomer(string $id): Customer } /** - * Estorna uma fatura pelo id: integral sem valor, parcial com o valor em centavos. + * Estorna uma fatura pelo id: o restante estornável sem valor, ou o valor em centavos. * Devolve o `Refund` criado; a fatura relida após o estorno está em `$refund->invoice()`. * * @param string $id - * @param int|null $partialValueCents + * @param int|null $partialValueCents valor em centavos; nulo estorna o restante * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return \Potelo\MultiPayment\Models\Refund * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException - * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException valor parcial zero ou negativo + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException valor zero ou negativo */ public function refundInvoice(string $id, ?int $partialValueCents = null, ?string $idempotencyKey = null): Refund { - if (!is_null($partialValueCents) && $partialValueCents <= 0) { - throw ModelAttributeValidationException::invalid( - 'Invoice', - 'refundedAmount', - 'The partial refund value must be a positive amount in cents; omit it for a full refund.' - ); - } - $invoice = new Invoice(); $invoice->id = $id; $invoice->gateway = $this->gateway; - if (!is_null($partialValueCents)) { - $invoice->refundedAmount = $partialValueCents; - } + return $invoice->refund($partialValueCents, $idempotencyKey); + } + + /** + * Valor que ainda pode ser estornado na fatura, em centavos; lê a fatura no gateway. + * + * @param string $id + * @return int + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + */ + public function refundableAmount(string $id): int + { + $invoice = new Invoice(); + $invoice->id = $id; + $invoice->gateway = $this->gateway; - return $invoice->refund($idempotencyKey); + return $invoice->refundableAmount(); } /** diff --git a/src/config/multi-payment.php b/src/config/multi-payment.php index 8d513ee..7e44d5c 100644 --- a/src/config/multi-payment.php +++ b/src/config/multi-payment.php @@ -69,6 +69,8 @@ 'api_key' => env('IUGU_APIKEY'), 'customer_column' => 'iugu_id', 'class' => \Potelo\MultiPayment\Gateways\IuguGateway::class, + // máximo de parcelas habilitado na conta; a lib o publica em restriction(INSTALLMENTS) + 'max_installments' => env('IUGU_MAX_INSTALLMENTS', 12), ], 'stripe' => [ 'api_key' => env('STRIPE_APIKEY'), diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index 8a9ac06..1af2ad9 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -10,7 +10,6 @@ use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Enums\InvoiceOriginType; use Potelo\MultiPayment\Facades\MultiPayment; -use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\ChargingException; @@ -184,7 +183,7 @@ public function testShouldManageCreditCardLifecycle($gateway) MultiPayment::setGateway($gateway)->deleteCard($customer->id, $creditCard->id); // após o detach o PaymentMethod não pertence mais ao customer - $this->expectException(GatewayException::class); + $this->expectException(UnsupportedOperationException::class); MultiPayment::setGateway($gateway)->getCard($customer->id, $creditCard->id); } diff --git a/tests/Unit/Capabilities/CapabilityRestrictionTest.php b/tests/Unit/Capabilities/CapabilityRestrictionTest.php new file mode 100644 index 0000000..01fe49f --- /dev/null +++ b/tests/Unit/Capabilities/CapabilityRestrictionTest.php @@ -0,0 +1,50 @@ +assertSame('Só numa parte dos casos.', $restriction->description); + $this->assertNull($restriction->allowedPaymentMethods); + $this->assertNull($restriction->allowedBrands); + $this->assertNull($restriction->maxInstallments); + } + + public function testAllowsPaymentMethodFollowsTheListAndIsTrueWithoutOne(): void + { + $byMethod = new CapabilityRestriction('Só Pix.', allowedPaymentMethods: [PaymentMethod::PIX]); + $unrestricted = new CapabilityRestriction('Outra restrição.', maxInstallments: 12); + + $this->assertTrue($byMethod->allowsPaymentMethod(PaymentMethod::PIX)); + $this->assertFalse($byMethod->allowsPaymentMethod(PaymentMethod::CREDIT_CARD)); + $this->assertTrue($unrestricted->allowsPaymentMethod(PaymentMethod::CREDIT_CARD)); + } + + public function testAllowsBrandIgnoresCaseAndIsTrueWithoutAList(): void + { + $byBrand = new CapabilityRestriction('Só Visa e Mastercard.', allowedBrands: ['visa', 'mastercard']); + $unrestricted = new CapabilityRestriction('Outra restrição.'); + + $this->assertTrue($byBrand->allowsBrand('visa')); + $this->assertTrue($byBrand->allowsBrand('Mastercard')); + $this->assertFalse($byBrand->allowsBrand('elo')); + $this->assertTrue($unrestricted->allowsBrand('elo')); + } + + public function testIsReadOnly(): void + { + $restriction = new CapabilityRestriction('Só numa parte dos casos.', maxInstallments: 12); + + $this->expectException(\Error::class); + + $restriction->maxInstallments = 6; + } +} diff --git a/tests/Unit/Exceptions/ExceptionHierarchyTest.php b/tests/Unit/Exceptions/ExceptionHierarchyTest.php index eb7f40d..eaa2743 100644 --- a/tests/Unit/Exceptions/ExceptionHierarchyTest.php +++ b/tests/Unit/Exceptions/ExceptionHierarchyTest.php @@ -77,6 +77,16 @@ public static function outsideGatewayExceptionProvider(): array ]; } + /** + * `RefundNotSupportedException` herda direto de `MultiPaymentException` e fica fora da árvore + * de `UnsupportedOperationException`. + */ + public function testRefundNotSupportedExceptionStaysOutsideUnsupportedOperationException(): void + { + $this->assertFalse(is_subclass_of(RefundNotSupportedException::class, UnsupportedOperationException::class)); + $this->assertSame(MultiPaymentException::class, get_parent_class(RefundNotSupportedException::class)); + } + public function testChargingExceptionIsTheDeprecatedNameOfCardDeclinedException(): void { $this->assertSame(CardDeclinedException::class, get_parent_class(ChargingException::class)); diff --git a/tests/Unit/Exceptions/NoLocalGatewayExceptionTest.php b/tests/Unit/Exceptions/NoLocalGatewayExceptionTest.php new file mode 100644 index 0000000..01a0737 --- /dev/null +++ b/tests/Unit/Exceptions/NoLocalGatewayExceptionTest.php @@ -0,0 +1,300 @@ +sourceFiles() as $file) { + foreach (self::gatewayExceptionCreations($file) as $creation) { + $context = $creation['class'] . '::' . $creation['function']; + if ($creation['insideCatch'] || in_array($context, self::CLASSIFIERS, true)) { + continue; + } + $offenders[] = "{$creation['file']}:{$creation['line']} ({$context})"; + } + } + + $this->assertSame( + [], + $offenders, + "GatewayException criada fora de um classificador ou de um catch (use ModelAttributeValidationException, " + . "UnsupportedOperationException::restricted() ou ConfigurationException):\n" . implode("\n", $offenders) + ); + } + + /** + * A varredura reconhece os pontos legítimos, senão o teste passaria por não olhar nada. + */ + public function testTheScanFindsTheClassifiers(): void + { + $found = []; + foreach ($this->sourceFiles() as $file) { + foreach (self::gatewayExceptionCreations($file) as $creation) { + $found[] = $creation['class'] . '::' . $creation['function']; + } + } + + foreach (self::CLASSIFIERS as $classifier) { + $this->assertContains($classifier, $found, "a varredura não encontrou {$classifier}"); + } + } + + /** + * Controle negativo sobre uma fixture: criação direta, construtor estático, nome qualificado + * e `throw` depois de um `catch` já fechado são reportados fora de `catch`; só a criação + * dentro do `catch` (inclusive numa closure) é marcada como tal, e `::class` é ignorado. + */ + public function testTheScanReportsCreationsOutsideACatchAndTracksTheCatchDepth(): void + { + $code = <<<'PHP' + nome} {$this->outro}"; + throw new GatewayException('local'); + } + + public function estatica(): void + { + $classe = GatewayException::class; + throw GatewayException::methodNotFound('a', 'b'); + } + + public function qualificada(): void + { + throw new \Potelo\MultiPayment\Exceptions\GatewayException('local'); + } + + public function depoisDoCatch(): void + { + try { + $this->x(); + } catch (\Exception $e) { + $this->log($e); + } + throw new GatewayException('fora do catch'); + } + + public function dentroDoCatch(): void + { + try { + $this->x(); + } catch (\Exception $e) { + $f = function () use ($e) { + throw new GatewayException('closure', null, $e); + }; + throw new GatewayException('direto', null, $e); + } + } + + public function arm(): int + { + return match (true) { + default => throw new GatewayException('arm'), + }; + } + } + PHP; + $path = tempnam(sys_get_temp_dir(), 'scan') . '.php'; + file_put_contents($path, $code); + + try { + $method = new \ReflectionMethod(self::class, 'gatewayExceptionCreations'); + $creations = $method->invoke(null, $path); + } finally { + unlink($path); + } + + $this->assertSame([ + ['direta', false], + ['estatica', false], + ['qualificada', false], + ['depoisDoCatch', false], + ['dentroDoCatch', true], + ['dentroDoCatch', true], + ['arm', false], + ], array_map(static fn (array $c) => [$c['function'], $c['insideCatch']], $creations)); + } + + /** + * @return string[] + */ + private function sourceFiles(): array + { + $root = realpath(__DIR__ . '/../../../src'); + $files = []; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root)); + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php' && basename($file->getPathname()) !== 'GatewayException.php') { + $files[] = $file->getPathname(); + } + } + sort($files); + $this->assertNotEmpty($files); + + return $files; + } + + /** + * Ocorrências de `new GatewayException(` e de `GatewayException::metodo(` no arquivo, cada + * uma com o método que a contém e se está dentro de um bloco `catch`. + * + * @param string $path + * @return array + */ + private static function gatewayExceptionCreations(string $path): array + { + $tokens = token_get_all(file_get_contents($path)); + $class = basename($path, '.php'); + $creations = []; + + $braces = []; // pilha de chaves abertas: 'code' ou 'interp' (interpolação em string) + $function = null; + $functionDepth = null; + $catchDepths = []; + $pendingFunction = false; + $pendingCatch = false; + $codeDepth = 0; + + $count = count($tokens); + for ($i = 0; $i < $count; $i++) { + $token = $tokens[$i]; + + if (is_array($token)) { + [$id, $text, $line] = $token; + + if ($id === T_FUNCTION) { + $pendingFunction = true; + } elseif ($pendingFunction && $id === T_STRING) { + $function = $text; + $functionDepth = null; + $pendingFunction = false; + } elseif ($id === T_CATCH) { + $pendingCatch = true; + } elseif ($id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) { + $braces[] = 'interp'; + } elseif ($id === T_NEW) { + $next = self::nextSignificant($tokens, $i); + if (self::isGatewayExceptionName($next)) { + $creations[] = self::creation($path, $line, $class, $function, $catchDepths); + } + } elseif ($id === T_DOUBLE_COLON && self::isGatewayExceptionName(self::previousSignificant($tokens, $i))) { + $next = self::nextSignificant($tokens, $i); + if (is_array($next) && $next[0] === T_STRING) { + $creations[] = self::creation($path, $line, $class, $function, $catchDepths); + } + } + + continue; + } + + if ($token === '(' && $pendingFunction) { + // closure: não tem nome e não troca o método corrente + $pendingFunction = false; + } elseif ($token === '{') { + $braces[] = 'code'; + $codeDepth++; + if ($pendingCatch) { + $catchDepths[] = $codeDepth; + $pendingCatch = false; + } + if (!is_null($function) && is_null($functionDepth)) { + $functionDepth = $codeDepth; + } + } elseif ($token === '}') { + $kind = array_pop($braces); + if ($kind !== 'code') { + continue; + } + if (!empty($catchDepths) && end($catchDepths) === $codeDepth) { + array_pop($catchDepths); + } + if ($functionDepth === $codeDepth) { + $function = null; + $functionDepth = null; + } + $codeDepth--; + } + } + + return $creations; + } + + /** + * @param array $tokens + * @return array|string|null + */ + private static function nextSignificant(array $tokens, int $from): array|string|null + { + for ($j = $from + 1, $count = count($tokens); $j < $count; $j++) { + if (!is_array($tokens[$j]) || !in_array($tokens[$j][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + return $tokens[$j]; + } + } + + return null; + } + + /** + * @param array $tokens + * @return array|string|null + */ + private static function previousSignificant(array $tokens, int $from): array|string|null + { + for ($j = $from - 1; $j >= 0; $j--) { + if (!is_array($tokens[$j]) || !in_array($tokens[$j][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + return $tokens[$j]; + } + } + + return null; + } + + private static function isGatewayExceptionName(array|string|null $token): bool + { + if (!is_array($token) || !in_array($token[0], [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED], true)) { + return false; + } + + $name = ltrim($token[1], '\\'); + $basename = substr(strrchr('\\' . $name, '\\'), 1); + + return $basename === 'GatewayException'; + } + + /** + * @return array{file: string, line: int, class: string, function: string, insideCatch: bool} + */ + private static function creation(string $path, int $line, string $class, ?string $function, array $catchDepths): array + { + return [ + 'file' => substr($path, strpos($path, '/src/') + 1), + 'line' => $line, + 'class' => $class, + 'function' => $function ?? '(fora de método)', + 'insideCatch' => !empty($catchDepths), + ]; + } +} diff --git a/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php b/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php index 26ae025..b276c40 100644 --- a/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php +++ b/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php @@ -4,8 +4,10 @@ use Carbon\Carbon; use PHPUnit\Framework\TestCase; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\MultiPaymentException; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; class RefundNotSupportedExceptionTest extends TestCase { @@ -14,6 +16,92 @@ public function testExtendsThePackageBaseException(): void $this->assertInstanceOf(MultiPaymentException::class, RefundNotSupportedException::boletoNoRefund('iugu')); } + /** + * A classe fica fora da árvore de `UnsupportedOperationException`: `instanceof` e + * `is_subclass_of` respondem falso. + */ + public function testIsNotAnUnsupportedOperation(): void + { + $this->assertNotInstanceOf(UnsupportedOperationException::class, RefundNotSupportedException::boletoNoRefund('iugu')); + $this->assertFalse(is_subclass_of(RefundNotSupportedException::class, UnsupportedOperationException::class)); + } + + public function testIsCaughtByItsOwnNameAndNotByUnsupportedOperation(): void + { + $caught = []; + + try { + throw RefundNotSupportedException::boletoNoRefund('stripe'); + } catch (RefundNotSupportedException $e) { + $caught[] = 'refund'; + } + + try { + throw RefundNotSupportedException::alreadyRefunded('stripe', 'pix'); + } catch (UnsupportedOperationException $e) { + $caught[] = 'unsupported'; + } catch (MultiPaymentException $e) { + $caught[] = 'base'; + } + + $this->assertSame(['refund', 'base'], $caught); + } + + public function testCapabilityLimitationsCarryTheCapabilityAndTheGateway(): void + { + $boleto = RefundNotSupportedException::boletoNoRefund('iugu'); + $pix = RefundNotSupportedException::pixPartialNotSupported('iugu', 500, 1000); + + $this->assertTrue($boleto->isCapabilityLimitation()); + $this->assertSame(Capability::REFUND_BANK_SLIP, $boleto->capability); + $this->assertSame('iugu', $boleto->gateway); + $this->assertTrue($pix->isCapabilityLimitation()); + $this->assertSame(Capability::PARTIAL_REFUND_PIX, $pix->capability); + } + + public function testStateRefusalsHaveNoCapability(): void + { + $refusals = [ + RefundNotSupportedException::alreadyRefunded('stripe', 'pix'), + RefundNotSupportedException::amountExceedsRefundable('stripe', 'credit_card', 11000, 10000), + RefundNotSupportedException::refundWindowExpired('iugu', 'pix', Carbon::parse('2026-05-01'), 90), + ]; + + foreach ($refusals as $refusal) { + $this->assertFalse($refusal->isCapabilityLimitation(), $refusal->reason); + $this->assertNull($refusal->capability, $refusal->reason); + } + $this->assertSame('stripe', $refusals[0]->gateway); + } + + public function testHttpStatusIsAlwaysNull(): void + { + $this->assertNull(RefundNotSupportedException::boletoNoRefund('iugu')->httpStatus); + $this->assertNull(RefundNotSupportedException::alreadyRefunded('stripe', 'pix')->httpStatus); + } + + /** + * O construtor de cinco argumentos continua aceito; gateway e capability ficam vazios. + */ + public function testKeepsThePreviousConstructorSignature(): void + { + $previous = new \RuntimeException('sdk'); + $exception = new RefundNotSupportedException('msg', 'pix', RefundNotSupportedException::REASON_ALREADY_REFUNDED, false, $previous); + + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame('', $exception->gateway); + $this->assertNull($exception->capability); + $this->assertFalse($exception->isCapabilityLimitation()); + $this->assertSame('already_refunded', $exception->reason); + } + + public function testIsNotImplementedIsDeprecatedAndAlwaysFalse(): void + { + $this->expectUserDeprecationMessage('RefundNotSupportedException::isNotImplemented() está obsoleto desde 2026-09-02 e responde sempre falso'); + + $this->assertFalse(RefundNotSupportedException::boletoNoRefund('iugu')->isNotImplemented()); + } + public function testBoletoNoRefundRequiresManualRefund(): void { $exception = RefundNotSupportedException::boletoNoRefund('stripe'); diff --git a/tests/Unit/Exceptions/UnsupportedOperationExceptionTest.php b/tests/Unit/Exceptions/UnsupportedOperationExceptionTest.php index 597176f..e2926d7 100644 --- a/tests/Unit/Exceptions/UnsupportedOperationExceptionTest.php +++ b/tests/Unit/Exceptions/UnsupportedOperationExceptionTest.php @@ -7,7 +7,6 @@ use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Exceptions\MultiPaymentException; -use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; class UnsupportedOperationExceptionTest extends TestCase @@ -80,62 +79,4 @@ public function testForGatewayReadsTheReasonFromTheDeclaration(): void $this->assertStringEndsWith(' Detalhe.', $limitation->getMessage()); } - public function testRefundNotSupportedIsAnUnsupportedOperationWithItsOwnReason(): void - { - $exception = RefundNotSupportedException::boletoNoRefund('iugu'); - - $this->assertInstanceOf(UnsupportedOperationException::class, $exception); - $this->assertSame(Capability::REFUND_BANK_SLIP, $exception->capability); - $this->assertSame('iugu', $exception->gateway); - $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $exception->reason); - $this->assertSame('bank_slip', $exception->paymentMethod); - $this->assertTrue($exception->manualRefundRequired); - $this->assertFalse($exception->isNotImplemented()); - } - - public function testRefundNotSupportedCapabilityPerReason(): void - { - $this->assertSame( - Capability::PARTIAL_REFUND_PIX, - RefundNotSupportedException::pixPartialNotSupported('iugu', 500, 1000)->capability - ); - $this->assertNull(RefundNotSupportedException::alreadyRefunded('stripe', 'pix')->capability); - $this->assertNull( - RefundNotSupportedException::refundWindowExpired('iugu', 'pix', \Carbon\Carbon::parse('2026-05-01'), 90)->capability - ); - $this->assertSame('stripe', RefundNotSupportedException::alreadyRefunded('stripe', 'pix')->gateway); - } - - /** - * O construtor de cinco argumentos continua aceito; gateway e capability ficam vazios. - */ - public function testRefundNotSupportedKeepsThePreviousConstructorSignature(): void - { - $previous = new \RuntimeException('sdk'); - $exception = new RefundNotSupportedException('msg', 'pix', RefundNotSupportedException::REASON_ALREADY_REFUNDED, false, $previous); - - $this->assertSame($previous, $exception->getPrevious()); - $this->assertSame('', $exception->gateway); - $this->assertNull($exception->capability); - $this->assertSame('already_refunded', $exception->reason); - } - - public function testRefundNotSupportedIsCaughtByBothNames(): void - { - $caught = []; - - try { - throw RefundNotSupportedException::boletoNoRefund('stripe'); - } catch (RefundNotSupportedException $e) { - $caught[] = 'refund'; - } - - try { - throw RefundNotSupportedException::boletoNoRefund('stripe'); - } catch (UnsupportedOperationException $e) { - $caught[] = 'unsupported'; - } - - $this->assertSame(['refund', 'unsupported'], $caught); - } } diff --git a/tests/Unit/Gateways/GatewayCapabilitiesTest.php b/tests/Unit/Gateways/GatewayCapabilitiesTest.php index 1e31e2d..974c653 100644 --- a/tests/Unit/Gateways/GatewayCapabilitiesTest.php +++ b/tests/Unit/Gateways/GatewayCapabilitiesTest.php @@ -7,6 +7,8 @@ use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; use Potelo\MultiPayment\Enums\Capability; +use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\MultiPayment; use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Contracts\PlanContract; @@ -75,6 +77,7 @@ public static function matrixProvider(): array Capability::PARTIAL_REFUND_PIX->name => [self::LIMITATION, self::SUPPORTED], Capability::REFUND_BANK_SLIP->name => [self::LIMITATION, self::LIMITATION], Capability::INVOICE_DUPLICATION->name => [self::SUPPORTED, self::SUPPORTED], + Capability::INVOICE_CANCELLATION->name => [self::SUPPORTED, self::SUPPORTED], Capability::IDEMPOTENCY->name => [self::SUPPORTED, self::SUPPORTED], Capability::IDEMPOTENCY_ALL_ENDPOINTS->name => [self::LIMITATION, self::SUPPORTED], Capability::SUBSCRIPTIONS->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], @@ -146,15 +149,118 @@ public function testReadmeContainsTheGeneratedTable(): void $this->assertStringContainsString($table, $readme, 'README desatualizado: rode `composer capabilities:table` e cole a saída na seção Capabilities'); } - public function testTableHasOneRowPerCapabilityAndOneColumnPerGateway(): void + public function testTableHasOneRowPerCapabilityOneColumnPerGatewayAndARestrictionsColumn(): void { $table = CapabilitiesTable::markdown(['iugu' => self::driver('iugu'), 'stripe' => self::driver('stripe')]); $lines = explode("\n", trim($table)); - $this->assertSame('| Capability | Significado | Iugu | Stripe |', $lines[0]); - $this->assertSame('|---|---|---|---|', $lines[1]); + $this->assertSame('| Capability | Significado | Iugu | Stripe | Restrições |', $lines[0]); + $this->assertSame('|---|---|---|---|---|', $lines[1]); $this->assertCount(count(Capability::cases()) + 2, $lines); - $this->assertStringStartsWith('| `CREDIT_CARD` | Fatura paga com cartão de crédito. | sim | sim |', $lines[2]); + $this->assertStringStartsWith('| `CREDIT_CARD` | Fatura paga com cartão de crédito. | sim | sim | Stripe: ', $lines[2]); + $this->assertStringEndsWith('| sim | sim | |', $lines[3], 'PIX não tem restrição em nenhum gateway'); + } + + /** + * Cada restrição declarada aparece na coluna com o nome do gateway; capability sem restrição + * deixa a célula vazia. + */ + public function testRestrictionsCellListsEveryGatewayThatRestrictsTheCapability(): void + { + $gateways = ['iugu' => self::driver('iugu'), 'stripe' => self::driver('stripe')]; + + $this->assertSame('', CapabilitiesTable::restrictionsCell($gateways, Capability::PIX)); + $this->assertStringStartsWith('Iugu: ', CapabilitiesTable::restrictionsCell($gateways, Capability::INSTALLMENTS)); + $this->assertStringStartsWith('Stripe: ', CapabilitiesTable::restrictionsCell($gateways, Capability::INVOICE_DUPLICATION)); + } + + #[DataProvider('restrictionProvider')] + public function testDriverDeclaresTheExpectedRestriction(string $gateway, Capability $capability, ?array $expected): void + { + $restriction = self::driver($gateway)->restriction($capability); + + if (is_null($expected)) { + $this->assertNull($restriction); + + return; + } + + $this->assertNotNull($restriction); + $this->assertNotSame('', $restriction->description); + $this->assertSame($expected['payment_methods'] ?? null, $restriction->allowedPaymentMethods); + $this->assertSame($expected['brands'] ?? null, $restriction->allowedBrands); + $this->assertSame($expected['max_installments'] ?? null, $restriction->maxInstallments); + } + + public static function restrictionProvider(): array + { + return [ + 'iugu parcelamento' => ['iugu', Capability::INSTALLMENTS, ['max_installments' => 12]], + 'iugu cartão sem restrição' => ['iugu', Capability::CREDIT_CARD, null], + 'iugu duplicação sem restrição' => ['iugu', Capability::INVOICE_DUPLICATION, null], + 'stripe bandeiras' => ['stripe', Capability::CREDIT_CARD, ['brands' => ['visa', 'mastercard']]], + 'stripe duplicação só pix' => ['stripe', Capability::INVOICE_DUPLICATION, ['payment_methods' => [PaymentMethod::PIX]]], + 'stripe cancelamento de rascunho' => ['stripe', Capability::INVOICE_CANCELLATION, []], + 'stripe pix sem restrição' => ['stripe', Capability::PIX, null], + ]; + } + + /** + * Uma restrição só faz sentido sobre uma capability suportada: célula "não implementado" ou + * "limitação do gateway" não pode ter restrição. + */ + #[DataProvider('driverProvider')] + public function testRestrictionsOnlyCoverSupportedCapabilities(string $gateway): void + { + $driver = self::driver($gateway); + + foreach ($driver->restrictions() as $value => $restriction) { + $capability = Capability::from($value); + $this->assertTrue($driver->supports($capability), "{$gateway} restringe {$capability->name} sem suportá-la"); + $this->assertEquals($restriction, $driver->restriction($capability)); + } + } + + /** + * O máximo de parcelas da Iugu vem da configuração da conta, com 12 como padrão. + */ + public function testIuguMaxInstallmentsComesFromTheConfiguration(): void + { + Facade::getFacadeApplication()['config']->set('multi-payment.gateways.iugu.max_installments', 6); + + $restriction = self::driver('iugu')->restriction(Capability::INSTALLMENTS); + + $this->assertSame(6, $restriction->maxInstallments); + $this->assertStringContainsString('até 6', $restriction->description); + } + + #[DataProvider('driverProvider')] + public function testSupportsAllRequiresEveryCapability(string $gateway): void + { + $driver = self::driver($gateway); + + $this->assertTrue($driver->supportsAll()); + $this->assertTrue($driver->supportsAll(Capability::CREDIT_CARD, Capability::PIX)); + $this->assertFalse($driver->supportsAll(Capability::CREDIT_CARD, Capability::REFUND_BANK_SLIP)); + } + + /** + * A fachada expõe `supportsAll()`, `restriction()` e `restrictions()` do gateway. + */ + public function testTheFacadeExposesSupportsAllAndTheRestrictions(): void + { + Facade::getFacadeApplication()['config']->set('multi-payment.gateways.iugu.class', IuguGateway::class); + Facade::getFacadeApplication()['config']->set('multi-payment.gateways.stripe.class', StripeGateway::class); + + $payment = new MultiPayment('stripe'); + + $this->assertTrue($payment->supportsAll(Capability::CREDIT_CARD, Capability::PIX)); + $this->assertFalse($payment->supportsAll(Capability::CREDIT_CARD, Capability::BANK_SLIP)); + $this->assertSame(['visa', 'mastercard'], $payment->restriction(Capability::CREDIT_CARD)->allowedBrands); + $this->assertNull($payment->restriction(Capability::PIX)); + $this->assertSame(12, $payment->restriction(Capability::INSTALLMENTS, 'iugu')->maxInstallments); + $this->assertArrayHasKey(Capability::INVOICE_DUPLICATION->value, $payment->restrictions()); + $this->assertArrayHasKey(Capability::INSTALLMENTS->value, $payment->restrictions('iugu')); } private static function driver(string $gateway): GatewayContract&DeclaresCapabilities diff --git a/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php index b352a69..d878b64 100644 --- a/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php +++ b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php @@ -187,7 +187,7 @@ public static function storeEndpointProvider(): array 'PUT', '/invoices/inv_1/cancel', ], 'refundInvoice (operação inteira guardada: nem a leitura prévia se repete)' => [ - fn (IuguGateway $g, string $key) => $g->refundInvoice(self::invoiceWithId(), $key), + fn (IuguGateway $g, string $key) => $g->refundInvoice(self::invoiceWithId(), null, $key), [$paidCardInvoice, $refundedInvoice], 'POST', '/invoices/inv_1/refund', ], @@ -333,14 +333,48 @@ public function testRefundRetryReturnsTheStoredRefundWithoutReadingTheInvoiceAga $api = new QueuedIuguApiRequest([$paid, $refunded]); $gateway = new IuguGateway($api, new InMemoryIdempotencyStore()); - $first = $gateway->refundInvoice(self::invoiceWithId(), 'chave-1'); - $second = $gateway->refundInvoice(self::invoiceWithId(), 'chave-1'); + $first = $gateway->refundInvoice(self::invoiceWithId(), null, 'chave-1'); + $second = $gateway->refundInvoice(self::invoiceWithId(), null, 'chave-1'); $this->assertCount(2, $api->calls); $this->assertSame(10000, $first->amount); $this->assertSame($first, $second); } + /** + * O caminho antigo (valor escrito em `refundedAmount`) também vale com chave: o valor é + * resolvido antes de a operação entrar na store, e o retry devolve o mesmo `Refund`. + */ + #[IgnoreDeprecations] + public function testRefundWithAKeyStillHonoursTheLegacyRefundedAmount(): void + { + $paid = self::pendingInvoiceResponse([ + 'status' => 'paid', + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'paid_cents' => 10000, + 'payment_method' => 'iugu_credit_card', + ]); + $partiallyRefunded = self::pendingInvoiceResponse([ + 'status' => 'partially_refunded', + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'paid_cents' => 7500, + 'refunded_cents' => 2500, + 'payment_method' => 'iugu_credit_card', + ]); + $api = new QueuedIuguApiRequest([$paid, $partiallyRefunded]); + $gateway = new IuguGateway($api, new InMemoryIdempotencyStore()); + $invoice = self::invoiceWithId(); + $invoice->refundedAmount = 2500; + + $first = $gateway->refundInvoice($invoice, null, 'chave-legada'); + $second = $gateway->refundInvoice(self::invoiceWithId(), null, 'chave-legada'); + + $this->assertCount(2, $api->calls); + $this->assertSame(['partial_value_refund_cents' => 2500], $api->calls[1]['data']); + $this->assertSame(2500, $first->amount); + $this->assertSame($first, $second); + } + public function testTheSameKeyOnAnotherOperationIsAConflict(): void { $api = new QueuedIuguApiRequest([ diff --git a/tests/Unit/Gateways/IuguGatewayRefundTest.php b/tests/Unit/Gateways/IuguGatewayRefundTest.php index c7a36fe..9a6b6c1 100644 --- a/tests/Unit/Gateways/IuguGatewayRefundTest.php +++ b/tests/Unit/Gateways/IuguGatewayRefundTest.php @@ -84,9 +84,7 @@ public function testPartialPixRefundThrowsBeforeTheNetwork(): void { $api = new QueuedIuguApiRequest([$this->paidPixInvoiceResponse()]); $invoice = $this->invoiceWithId(); - $invoice->refundedAmount = 5000; - - $exception = $this->refundExpectingRefusal($api, $invoice); + $exception = $this->refundExpectingRefusal($api, $invoice, 5000); $this->assertSame(RefundNotSupportedException::REASON_PIX_PARTIAL_NOT_SUPPORTED, $exception->reason); $this->assertSame(PaymentMethod::PIX->value, $exception->paymentMethod); @@ -137,9 +135,7 @@ public function testPixRefundOfTheFullPaidAmountIsSentAsIntegral(): void $this->paidPixInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), ]); $invoice = $this->invoiceWithId(); - $invoice->refundedAmount = 10000; - - $result = (new IuguGateway($api))->refundInvoice($invoice); + $result = (new IuguGateway($api))->refundInvoice($invoice, 10000); $this->assertSame([], $api->calls[1]['data']); $this->assertSame(10000, $result->amount); @@ -153,9 +149,7 @@ public function testPartialCardRefundSendsThePartialValue(): void $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), ]); $invoice = $this->invoiceWithId(); - $invoice->refundedAmount = 2500; - - $result = (new IuguGateway($api))->refundInvoice($invoice); + $result = (new IuguGateway($api))->refundInvoice($invoice, 2500); $this->assertSame('POST', $api->calls[1]['method']); $this->assertSame(['partial_value_refund_cents' => 2500], $api->calls[1]['data']); @@ -179,9 +173,7 @@ public function testRefundingTheExactRemainderOfAPartiallyRefundedInvoiceGoesAsI $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), ]); $invoice = $this->invoiceWithId(); - $invoice->refundedAmount = 7500; - - $result = (new IuguGateway($api))->refundInvoice($invoice); + $result = (new IuguGateway($api))->refundInvoice($invoice, 7500); $this->assertCount(2, $api->calls); $this->assertSame([], $api->calls[1]['data']); @@ -200,9 +192,7 @@ public function testSecondPartialRefundWithinTheRemainderGoesToTheGateway(): voi $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 4500, 'paid_cents' => 5500]), ]); $invoice = $this->invoiceWithId(); - $invoice->refundedAmount = 2000; - - $result = (new IuguGateway($api))->refundInvoice($invoice); + $result = (new IuguGateway($api))->refundInvoice($invoice, 2000); $this->assertSame(['partial_value_refund_cents' => 2000], $api->calls[1]['data']); $this->assertSame(2000, $result->amount); @@ -219,9 +209,7 @@ public function testSecondPartialRefundAboveTheRemainderThrowsBeforeTheNetwork() $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), ]); $invoice = $this->invoiceWithId(); - $invoice->refundedAmount = 8000; - - $exception = $this->refundExpectingRefusal($api, $invoice); + $exception = $this->refundExpectingRefusal($api, $invoice, 8000); $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); $this->assertSame(PaymentMethod::CREDIT_CARD->value, $exception->paymentMethod); @@ -230,7 +218,7 @@ public function testSecondPartialRefundAboveTheRemainderThrowsBeforeTheNetwork() $this->assertStringContainsString('8000', $exception->getMessage()); $this->assertStringContainsString('7500', $exception->getMessage()); $this->assertOnlyTheInvoiceWasRead($api); - $this->assertSame(8000, $invoice->refundedAmount); + $this->assertNull($invoice->refundedAmount, 'a leitura prévia não altera o model do chamador'); } /** @@ -245,42 +233,148 @@ public function testRefusedRefundLeavesTheCallerNestedObjectsUntouched(): void $invoice = $this->invoiceWithId(); $invoice->customer = new Customer(); $invoice->customer->name = 'Nome do chamador'; - $invoice->refundedAmount = 8000; - - $this->refundExpectingRefusal($api, $invoice); + $this->refundExpectingRefusal($api, $invoice, 8000); $this->assertSame('Nome do chamador', $invoice->customer->name); $this->assertNull($invoice->customer->id); } /** - * Model lido do gateway em `partially_refunded` carrega o acumulado em `refundedAmount`, e - * `refund()` sem alterar o valor reenvia o acumulado como novo estorno parcial. Para - * estornar o restante, o chamador limpa `refundedAmount` antes (documentado no README). + * Caminho antigo: escrever `refundedAmount` antes de estornar continua pedindo o estorno + * parcial desse valor, com aviso de deprecação, mesmo num model lido do gateway em que a + * propriedade trazia o acumulado. */ - public function testRefundOnAPartiallyRefundedModelReadFromTheGatewayResendsTheAccumulatedAmount(): void + public function testWritingRefundedAmountOnAModelReadFromTheGatewayStillRequestsThatPartialRefund(): void { $api = new QueuedIuguApiRequest([ $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), - $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 5000, 'paid_cents' => 5000]), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 4500, 'paid_cents' => 5500]), ]); $gateway = new IuguGateway($api); - $invoice = $gateway->getInvoice($this->invoiceWithId()); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->refundedAmount = 2000; $result = $gateway->refundInvoice($invoice); + $this->assertSame(['partial_value_refund_cents' => 2000], $api->calls[1]['data']); + $this->assertSame(2000, $result->amount); + $this->assertSame(4500, $invoice->refundedAmount); + $this->assertNull($invoice->requestedRefundAmount(), 'o estorno feito apaga o valor pedido pelo caminho antigo'); + } + + /** + * Caminho antigo num model montado só com o id: o valor escrito em `refundedAmount` vai + * como estorno parcial, com aviso de deprecação; zero continua sendo estorno do restante. + */ + public function testWritingRefundedAmountOnABareModelStillRequestsThatPartialRefund(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + ]); + $invoice = $this->invoiceWithId(); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->refundedAmount = 2500; + $this->assertSame(2500, $invoice->requestedRefundAmount()); + + $result = (new IuguGateway($api))->refundInvoice($invoice); + $this->assertSame(['partial_value_refund_cents' => 2500], $api->calls[1]['data']); $this->assertSame(2500, $result->amount); - $this->assertSame(5000, $invoice->refundedAmount); + } + + /** + * O argumento prevalece sobre o caminho antigo quando os dois são informados. + */ + public function testTheAmountArgumentPrevailsOverTheLegacyRefundedAmount(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 1000, 'paid_cents' => 9000]), + ]); + $invoice = $this->invoiceWithId(); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->refundedAmount = 2500; + $result = (new IuguGateway($api))->refundInvoice($invoice, 1000); + + $this->assertSame(['partial_value_refund_cents' => 1000], $api->calls[1]['data']); + $this->assertSame(1000, $result->amount); + } + + public function testZeroOrNegativeAmountIsRejectedBeforeAnyRequest(): void + { + $api = new QueuedIuguApiRequest([]); + + foreach ([0, -100] as $amount) { + try { + (new IuguGateway($api))->refundInvoice($this->invoiceWithId(), $amount); + $this->fail("Esperava ModelAttributeValidationException para {$amount}"); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('amount', $e->getMessage()); + } + } + + $this->assertSame([], $api->calls); + } + + /** + * `refundableAmount()` é `paid_cents`, que a Iugu devolve líquido do já estornado; um model + * que já traz `paidAmount` não paga requisição, um model só com o id lê a fatura. + */ + public function testRefundableAmountIsThePaidCentsAndReadsTheInvoiceOnlyWhenNeeded(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + $this->paidInvoiceResponse(['status' => 'pending', 'paid_at' => null, 'paid_cents' => 0, 'payment_method' => null]), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + ]); + $gateway = new IuguGateway($api); + + $this->assertSame(7500, $gateway->refundableAmount($this->invoiceWithId())); + $this->assertSame(0, $gateway->refundableAmount($this->invoiceWithId())); + $this->assertSame(0, $gateway->refundableAmount($this->invoiceWithId()), 'fatura não paga'); + $this->assertCount(3, $api->calls); + + $read = $gateway->getInvoice($this->invoiceWithId()); + $this->assertSame(7500, $gateway->refundableAmount($read)); + $preloaded = $this->invoiceWithId(); + $preloaded->paidAmount = 5000; + $this->assertSame(5000, $gateway->refundableAmount($preloaded)); + $this->assertCount(4, $api->calls, 'o model com paidAmount não custa requisição'); + } + + /** + * `refundableAmount()` é o restante aritmético: boleto pago devolve `paid_cents` mesmo sem + * estorno pela API; a recusa de boleto continua em `refundInvoice()`. + */ + public function testRefundableAmountOfAPaidBankSlipIsTheArithmeticRemainder(): void + { + $api = new QueuedIuguApiRequest([$this->paidInvoiceResponse([ + 'payment_method' => 'iugu_bank_slip', + 'payable_with' => 'bank_slip', + ])]); + $gateway = new IuguGateway($api); + + $this->assertSame(10000, $gateway->refundableAmount($this->invoiceWithId())); + $this->assertOnlyTheInvoiceWasRead($api); + } + + public function testRefundableAmountRequiresTheInvoiceId(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new IuguGateway(new QueuedIuguApiRequest([])))->refundableAmount(new Invoice()); } public function testFirstRefundAboveThePaidAmountThrowsBeforeTheNetwork(): void { $api = new QueuedIuguApiRequest([$this->paidInvoiceResponse()]); $invoice = $this->invoiceWithId(); - $invoice->refundedAmount = 10001; - - $exception = $this->refundExpectingRefusal($api, $invoice); + $exception = $this->refundExpectingRefusal($api, $invoice, 10001); $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); $this->assertOnlyTheInvoiceWasRead($api); @@ -294,9 +388,7 @@ public function testPixRefundAboveThePaidAmountIsRefusedAsExceeding(): void { $api = new QueuedIuguApiRequest([$this->paidPixInvoiceResponse()]); $invoice = $this->invoiceWithId(); - $invoice->refundedAmount = 15000; - - $exception = $this->refundExpectingRefusal($api, $invoice); + $exception = $this->refundExpectingRefusal($api, $invoice, 15000); $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); $this->assertSame(PaymentMethod::PIX->value, $exception->paymentMethod); @@ -304,7 +396,7 @@ public function testPixRefundAboveThePaidAmountIsRefusedAsExceeding(): void /** * Regressão: a leitura prévia não pode vazar para o model do chamador. Se o estorno falha, - * `refundedAmount` continua sendo o valor pedido, senão um retry viraria estorno integral. + * o model continua como o chamador o montou. */ public function testFailedRefundLeavesTheCallerModelUntouched(): void { @@ -313,15 +405,14 @@ public function testFailedRefundLeavesTheCallerModelUntouched(): void (object) ['errors' => 'Fatura não pode ser reembolsada'], ]); $invoice = $this->invoiceWithId(); - $invoice->refundedAmount = 2500; try { - (new IuguGateway($api))->refundInvoice($invoice); + (new IuguGateway($api))->refundInvoice($invoice, 2500); $this->fail('Esperava GatewayException'); } catch (GatewayException $e) { } - $this->assertSame(2500, $invoice->refundedAmount); + $this->assertNull($invoice->refundedAmount); $this->assertNull($invoice->status); $this->assertNull($invoice->paymentMethod); } @@ -330,11 +421,9 @@ public function testRefusedRefundLeavesTheCallerModelUntouched(): void { $api = new QueuedIuguApiRequest([$this->paidPixInvoiceResponse()]); $invoice = $this->invoiceWithId(); - $invoice->refundedAmount = 5000; - - $this->refundExpectingRefusal($api, $invoice); + $this->refundExpectingRefusal($api, $invoice, 5000); - $this->assertSame(5000, $invoice->refundedAmount); + $this->assertNull($invoice->refundedAmount); $this->assertNull($invoice->status); } @@ -353,9 +442,7 @@ public function testPixRefundByAmountWithoutPaidAmountReadsTheInvoiceFirst(): vo $invoice->paymentMethod = PaymentMethod::PIX; $invoice->status = InvoiceStatus::PAID; $invoice->paidAt = Carbon::parse('2026-08-20'); - $invoice->refundedAmount = 10000; - - $result = (new IuguGateway($api))->refundInvoice($invoice); + $result = (new IuguGateway($api))->refundInvoice($invoice, 10000); $this->assertCount(2, $api->calls); $this->assertSame('GET', $api->calls[0]['method']); @@ -484,9 +571,9 @@ public function testInvoiceReadFromTheGatewayDoesNotPayTheExtraGet(): void } /** - * Regressão: model lido do gateway, já parcialmente estornado, com `refundedAmount` limpo - * pelo chamador para pedir o restante. Sem leitura prévia, o valor do `Refund` vem do - * `paid_cents` que o model trazia. + * Model lido do gateway, já parcialmente estornado: `refundInvoice()` sem valor estorna o + * restante (o acumulado em `refundedAmount` é só leitura e não vira pedido). Sem leitura + * prévia, o valor do `Refund` vem do `paid_cents` que o model trazia. */ public function testIntegralRefundOfAPartiallyRefundedInvoiceReadFromTheGatewayReportsTheRemainder(): void { @@ -497,7 +584,6 @@ public function testIntegralRefundOfAPartiallyRefundedInvoiceReadFromTheGatewayR $gateway = new IuguGateway($api); $invoice = $gateway->getInvoice($this->invoiceWithId()); - $invoice->refundedAmount = null; $result = $gateway->refundInvoice($invoice); $this->assertCount(2, $api->calls); @@ -634,10 +720,10 @@ private function invoiceWithId(): Invoice return $invoice; } - private function refundExpectingRefusal(QueuedIuguApiRequest $api, Invoice $invoice): RefundNotSupportedException + private function refundExpectingRefusal(QueuedIuguApiRequest $api, Invoice $invoice, ?int $amount = null): RefundNotSupportedException { try { - (new IuguGateway($api))->refundInvoice($invoice); + (new IuguGateway($api))->refundInvoice($invoice, $amount); } catch (RefundNotSupportedException $e) { return $e; } diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index 3807ee9..6daf4bb 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -1348,8 +1348,8 @@ public function testInvalidIntervalIsRejectedBeforeTheRequest(PlanInterval|strin try { (new IuguGateway($api))->createPlan($plan); - $this->fail('Expected GatewayException'); - } catch (GatewayException $e) { + $this->fail('Expected ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { $this->assertMatchesRegularExpression($message, $e->getMessage()); } @@ -1760,7 +1760,7 @@ public function testNextBillingAtAndTrialEndsAtTogetherAreRejected(): void 'trial_ends_at' => '2026-09-15', ]); - $this->expectException(GatewayException::class); + $this->expectException(ModelAttributeValidationException::class); $this->expectExceptionMessageMatches('/same field/'); (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); @@ -1901,7 +1901,7 @@ public function testPaginationBoundsAreRejected(int $page, int $limit, string $m $customer->id = 'cus_1'; $gateway = new IuguGateway(new QueuedIuguApiRequest([])); - $this->expectException(GatewayException::class); + $this->expectException(ModelAttributeValidationException::class); $this->expectExceptionMessageMatches($mensagem); $gateway->listSubscriptions($customer, $page, $limit); @@ -1912,7 +1912,7 @@ public function testPlanPaginationBoundsAreRejected(int $page, int $limit, strin { $gateway = new IuguGateway(new QueuedIuguApiRequest([])); - $this->expectException(GatewayException::class); + $this->expectException(ModelAttributeValidationException::class); $this->expectExceptionMessageMatches(str_replace('Subscription', 'Plan', $mensagem)); $gateway->listPlans($page, $limit); @@ -2090,7 +2090,7 @@ public function testDivergingNextBillingAndTrialEndAreStillRejected(): void 'trial_ends_at' => '2026-09-15', ]); - $this->expectException(GatewayException::class); + $this->expectException(ModelAttributeValidationException::class); $this->expectExceptionMessageMatches('/different dates/'); (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); @@ -2896,7 +2896,7 @@ public function testTrialDaysConflictingWithNextBillingAtIsRejectedBeforeTheNetw $subscription = new Subscription(); $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1'], 'trial_days' => 7, 'next_billing_at' => '2026-10-01']); - $this->expectException(GatewayException::class); + $this->expectException(ModelAttributeValidationException::class); $this->expectExceptionMessageMatches('/trialEndsAt \(or trialDays\)/'); (new IuguGateway($api))->createSubscription($subscription); diff --git a/tests/Unit/Gateways/StripeGatewayCreditCardTest.php b/tests/Unit/Gateways/StripeGatewayCreditCardTest.php index 88a191c..670a91e 100644 --- a/tests/Unit/Gateways/StripeGatewayCreditCardTest.php +++ b/tests/Unit/Gateways/StripeGatewayCreditCardTest.php @@ -152,10 +152,14 @@ public function testGetCreditCardValidatesOwnershipWhenCustomerIsInformed(): voi $creditCard = $this->creditCardModel(); $creditCard->id = 'pm_fake123'; - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('does not belong to customer'); - - (new StripeGateway())->getCreditCard($creditCard); + try { + (new StripeGateway())->getCreditCard($creditCard); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertStringContainsString('does not belong to customer', $e->getMessage()); + $this->assertSame(Capability::CREDIT_CARD, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + } } public function testGetCreditCardSkipsOwnershipCheckWhenCustomerOmitted(): void diff --git a/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php index f51c56b..72678fc 100644 --- a/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php +++ b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php @@ -91,7 +91,7 @@ public function testRefundRetryOnARefundedInvoiceReplaysTheOriginalRefund(): voi $refunded, ]); - $refund = (new StripeGateway())->refundInvoice(self::invoiceWithId(), 'chave-1'); + $refund = (new StripeGateway())->refundInvoice(self::invoiceWithId(), null, 'chave-1'); $this->assertSame('re_original', $refund->id); $this->assertSame('chave-1', $httpClient->header(1, 'Idempotency-Key')); @@ -132,7 +132,7 @@ public function testRefundRetryRefusedByStripeSurfacesTheGuardRefusal(): void ]); try { - (new StripeGateway())->refundInvoice(self::invoiceWithId(), 'outra-chave'); + (new StripeGateway())->refundInvoice(self::invoiceWithId(), null, 'outra-chave'); $this->fail('Esperava RefundNotSupportedException'); } catch (RefundNotSupportedException $e) { $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $e->reason); @@ -190,7 +190,7 @@ public function testDeleteOfADetachedCardWithoutAKeyIsRefused(): void $creditCard = self::creditCardModel(); $creditCard->id = 'pm_fake123'; - $this->expectException(GatewayException::class); + $this->expectException(UnsupportedOperationException::class); $this->expectExceptionMessageMatches('/does not belong/'); (new StripeGateway())->deleteCreditCard($creditCard); @@ -321,7 +321,7 @@ function (StripeGateway $g, ?string $key) { ['post /v1/payment_intents' => 'chave-1'], ], 'refundInvoice' => [ - fn (StripeGateway $g, ?string $key) => $g->refundInvoice(self::invoiceWithId(), $key), + fn (StripeGateway $g, ?string $key) => $g->refundInvoice(self::invoiceWithId(), null, $key), [ self::paidCardPaymentIntentResponse(), ['id' => 're_fake123', 'object' => 'refund', 'amount' => 12345, 'status' => 'pending', 'created' => 1786700100, 'reason' => null], diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index d6bcf45..eb10b62 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -928,10 +928,15 @@ public function testChargeInvoiceRejectsCardFromAnotherCustomer(): void $invoice->creditCard = new CreditCard(); $invoice->creditCard->id = 'pm_fake123'; - $this->expectException(GatewayException::class); - $this->expectExceptionMessage('does not belong to customer'); - - (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + try { + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertStringContainsString('does not belong to customer', $e->getMessage()); + $this->assertSame(Capability::CREDIT_CARD, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertNull($e->httpStatus); + } } public function testChargeInvoiceWithLegacyTokenConvertsItIntoPaymentMethod(): void @@ -1105,8 +1110,7 @@ public function testRefundsInvoicePartially(): void $invoice = new Invoice(); $invoice->id = 'pi_fake123'; - $invoice->refundedAmount = 2345; - $result = (new StripeGateway())->refundInvoice($invoice); + $result = (new StripeGateway())->refundInvoice($invoice, 2345); $this->assertSame( ['payment_intent' => 'pi_fake123', 'amount' => 2345], @@ -1210,8 +1214,7 @@ public function testPartialPixRefundGoesToTheGateway(): void $invoice = new Invoice(); $invoice->id = 'pi_fake123'; $invoice->paymentMethod = PaymentMethod::PIX; - $invoice->refundedAmount = 2345; - $result = (new StripeGateway())->refundInvoice($invoice); + $result = (new StripeGateway())->refundInvoice($invoice, 2345); $this->assertSame([ 'get /v1/payment_intents/pi_fake123', @@ -1241,8 +1244,7 @@ public function testPaidInvoiceReadFromTheGatewayDoesNotPayTheExtraGet(): void $gateway = new StripeGateway(); $invoice = $gateway->getInvoice($this->invoiceWithId()); - $invoice->refundedAmount = 2345; - $result = $gateway->refundInvoice($invoice); + $result = $gateway->refundInvoice($invoice, 2345); $this->assertSame([ 'get /v1/payment_intents/pi_fake123', @@ -1254,8 +1256,8 @@ public function testPaidInvoiceReadFromTheGatewayDoesNotPayTheExtraGet(): void /** * Fatura parcialmente estornada aceita novo estorno até o restante. Com o status fora de - * `PAID` o driver relê a fatura mesmo com o model preenchido, porque o acumulado que o - * chamador tinha em `refundedAmount` foi sobrescrito pelo valor pedido. + * `PAID` e sem o acumulado em `refundedAmount`, o driver relê a fatura para conhecer o + * restante. */ public function testSecondPartialRefundWithinTheRemainderGoesToTheGateway(): void { @@ -1274,8 +1276,7 @@ public function testSecondPartialRefundWithinTheRemainderGoesToTheGateway(): voi $invoice->paymentMethod = PaymentMethod::CREDIT_CARD; $invoice->status = InvoiceStatus::PARTIALLY_REFUNDED; $invoice->paidAmount = 12345; - $invoice->refundedAmount = 5000; - $result = (new StripeGateway())->refundInvoice($invoice); + $result = (new StripeGateway())->refundInvoice($invoice, 5000); $this->assertSame([ 'get /v1/payment_intents/pi_fake123', @@ -1299,10 +1300,9 @@ public function testRefundAboveThePaidAmountOnAModelReadFromTheGatewayThrowsWith $gateway = new StripeGateway(); $invoice = $gateway->getInvoice($this->invoiceWithId()); - $invoice->refundedAmount = 12346; try { - $gateway->refundInvoice($invoice); + $gateway->refundInvoice($invoice, 12346); $this->fail('Esperava RefundNotSupportedException'); } catch (RefundNotSupportedException $e) { $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $e->reason); @@ -1326,9 +1326,7 @@ public function testRefusedRefundLeavesTheCallerNestedObjectsUntouched(): void $invoice->customer = new Customer(); $invoice->customer->name = 'Nome do chamador'; $invoice->creditCard = new CreditCard(); - $invoice->refundedAmount = 11000; - - $this->refundExpectingRefusal($invoice); + $this->refundExpectingRefusal($invoice, 11000); $this->assertSame('Nome do chamador', $invoice->customer->name); $this->assertNull($invoice->customer->id); @@ -1336,11 +1334,73 @@ public function testRefusedRefundLeavesTheCallerNestedObjectsUntouched(): void } /** - * Model lido do gateway em `partially_refunded` carrega o acumulado em `refundedAmount`, e - * `refund()` sem alterar o valor reenvia o acumulado como novo estorno parcial. Para - * estornar o restante, o chamador limpa `refundedAmount` antes (documentado no README). + * Model lido do gateway em `partially_refunded`: `refundInvoice()` sem valor estorna o + * restante, sem enviar `amount` (o acumulado em `refundedAmount` é só leitura). + */ + public function testRefundWithoutAmountOnAPartiallyRefundedModelReadFromTheGatewayRefundsTheRemainder(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([ + $partiallyRefunded, + $this->refundResponse('re_fake456', 10000, 'succeeded'), + $refunded, + ]); + $gateway = new StripeGateway(); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $result = $gateway->refundInvoice($invoice); + + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'post /v1/refunds', + 'get /v1/payment_intents/pi_fake123', + ], $this->calledPaths($httpClient)); + $this->assertSame(['payment_intent' => 'pi_fake123'], $httpClient->calls[1][2]); + $this->assertSame(10000, $result->amount); + $this->assertSame(InvoiceStatus::REFUNDED, $invoice->status); + $this->assertSame(12345, $invoice->refundedAmount); + } + + /** + * Model lido do gateway em `partially_refunded` traz o acumulado confiável, então o estorno + * por valor não paga o GET extra: o restante é `paidAmount` menos `refundedAmount`. + */ + public function testPartialRefundOnAPartiallyRefundedModelReadFromTheGatewayDoesNotPayTheExtraGet(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $refundedTwice = $this->paidCardPaymentIntentResponse(); + $refundedTwice['latest_charge']['amount_refunded'] = 7345; + $httpClient = RecordingStripeHttpClient::withResponses([ + $partiallyRefunded, + $this->refundResponse('re_fake456', 5000, 'succeeded'), + $refundedTwice, + ]); + $gateway = new StripeGateway(); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $result = $gateway->refundInvoice($invoice, 5000); + + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'post /v1/refunds', + 'get /v1/payment_intents/pi_fake123', + ], $this->calledPaths($httpClient)); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 5000], $httpClient->calls[1][2]); + $this->assertSame(5000, $result->amount); + $this->assertSame(7345, $invoice->refundedAmount); + } + + /** + * Caminho antigo: escrever `refundedAmount` num model lido do gateway continua pedindo o + * estorno parcial desse valor, com aviso de deprecação; como o acumulado deixou de ser + * confiável, o driver relê a fatura antes. */ - public function testRefundOnAPartiallyRefundedModelReadFromTheGatewayResendsTheAccumulatedAmount(): void + public function testWritingRefundedAmountOnAModelReadFromTheGatewayStillRequestsThatPartialRefund(): void { $partiallyRefunded = $this->paidCardPaymentIntentResponse(); $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; @@ -1353,14 +1413,116 @@ public function testRefundOnAPartiallyRefundedModelReadFromTheGatewayResendsTheA $refundedTwice, ]); $gateway = new StripeGateway(); - $invoice = $gateway->getInvoice($this->invoiceWithId()); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->refundedAmount = 2345; $result = $gateway->refundInvoice($invoice); $this->assertSame('post /v1/refunds', $this->calledPaths($httpClient)[2]); $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 2345], $httpClient->calls[2][2]); $this->assertSame(2345, $result->amount); $this->assertSame(4690, $invoice->refundedAmount); + $this->assertNull($invoice->requestedRefundAmount()); + } + + /** + * Model em `PAID` com `paidAmount` e sem acumulado estornado é confiável: o restante é o + * valor pago, e o estorno por valor não relê a fatura. + */ + public function testPartialRefundOnAPaidModelWithThePaidAmountInHandDoesNotReadTheInvoiceFirst(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->refundResponse('re_fake456', 2345, 'succeeded'), + $partiallyRefunded, + ]); + $invoice = $this->invoiceWithId(); + $invoice->paymentMethod = PaymentMethod::CREDIT_CARD; + $invoice->status = InvoiceStatus::PAID; + $invoice->paidAmount = 12345; + + $result = (new StripeGateway())->refundInvoice($invoice, 2345); + + $this->assertSame(['post /v1/refunds', 'get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient)); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 2345], $httpClient->calls[0][2]); + $this->assertSame(2345, $result->amount); + } + + public function testRefundAboveThePaidAmountOnAPaidModelWithThePaidAmountInHandIsRefusedWithoutAnyRequest(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->invoiceWithId(); + $invoice->paymentMethod = PaymentMethod::CREDIT_CARD; + $invoice->status = InvoiceStatus::PAID; + $invoice->paidAmount = 12345; + + $exception = $this->refundExpectingRefusal($invoice, 12346); + + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); + $this->assertSame([], $httpClient->calls); + } + + public function testZeroOrNegativeAmountIsRejectedBeforeAnyRequest(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + foreach ([0, -1] as $amount) { + try { + (new StripeGateway())->refundInvoice($this->invoiceWithId(), $amount); + $this->fail("Esperava ModelAttributeValidationException para {$amount}"); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('amount', $e->getMessage()); + } + } + + $this->assertSame([], $httpClient->calls); + } + + /** + * `refundableAmount()` é `paidAmount` menos `refundedAmount` (o valor pago da Stripe vem + * bruto); um model lido do gateway não paga requisição, um model só com o id lê a fatura, e + * um model com o acumulado escrito pelo caminho antigo relê a fatura. + */ + public function testRefundableAmountIsThePaidMinusTheRefundedAndReadsTheInvoiceOnlyWhenNeeded(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([ + $partiallyRefunded, + $partiallyRefunded, + $refunded, + $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'), + ]); + $gateway = new StripeGateway(); + + $read = $gateway->getInvoice($this->invoiceWithId()); + $this->assertSame(10000, $gateway->refundableAmount($read)); + $this->assertCount(1, $httpClient->calls, 'o model lido do gateway não custa requisição'); + + $this->assertSame(10000, $gateway->refundableAmount($this->invoiceWithId())); + $this->assertSame(0, $gateway->refundableAmount($this->invoiceWithId())); + $this->assertSame(0, $gateway->refundableAmount($this->invoiceWithId()), 'fatura não paga'); + $this->assertCount(4, $httpClient->calls); + } + + public function testRefundableAmountReReadsTheInvoiceWhenTheLegacyPathWroteTheRefundedAmount(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $httpClient = RecordingStripeHttpClient::withResponses([$partiallyRefunded, $partiallyRefunded]); + $gateway = new StripeGateway(); + $invoice = $gateway->getInvoice($this->invoiceWithId()); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->refundedAmount = 5000; + + $this->assertSame(10000, $gateway->refundableAmount($invoice)); + $this->assertCount(2, $httpClient->calls); } public function testSecondPartialRefundAboveTheRemainderThrowsBeforePosting(): void @@ -1371,8 +1533,7 @@ public function testSecondPartialRefundAboveTheRemainderThrowsBeforePosting(): v $invoice = new Invoice(); $invoice->id = 'pi_fake123'; - $invoice->refundedAmount = 11000; - $exception = $this->refundExpectingRefusal($invoice); + $exception = $this->refundExpectingRefusal($invoice, 11000); $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); $this->assertSame(PaymentMethod::CREDIT_CARD->value, $exception->paymentMethod); @@ -1380,7 +1541,7 @@ public function testSecondPartialRefundAboveTheRemainderThrowsBeforePosting(): v $this->assertStringContainsString('11000', $exception->getMessage()); $this->assertStringContainsString('10000', $exception->getMessage()); $this->assertSame(['get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient)); - $this->assertSame(11000, $invoice->refundedAmount, 'a leitura prévia não altera o model do chamador'); + $this->assertNull($invoice->refundedAmount, 'a leitura prévia não altera o model do chamador'); } public function testRefundingTheExactRemainderOfAPartiallyRefundedInvoiceGoesToTheGateway(): void @@ -1399,8 +1560,7 @@ public function testRefundingTheExactRemainderOfAPartiallyRefundedInvoiceGoesToT $invoice = new Invoice(); $invoice->id = 'pi_fake123'; $invoice->status = InvoiceStatus::PARTIALLY_REFUNDED; - $invoice->refundedAmount = 10000; - $result = (new StripeGateway())->refundInvoice($invoice); + $result = (new StripeGateway())->refundInvoice($invoice, 10000); $this->assertSame('post', $httpClient->calls[1][0]); $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 10000], $httpClient->calls[1][2]); @@ -1683,6 +1843,28 @@ public function testDuplicateRejectsPaidInvoice(): void } } + /** + * Fatura sem cliente no PaymentIntent é restrição de `INVOICE_DUPLICATION`: a recusa vem + * depois da leitura da original e antes de criar ou cancelar qualquer coisa. + */ + public function testDuplicateRejectsAnInvoiceWithoutCustomerAsARestriction(): void + { + $pendingPix = $this->pendingPixPaymentIntentResponse(); + $pendingPix['customer'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$pendingPix]); + + try { + (new StripeGateway())->duplicateInvoice($this->invoiceWithId(), Carbon::parse('2026-10-01')); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_DUPLICATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertNull($e->httpStatus); + $this->assertStringContainsString('has no customer', $e->getMessage()); + } + $this->assertSame(['get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient), 'a original não é cancelada'); + } + public function testDuplicateRejectsNonPixInvoice(): void { $response = $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'); @@ -1738,10 +1920,10 @@ private function invoiceWithId(): Invoice return $invoice; } - private function refundExpectingRefusal(Invoice $invoice): RefundNotSupportedException + private function refundExpectingRefusal(Invoice $invoice, ?int $amount = null): RefundNotSupportedException { try { - (new StripeGateway())->refundInvoice($invoice); + (new StripeGateway())->refundInvoice($invoice, $amount); } catch (RefundNotSupportedException $e) { return $e; } diff --git a/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php index f6999af..c90e37c 100644 --- a/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php @@ -648,10 +648,13 @@ public function testCancelInvoiceRefusesADraftWithoutVoiding(): void try { (new StripeGateway())->cancelInvoice($invoice); - $this->fail('Rascunho deveria lançar GatewayException'); - } catch (GatewayException $e) { + $this->fail('Rascunho deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { $this->assertStringContainsString('rascunho', $e->getMessage()); $this->assertStringContainsString('in_1UBHTnPjx0CusuMrjxjg8WhK', $e->getMessage()); + $this->assertSame(Capability::INVOICE_CANCELLATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertNull($e->httpStatus); } $this->assertSame(['get /v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK'], self::calledPaths($httpClient)); } @@ -750,6 +753,26 @@ public function testChargeInvoiceWithCreditCardOnAStripeInvoiceIsNotImplementedY $this->assertSame([], $httpClient->calls); } + /** + * `refundableAmount()` segue `refundInvoice()`: a fatura de assinatura é recusada antes de + * qualquer leitura, para o restante nunca prometer um estorno que o driver recusa. + */ + public function testRefundableAmountRefusesAStripeInvoiceBeforeAnyRequest(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + + try { + (new StripeGateway())->refundableAmount($invoice); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + $this->assertTrue($e->isNotImplemented()); + } + $this->assertSame([], $httpClient->calls); + } + private function getInvoice(string $id): Invoice { $invoice = new Invoice(); diff --git a/tests/Unit/IdempotencyKeyPropagationTest.php b/tests/Unit/IdempotencyKeyPropagationTest.php index 60f0046..ed829d9 100644 --- a/tests/Unit/IdempotencyKeyPropagationTest.php +++ b/tests/Unit/IdempotencyKeyPropagationTest.php @@ -81,8 +81,9 @@ public function testFacadeInvoiceOperationsPassTheKey(): void ], array_column($this->calls, 0)); [$refund, $cancel, $charge, $duplicate, $reschedule] = array_column($this->calls, 1); $this->assertSame('inv_1', $refund[0]->id); - $this->assertSame(500, $refund[0]->refundedAmount); - $this->assertSame('k-refund', $refund[1]); + $this->assertSame(500, $refund[1]); + $this->assertSame('k-refund', $refund[2]); + $this->assertNull($refund[0]->refundedAmount, 'o valor vai como argumento; refundedAmount é só de leitura'); $this->assertSame(['inv_1', 'k-cancel'], [$cancel[0]->id, $cancel[1]]); $this->assertSame(['tok_1', 'k-charge'], [$charge[0]->creditCard->token, $charge[1]]); $this->assertSame($expiresAt, $duplicate[1]); diff --git a/tests/Unit/InvoiceTest.php b/tests/Unit/InvoiceTest.php index 164e474..c780713 100644 --- a/tests/Unit/InvoiceTest.php +++ b/tests/Unit/InvoiceTest.php @@ -347,4 +347,96 @@ public function testBuilderSetExpiresAtIsADeprecatedAliasOfSetDueDate(): void $this->assertSame('2026-10-01', $invoice->dueDate->format('Y-m-d')); $this->assertNull($invoice->pixExpiresAt); } + + /** + * `refundedAmount` é só de leitura: os drivers a preenchem por `setRefundedAmountFromGateway()`, + * que não marca o valor como pedido de estorno. + */ + public function testRefundedAmountWrittenByTheDriverIsNotARefundRequest(): void + { + $invoice = new Invoice(); + $invoice->setRefundedAmountFromGateway(3000); + + $this->assertSame(3000, $invoice->refundedAmount); + $this->assertTrue(isset($invoice->refundedAmount)); + $this->assertNull($invoice->requestedRefundAmount()); + $this->assertNull($invoice->resolveRefundAmount(null)); + $this->assertSame(500, $invoice->resolveRefundAmount(500)); + $this->assertSame(['refunded_amount' => 3000], $invoice->toArray()); + $this->assertSame(3000, json_decode(json_encode($invoice), true)['refundedAmount']); + } + + /** + * Escrever em `refundedAmount` é o caminho antigo de pedir estorno parcial: o valor fica + * como pedido, com aviso de deprecação, e o argumento de `refund()` prevalece sobre ele. + */ + public function testWritingRefundedAmountIsTheDeprecatedWayOfRequestingAPartialRefund(): void + { + $invoice = new Invoice(); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->refundedAmount = 2500; + + $this->assertSame(2500, $invoice->refundedAmount); + $this->assertSame(2500, $invoice->requestedRefundAmount()); + $this->assertSame(2500, $invoice->resolveRefundAmount(null)); + $this->assertSame(1000, $invoice->resolveRefundAmount(1000)); + + $invoice->setRefundedAmountFromGateway(2500); + $this->assertNull($invoice->requestedRefundAmount(), 'a leitura do gateway apaga o pedido'); + } + + #[DataProvider('refundedAmountKeyProvider')] + public function testFillWithRefundedAmountFollowsTheDeprecatedPathInBothSpellings(string $key): void + { + $invoice = new Invoice(); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->fill(['id' => 'inv_1', $key => 700]); + + $this->assertSame('inv_1', $invoice->id); + $this->assertSame(700, $invoice->refundedAmount); + $this->assertSame(700, $invoice->requestedRefundAmount()); + } + + public static function refundedAmountKeyProvider(): array + { + return ['snake_case' => ['refunded_amount'], 'camelCase' => ['refundedAmount']]; + } + + /** + * A propriedade privada que guarda o pedido do caminho antigo é estado interno: a chave é + * desconhecida para `fill()`, como qualquer outra fora de `fillableKeys()`. + */ + public function testFillRejectsTheKeyOfThePrivateRequestedRefundAmount(): void + { + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`requested_refund_amount` key is unknown/'); + + (new Invoice())->fill(['requested_refund_amount' => 123]); + } + + #[IgnoreDeprecations] + public function testZeroWrittenInRefundedAmountMeansNoPartialRequest(): void + { + $invoice = new Invoice(); + $invoice->refundedAmount = 0; + + $this->assertSame(0, $invoice->refundedAmount); + $this->assertNull($invoice->requestedRefundAmount()); + } + + public function testResolveRefundAmountRejectsZeroAndNegative(): void + { + $invoice = new Invoice(); + + foreach ([0, -1] as $amount) { + try { + $invoice->resolveRefundAmount($amount); + $this->fail("Esperava ModelAttributeValidationException para {$amount}"); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('positive', $e->getMessage()); + } + } + } } diff --git a/tests/Unit/ModelFillTest.php b/tests/Unit/ModelFillTest.php index 44df2da..8f500b5 100644 --- a/tests/Unit/ModelFillTest.php +++ b/tests/Unit/ModelFillTest.php @@ -176,7 +176,7 @@ public function testFillableKeysAreTheSnakeCasePropertiesIncludingEnums(): void $this->assertSame(['description', 'price', 'quantity', 'gateway_options'], InvoiceItem::fillableKeys()); $keys = Invoice::fillableKeys(); - foreach (['id', 'status', 'amount', 'payment_method', 'available_payment_methods', 'origin_type', 'credit_card', 'due_date', 'pix_expires_at', 'gateway_options'] as $key) { + foreach (['id', 'status', 'amount', 'refunded_amount', 'payment_method', 'available_payment_methods', 'origin_type', 'credit_card', 'due_date', 'pix_expires_at', 'gateway_options'] as $key) { $this->assertContains($key, $keys); } // o nome antigo é alias, fora da lista, como `gateway_adicional_options` diff --git a/tests/Unit/MultiPaymentReadTest.php b/tests/Unit/MultiPaymentReadTest.php index ad70694..ad4212a 100644 --- a/tests/Unit/MultiPaymentReadTest.php +++ b/tests/Unit/MultiPaymentReadTest.php @@ -72,6 +72,30 @@ public function testGetSubscriptionReadsTheSubscriptionById(): void $this->assertSame('iugu', $subscription->gateway); } + /** + * `refundableAmount()` pela fachada lê a fatura e devolve o restante que o driver calcula. + */ + public function testRefundableAmountReadsTheInvoiceAndReturnsTheRemainder(): void + { + $api = (new QueuedIuguApiRequest([ + (object) [ + 'id' => 'inv_1', + 'status' => 'partially_refunded', + 'total_cents' => 10000, + 'paid_cents' => 7500, + 'refunded_cents' => 2500, + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'payment_method' => 'iugu_credit_card', + 'items' => [], + 'variables' => [], + ], + ]))->installAsSdkRequester(); + + $this->assertSame(7500, (new MultiPayment('iugu'))->refundableAmount('inv_1')); + $this->assertCount(1, $api->calls); + $this->assertStringEndsWith('/invoices/inv_1', $api->calls[0]['url']); + } + /** * O valor informado é buscado primeiro como identificador do plano. */ diff --git a/tests/Unit/RefundTest.php b/tests/Unit/RefundTest.php index a3ae3b8..0ed0d2d 100644 --- a/tests/Unit/RefundTest.php +++ b/tests/Unit/RefundTest.php @@ -168,7 +168,7 @@ public function testMultiPaymentRejectsAZeroOrNegativePartialValueBeforeAnyReque (new MultiPayment('iugu'))->refundInvoice('inv_1', $value); $this->fail("Esperava ModelAttributeValidationException para {$value}"); } catch (ModelAttributeValidationException $e) { - $this->assertStringContainsString('refundedAmount', $e->getMessage()); + $this->assertStringContainsString('amount', $e->getMessage()); } } diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php index 8e188fc..0bdb501 100644 --- a/tests/Unit/SubscriptionTest.php +++ b/tests/Unit/SubscriptionTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Potelo\MultiPayment\Models\Plan; use Potelo\MultiPayment\Models\Customer; +use Potelo\MultiPayment\MultiPayment; use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\InvoiceItem; @@ -19,6 +20,7 @@ use Potelo\MultiPayment\Models\SubscriptionPlanChange; use Potelo\MultiPayment\Enums\ProrationBehavior; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\ConfigurationException; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -671,7 +673,7 @@ public function testDomainMethodsRejectAGatewayWithoutTheSubscriptionsCapability /** * Gateway que declara a capability sem implementar o contract é erro de driver e chega como - * `GatewayException`. + * `ConfigurationException`, sem `httpStatus`. */ public function testGatewayDeclaringTheCapabilityWithoutTheContractIsADriverError(): void { @@ -681,7 +683,7 @@ public function testGatewayDeclaringTheCapabilityWithoutTheContractIsADriverErro $subscription = new Subscription(); $subscription->id = 'sub_1'; - $this->expectException(GatewayException::class); + $this->expectException(ConfigurationException::class); $this->expectExceptionMessageMatches('/declares the subscriptions capability but does not implement SubscriptionContract/'); $subscription->suspend($gateway); @@ -722,6 +724,37 @@ public function testUpdateStillValidatesAvailablePaymentMethods(): void $subscription->save($gateway); } + /** + * Gateway que declara a capability mas não tem o método do despacho por convenção é erro + * de driver e chega como `ConfigurationException`, sem requisição. + */ + public function testGatewayWithoutTheDispatchMethodIsAConfigurationError(): void + { + $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('supports')->with(Capability::PLANS)->andReturn(true); + + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/does not have method \[createPlan\]/'); + + $plan->save($gateway); + } + + public function testTheFacadeRejectsAGatewayDeclaringTheCapabilityWithoutTheContractAsAConfigurationError(): void + { + $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('supports')->with(Capability::PLANS)->andReturn(true); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/declares the plans capability but does not implement PlanContract/'); + + (new MultiPayment($gateway))->listPlans(); + } + public function testPlanWithIdCannotBeSavedAgain(): void { $plan = new Plan(); @@ -730,7 +763,7 @@ public function testPlanWithIdCannotBeSavedAgain(): void $plan->amount = 10000; $plan->interval = PlanInterval::MONTH; - $this->expectException(GatewayException::class); + $this->expectException(ModelAttributeValidationException::class); $this->expectExceptionMessageMatches('/cannot be updated/'); $plan->save(Mockery::mock(GatewayContract::class)); From bf53557fb49b602c7c563a3aab417bdfc21b6b38 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Wed, 2 Sep 2026 19:42:38 -0300 Subject: [PATCH 28/32] =?UTF-8?q?feat(stripe):=20salva=20cart=C3=A3o=20por?= =?UTF-8?q?=20SetupIntent=20e=20exp=C3=B5e=20requiresAction=20para=20auten?= =?UTF-8?q?tica=C3=A7=C3=A3o=20do=20pagador?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreditCardContract ganha confirmCreditCardSetup(); MultiPayment, Facade e CreditCard::confirmSetup() a expõem - StripeGateway::createCreditCard() cria e confirma um SetupIntent (usage off_session): succeeded devolve o cartão cobrável, requires_action devolve requiresAction, setupId, clientSecret e actionUrl sem anexar, recusa é ChargingException - Capability CARD_SETUP_AUTHENTICATION: Stripe sim, Iugu limitação do gateway (Zero Auth não autentica o portador) - Venda avulsa com token que exige autenticação lança AUTHENTICATION_REQUIRED antes do PaymentIntent - Fixtures de SetupIntent gravadas na sandbox, testes unitários e de integração, README com o fluxo de 3DS --- README.md | 91 +++- src/Contracts/CreditCardContract.php | 18 + src/Enums/Capability.php | 7 + src/Facades/MultiPayment.php | 1 + src/Gateways/IuguGateway.php | 17 + src/Gateways/Stripe/DeclineCodes.php | 2 + src/Gateways/StripeGateway.php | 316 +++++++++++-- src/Models/CreditCard.php | 71 +++ src/MultiPayment.php | 16 + tests/Integration/StripeGatewayTest.php | 46 +- tests/Unit/CapabilityGuardsTest.php | 18 +- .../Unit/Gateways/GatewayCapabilitiesTest.php | 1 + .../Unit/Gateways/Stripe/DeclineCodesTest.php | 2 + .../Gateways/StripeGatewayCreditCardTest.php | 417 +++++++++++++++++- .../Gateways/StripeGatewayIdempotencyTest.php | 56 ++- .../Gateways/StripeGatewayInvoiceTest.php | 25 +- tests/Unit/IdempotencyKeyPropagationTest.php | 12 +- tests/Unit/ModelFillTest.php | 35 ++ tests/Unit/MultiPaymentGatewayRoutingTest.php | 28 ++ tests/fixtures/stripe/README.md | 15 + .../stripe/setup_intents/card_declined.json | 165 +++++++ .../stripe/setup_intents/requires_action.json | 104 +++++ .../requires_action_redirect.json | 98 ++++ .../stripe/setup_intents/succeeded.json | 95 ++++ 24 files changed, 1577 insertions(+), 79 deletions(-) create mode 100644 tests/fixtures/stripe/setup_intents/card_declined.json create mode 100644 tests/fixtures/stripe/setup_intents/requires_action.json create mode 100644 tests/fixtures/stripe/setup_intents/requires_action_redirect.json create mode 100644 tests/fixtures/stripe/setup_intents/succeeded.json diff --git a/README.md b/README.md index 4ef9611..c8b0c70 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [Pix Automático: quem agenda a cobrança](#pix-automático-quem-agenda-a-cobrança) - [Assinaturas e planos](#assinaturas-e-planos) - [CustomerBuilder](#customerbuilder) + - [Salvar cartão (CreditCardBuilder)](#salvar-cartão-creditcardbuilder) - [getInvoice](#getinvoice) - [Outras operações de fatura](#outras-operações-de-fatura) - [Estorno](#estorno) @@ -165,6 +166,7 @@ coluna "Restrições" é o que `restriction()` devolve para cada gateway. | `AUTOMATIC_PIX` | Recorrência de Pix Automático criada junto com a fatura, com reagendamento e cancelamento pela lib. | sim | não implementado | | | `MULTIPLE_PAYMENT_METHODS` | Fatura aberta a mais de um método de pagamento, escolhido pelo pagador na hora de pagar. | sim | não implementado | | | `RAW_CARD_DATA` | Cartão informado com número e CVV pela API; sem ela, o cartão é tokenizado no navegador e só o token chega à lib. | sim | limitação do gateway | | +| `CARD_SETUP_AUTHENTICATION` | Autenticação do portador com o emissor (3DS) ao salvar o cartão: cartão que exige ação do pagador volta com `CreditCard::$requiresAction` verdadeiro e `id` nulo, e `confirmCreditCardSetup()` conclui o salvamento depois da autenticação. | limitação do gateway | sim | | | `INSTALLMENTS` | Parcelamento da cobrança no cartão de crédito. | sim | limitação do gateway | Iugu: O número de parcelas vai em gatewayOptions['months'], até 12 (máximo da conta, configurável em multi-payment.gateways.iugu.max_installments); a lib não lê as parcelas da fatura paga. | | `DELAYED_CAPTURE` | Cobrança em duas etapas no cartão: reserva do valor agora e captura depois. | não implementado | não implementado | | | `PARTIAL_REFUND_CARD` | Estorno de parte do valor numa fatura paga com cartão. | sim | sim | | @@ -192,6 +194,10 @@ Sobre as restrições e algumas células: A bandeira fora da restrição de `CREDIT_CARD` chega depois, na cobrança, como `CardDeclinedException` com `DeclineCode::BRAND_NOT_SUPPORTED`; por isso vale consultar `restriction()->allowsBrand()` antes de tokenizar. +- **`CARD_SETUP_AUTHENTICATION`** faz `newCreditCard()->create()` devolver um cartão com + `requiresAction` quando o emissor exige autenticação; na Iugu, que só valida o cartão + (Zero Auth, sem 3DS), o cartão volta sempre cobrável (ver + [Salvar cartão](#salvar-cartão-creditcardbuilder)). - **`INSTALLMENTS` na Iugu** é informado em `gateway_options['months']`; a lib não modela parcelas nem lê os campos da fatura parcelada. O máximo publicado em `maxInstallments` vem de `multi-payment.gateways.iugu.max_installments` (`IUGU_MAX_INSTALLMENTS`, 12 por padrão) e @@ -412,12 +418,15 @@ recusado na validação, porque a fatura com Pix Automático é criada com `PIX` `retryable` diz se vale repetir com o mesmo cartão (ver [Códigos de recusa](#códigos-de-recusa)). `GatewayNotAvailableException` também sinaliza "tente outro gateway"; `AuthenticationException` sinaliza credencial errada e não deve gerar fallback (ver [Tratamento de erros](#tratamento-de-erros)). -- **Cartão salvo não garante cobrança futura.** Salvar o cartão (`newCreditCard()->create()`) - faz só o `attach` do PaymentMethod ao cliente, sem autenticar com o emissor. Um cartão que - exige autenticação (3DS) é salvo normalmente e recusado na primeira cobrança `off_session`, - com `CardDeclinedException::$declineCode` igual a `DeclineCode::AUTHENTICATION_REQUIRED`. Esse - código pede ação do pagador (autenticar o cartão ou informar outro); o gateway respondeu - normalmente e não cabe fallback. Autenticar no momento de salvar (SetupIntent) está planejado para uma versão futura. +- **Salvar cartão autentica o portador quando o emissor exige.** `newCreditCard()->create()` + cria e confirma um SetupIntent (`usage: off_session`): o cartão que o emissor aprova volta + salvo e cobrável; o cartão que exige autenticação (3DS) volta com `requiresAction` + verdadeiro, sem `id`, e só fica cobrável depois de `confirmCreditCardSetup()` (ver + [Salvar cartão](#salvar-cartão-creditcardbuilder)). Na venda avulsa com token + (`addCreditCardToken()`), um cartão que exige autenticação lança `CardDeclinedException` com + `DeclineCode::AUTHENTICATION_REQUIRED` antes de criar a fatura: a cobrança fora de sessão não + tem como atendê-la. Esse código pede ação do pagador (autenticar o cartão ou informar outro); + o gateway respondeu normalmente e não cabe fallback. - **Pix exige `tax_document` do cliente** (CPF/CNPJ vai nos billing details do pagamento). - **A expiração do QR Code do Pix é `pixExpiresAt`** (opcional; default do Stripe: 4 horas) e, quando informada, deve ficar entre 10 segundos e 14 dias no futuro. Sem ela, `dueDate` faz o @@ -669,6 +678,7 @@ deduplica por conta própria com a `IdempotencyStore` (abaixo): | Criar assinatura | gateway | (não implementado) | | Atualizar cliente, definir cartão padrão | store da lib | gateway | | Salvar cartão, excluir cartão | store da lib | gateway | +| Concluir o setup do cartão (`confirmCreditCardSetup`) | (limitação do gateway) | gateway, nas escritas secundárias (`{chave}:attach`, `{chave}:metadata`, `{chave}:default`); a leitura do setup não leva chave | | Estornar, cancelar, duplicar fatura | store da lib | gateway | | Suspender, retomar, cancelar, atualizar assinatura, trocar de plano | store da lib | (não implementado) | | Criar plano | store da lib | (não implementado) | @@ -750,7 +760,7 @@ MultiPaymentException RefundNotSupportedException estorno recusado pela lib antes da requisição (limitação do gateway ou estado da fatura) AuthenticationException credencial recusada (401, 403) ou não configurada GatewayNotAvailableException 5xx, falha de conexão ou timeout - CardDeclinedException cobrança recusada: declineCode, gatewayCode, retryable + CardDeclinedException cartão recusado, na cobrança ou ao salvar: declineCode, gatewayCode, retryable ChargingException nome antigo, deprecado; é a classe que os drivers lançam GatewayException resposta de erro do gateway: httpStatus e getErrors() ValidationException 400 ou 422: fieldErrors por campo @@ -761,7 +771,7 @@ MultiPaymentException | Exceção | Quando | O que fazer | |---|---|---| -| `CardDeclinedException` | O gateway respondeu e a cobrança foi recusada pelo emissor, pelo adquirente ou pelo antifraude. `declineCode` (`DeclineCode`) é o motivo normalizado, `gatewayCode` o código original (`decline_code` da Stripe, LR da Iugu), `retryable` diz se vale repetir com o mesmo cartão | Ramificar por `declineCode`: outro gateway em `BRAND_NOT_SUPPORTED`, nova tentativa só se `retryable`, ação do pagador nos demais (ver [Códigos de recusa](#códigos-de-recusa)) | +| `CardDeclinedException` | O gateway respondeu e a cobrança foi recusada pelo emissor, pelo adquirente ou pelo antifraude. `declineCode` (`DeclineCode`) é o motivo normalizado, `gatewayCode` o código original (`decline_code` da Stripe, LR da Iugu), `retryable` diz se vale repetir com o mesmo cartão. Também é a recusa ao salvar o cartão (`newCreditCard()->create()`, `confirmCreditCardSetup()`); quando vem do estado do setup lido (`last_setup_error`), `httpStatus` é nulo e o SetupIntent vai em `chargeResponse` | Ramificar por `declineCode`: outro gateway em `BRAND_NOT_SUPPORTED`, nova tentativa só se `retryable`, ação do pagador nos demais (ver [Códigos de recusa](#códigos-de-recusa)) | | `ChargingException` | Nome antigo de `CardDeclinedException`, deprecado. É a classe que os drivers lançam, então `catch` por qualquer um dos dois nomes captura a mesma exceção | Migrar o `catch` para `CardDeclinedException` | | `ValidationException` | O gateway recusou o payload (400 ou 422 na Iugu, `invalid_request_error` na Stripe); `fieldErrors` traz as mensagens por campo (`base` para erro sem campo) | Corrigir a chamada; repetir igual falha de novo | | `NotFoundException` | Recurso inexistente no gateway (404 na Iugu, `resource_missing` na Stripe): id errado, de outra conta ou removido | Conferir o id; não repetir | @@ -834,7 +844,7 @@ do pagador antes de qualquer nova tentativa. | `INVALID_CARD` | Outros dados inválidos: validade, conta inexistente, cartão não desbloqueado | não | `invalid_expiry_month`, `invalid_expiry_year`, `invalid_account`, `new_account_information_available`, `incorrect_address`, `incorrect_zip` | 1, 12, 15, 30, 46, 56, 78, 101, 111, 115, 122, 6P, AV, BM, BP, BR, CF, CG, DF, DQ, G4, KA, KE, U3 | | `LOST_OR_STOLEN` | Perdido, roubado, retido ou bloqueado pelo emissor; não exibir o motivo ao pagador | não | `lost_card`, `stolen_card`, `pickup_card`, `restricted_card` | 4, 41, 43, 62, 146, BN | | `FRAUD_SUSPECTED` | Suspeita de fraude ou antifraude; tratar como recusa genérica diante do pagador | não | `fraudulent`, `merchant_blacklist` | 7, 59, AF01, AF02, BP171 | -| `AUTHENTICATION_REQUIRED` | O emissor exige autenticação (3DS); a cobrança fora de sessão não atende | não | `authentication_required`, `authentication_not_handled`, `mobile_device_authentication_required` | AI | +| `AUTHENTICATION_REQUIRED` | O emissor exige autenticação (3DS); a cobrança fora de sessão não atende | não | `authentication_required`, `authentication_not_handled`, `mobile_device_authentication_required`, `setup_intent_authentication_failure`, `payment_intent_authentication_failure` | AI | | `BRAND_NOT_SUPPORTED` | Bandeira, função (crédito ou débito) ou moeda não aceita nesta cobrança; candidato a outro gateway | não | `card_not_supported`, `currency_not_supported` | 39, 52, 53, 57, 79, 5C, AB, AC, AH, C1, DS, EK, G5 | | `DO_NOT_HONOR` | O emissor recusou sem detalhar e orienta o pagador a procurá-lo | não | `do_not_honor`, `call_issuer`, `no_action_taken`, `not_permitted`, `security_violation`, `service_not_allowed`, `stop_payment_order`, `transaction_not_allowed`, `revocation_of_authorization`, `revocation_of_all_authorizations`, `do_not_try_again` | 5, 6, 60, 63, 67, 93, 99, 100, 109, 110, 116, 121, 181, 200, B1, B2, BP176, C2, C3, FC, FG, GA, GD, GF, GK, GT, N7, NR, R0, R1, R2, R3, RE, RP, SC | | `TRY_AGAIN` | Falha temporária no emissor, no adquirente ou na comunicação | sim | `processing_error`, `issuer_not_available`, `reenter_transaction`, `try_again_later`, `approve_with_id` | 19, 28, 85, 89, 90, 91, 92, 96, 98, 911, 912, 999, 99A, 99B, 99C, 99TA, 99Z, AA, AF, AG, BD, BO, BP900, BP901, BP902 | @@ -1323,6 +1333,69 @@ $customer = $customerBuilder->setName('Nome') ``` Confira `src/MultiPayment/Builders/CustomerBuilder.php` para saber quais métodos estão disponíveis. +#### Salvar cartão (CreditCardBuilder) + +O cartão salvo fica vinculado ao cliente e pode ser cobrado depois por `addCreditCardId()`, +`setCreditCard()` na assinatura ou `chargeInvoiceWithCreditCard()`. No Stripe o token é o +PaymentMethod criado no navegador com Stripe.js (`pm_...`); na Iugu, o token do iugu.js ou os +dados do cartão (`setNumber()`, `setCvv()`...), que só a Iugu aceita (`RAW_CARD_DATA`). + +```php +$payment = new \Potelo\MultiPayment\MultiPayment('stripe'); // ou 'iugu' + +$card = $payment->newCreditCard() + ->setCustomerId($customer->id) + ->setToken($request->payment_method_id) + ->setDescription('Cartão principal') + ->setAsDefault() + ->withIdempotencyKey("card-{$request->uuid}") + ->create(); + +if ($card->requiresAction) { + // o emissor exige autenticação do pagador (3DS) antes de o cartão ficar cobrável; o cartão + // ainda não foi salvo (id nulo). O navegador conclui com o SDK do gateway: + // stripe.confirmCardSetup($card->clientSecret) e, depois, o servidor confirma abaixo + return response()->json(['setup_id' => $card->setupId, 'client_secret' => $card->clientSecret]); +} + +$card->id; // cartão salvo e cobrável +``` + +```php +// depois que o pagador autenticou no navegador +$card = $payment->confirmCreditCardSetup($setupId); // ou $card->confirmSetup() + +if ($card->requiresAction) { + // o pagador ainda não concluiu a autenticação; peça de novo ou desista do cartão +} +$card->id; // cartão salvo; a descrição e a marcação de padrão pedidas na criação já foram aplicadas +``` + +Regras do fluxo: + +- **Quem autentica é o gateway com `CARD_SETUP_AUTHENTICATION`** (Stripe). Na Iugu a + capability é limitação do gateway: o Zero Auth confere a validade do cartão com uma + autorização de valor zero e não autentica o portador, então `requiresAction` é sempre falso, + `setupId` é nulo e `confirmCreditCardSetup()` lança `UnsupportedOperationException`. +- **Página hospedada de autenticação.** No Stripe, `setGatewayOptions(['return_url' => ...])` + faz a Stripe devolver a página de 3DS em `actionUrl`; sem `return_url`, `actionUrl` fica + nulo e a autenticação é pelo Stripe.js com `clientSecret`. +- **Recusa é `CardDeclinedException`**, na criação ou na confirmação: cartão recusado pelo + emissor no setup, autenticação que falhou (`DeclineCode::AUTHENTICATION_REQUIRED`, com + `gatewayCode` `setup_intent_authentication_failure`) ou setup cancelado + (`DeclineCode::UNKNOWN`). O SetupIntent vai em `chargeResponse`. +- **Idempotência.** A chave de `withIdempotencyKey()` vai no SetupIntent; a confirmação aceita + a própria chave como segundo argumento (`confirmCreditCardSetup($setupId, $key)`). + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, `newCreditCard()->create()` no +> Stripe fazia só o `attach` do PaymentMethod: um cartão que exigia autenticação era salvo +> como cobrável e recusado na primeira cobrança `off_session`, com +> `DeclineCode::AUTHENTICATION_REQUIRED`. Agora esse cartão volta com `requiresAction` e sem +> `id`, e só é salvo depois da autenticação. Quem persiste `$card->id` logo após o `create()` +> precisa tratar `requiresAction` antes. A recusa do emissor passa a chegar no setup, e +> `retryable` segue o `advice_code` que a Stripe envia nesse ponto, que pode diferir do que +> vinha na cobrança. + #### getInvoice ```php $invoiceId = '312ASDHGZXSGRTET312ASDHGZXSGRTET'; diff --git a/src/Contracts/CreditCardContract.php b/src/Contracts/CreditCardContract.php index 9f6123c..5123f84 100644 --- a/src/Contracts/CreditCardContract.php +++ b/src/Contracts/CreditCardContract.php @@ -22,6 +22,24 @@ interface CreditCardContract */ public function createCreditCard(CreditCard $creditCard, ?string $idempotencyKey = null): CreditCard; + /** + * Conclui o salvamento de um cartão que `createCreditCard()` devolveu com `requiresAction` + * verdadeiro, depois que o pagador autenticou com o emissor. Devolve o cartão cobrável + * (`id` preenchido) quando a autenticação foi concluída, o cartão ainda com `requiresAction` + * quando o pagador não a concluiu, e lança `CardDeclinedException` quando o gateway + * recusou o cartão ou o setup foi cancelado. Gateway sem `CARD_SETUP_AUTHENTICATION` lança + * `UnsupportedOperationException` antes de qualquer requisição. + * + * @param string $setupId `CreditCard::$setupId` devolvido por `createCreditCard()` + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * + * @return CreditCard + * @throws GatewayException|GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\CardDeclinedException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException + */ + public function confirmCreditCardSetup(string $setupId, ?string $idempotencyKey = null): CreditCard; + /** * Get a credit card by its ID * diff --git a/src/Enums/Capability.php b/src/Enums/Capability.php index ac644f2..bed5c26 100644 --- a/src/Enums/Capability.php +++ b/src/Enums/Capability.php @@ -30,6 +30,13 @@ enum Capability: string */ case RAW_CARD_DATA = 'raw_card_data'; + /** + * Autenticação do portador com o emissor (3DS) ao salvar o cartão: cartão que exige ação + * do pagador volta com `CreditCard::$requiresAction` verdadeiro e `id` nulo, e + * `confirmCreditCardSetup()` conclui o salvamento depois da autenticação. + */ + case CARD_SETUP_AUTHENTICATION = 'card_setup_authentication'; + /** Parcelamento da cobrança no cartão de crédito. */ case INSTALLMENTS = 'installments'; diff --git a/src/Facades/MultiPayment.php b/src/Facades/MultiPayment.php index ec68427..095c995 100644 --- a/src/Facades/MultiPayment.php +++ b/src/Facades/MultiPayment.php @@ -26,6 +26,7 @@ * @method static int refundableAmount(string $id) * @method static Invoice duplicateInvoice(Invoice|string $invoice, \Carbon\Carbon $expiresAt, array $gatewayOptions = [], ?string $idempotencyKey = null) * @method static CreditCard getCard(string $customerId, string $creditCardId) + * @method static CreditCard confirmCreditCardSetup(string $setupId, ?string $idempotencyKey = null) * @method static void deleteCard(string $customerId, string $creditCardId, ?string $idempotencyKey = null) * @method static \Potelo\MultiPayment\MultiPayment setGateway($gateway) * @method static \Potelo\MultiPayment\Contracts\GatewayContract gateway($gateway = null) diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index c84d2a1..255b348 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -619,6 +619,23 @@ public function createCreditCard(CreditCard $creditCard, ?string $idempotencyKey return $this->parseIuguCard($iuguCreditCard, $creditCard); } + /** + * @inheritDoc + * + * Na Iugu o cartão salvo por `createCreditCard()` já volta cobrável (o Zero Auth só + * confere a validade do cartão, com uma autorização de valor zero, sem autenticar o + * portador), então `CARD_SETUP_AUTHENTICATION` fica fora das listas do driver e este + * método lança sempre `UnsupportedOperationException` com `reason` `gateway_limitation`. + */ + public function confirmCreditCardSetup(string $setupId, ?string $idempotencyKey = null): CreditCard + { + throw UnsupportedOperationException::forGateway( + $this, + Capability::CARD_SETUP_AUTHENTICATION, + 'Na Iugu o cartão salvo por createCreditCard() já é cobrável; não há setup a confirmar.' + ); + } + /** * @inheritDoc */ diff --git a/src/Gateways/Stripe/DeclineCodes.php b/src/Gateways/Stripe/DeclineCodes.php index 7eef701..fcc89be 100644 --- a/src/Gateways/Stripe/DeclineCodes.php +++ b/src/Gateways/Stripe/DeclineCodes.php @@ -47,6 +47,8 @@ final class DeclineCodes 'authentication_required' => DeclineCode::AUTHENTICATION_REQUIRED, 'authentication_not_handled' => DeclineCode::AUTHENTICATION_REQUIRED, 'mobile_device_authentication_required' => DeclineCode::AUTHENTICATION_REQUIRED, + 'setup_intent_authentication_failure' => DeclineCode::AUTHENTICATION_REQUIRED, + 'payment_intent_authentication_failure' => DeclineCode::AUTHENTICATION_REQUIRED, 'card_not_supported' => DeclineCode::BRAND_NOT_SUPPORTED, 'currency_not_supported' => DeclineCode::BRAND_NOT_SUPPORTED, diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 31bcb70..584a1f6 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -8,6 +8,7 @@ use Stripe\Customer as StripeCustomer; use Stripe\PaymentIntent as StripePaymentIntent; use Stripe\PaymentMethod as StripePaymentMethod; +use Stripe\SetupIntent as StripeSetupIntent; use Stripe\Exception\CardException; use Stripe\Exception\ApiErrorException; use Stripe\Exception\PermissionException; @@ -85,6 +86,16 @@ class StripeGateway implements GatewayContract */ private const INVOICE_EXPAND = ['payments.data.payment.payment_intent']; + /** + * Expand de toda leitura ou criação de SetupIntent: o PaymentMethod expandido traz a + * bandeira e os últimos dígitos do cartão e diz se a Stripe já o anexou ao cliente. + */ + private const SETUP_INTENT_EXPAND = ['payment_method']; + + /** Chaves de `metadata` do SetupIntent que guardam o que aplicar ao cartão quando o setup conclui. */ + private const SETUP_METADATA_DESCRIPTION = 'description'; + private const SETUP_METADATA_DEFAULT = 'set_as_default'; + /** Prefixo do id de um objeto Invoice da Stripe; o de PaymentIntent é `pi_`. */ private const STRIPE_INVOICE_ID_PREFIX = 'in_'; @@ -153,6 +164,7 @@ public function capabilities(): array return [ Capability::CREDIT_CARD, Capability::PIX, + Capability::CARD_SETUP_AUTHENTICATION, Capability::PARTIAL_REFUND_CARD, Capability::PARTIAL_REFUND_PIX, Capability::INVOICE_DUPLICATION, @@ -662,18 +674,33 @@ private function translateStripeException(\Throwable $e): MultiPaymentException } /** - * Traduz uma recusa de cartão do stripe-php para `ChargingException`: o `decline_code` (ou, - * na falta dele, o `code`) vira `DeclineCode`, o `advice_code` decide `retryable` quando - * presente, e a resposta bruta vai em `chargeResponse`. Código fora da tabela vira - * `DeclineCode::UNKNOWN`, com o original preservado em `gatewayCode` e registro em nível - * `info`. + * Traduz uma recusa de cartão do stripe-php para `ChargingException` a partir do erro da + * exceção, com ela em `previous` e o status HTTP da resposta. * * @param CardException $e * @return ChargingException */ private function cardDeclined(CardException $e): ChargingException { - $error = $e->getError(); + return $this->declinedFromStripeError($e->getError(), $e->getMessage(), $e, $e->getHttpStatus()); + } + + /** + * Monta a `ChargingException` de uma recusa de cartão a partir do objeto de erro da Stripe + * (o de uma `CardException` ou o `last_setup_error` de um SetupIntent): o `decline_code` + * (ou, na falta dele, o `code`) vira `DeclineCode`, o `advice_code` decide `retryable` + * quando presente, e o erro bruto vai em `chargeResponse`. Código fora da tabela vira + * `DeclineCode::UNKNOWN`, com o original preservado em `gatewayCode` e registro em nível + * `info`. + * + * @param object|null $error `\Stripe\ErrorObject` ou `\Stripe\StripeObject` com `code`, `decline_code` e `advice_code` + * @param string $message + * @param \Throwable|null $previous + * @param int|null $httpStatus + * @return ChargingException + */ + private function declinedFromStripeError(?object $error, string $message, ?\Throwable $previous, ?int $httpStatus): ChargingException + { $code = $error?->code ?? null; $stripeDeclineCode = $error?->decline_code ?? null; $gatewayCode = $stripeDeclineCode ?: $code; @@ -690,13 +717,13 @@ private function cardDeclined(CardException $e): ChargingException 'stripe', $declineCode, $gatewayCode, - $e->getMessage(), - $e, - $e->getHttpStatus(), + $message, + $previous, + $httpStatus, StripeDeclineCodes::retryableFromAdvice($error?->advice_code ?? null) ); // array em vez do ErrorObject, para o formato ser o mesmo em qualquer operação - $exception->chargeResponse = $error?->toArray(); + $exception->chargeResponse = is_object($error) && method_exists($error, 'toArray') ? $error->toArray() : $error; $exception->reason = self::chargeFailureReason($code, $stripeDeclineCode); return $exception; @@ -776,7 +803,11 @@ private function invoicePaymentMethod(Invoice $invoice): PaymentMethod } /** - * Cria e confirma um PaymentIntent de cartão (síncrono: succeeded ou recusa na hora). + * Cria e confirma um PaymentIntent de cartão (síncrono: succeeded ou recusa na hora). Um + * cartão informado por token é salvo antes por `createCreditCard()`; se o emissor exigir + * autenticação do pagador para salvá-lo, a cobrança fora de sessão não tem como atendê-la + * e a fatura não é criada: `ChargingException` com `DeclineCode::AUTHENTICATION_REQUIRED` + * e o SetupIntent em `chargeResponse`. * * @param \Potelo\MultiPayment\Models\Invoice $invoice * @param string|null $idempotencyKey @@ -793,11 +824,23 @@ private function createCreditCardInvoice(Invoice $invoice, ?string $idempotencyK if (empty($invoice->creditCard->customer)) { $invoice->creditCard->customer = $invoice->customer; } - // a Stripe valida o cartão já no attach; a recusa nesse ponto é ChargingException + // a Stripe valida o cartão já no setup; a recusa nesse ponto é ChargingException $invoice->creditCard = $this->createCreditCard( $invoice->creditCard, self::derivedIdempotencyKey($idempotencyKey, 'card') ); + if ($invoice->creditCard->requiresAction) { + $exception = ChargingException::declined( + 'stripe', + DeclineCode::AUTHENTICATION_REQUIRED, + 'authentication_required', + 'O emissor exige autenticação do pagador para este cartão; salve-o com createCreditCard(),' + . ' conclua a autenticação com confirmCreditCardSetup() e cobre pelo id do cartão salvo.' + ); + $exception->chargeResponse = $invoice->creditCard->original?->toArray(); + + throw $exception; + } } $stripePaymentIntentData = $this->invoiceToStripeData($invoice); @@ -2132,11 +2175,23 @@ private function voidStripeInvoice(Invoice $invoice, ?string $idempotencyKey): I /** * @inheritDoc * - * A chave de idempotência vai no cabeçalho `Idempotency-Key` do attach; as requisições + * O cartão é salvo por um SetupIntent criado e confirmado na mesma requisição + * (`usage: off_session`), que autentica o portador com o emissor quando ele exige. Em + * `succeeded` a Stripe anexa o PaymentMethod ao cliente e o cartão volta cobrável, com + * `id`. Em `requires_action` nada é anexado: o cartão volta com `requiresAction` + * verdadeiro, `setupId`, `clientSecret` (para `stripe.confirmCardSetup()` no navegador) e + * `actionUrl` quando `gatewayOptions['return_url']` foi informado (página hospedada de + * 3DS), com `id` nulo até `confirmCreditCardSetup()`. Recusa no setup é + * `ChargingException`. A descrição e a marcação de padrão vão em `metadata` do SetupIntent + * (mesclado ao `metadata` de `gatewayOptions`, quando há) e são aplicadas quando o setup + * conclui, nesta chamada ou na confirmação. + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` do SetupIntent; as requisições * secundárias usam chaves derivadas: `{chave}:payment_method` na conversão de token legado, + * `{chave}:attach` no anexo (só quando a Stripe devolve o PaymentMethod sem cliente), * `{chave}:metadata` na descrição e `{chave}:default` ao marcar como padrão. * - * @throws ModelAttributeValidationException|UnsupportedOperationException + * @throws ChargingException|ModelAttributeValidationException|UnsupportedOperationException */ public function createCreditCard(CreditCard $creditCard, ?string $idempotencyKey = null): CreditCard { @@ -2153,37 +2208,206 @@ public function createCreditCard(CreditCard $creditCard, ?string $idempotencyKey ); } - $stripePaymentMethod = $this->stripeRequest(function () use ($creditCard, $idempotencyKey) { + $stripeSetupIntent = $this->stripeRequest(function () use ($creditCard, $idempotencyKey) { $paymentMethodId = $this->resolvePaymentMethodId( $creditCard->token, self::derivedIdempotencyKey($idempotencyKey, 'payment_method') ); - $stripePaymentMethod = $this->client->paymentMethods->attach( - $paymentMethodId, - ['customer' => $creditCard->customer->id], + $stripeSetupIntentData = [ + 'customer' => $creditCard->customer->id, + 'payment_method' => $paymentMethodId, + 'payment_method_types' => ['card'], + 'usage' => 'off_session', + 'confirm' => true, + ]; + $stripeSetupIntentData = $this->mergeGatewayOptions($stripeSetupIntentData, $creditCard); + // o metadata do consumidor (gatewayOptions) convive com as chaves do setup + $metadata = array_merge($stripeSetupIntentData['metadata'] ?? [], self::cardSetupMetadata($creditCard)); + if (!empty($metadata)) { + $stripeSetupIntentData['metadata'] = $metadata; + } + + return $this->client->setupIntents->create( + $this->withExpand($stripeSetupIntentData, self::SETUP_INTENT_EXPAND), self::stripeOptions($idempotencyKey) ); + }); - // o PaymentMethod da Stripe não tem campo de descrição — vai para metadata - if (!empty($creditCard->description)) { - $stripePaymentMethod = $this->client->paymentMethods->update( - $stripePaymentMethod->id, - ['metadata' => ['description' => $creditCard->description]], - self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'metadata')) - ); - } + return $this->finishCardSetup($stripeSetupIntent, $creditCard, $idempotencyKey); + } + + /** + * @inheritDoc + * + * Lê o SetupIntent e aplica o desfecho: `succeeded` anexa o cartão ao cliente quando a + * Stripe ainda não o fez, aplica a descrição e a marcação de padrão guardadas em `metadata` + * do setup e devolve o cartão cobrável; `requires_action` devolve o cartão ainda com + * `requiresAction` (o pagador não concluiu a autenticação); os demais estados lançam + * `ChargingException` com o `last_setup_error` quando há um. Um SetupIntent sem cliente + * (criado fora de `createCreditCard()`) é recusado com `ModelAttributeValidationException` + * antes de qualquer escrita. A chave de idempotência vai nas escritas secundárias, + * derivada: `{chave}:attach`, `{chave}:metadata` e `{chave}:default`. + * + * @throws ModelAttributeValidationException + */ + public function confirmCreditCardSetup(string $setupId, ?string $idempotencyKey = null): CreditCard + { + $stripeSetupIntent = $this->stripeRequest(function () use ($setupId) { + return $this->client->setupIntents->retrieve($setupId, ['expand' => self::SETUP_INTENT_EXPAND]); + }); + + $customerId = is_object($stripeSetupIntent->customer) + ? $stripeSetupIntent->customer->id + : $stripeSetupIntent->customer; + if (empty($customerId)) { + throw ModelAttributeValidationException::invalid( + 'CreditCard', + 'setupId', + "SetupIntent [{$setupId}] has no customer; only a setup created by createCreditCard() can be confirmed as a saved card" + ); + } + + $creditCard = new CreditCard(); + $creditCard->customer = new Customer(); + $creditCard->customer->id = $customerId; + + return $this->finishCardSetup($stripeSetupIntent, $creditCard, $idempotencyKey); + } + + /** + * `metadata` do SetupIntent com o que aplicar ao cartão quando o setup conclui: a + * descrição (o PaymentMethod da Stripe não tem campo de descrição) e a marcação de padrão. + * Vazio quando o model não informa nenhum dos dois. + * + * @param \Potelo\MultiPayment\Models\CreditCard $creditCard + * @return array + */ + private static function cardSetupMetadata(CreditCard $creditCard): array + { + $metadata = []; + if (!empty($creditCard->description)) { + $metadata[self::SETUP_METADATA_DESCRIPTION] = $creditCard->description; + } + if (!empty($creditCard->default)) { + $metadata[self::SETUP_METADATA_DEFAULT] = '1'; + } + + return $metadata; + } + + /** + * Converte o SetupIntent, com `payment_method` expandido, no `CreditCard` que a operação + * devolve. `succeeded`: anexa o PaymentMethod ao cliente quando ele voltou sem cliente (a + * Stripe anexa ao confirmar um SetupIntent com cliente, e este anexo só cobre a resposta + * em que isso não aconteceu), grava a descrição em `metadata` do PaymentMethod e o marca + * como padrão do cliente (`default` verdadeiro no model), conforme `metadata` do setup. + * `requires_action`: cartão com + * `requiresAction`, `setupId`, `clientSecret`, `actionUrl` (quando `next_action` é + * `redirect_to_url`), os dados do cartão para exibição e `id` nulo. Os demais estados + * (`requires_payment_method` depois de uma autenticação que falhou, `canceled`, + * `processing`, `requires_confirmation`) lançam `ChargingException`. + * + * @param \Stripe\SetupIntent $stripeSetupIntent + * @param \Potelo\MultiPayment\Models\CreditCard $creditCard model a preencher, com `customer->id` + * @param string|null $idempotencyKey + * @return \Potelo\MultiPayment\Models\CreditCard + * @throws ChargingException|GatewayException|GatewayNotAvailableException + */ + private function finishCardSetup(StripeSetupIntent $stripeSetupIntent, CreditCard $creditCard, ?string $idempotencyKey): CreditCard + { + $stripePaymentMethod = is_object($stripeSetupIntent->payment_method) ? $stripeSetupIntent->payment_method : null; + $metadata = !empty($stripeSetupIntent->metadata) ? $stripeSetupIntent->metadata->toArray() : []; + + if ($stripeSetupIntent->status === StripeSetupIntent::STATUS_SUCCEEDED) { + $stripePaymentMethod = $this->stripeRequest(function () use ($stripeSetupIntent, $stripePaymentMethod, $metadata, $creditCard, $idempotencyKey) { + $paymentMethodId = $stripePaymentMethod?->id ?? $stripeSetupIntent->payment_method; + if (empty($stripePaymentMethod?->customer)) { + $stripePaymentMethod = $this->client->paymentMethods->attach( + $paymentMethodId, + ['customer' => $creditCard->customer->id], + self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'attach')) + ); + } - if (!empty($creditCard->default)) { - $this->client->customers->update($creditCard->customer->id, [ - 'invoice_settings' => ['default_payment_method' => $stripePaymentMethod->id], - ], self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'default'))); + if (!empty($metadata[self::SETUP_METADATA_DESCRIPTION])) { + $stripePaymentMethod = $this->client->paymentMethods->update( + $paymentMethodId, + ['metadata' => [self::SETUP_METADATA_DESCRIPTION => $metadata[self::SETUP_METADATA_DESCRIPTION]]], + self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'metadata')) + ); + } + + if (!empty($metadata[self::SETUP_METADATA_DEFAULT])) { + $this->client->customers->update($creditCard->customer->id, [ + 'invoice_settings' => ['default_payment_method' => $paymentMethodId], + ], self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'default'))); + } + + return $stripePaymentMethod; + }); + + $creditCard = $this->parseStripeCard($stripePaymentMethod, $creditCard); + if (!empty($metadata[self::SETUP_METADATA_DEFAULT])) { + $creditCard->default = true; } + $creditCard->setupId = $stripeSetupIntent->id; + $creditCard->requiresAction = false; + $creditCard->actionUrl = null; + $creditCard->clientSecret = null; - return $stripePaymentMethod; - }); + return $creditCard; + } - return $this->parseStripeCard($stripePaymentMethod, $creditCard); + if ($stripeSetupIntent->status === StripeSetupIntent::STATUS_REQUIRES_ACTION) { + $this->fillCardFields($creditCard, $stripePaymentMethod?->card ?? null); + $creditCard->id = null; + $creditCard->description = $metadata[self::SETUP_METADATA_DESCRIPTION] ?? $creditCard->description; + $creditCard->requiresAction = true; + $creditCard->setupId = $stripeSetupIntent->id; + $creditCard->clientSecret = $stripeSetupIntent->client_secret; + $creditCard->actionUrl = $stripeSetupIntent->next_action->redirect_to_url->url ?? null; + $creditCard->gateway = 'stripe'; + $creditCard->original = $stripeSetupIntent; + $creditCard->createdAt = Carbon::createFromTimestamp($stripeSetupIntent->created); + + return $creditCard; + } + + throw $this->cardSetupFailed($stripeSetupIntent); + } + + /** + * `ChargingException` de um SetupIntent que não chegou a `succeeded` nem parou em + * `requires_action`: com `last_setup_error`, a recusa segue a tradução normal do código + * (`setup_intent_authentication_failure` vira `DeclineCode::AUTHENTICATION_REQUIRED`); sem + * ele (setup cancelado, por exemplo), `DeclineCode::UNKNOWN` com `cancellation_reason` ou + * o status em `gatewayCode`. O SetupIntent vai em `chargeResponse` nos dois casos. + * + * @param \Stripe\SetupIntent $stripeSetupIntent + * @return ChargingException + */ + private function cardSetupFailed(StripeSetupIntent $stripeSetupIntent): ChargingException + { + $error = $stripeSetupIntent->last_setup_error ?? null; + if (is_object($error)) { + $exception = $this->declinedFromStripeError( + $error, + (string) ($error->message ?? "SetupIntent {$stripeSetupIntent->id} em {$stripeSetupIntent->status}"), + null, + null + ); + } else { + $exception = ChargingException::declined( + 'stripe', + DeclineCode::UNKNOWN, + $stripeSetupIntent->cancellation_reason ?? $stripeSetupIntent->status, + "SetupIntent {$stripeSetupIntent->id} em {$stripeSetupIntent->status}; o cartão não foi salvo." + ); + } + $exception->chargeResponse = $stripeSetupIntent->toArray(); + + return $exception; } /** @@ -2280,14 +2504,8 @@ private function parseStripeCard(StripePaymentMethod $stripePaymentMethod, ?Cred $creditCard = new CreditCard(); } - $card = isset($stripePaymentMethod->card) ? $stripePaymentMethod->card : null; $creditCard->id = $stripePaymentMethod->id; - $creditCard->brand = $card->brand ?? null; - $creditCard->lastDigits = $card->last4 ?? null; - $creditCard->month = isset($card->exp_month) - ? str_pad((string) $card->exp_month, 2, '0', STR_PAD_LEFT) - : null; - $creditCard->year = isset($card->exp_year) ? (string) $card->exp_year : null; + $this->fillCardFields($creditCard, isset($stripePaymentMethod->card) ? $stripePaymentMethod->card : null); $metadata = !empty($stripePaymentMethod->metadata) ? $stripePaymentMethod->metadata->toArray() : []; $creditCard->description = $metadata['description'] ?? $creditCard->description; @@ -2305,6 +2523,24 @@ private function parseStripeCard(StripePaymentMethod $stripePaymentMethod, ?Cred return $creditCard; } + /** + * Preenche bandeira, últimos dígitos e validade a partir do objeto `card` de um + * PaymentMethod da Stripe; nulos quando o objeto não veio. + * + * @param \Potelo\MultiPayment\Models\CreditCard $creditCard + * @param object|null $card + * @return void + */ + private function fillCardFields(CreditCard $creditCard, ?object $card): void + { + $creditCard->brand = $card->brand ?? null; + $creditCard->lastDigits = $card->last4 ?? null; + $creditCard->month = isset($card->exp_month) + ? str_pad((string) $card->exp_month, 2, '0', STR_PAD_LEFT) + : null; + $creditCard->year = isset($card->exp_year) ? (string) $card->exp_year : null; + } + /** * @inheritDoc */ diff --git a/src/Models/CreditCard.php b/src/Models/CreditCard.php index 310caf0..8a9e662 100644 --- a/src/Models/CreditCard.php +++ b/src/Models/CreditCard.php @@ -3,6 +3,8 @@ namespace Potelo\MultiPayment\Models; use Carbon\Carbon; +use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; /** @@ -76,6 +78,41 @@ class CreditCard extends Model */ public ?bool $default = null; + /** + * Verdadeiro quando o gateway exige ação do pagador (autenticação com o emissor) antes de + * o cartão ficar cobrável: `id` fica nulo, `setupId` e `clientSecret` (e `actionUrl`, + * quando há) dizem como concluir, e `confirmCreditCardSetup()` termina o salvamento. + * Falso por padrão e sempre falso na Iugu. + * + * @var bool|null + */ + public ?bool $requiresAction = false; + + /** + * Página hospedada pelo gateway para o pagador autenticar o cartão, quando ele a oferece + * (no Stripe, só com `return_url` em `gatewayOptions`). Nula quando a autenticação é feita + * pelo SDK do gateway no navegador, com `clientSecret`. + * + * @var string|null + */ + public ?string $actionUrl = null; + + /** + * Segredo do setup para o SDK do gateway no navegador concluir a autenticação (no Stripe, + * `stripe.confirmCardSetup(clientSecret)`). Preenchido só quando `requiresAction`. + * + * @var string|null + */ + public ?string $clientSecret = null; + + /** + * Id do setup que salva o cartão no gateway (SetupIntent `seti_` no Stripe), argumento de + * `confirmCreditCardSetup()`. Nulo na Iugu. + * + * @var string|null + */ + public ?string $setupId = null; + /** * @var string|null */ @@ -173,6 +210,40 @@ public function attributesExtraValidation(array $attributes): void } } + /** + * Conclui o salvamento deste cartão depois que o pagador autenticou, pelo `setupId` que + * `create()` devolveu com `requiresAction`. Atualiza este model com o que o gateway + * devolveu (cartão cobrável, ou ainda com `requiresAction`) e o devolve; o `customer` já + * preenchido é mantido. Recusa do gateway é `CardDeclinedException` (ver + * `CreditCardContract::confirmCreditCardSetup()`). + * + * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return $this + * @throws ModelAttributeValidationException `setupId` vazio + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\CardDeclinedException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException + */ + public function confirmSetup(GatewayContract|string|null $gateway = null, ?string $idempotencyKey = null): static + { + if (empty($this->setupId)) { + throw ModelAttributeValidationException::required($this->getClassName(), 'setupId'); + } + $gateway = ConfigurationHelper::resolveGateway($gateway ?? $this->gateway); + + $confirmed = $gateway->confirmCreditCardSetup($this->setupId, $idempotencyKey); + foreach (get_object_vars($confirmed) as $property => $value) { + if ($property === 'customer' && !empty($this->customer)) { + continue; + } + $this->{$property} = $value; + } + + return $this; + } + /** * @inheritDoc */ diff --git a/src/MultiPayment.php b/src/MultiPayment.php index ded2199..c3add45 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -505,6 +505,22 @@ public function getCard(string $customerId, string $creditCardId): CreditCard return $creditCard->get($this->gateway); } + /** + * Conclui o salvamento de um cartão que voltou de `newCreditCard()->create()` com + * `requiresAction`, depois que o pagador autenticou (ver + * `CreditCardContract::confirmCreditCardSetup()`). + * + * @param string $setupId `CreditCard::$setupId` + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return CreditCard + * @throws GatewayException|GatewayNotAvailableException|UnsupportedOperationException + * @throws \Potelo\MultiPayment\Exceptions\CardDeclinedException + */ + public function confirmCreditCardSetup(string $setupId, ?string $idempotencyKey = null): CreditCard + { + return $this->gateway->confirmCreditCardSetup($setupId, $idempotencyKey); + } + /** * Delete a credit card * diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index 1af2ad9..ad80349 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -112,7 +112,9 @@ public function testShouldRaiseChargingExceptionOnDeclinedCard($gateway) $this->assertEquals('card_declined', $exception->reason); $this->assertSame(DeclineCode::GENERIC, $exception->declineCode); $this->assertSame('generic_decline', $exception->gatewayCode); - $this->assertFalse($exception->retryable); + // a recusa acontece no SetupIntent que salva o token, onde a Stripe envia + // advice_code try_again_later para este cartão de teste + $this->assertTrue($exception->retryable); $this->assertNotEmpty($exception->chargeResponse); } } @@ -165,17 +167,23 @@ public function testShouldManageCreditCardLifecycle($gateway) ->setCustomerId($customer->id) ->setToken('pm_card_visa') ->setDescription('cartão de teste') + ->setAsDefault() ->create(); $this->assertNotNull($creditCard->id); + $this->assertFalse($creditCard->requiresAction); + $this->assertStringStartsWith('seti_', $creditCard->setupId); $this->assertEquals('visa', $creditCard->brand); $this->assertEquals('4242', $creditCard->lastDigits); $this->assertEquals('cartão de teste', $creditCard->description); $this->assertEquals($gateway, $creditCard->gateway); + // a descrição e a marcação de padrão viajam em metadata do SetupIntent e são aplicadas no setup concluído $cardFetched = MultiPayment::setGateway($gateway)->getCard($customer->id, $creditCard->id); $this->assertEquals($creditCard->id, $cardFetched->id); $this->assertEquals('4242', $cardFetched->lastDigits); + $this->assertEquals('cartão de teste', $cardFetched->description); + $this->assertEquals($creditCard->id, MultiPayment::setGateway($gateway)->getCustomer($customer->id)->defaultCard->id); $customerUpdated = MultiPayment::setGateway($gateway)->setDefaultCard($customer->id, $creditCard->id); $this->assertEquals($creditCard->id, $customerUpdated->defaultCard->id); @@ -187,6 +195,42 @@ public function testShouldManageCreditCardLifecycle($gateway) MultiPayment::setGateway($gateway)->getCard($customer->id, $creditCard->id); } + /** + * Cartão que exige autenticação do portador (`pm_card_authenticationRequired`) volta com + * `requiresAction` e sem id, e a confirmação antes de o pagador autenticar devolve o mesmo + * estado; nada é anexado ao cliente. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldReturnRequiresActionForACardThatNeedsAuthentication($gateway) + { + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + + $creditCard = MultiPayment::setGateway($gateway)->newCreditCard() + ->setCustomerId($customer->id) + ->setToken('pm_card_authenticationRequired') + ->setDescription('cartão com autenticação') + ->create(); + + $this->assertTrue($creditCard->requiresAction); + $this->assertNull($creditCard->id); + $this->assertStringStartsWith('seti_', $creditCard->setupId); + $this->assertNotEmpty($creditCard->clientSecret); + $this->assertNull($creditCard->actionUrl, 'sem return_url a autenticação é pelo Stripe.js'); + $this->assertEquals('3184', $creditCard->lastDigits); + $this->assertEquals('cartão com autenticação', $creditCard->description); + + $confirmed = MultiPayment::setGateway($gateway)->confirmCreditCardSetup($creditCard->setupId); + $this->assertTrue($confirmed->requiresAction); + $this->assertNull($confirmed->id); + $this->assertEquals($creditCard->setupId, $confirmed->setupId); + $this->assertEquals($customer->id, $confirmed->customer->id); + + $attached = $this->stripeClient()->paymentMethods->all(['customer' => $customer->id, 'type' => 'card']); + $this->assertCount(0, $attached->data, 'o cartão só é anexado depois da autenticação'); + } + /** * Deve criar fatura pix server-side com QR code e refletir o pagamento mágico da sandbox. * diff --git a/tests/Unit/CapabilityGuardsTest.php b/tests/Unit/CapabilityGuardsTest.php index 45bc827..6336cc9 100644 --- a/tests/Unit/CapabilityGuardsTest.php +++ b/tests/Unit/CapabilityGuardsTest.php @@ -377,6 +377,22 @@ public function testIuguInvoiceWithEveryMethodPassesTheGuardAndCreatesTheCustome $this->assertSame('inv_1', $invoice->id); } + /** + * A Iugu não autentica o portador ao salvar o cartão: concluir um setup é limitação do + * gateway, recusada antes de qualquer requisição. + */ + public function testConfirmCreditCardSetupOnIuguIsAGatewayLimitation(): void + { + $this->assertUnsupported( + Capability::CARD_SETUP_AUTHENTICATION, + UnsupportedOperationException::REASON_GATEWAY_LIMITATION, + 'iugu', + fn () => (new MultiPayment('iugu'))->confirmCreditCardSetup('seti_1') + ); + $this->assertFalse((new MultiPayment('iugu'))->supports(Capability::CARD_SETUP_AUTHENTICATION)); + $this->assertTrue((new MultiPayment('stripe'))->supports(Capability::CARD_SETUP_AUTHENTICATION)); + } + public function testFacadeExposesTheDeclarations(): void { $multiPayment = new MultiPayment('stripe'); @@ -399,7 +415,7 @@ public function testFacadeDocblockAnnotatesTheCapabilityMethods(): void { $docblock = (new \ReflectionClass(\Potelo\MultiPayment\Facades\MultiPayment::class))->getDocComment(); - foreach (['gateway(', 'supports(', 'capabilities(', 'notYetImplemented('] as $method) { + foreach (['gateway(', 'supports(', 'capabilities(', 'notYetImplemented(', 'confirmCreditCardSetup('] as $method) { $this->assertMatchesRegularExpression('/@method static .*' . preg_quote($method, '/') . '/', $docblock, $method); } } diff --git a/tests/Unit/Gateways/GatewayCapabilitiesTest.php b/tests/Unit/Gateways/GatewayCapabilitiesTest.php index 974c653..4741c67 100644 --- a/tests/Unit/Gateways/GatewayCapabilitiesTest.php +++ b/tests/Unit/Gateways/GatewayCapabilitiesTest.php @@ -71,6 +71,7 @@ public static function matrixProvider(): array Capability::AUTOMATIC_PIX->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], Capability::MULTIPLE_PAYMENT_METHODS->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], Capability::RAW_CARD_DATA->name => [self::SUPPORTED, self::LIMITATION], + Capability::CARD_SETUP_AUTHENTICATION->name => [self::LIMITATION, self::SUPPORTED], Capability::INSTALLMENTS->name => [self::SUPPORTED, self::LIMITATION], Capability::DELAYED_CAPTURE->name => [self::NOT_IMPLEMENTED, self::NOT_IMPLEMENTED], Capability::PARTIAL_REFUND_CARD->name => [self::SUPPORTED, self::SUPPORTED], diff --git a/tests/Unit/Gateways/Stripe/DeclineCodesTest.php b/tests/Unit/Gateways/Stripe/DeclineCodesTest.php index e5376ff..6ec3c70 100644 --- a/tests/Unit/Gateways/Stripe/DeclineCodesTest.php +++ b/tests/Unit/Gateways/Stripe/DeclineCodesTest.php @@ -41,6 +41,8 @@ public static function codeProvider(): array 'merchant_blacklist' => ['merchant_blacklist', DeclineCode::FRAUD_SUSPECTED], 'authentication_required' => ['authentication_required', DeclineCode::AUTHENTICATION_REQUIRED], 'authentication_not_handled' => ['authentication_not_handled', DeclineCode::AUTHENTICATION_REQUIRED], + 'setup_intent_authentication_failure' => ['setup_intent_authentication_failure', DeclineCode::AUTHENTICATION_REQUIRED], + 'payment_intent_authentication_failure' => ['payment_intent_authentication_failure', DeclineCode::AUTHENTICATION_REQUIRED], 'card_not_supported' => ['card_not_supported', DeclineCode::BRAND_NOT_SUPPORTED], 'currency_not_supported' => ['currency_not_supported', DeclineCode::BRAND_NOT_SUPPORTED], 'do_not_honor' => ['do_not_honor', DeclineCode::DO_NOT_HONOR], diff --git a/tests/Unit/Gateways/StripeGatewayCreditCardTest.php b/tests/Unit/Gateways/StripeGatewayCreditCardTest.php index 670a91e..07ab9f1 100644 --- a/tests/Unit/Gateways/StripeGatewayCreditCardTest.php +++ b/tests/Unit/Gateways/StripeGatewayCreditCardTest.php @@ -8,16 +8,28 @@ use Illuminate\Config\Repository; use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; +use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\CreditCard; +use Potelo\MultiPayment\Models\InvoiceItem; +use Potelo\MultiPayment\Enums\Capability; +use Potelo\MultiPayment\Enums\DeclineCode; +use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Gateways\StripeGateway; -use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\ChargingException; +use Potelo\MultiPayment\Exceptions\CardDeclinedException; use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; -use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; +/** + * Cartão no driver da Stripe: salvamento por SetupIntent confirmado na hora (cartão cobrável, + * cartão que exige autenticação do pagador, cartão recusado), conclusão do setup depois da + * autenticação, leitura e exclusão, tudo com o fake da camada HTTP do stripe-php. + */ class StripeGatewayCreditCardTest extends TestCase { + private const FIXTURES = __DIR__ . '/../../fixtures/stripe/setup_intents/'; + protected function setUp(): void { parent::setUp(); @@ -74,19 +86,33 @@ public function testCreateCreditCardRequiresCustomer(): void (new StripeGateway())->createCreditCard($creditCard); } - public function testCreateCreditCardAttachesTokenizedPaymentMethod(): void + /** + * O SetupIntent confirmado com cliente anexa o PaymentMethod; nenhum attach é enviado. + */ + public function testCreateCreditCardSavesTheCardThroughAConfirmedSetupIntent(): void { - $httpClient = RecordingStripeHttpClient::withResponses([$this->paymentMethodResponse()]); + $httpClient = RecordingStripeHttpClient::withResponses([$this->setupIntentResponse()]); $result = (new StripeGateway())->createCreditCard($this->creditCardModel()); $this->assertCount(1, $httpClient->calls); [$method, $url, $params] = $httpClient->calls[0]; $this->assertSame('post', $method); - $this->assertSame('/v1/payment_methods/pm_fake123/attach', parse_url($url, PHP_URL_PATH)); - $this->assertSame(['customer' => 'cus_fake123'], $params); + $this->assertSame('/v1/setup_intents', parse_url($url, PHP_URL_PATH)); + $this->assertSame([ + 'customer' => 'cus_fake123', + 'payment_method' => 'pm_fake123', + 'payment_method_types' => ['card'], + 'usage' => 'off_session', + 'confirm' => 'true', + 'expand' => ['payment_method'], + ], $params); $this->assertSame('pm_fake123', $result->id); + $this->assertSame('seti_fake123', $result->setupId); + $this->assertFalse($result->requiresAction); + $this->assertNull($result->clientSecret); + $this->assertNull($result->actionUrl); $this->assertSame('visa', $result->brand); $this->assertSame('4242', $result->lastDigits); $this->assertSame('08', $result->month); @@ -94,14 +120,38 @@ public function testCreateCreditCardAttachesTokenizedPaymentMethod(): void $this->assertSame('Faker', $result->firstName); $this->assertSame('Teste', $result->lastName); $this->assertSame('stripe', $result->gateway); + $this->assertInstanceOf(\Stripe\PaymentMethod::class, $result->original); $this->assertInstanceOf(Carbon::class, $result->createdAt); } + /** + * Resposta em que a Stripe devolve o PaymentMethod sem cliente: o driver anexa por conta + * própria antes de devolver o cartão. + */ + public function testCreateCreditCardAttachesWhenStripeReturnsTheCardWithoutCustomer(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->setupIntentResponse(paymentMethodCustomer: null), + $this->paymentMethodResponse(customer: 'cus_fake123'), + ]); + + $result = (new StripeGateway())->createCreditCard($this->creditCardModel()); + + $this->assertSame([ + 'post /v1/setup_intents', + 'post /v1/payment_methods/pm_fake123/attach', + ], $this->paths($httpClient)); + $this->assertSame(['customer' => 'cus_fake123'], $httpClient->calls[1][2]); + $this->assertSame('pm_fake123', $result->id); + $this->assertFalse($result->requiresAction); + } + public function testCreateDefaultCreditCardWithDescriptionIssuesExtraUpdates(): void { + $metadata = ['description' => 'cartão principal', 'set_as_default' => '1']; $httpClient = RecordingStripeHttpClient::withResponses([ - $this->paymentMethodResponse(), - $this->paymentMethodResponse(metadata: ['description' => 'cartão principal']), + $this->setupIntentResponse(metadata: $metadata), + $this->paymentMethodResponse(customer: 'cus_fake123', metadata: ['description' => 'cartão principal']), $this->stripeCustomerResponse(), ]); @@ -110,12 +160,12 @@ public function testCreateDefaultCreditCardWithDescriptionIssuesExtraUpdates(): $creditCard->default = true; $result = (new StripeGateway())->createCreditCard($creditCard); - $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); $this->assertSame([ - 'post /v1/payment_methods/pm_fake123/attach', + 'post /v1/setup_intents', 'post /v1/payment_methods/pm_fake123', 'post /v1/customers/cus_fake123', - ], $paths); + ], $this->paths($httpClient)); + $this->assertSame($metadata, $httpClient->calls[0][2]['metadata']); $this->assertSame(['metadata' => ['description' => 'cartão principal']], $httpClient->calls[1][2]); $this->assertSame( ['invoice_settings' => ['default_payment_method' => 'pm_fake123']], @@ -124,23 +174,319 @@ public function testCreateDefaultCreditCardWithDescriptionIssuesExtraUpdates(): $this->assertSame('cartão principal', $result->description); } + /** + * Resposta gravada na sandbox com `pm_card_visa`: o PaymentMethod expandido já vem com o + * cliente, e a descrição e a marcação de padrão em `metadata` do setup viram as duas + * escritas seguintes. + */ + public function testCreateCreditCardParsesTheRecordedSucceededSetupIntent(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('succeeded'), + $this->paymentMethodResponse(customer: 'cus_VBjzroZKS8d5LY', metadata: ['description' => 'cartão de teste']), + $this->stripeCustomerResponse(), + ]); + + $creditCard = $this->creditCardModel(); + $creditCard->customer->id = 'cus_VBjzroZKS8d5LY'; + $creditCard->description = 'cartão de teste'; + $creditCard->default = true; + $result = (new StripeGateway())->createCreditCard($creditCard); + + $this->assertSame([ + 'post /v1/setup_intents', + 'post /v1/payment_methods/pm_1UBMVgPjx0CusuMrtGi8Aruq', + 'post /v1/customers/cus_VBjzroZKS8d5LY', + ], $this->paths($httpClient)); + $this->assertSame( + ['invoice_settings' => ['default_payment_method' => 'pm_1UBMVgPjx0CusuMrtGi8Aruq']], + $httpClient->calls[2][2] + ); + $this->assertSame('seti_1UBMVgPjx0CusuMrtgTZPbBG', $result->setupId); + $this->assertFalse($result->requiresAction); + $this->assertSame('cartão de teste', $result->description); + } + + /** + * `metadata` de `gatewayOptions` convive com as chaves que o setup usa para a descrição + * e o padrão. + */ + public function testCreateCreditCardMergesTheConsumerMetadataWithTheSetupMetadata(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->setupIntentResponse(metadata: ['user_id' => '7', 'description' => 'cartão principal']), + $this->paymentMethodResponse(customer: 'cus_fake123', metadata: ['description' => 'cartão principal']), + ]); + + $creditCard = $this->creditCardModel(); + $creditCard->description = 'cartão principal'; + $creditCard->gatewayOptions = ['metadata' => ['user_id' => '7']]; + $result = (new StripeGateway())->createCreditCard($creditCard); + + $this->assertSame(['user_id' => '7', 'description' => 'cartão principal'], $httpClient->calls[0][2]['metadata']); + $this->assertSame('post /v1/payment_methods/pm_fake123', $this->paths($httpClient)[1]); + $this->assertSame('cartão principal', $result->description); + } + + public function testConfirmCreditCardSetupRefusesASetupWithoutCustomerBeforeAnyWrite(): void + { + $setupIntent = $this->setupIntentResponse(paymentMethodCustomer: null); + $setupIntent['customer'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$setupIntent]); + + try { + (new StripeGateway())->confirmCreditCardSetup('seti_fake123'); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('no customer', $e->getMessage()); + } + $this->assertSame(['get /v1/setup_intents/seti_fake123'], $this->paths($httpClient)); + } + public function testCreateCreditCardConvertsLegacyTokenIntoPaymentMethod(): void { $httpClient = RecordingStripeHttpClient::withResponses([ $this->paymentMethodResponse(), - $this->paymentMethodResponse(), + $this->setupIntentResponse(), ]); $creditCard = $this->creditCardModel(); $creditCard->token = 'tok_fake123'; (new StripeGateway())->createCreditCard($creditCard); - $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); $this->assertSame([ 'post /v1/payment_methods', - 'post /v1/payment_methods/pm_fake123/attach', - ], $paths); + 'post /v1/setup_intents', + ], $this->paths($httpClient)); $this->assertSame(['type' => 'card', 'card' => ['token' => 'tok_fake123']], $httpClient->calls[0][2]); + $this->assertSame('pm_fake123', $httpClient->calls[1][2]['payment_method']); + } + + /** + * Resposta gravada na sandbox com `pm_card_authenticationRequired`: o cartão volta sem id, + * com o que o navegador precisa para autenticar, e nada é anexado ao cliente. + */ + public function testCreateCreditCardReturnsRequiresActionWhenTheIssuerAsksForAuthentication(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('requires_action')]); + + $creditCard = $this->creditCardModel(); + $creditCard->description = 'cartão 3ds'; + $result = (new StripeGateway())->createCreditCard($creditCard); + + $this->assertSame(['post /v1/setup_intents'], $this->paths($httpClient)); + $this->assertTrue($result->requiresAction); + $this->assertNull($result->id); + $this->assertSame('seti_1UBMViPjx0CusuMrBNDQEzqv', $result->setupId); + $this->assertSame('seti_1UBMViPjx0CusuMrBNDQEzqv_secret_PLACEHOLDER', $result->clientSecret); + $this->assertNull($result->actionUrl, 'sem return_url a autenticação é pelo Stripe.js (use_stripe_sdk)'); + $this->assertSame('visa', $result->brand); + $this->assertSame('3184', $result->lastDigits); + $this->assertSame('09', $result->month); + $this->assertSame('2027', $result->year); + $this->assertSame('cartão 3ds', $result->description); + $this->assertSame('cus_fake123', $result->customer->id); + $this->assertSame('stripe', $result->gateway); + $this->assertInstanceOf(\Stripe\SetupIntent::class, $result->original); + $this->assertInstanceOf(Carbon::class, $result->createdAt); + } + + /** + * Com `return_url` em `gatewayOptions`, a Stripe devolve a página hospedada de 3DS em + * `next_action.redirect_to_url`, que vira `actionUrl`. + */ + public function testCreateCreditCardExposesTheHostedAuthenticationPageWhenReturnUrlIsGiven(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('requires_action_redirect')]); + + $creditCard = $this->creditCardModel(); + $creditCard->gatewayOptions = ['return_url' => 'https://exemplo.com/retorno']; + $result = (new StripeGateway())->createCreditCard($creditCard); + + $this->assertSame('https://exemplo.com/retorno', $httpClient->calls[0][2]['return_url']); + $this->assertTrue($result->requiresAction); + $this->assertStringStartsWith('https://hooks.stripe.com/3d_secure_2/hosted?', $result->actionUrl); + $this->assertSame('seti_1UBMVjPjx0CusuMr53vgIbuw', $result->setupId); + } + + /** + * Recusa no setup (resposta 402 gravada com `pm_card_chargeDeclined`) é `ChargingException` + * com o código traduzido e o `advice_code` decidindo `retryable`. + */ + public function testCreateCreditCardDeclinedAtSetupBecomesCardDeclinedException(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([[self::fixture('card_declined'), 402]]); + + try { + (new StripeGateway())->createCreditCard($this->creditCardModel()); + $this->fail('Esperava CardDeclinedException'); + } catch (CardDeclinedException $e) { + $this->assertInstanceOf(ChargingException::class, $e); + $this->assertSame(DeclineCode::GENERIC, $e->declineCode); + $this->assertSame('generic_decline', $e->gatewayCode); + $this->assertTrue($e->retryable, 'advice_code try_again_later'); + $this->assertSame(402, $e->httpStatus); + $this->assertSame('card_declined', $e->reason); + $this->assertInstanceOf(\Stripe\Exception\CardException::class, $e->getPrevious()); + $this->assertIsArray($e->chargeResponse); + $this->assertSame('generic_decline', $e->chargeResponse['decline_code']); + $this->assertSame('requires_payment_method', $e->chargeResponse['setup_intent']['status']); + } + $this->assertSame(['post /v1/setup_intents'], $this->paths($httpClient)); + } + + /** + * Depois da autenticação: o setup é lido, o cartão já está anexado, e a descrição e a + * marcação de padrão guardadas em `metadata` do setup são aplicadas. + */ + public function testConfirmCreditCardSetupAppliesTheSetupMetadataAndReturnsTheCard(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->setupIntentResponse(metadata: ['description' => 'cartão principal', 'set_as_default' => '1']), + $this->paymentMethodResponse(customer: 'cus_fake123', metadata: ['description' => 'cartão principal']), + $this->stripeCustomerResponse(), + ]); + + $result = (new StripeGateway())->confirmCreditCardSetup('seti_fake123'); + + $this->assertSame([ + 'get /v1/setup_intents/seti_fake123', + 'post /v1/payment_methods/pm_fake123', + 'post /v1/customers/cus_fake123', + ], $this->paths($httpClient)); + $this->assertSame(['expand' => ['payment_method']], $httpClient->calls[0][2]); + $this->assertSame('pm_fake123', $result->id); + $this->assertSame('seti_fake123', $result->setupId); + $this->assertFalse($result->requiresAction); + $this->assertSame('cartão principal', $result->description); + $this->assertTrue($result->default); + $this->assertSame('cus_fake123', $result->customer->id); + $this->assertSame('4242', $result->lastDigits); + } + + public function testConfirmCreditCardSetupKeepsRequiresActionWhileThePayerHasNotAuthenticated(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('requires_action')]); + + $result = (new StripeGateway())->confirmCreditCardSetup('seti_1UBMViPjx0CusuMrBNDQEzqv'); + + $this->assertSame(['get /v1/setup_intents/seti_1UBMViPjx0CusuMrBNDQEzqv'], $this->paths($httpClient)); + $this->assertTrue($result->requiresAction); + $this->assertNull($result->id); + $this->assertSame('seti_1UBMViPjx0CusuMrBNDQEzqv', $result->setupId); + $this->assertSame('cus_VBjzroZKS8d5LY', $result->customer->id); + $this->assertNotEmpty($result->clientSecret); + $this->assertSame('cartão 3ds', $result->description, 'a descrição volta de metadata do setup'); + $this->assertSame('3184', $result->lastDigits); + } + + /** + * Autenticação que falhou: o setup volta a `requires_payment_method` com + * `last_setup_error`, e a recusa pede ação do pagador. + */ + public function testConfirmCreditCardSetupOnAFailedAuthenticationThrowsCardDeclined(): void + { + RecordingStripeHttpClient::withResponses([ + $this->setupIntentResponse(status: 'requires_payment_method', paymentMethodCustomer: null, lastSetupError: [ + 'type' => 'invalid_request_error', + 'code' => 'setup_intent_authentication_failure', + 'message' => 'The latest attempt to set up the payment method has failed because authentication failed.', + ]), + ]); + + try { + (new StripeGateway())->confirmCreditCardSetup('seti_fake123'); + $this->fail('Esperava CardDeclinedException'); + } catch (CardDeclinedException $e) { + $this->assertSame(DeclineCode::AUTHENTICATION_REQUIRED, $e->declineCode); + $this->assertSame('setup_intent_authentication_failure', $e->gatewayCode); + $this->assertTrue($e->declineCode->requiresPayerAction()); + $this->assertFalse($e->retryable); + $this->assertNull($e->httpStatus); + $this->assertSame('seti_fake123', $e->chargeResponse['id']); + } + } + + public function testConfirmCreditCardSetupOnACanceledSetupThrowsCardDeclined(): void + { + RecordingStripeHttpClient::withResponses([ + $this->setupIntentResponse(status: 'canceled', paymentMethodCustomer: null, cancellationReason: 'abandoned'), + ]); + + try { + (new StripeGateway())->confirmCreditCardSetup('seti_fake123'); + $this->fail('Esperava CardDeclinedException'); + } catch (CardDeclinedException $e) { + $this->assertSame(DeclineCode::UNKNOWN, $e->declineCode); + $this->assertSame('abandoned', $e->gatewayCode); + $this->assertFalse($e->retryable); + $this->assertStringContainsString('canceled', $e->getMessage()); + $this->assertSame('seti_fake123', $e->chargeResponse['id']); + $this->assertSame('canceled', $e->chargeResponse['status']); + } + } + + /** + * Venda avulsa com token de cartão que exige autenticação: a cobrança fora de sessão não + * tem como atendê-la, e a fatura não chega a ser criada. + */ + public function testCreateInvoiceWithATokenThatRequiresAuthenticationFailsBeforeThePaymentIntent(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('requires_action')]); + + $invoice = new Invoice(); + $invoice->customer = new Customer(); + $invoice->customer->id = 'cus_fake123'; + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD]; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'pm_fake123'; + $item = new InvoiceItem(); + $item->description = 'Curso'; + $item->price = 19900; + $item->quantity = 1; + $invoice->items = [$item]; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Esperava CardDeclinedException'); + } catch (CardDeclinedException $e) { + $this->assertSame(DeclineCode::AUTHENTICATION_REQUIRED, $e->declineCode); + $this->assertSame('authentication_required', $e->gatewayCode); + $this->assertTrue($e->declineCode->requiresPayerAction()); + $this->assertFalse($e->retryable); + $this->assertStringContainsString('confirmCreditCardSetup', $e->getMessage()); + $this->assertSame('seti_1UBMViPjx0CusuMrBNDQEzqv', $e->chargeResponse['id']); + } + $this->assertSame(['post /v1/setup_intents'], $this->paths($httpClient), 'nenhum PaymentIntent é criado'); + } + + public function testCreditCardConfirmSetupDelegatesToTheGatewayWithItsSetupId(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->setupIntentResponse()]); + + $creditCard = new CreditCard(); + $creditCard->setupId = 'seti_fake123'; + $creditCard->requiresAction = true; + $creditCard->clientSecret = 'seti_fake123_secret_fake'; + $creditCard->customer = new Customer(); + $creditCard->customer->id = 'cus_fake123'; + $creditCard->customer->name = 'Faker Teste'; + $result = $creditCard->confirmSetup(new StripeGateway()); + + $this->assertSame(['get /v1/setup_intents/seti_fake123'], $this->paths($httpClient)); + $this->assertSame($creditCard, $result, 'o próprio model é atualizado e devolvido'); + $this->assertSame('pm_fake123', $creditCard->id); + $this->assertFalse($creditCard->requiresAction); + $this->assertNull($creditCard->clientSecret); + $this->assertSame('Faker Teste', $creditCard->customer->name, 'o customer já preenchido é mantido'); + } + + public function testCreditCardConfirmSetupRequiresTheSetupId(): void + { + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('setupId'); + + (new CreditCard())->confirmSetup(new StripeGateway()); } public function testGetCreditCardValidatesOwnershipWhenCustomerIsInformed(): void @@ -174,6 +520,7 @@ public function testGetCreditCardSkipsOwnershipCheckWhenCustomerOmitted(): void $this->assertSame('pm_fake123', $result->id); $this->assertSame('4242', $result->lastDigits); + $this->assertFalse($result->requiresAction); } public function testDeleteCreditCardDetachesThePaymentMethod(): void @@ -187,11 +534,20 @@ public function testDeleteCreditCardDetachesThePaymentMethod(): void $creditCard->id = 'pm_fake123'; (new StripeGateway())->deleteCreditCard($creditCard); - $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); $this->assertSame([ 'get /v1/payment_methods/pm_fake123', 'post /v1/payment_methods/pm_fake123/detach', - ], $paths); + ], $this->paths($httpClient)); + } + + private function paths(RecordingStripeHttpClient $httpClient): array + { + return array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + } + + private static function fixture(string $name): array + { + return json_decode(file_get_contents(self::FIXTURES . $name . '.json'), true); } private function creditCardModel(): CreditCard @@ -204,6 +560,33 @@ private function creditCardModel(): CreditCard return $creditCard; } + /** + * SetupIntent com o PaymentMethod expandido, no formato da resposta da Stripe. + */ + private function setupIntentResponse( + string $status = 'succeeded', + ?string $paymentMethodCustomer = 'cus_fake123', + array $metadata = [], + ?array $lastSetupError = null, + ?string $cancellationReason = null + ): array { + return [ + 'id' => 'seti_fake123', + 'object' => 'setup_intent', + 'status' => $status, + 'customer' => 'cus_fake123', + 'usage' => 'off_session', + 'client_secret' => 'seti_fake123_secret_fake', + 'created' => 1786700000, + 'payment_method_types' => ['card'], + 'metadata' => $metadata, + 'next_action' => null, + 'last_setup_error' => $lastSetupError, + 'cancellation_reason' => $cancellationReason, + 'payment_method' => $this->paymentMethodResponse(customer: $paymentMethodCustomer), + ]; + } + private function paymentMethodResponse(?string $customer = null, array $metadata = []): array { return [ diff --git a/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php index 72678fc..d5318eb 100644 --- a/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php +++ b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php @@ -309,9 +309,9 @@ function (StripeGateway $g, ?string $key) { return $g->createInvoice($invoice, $key); }, - [self::paymentMethodResponse(), self::paidCardPaymentIntentResponse()], + [self::setupIntentResponse(), self::paidCardPaymentIntentResponse()], [ - 'post /v1/payment_methods/pm_fake123/attach' => 'chave-1:card', + 'post /v1/setup_intents' => 'chave-1:card', 'post /v1/payment_intents' => 'chave-1', ], ], @@ -406,12 +406,12 @@ function (StripeGateway $g, ?string $key) { return $g->createCreditCard($creditCard, $key); }, [ - self::paymentMethodResponse(), - self::paymentMethodResponse(), + self::setupIntentResponse(metadata: ['description' => 'principal', 'set_as_default' => '1']), + self::paymentMethodResponse('cus_fake123'), self::stripeCustomerResponse(), ], [ - 'post /v1/payment_methods/pm_fake123/attach' => 'chave-1', + 'post /v1/setup_intents' => 'chave-1', 'post /v1/payment_methods/pm_fake123' => 'chave-1:metadata', 'post /v1/customers/cus_fake123' => 'chave-1:default', ], @@ -423,10 +423,31 @@ function (StripeGateway $g, ?string $key) { return $g->createCreditCard($creditCard, $key); }, - [self::paymentMethodResponse(), self::paymentMethodResponse()], + [self::paymentMethodResponse(), self::setupIntentResponse()], [ 'post /v1/payment_methods' => 'chave-1:payment_method', - 'post /v1/payment_methods/pm_fake123/attach' => 'chave-1', + 'post /v1/setup_intents' => 'chave-1', + ], + ], + 'createCreditCard anexando o cartão que a Stripe devolveu sem cliente' => [ + fn (StripeGateway $g, ?string $key) => $g->createCreditCard(self::creditCardModel(), $key), + [self::setupIntentResponse(paymentMethodCustomer: null), self::paymentMethodResponse('cus_fake123')], + [ + 'post /v1/setup_intents' => 'chave-1', + 'post /v1/payment_methods/pm_fake123/attach' => 'chave-1:attach', + ], + ], + 'confirmCreditCardSetup' => [ + fn (StripeGateway $g, ?string $key) => $g->confirmCreditCardSetup('seti_fake123', $key), + [ + self::setupIntentResponse(metadata: ['description' => 'principal', 'set_as_default' => '1']), + self::paymentMethodResponse('cus_fake123'), + self::stripeCustomerResponse(), + ], + [ + 'get /v1/setup_intents/seti_fake123' => null, + 'post /v1/payment_methods/pm_fake123' => 'chave-1:metadata', + 'post /v1/customers/cus_fake123' => 'chave-1:default', ], ], 'deleteCreditCard' => [ @@ -574,6 +595,27 @@ private static function paymentMethodResponse(?string $customer = null): array ]; } + /** + * SetupIntent confirmado, com o PaymentMethod expandido (anexado ao cliente por padrão). + */ + private static function setupIntentResponse(?string $paymentMethodCustomer = 'cus_fake123', array $metadata = []): array + { + return [ + 'id' => 'seti_fake123', + 'object' => 'setup_intent', + 'status' => 'succeeded', + 'customer' => 'cus_fake123', + 'usage' => 'off_session', + 'client_secret' => 'seti_fake123_secret_fake', + 'created' => 1786700000, + 'payment_method_types' => ['card'], + 'metadata' => $metadata, + 'next_action' => null, + 'last_setup_error' => null, + 'payment_method' => self::paymentMethodResponse($paymentMethodCustomer), + ]; + } + private static function paidCardPaymentIntentResponse(string $status = 'succeeded'): array { return [ diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index eb10b62..02f5d4b 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -98,25 +98,44 @@ public function testCreatesCreditCardInvoiceChargingSavedCard(): void $this->assertSame('stripe', $result->gateway); } + /** + * O token vira cartão salvo por um SetupIntent confirmado (que anexa o PaymentMethod ao + * cliente) antes do PaymentIntent da cobrança. + */ public function testCreatesCreditCardInvoiceSavingTokenizedCardFirst(): void { $httpClient = RecordingStripeHttpClient::withResponses([ - $this->paymentMethodResponse(), + [ + 'id' => 'seti_fake123', + 'object' => 'setup_intent', + 'status' => 'succeeded', + 'customer' => 'cus_fake123', + 'usage' => 'off_session', + 'client_secret' => 'seti_fake123_secret_fake', + 'created' => 1786700000, + 'metadata' => [], + 'next_action' => null, + 'last_setup_error' => null, + 'payment_method' => $this->paymentMethodResponse('cus_fake123'), + ], $this->paidCardPaymentIntentResponse(), ]); $invoice = $this->creditCardInvoiceModel(); $invoice->creditCard = new CreditCard(); $invoice->creditCard->token = 'pm_fake123'; - (new StripeGateway())->createInvoice($invoice); + $result = (new StripeGateway())->createInvoice($invoice); $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); $this->assertSame([ - 'post /v1/payment_methods/pm_fake123/attach', + 'post /v1/setup_intents', 'post /v1/payment_intents', ], $paths); $this->assertSame('cus_fake123', $httpClient->calls[0][2]['customer']); + $this->assertSame('pm_fake123', $httpClient->calls[0][2]['payment_method']); $this->assertSame('pm_fake123', $httpClient->calls[1][2]['payment_method']); + $this->assertSame('pm_fake123', $result->creditCard->id); + $this->assertFalse($result->creditCard->requiresAction); } public function testRejectsInvoiceWithMultiplePaymentMethods(): void diff --git a/tests/Unit/IdempotencyKeyPropagationTest.php b/tests/Unit/IdempotencyKeyPropagationTest.php index ed829d9..e99c421 100644 --- a/tests/Unit/IdempotencyKeyPropagationTest.php +++ b/tests/Unit/IdempotencyKeyPropagationTest.php @@ -98,6 +98,7 @@ public function testFacadeCustomerCardAndAutomaticPixOperationsPassTheKey(): voi 'setCustomerDefaultCard' => fn (Customer $c) => $c, 'cancelAutomaticPixRecurrence' => fn () => new AutomaticPixCancellation(), 'cancelAutomaticPixScheduledPayment' => fn () => new AutomaticPixCancellation(), + 'confirmCreditCardSetup' => fn () => new CreditCard(), ]); $payment = new MultiPayment($gateway); @@ -105,8 +106,10 @@ public function testFacadeCustomerCardAndAutomaticPixOperationsPassTheKey(): voi $payment->setDefaultCard('cus_1', 'pm_1', 'k-default'); $payment->cancelAutomaticPixRecurrence('rec_1', 'k-rec'); $payment->cancelAutomaticPixScheduledPayment('pay_1', 'E1', 'k-pay'); + $payment->confirmCreditCardSetup('seti_1', 'k-confirm'); - [$delete, $default, $recurrence, $payment] = array_column($this->calls, 1); + [$delete, $default, $recurrence, $payment, $confirm] = array_column($this->calls, 1); + $this->assertSame(['seti_1', 'k-confirm'], $confirm); $this->assertSame(['pm_1', 'cus_1', 'k-delete'], [$delete[0]->id, $delete[0]->customer->id, $delete[1]]); $this->assertSame(['cus_1', 'pm_1', 'k-default'], [$default[0]->id, $default[1], $default[2]]); $this->assertInstanceOf(AutomaticPix::class, $recurrence[0]); @@ -210,6 +213,7 @@ public function testModelSaveAndDeletePassTheKey(): void 'createCustomer' => fn (Customer $c) => $c, 'updateCustomer' => fn (Customer $c) => $c, 'deleteCreditCard' => fn () => null, + 'confirmCreditCardSetup' => fn () => new CreditCard(), ]); $customer = new Customer(); @@ -223,11 +227,17 @@ public function testModelSaveAndDeletePassTheKey(): void $creditCard->id = 'pm_1'; $creditCard->delete($gateway, 'k-delete'); + $pendingCard = new CreditCard(); + $pendingCard->setupId = 'seti_1'; + $pendingCard->confirmSetup($gateway, 'k-confirm'); + $this->assertSame([ ['createCustomer', 'k-create'], ['updateCustomer', 'k-update'], ['deleteCreditCard', 'k-delete'], + ['confirmCreditCardSetup', 'k-confirm'], ], array_map(fn (array $call) => [$call[0], $call[1][1]], $this->calls)); + $this->assertSame('seti_1', $this->calls[3][1][0]); } public function testInvoiceSaveCreatesTheCustomerWithADerivedKey(): void diff --git a/tests/Unit/ModelFillTest.php b/tests/Unit/ModelFillTest.php index 8f500b5..2a4cd7a 100644 --- a/tests/Unit/ModelFillTest.php +++ b/tests/Unit/ModelFillTest.php @@ -171,6 +171,38 @@ public function testKeysConsumedBySpecializedFillsAreStillAccepted(): void $this->assertTrue($card->default); } + /** + * O cartão que aguarda autenticação serializa `requires_action`, `setup_id`, + * `client_secret` e `action_url`, e volta igual por `fill()`; sem autenticação pendente, + * `requires_action` (falso) fica fora do array. + */ + public function testCreditCardPendingAuthenticationRoundTripsThroughToArrayAndFill(): void + { + $card = new CreditCard(); + $card->requiresAction = true; + $card->setupId = 'seti_1'; + $card->clientSecret = 'seti_1_secret'; + $card->actionUrl = 'https://exemplo.com/3ds'; + + $array = $card->toArray(); + $this->assertSame( + ['requires_action' => true, 'action_url' => 'https://exemplo.com/3ds', 'client_secret' => 'seti_1_secret', 'setup_id' => 'seti_1'], + array_intersect_key($array, array_flip(['requires_action', 'action_url', 'client_secret', 'setup_id'])) + ); + + $copy = new CreditCard(); + $copy->fill($array); + $this->assertTrue($copy->requiresAction); + $this->assertSame('seti_1', $copy->setupId); + $this->assertSame('seti_1_secret', $copy->clientSecret); + $this->assertSame('https://exemplo.com/3ds', $copy->actionUrl); + + $saved = new CreditCard(); + $saved->id = 'pm_1'; + $this->assertArrayNotHasKey('requires_action', $saved->toArray()); + $this->assertFalse($saved->requiresAction); + } + public function testFillableKeysAreTheSnakeCasePropertiesIncludingEnums(): void { $this->assertSame(['description', 'price', 'quantity', 'gateway_options'], InvoiceItem::fillableKeys()); @@ -181,6 +213,9 @@ public function testFillableKeysAreTheSnakeCasePropertiesIncludingEnums(): void } // o nome antigo é alias, fora da lista, como `gateway_adicional_options` $this->assertNotContains('expires_at', $keys); + foreach (['requires_action', 'action_url', 'client_secret', 'setup_id'] as $key) { + $this->assertContains($key, CreditCard::fillableKeys()); + } foreach (['payment_method', 'credit_card', 'trial_days', 'trial_ends_at'] as $key) { $this->assertContains($key, Subscription::fillableKeys()); } diff --git a/tests/Unit/MultiPaymentGatewayRoutingTest.php b/tests/Unit/MultiPaymentGatewayRoutingTest.php index 9e9938a..1ac7c28 100644 --- a/tests/Unit/MultiPaymentGatewayRoutingTest.php +++ b/tests/Unit/MultiPaymentGatewayRoutingTest.php @@ -40,6 +40,34 @@ protected function tearDown(): void parent::tearDown(); } + /** + * O cartão devolvido com `requiresAction` traz `gateway` preenchido, então + * `confirmSetup()` sem argumento resolve o mesmo gateway pela configuração. + */ + public function testConfirmSetupOnTheReturnedCardResolvesTheGatewayFromTheModel(): void + { + $requiresAction = json_decode(file_get_contents(__DIR__ . '/../fixtures/stripe/setup_intents/requires_action.json'), true); + $httpClient = RecordingStripeHttpClient::withResponses([$requiresAction, $requiresAction]); + + $card = (new MultiPayment('stripe'))->newCreditCard() + ->setCustomerId('cus_VBjzroZKS8d5LY') + ->setToken('pm_fake123') + ->create(); + $this->assertTrue($card->requiresAction); + $this->assertSame('stripe', $card->gateway); + + $confirmed = $card->confirmSetup(); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'post /v1/setup_intents', + 'get /v1/setup_intents/seti_1UBMViPjx0CusuMrBNDQEzqv', + ], $paths); + $this->assertSame($card, $confirmed); + $this->assertTrue($card->requiresAction); + $this->assertSame('stripe', $card->gateway); + } + public function testDuplicateInvoiceUsesTheSelectedGatewayInsteadOfTheDefault(): void { $pendingPix = [ diff --git a/tests/fixtures/stripe/README.md b/tests/fixtures/stripe/README.md index 50c4f7a..4d81c78 100644 --- a/tests/fixtures/stripe/README.md +++ b/tests/fixtures/stripe/README.md @@ -43,6 +43,21 @@ Montadas sobre `open_requires_payment_method.json`, porque a sandbox não produz `needs_response.json` é o GET de `/v1/disputes?charge=` do charge disputado; `lost.json` é o mesmo com o status trocado. +## `setup_intents/` + +Gravadas na sandbox em 2026-09-02, com `expand[]=payment_method` e o SetupIntent criado e +confirmado na mesma requisição (`usage: off_session`, `payment_method_types: ['card']`), como +o driver faz em `createCreditCard()`: + +| Arquivo | Como foi produzida | +|---|---| +| `succeeded.json` | `pm_card_visa`, com `metadata` de descrição e de padrão; o PaymentMethod expandido já vem com `customer` (a Stripe anexa ao confirmar) | +| `requires_action.json` | `pm_card_authenticationRequired`, sem `return_url`: `next_action` do tipo `use_stripe_sdk` (o objeto interno foi reduzido a alguns campos; o certificado do servidor de diretório saiu) e PaymentMethod sem `customer` | +| `requires_action_redirect.json` | idem, com `return_url`: `next_action` do tipo `redirect_to_url`, com a chave publicável trocada por placeholder na URL | +| `card_declined.json` | corpo da resposta 402 de `pm_card_chargeDeclined` (`card_declined`, `generic_decline`, `advice_code` `try_again_later`), com o SetupIntent em `requires_payment_method` dentro do erro | + +O `client_secret` de todas foi substituído por um placeholder. + ## `subscriptions/` Montadas a partir do objeto Subscription documentado para a API `2026-07-29.dahlia` (a diff --git a/tests/fixtures/stripe/setup_intents/card_declined.json b/tests/fixtures/stripe/setup_intents/card_declined.json new file mode 100644 index 0000000..bcb529c --- /dev/null +++ b/tests/fixtures/stripe/setup_intents/card_declined.json @@ -0,0 +1,165 @@ +{ + "error": { + "advice_code": "try_again_later", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "network_decline_code": "01", + "payment_method": { + "id": "pm_1UBMVkPjx0CusuMrbXKNAWgd", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "mO7AbKfepAraPY63", + "funding": "credit", + "generated_from": null, + "last4": "0002", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788387604, + "customer": null, + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "setup_intent": { + "id": "seti_1UBMVkPjx0CusuMrFSlrDDSN", + "object": "setup_intent", + "allowed_payment_method_types": null, + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1UBMVkPjx0CusuMrFSlrDDSN_secret_PLACEHOLDER", + "created": 1788387604, + "customer": "cus_VBjzroZKS8d5LY", + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "flow_directions": null, + "last_setup_error": { + "advice_code": "try_again_later", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "network_decline_code": "01", + "payment_method": { + "id": "pm_1UBMVkPjx0CusuMrbXKNAWgd", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "mO7AbKfepAraPY63", + "funding": "credit", + "generated_from": null, + "last4": "0002", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788387604, + "customer": null, + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "type": "card_error" + }, + "latest_attempt": "setatt_1UBMVkPjx0CusuMrpt6Apm5Y", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "mandate": null, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "single_use_mandate": null, + "status": "requires_payment_method", + "usage": "off_session" + }, + "type": "card_error" + } +} diff --git a/tests/fixtures/stripe/setup_intents/requires_action.json b/tests/fixtures/stripe/setup_intents/requires_action.json new file mode 100644 index 0000000..ab5c538 --- /dev/null +++ b/tests/fixtures/stripe/setup_intents/requires_action.json @@ -0,0 +1,104 @@ +{ + "id": "seti_1UBMViPjx0CusuMrBNDQEzqv", + "object": "setup_intent", + "allowed_payment_method_types": null, + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1UBMViPjx0CusuMrBNDQEzqv_secret_PLACEHOLDER", + "created": 1788387602, + "customer": "cus_VBjzroZKS8d5LY", + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1UBMViPjx0CusuMrZ5hah14v", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "mandate": null, + "metadata": { + "description": "cartão 3ds" + }, + "next_action": { + "type": "use_stripe_sdk", + "use_stripe_sdk": { + "type": "stripe_3ds2_fingerprint", + "directory_server_name": "visa", + "merchant": "acct_1TzKkTPjx0CusuMr", + "server_transaction_id": "356f0f99-bc7f-4969-b013-6c23de73a65d", + "three_d_secure_2_source": "setatt_1UBMViPjx0CusuMrZ5hah14v", + "three_ds_method_url": "" + } + }, + "on_behalf_of": null, + "payment_method": { + "id": "pm_1UBMViPjx0CusuMrMQHrkUVI", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "unchecked" + }, + "country": "DE", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "qePYzmiPHESInjMG", + "funding": "credit", + "generated_from": null, + "last4": "3184", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788387602, + "customer": null, + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "single_use_mandate": null, + "status": "requires_action", + "usage": "off_session" +} diff --git a/tests/fixtures/stripe/setup_intents/requires_action_redirect.json b/tests/fixtures/stripe/setup_intents/requires_action_redirect.json new file mode 100644 index 0000000..b6ee21f --- /dev/null +++ b/tests/fixtures/stripe/setup_intents/requires_action_redirect.json @@ -0,0 +1,98 @@ +{ + "id": "seti_1UBMVjPjx0CusuMr53vgIbuw", + "object": "setup_intent", + "allowed_payment_method_types": null, + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1UBMVjPjx0CusuMr53vgIbuw_secret_PLACEHOLDER", + "created": 1788387603, + "customer": "cus_VBjzroZKS8d5LY", + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1UBMVjPjx0CusuMrI3AVs7Hx", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "mandate": null, + "metadata": [], + "next_action": { + "redirect_to_url": { + "return_url": "https://exemplo.com/retorno", + "url": "https://hooks.stripe.com/3d_secure_2/hosted?merchant=acct_1TzKkTPjx0CusuMr&publishable_key=pk_test_PLACEHOLDER&setup_intent=seti_1UBMVjPjx0CusuMr53vgIbuw&setup_intent_client_secret=seti_1UBMVjPjx0CusuMr53vgIbuw_secret_PLACEHOLDER&source=setatt_1UBMVjPjx0CusuMrI3AVs7Hx" + }, + "type": "redirect_to_url" + }, + "on_behalf_of": null, + "payment_method": { + "id": "pm_1UBMVjPjx0CusuMr6jGTU0hn", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "unchecked" + }, + "country": "DE", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "qePYzmiPHESInjMG", + "funding": "credit", + "generated_from": null, + "last4": "3184", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788387603, + "customer": null, + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "single_use_mandate": null, + "status": "requires_action", + "usage": "off_session" +} diff --git a/tests/fixtures/stripe/setup_intents/succeeded.json b/tests/fixtures/stripe/setup_intents/succeeded.json new file mode 100644 index 0000000..3f8fb0c --- /dev/null +++ b/tests/fixtures/stripe/setup_intents/succeeded.json @@ -0,0 +1,95 @@ +{ + "id": "seti_1UBMVgPjx0CusuMrtgTZPbBG", + "object": "setup_intent", + "allowed_payment_method_types": null, + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1UBMVgPjx0CusuMrtgTZPbBG_secret_PLACEHOLDER", + "created": 1788387600, + "customer": "cus_VBjzroZKS8d5LY", + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1UBMVgPjx0CusuMry4kdz5w8", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "mandate": null, + "metadata": { + "description": "cartão de teste", + "set_as_default": "1" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": { + "id": "pm_1UBMVgPjx0CusuMrtGi8Aruq", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788387600, + "customer": "cus_VBjzroZKS8d5LY", + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "single_use_mandate": null, + "status": "succeeded", + "usage": "off_session" +} From 8e1dfe05e34a79ac69bb46cc16e852639ba59e2d Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Fri, 4 Sep 2026 21:21:50 -0300 Subject: [PATCH 29/32] feat(stripe): implementa plano e assinatura sobre Product, Price e Subscription e grava fixtures de webhook --- README.md | 124 +- src/Gateways/StripeGateway.php | 1231 ++++++++++++++++- tests/Integration/StripeSubscriptionTest.php | 213 +++ tests/Unit/CapabilityGuardsTest.php | 104 +- .../Unit/Gateways/GatewayCapabilitiesTest.php | 13 +- .../Gateways/StripeGatewayIdempotencyTest.php | 200 +++ tests/Unit/Gateways/StripeGatewayPlanTest.php | 348 +++++ .../StripeGatewaySubscriptionTest.php | 933 +++++++++++++ tests/fixtures/stripe/README.md | 38 +- .../fixtures/stripe/subscriptions/active.json | 142 +- .../active_cancel_at_period_end.json | 252 ++++ .../active_pause_collection.json | 162 ++- .../stripe/subscriptions/canceled.json | 166 ++- .../stripe/subscriptions/incomplete.json | 142 +- .../subscriptions/incomplete_expired.json | 144 +- .../stripe/subscriptions/past_due.json | 142 +- .../fixtures/stripe/subscriptions/paused.json | 148 +- .../stripe/subscriptions/trialing.json | 146 +- .../fixtures/stripe/subscriptions/unpaid.json | 142 +- .../webhooks/charge.dispute.created.json | 105 ++ .../stripe/webhooks/charge.refunded.json | 137 ++ .../customer.subscription.created.json | 191 +++ .../customer.subscription.deleted.json | 191 +++ .../customer.subscription.updated.json | 268 ++++ .../stripe/webhooks/invoice.created.json | 172 +++ .../stripe/webhooks/invoice.finalized.json | 172 +++ .../stripe/webhooks/invoice.paid.json | 172 +++ .../webhooks/invoice.payment_failed.json | 170 +++ .../payment_intent.payment_failed.json | 141 ++ .../webhooks/payment_intent.succeeded.json | 79 ++ .../webhooks/setup_intent.succeeded.json | 55 + 31 files changed, 6388 insertions(+), 255 deletions(-) create mode 100644 tests/Integration/StripeSubscriptionTest.php create mode 100644 tests/Unit/Gateways/StripeGatewayPlanTest.php create mode 100644 tests/Unit/Gateways/StripeGatewaySubscriptionTest.php create mode 100644 tests/fixtures/stripe/subscriptions/active_cancel_at_period_end.json create mode 100644 tests/fixtures/stripe/webhooks/charge.dispute.created.json create mode 100644 tests/fixtures/stripe/webhooks/charge.refunded.json create mode 100644 tests/fixtures/stripe/webhooks/customer.subscription.created.json create mode 100644 tests/fixtures/stripe/webhooks/customer.subscription.deleted.json create mode 100644 tests/fixtures/stripe/webhooks/customer.subscription.updated.json create mode 100644 tests/fixtures/stripe/webhooks/invoice.created.json create mode 100644 tests/fixtures/stripe/webhooks/invoice.finalized.json create mode 100644 tests/fixtures/stripe/webhooks/invoice.paid.json create mode 100644 tests/fixtures/stripe/webhooks/invoice.payment_failed.json create mode 100644 tests/fixtures/stripe/webhooks/payment_intent.payment_failed.json create mode 100644 tests/fixtures/stripe/webhooks/payment_intent.succeeded.json create mode 100644 tests/fixtures/stripe/webhooks/setup_intent.succeeded.json diff --git a/README.md b/README.md index c8b0c70..e3a9f23 100644 --- a/README.md +++ b/README.md @@ -176,14 +176,14 @@ coluna "Restrições" é o que `restriction()` devolve para cada gateway. | `INVOICE_CANCELLATION` | Cancelamento de uma fatura ainda não paga (`cancelInvoice`). | sim | sim | Stripe: A fatura de assinatura (objeto Invoice) só é anulada depois de finalizada pela Stripe; rascunho é recusado. | | `IDEMPOTENCY` | Chave de idempotência (`idempotencyKey`) honrada em toda operação de escrita, pelo gateway ou pela deduplicação da lib (`IdempotencyStore`). | sim | sim | | | `IDEMPOTENCY_ALL_ENDPOINTS` | Chave de idempotência honrada pelo próprio gateway em toda operação de escrita, sem depender da deduplicação da lib. | limitação do gateway | sim | | -| `SUBSCRIPTIONS` | Assinatura recorrente: criar, buscar, atualizar, suspender, retomar, cancelar, trocar de plano e listar. | sim | não implementado | | -| `PLANS` | Plano de assinatura: criar, buscar e listar. | sim | não implementado | | -| `PLAN_DEACTIVATION` | Desativar um plano sem apagá-lo (`deactivatePlan`). | limitação do gateway | não implementado | | -| `CANCEL_AT_PERIOD_END` | Cancelar a assinatura só no fim do período já pago (`cancel(atPeriodEnd: true)`). | limitação do gateway | não implementado | | +| `SUBSCRIPTIONS` | Assinatura recorrente: criar, buscar, atualizar, suspender, retomar, cancelar, trocar de plano e listar. | sim | sim | Stripe: nextBillingAt vale só na criação da assinatura; na troca de plano e na atualização a Stripe não aceita uma data arbitrária de próxima cobrança. | +| `PLANS` | Plano de assinatura: criar, buscar e listar. | sim | sim | | +| `PLAN_DEACTIVATION` | Desativar um plano sem apagá-lo (`deactivatePlan`). | limitação do gateway | sim | | +| `CANCEL_AT_PERIOD_END` | Cancelar a assinatura só no fim do período já pago (`cancel(atPeriodEnd: true)`). | limitação do gateway | sim | | | `NATIVE_COUPONS` | Cupom de primeira classe na assinatura: desconto percentual e desconto limitado a vários ciclos. | limitação do gateway | não implementado | | -| `PLAN_CHANGE_PRORATION` | Crédito proporcional do período não usado, calculado pelo gateway, ao trocar de plano (`changePlan()` com `ProrationBehavior::CREDIT`). | limitação do gateway | não implementado | | +| `PLAN_CHANGE_PRORATION` | Crédito proporcional do período não usado, calculado pelo gateway, ao trocar de plano (`changePlan()` com `ProrationBehavior::CREDIT`). | limitação do gateway | sim | | | `SUBSCRIPTION_CREDITS` | Assinatura com saldo de créditos consumíveis, abatidos a cada uso. | não implementado | limitação do gateway | | -| `MANAGES_RECURRENCE` | O gateway agenda as cobranças do Pix Automático por conta própria; sem ela, a aplicação é o motor de recorrência e chama as operações de `AutomaticPixContract` na periodicidade certa. | limitação do gateway | não implementado | | +| `MANAGES_RECURRENCE` | O gateway agenda as cobranças do Pix Automático por conta própria; sem ela, a aplicação é o motor de recorrência e chama as operações de `AutomaticPixContract` na periodicidade certa. | limitação do gateway | sim | | Sobre as restrições e algumas células: @@ -312,8 +312,7 @@ Os nove estados: A precedência na Iugu é a ordem em que o driver testa as flags: `suspended` (com ou sem a marca) vence `in_trial`, que vence a derivação de `past_due`, que vence `active`. A regra de `EXPIRED` na Iugu segue o painel do gateway (assinatura "Expirada") e o webhook -`subscription.expired`. O driver Stripe ainda não lê -assinatura (planejado para uma versão futura); o mapa acima é o que ele vai aplicar. Um status +`subscription.expired`. Um status fora do mapa vira `UNKNOWN`, com um aviso no log da aplicação (nível `warning`) contendo o valor original e o gateway. @@ -509,8 +508,7 @@ O que muda na fatura de origem `INVOICE`: se resolve com nova tentativa de pagamento da mesma fatura. - **`refundInvoice()`, `refundableAmount()` e `chargeInvoiceWithCreditCard()`** sobre a fatura de assinatura ainda não estão disponíveis (`UnsupportedOperationException`, `SUBSCRIPTIONS`, - `not_implemented`, antes de qualquer requisição); entram em uma versão futura junto com a - assinatura no Stripe. + `not_implemented`, antes de qualquer requisição); estão planejados para uma versão futura. **Precedência de status.** O status do Invoice da Stripe manda no ciclo de vida da fatura; o PaymentIntent e o charge só refinam o detalhe de pagamento. Um PaymentIntent `succeeded` não @@ -675,13 +673,14 @@ deduplica por conta própria com a `IdempotencyStore` (abaixo): | Criar fatura (`create()`, `charge()`), com Pix, boleto ou cartão | gateway (`POST /invoices` ou `POST /charge`) | gateway | | Cobrar fatura com cartão (`chargeInvoiceWithCreditCard`) | gateway (`POST /charge`) | gateway | | Criar cliente | gateway | gateway | -| Criar assinatura | gateway | (não implementado) | +| Criar assinatura | gateway | gateway | | Atualizar cliente, definir cartão padrão | store da lib | gateway | | Salvar cartão, excluir cartão | store da lib | gateway | | Concluir o setup do cartão (`confirmCreditCardSetup`) | (limitação do gateway) | gateway, nas escritas secundárias (`{chave}:attach`, `{chave}:metadata`, `{chave}:default`); a leitura do setup não leva chave | | Estornar, cancelar, duplicar fatura | store da lib | gateway | -| Suspender, retomar, cancelar, atualizar assinatura, trocar de plano | store da lib | (não implementado) | -| Criar plano | store da lib | (não implementado) | +| Suspender, retomar, cancelar, atualizar assinatura, trocar de plano | store da lib | gateway | +| Criar plano | store da lib | gateway | +| Desativar plano (`deactivatePlan`) | (limitação do gateway) | gateway | | Reagendar e cancelar Pix Automático | store da lib | (não implementado) | Quando uma operação faz mais de uma requisição de escrita (salvar o cartão antes de cobrar, @@ -1034,9 +1033,9 @@ $multiPayment->listAutomaticPixCancellations($recurrenceId, page: 1, limit: 100) #### Pix Automático: quem agenda a cobrança -Os dois gateways dividem a responsabilidade pela recorrência de forma oposta, e a lib ainda não -expõe essa diferença em código (uma capability declarada pelo gateway está planejada para uma -versão futura). Até lá, a regra é esta: +Os dois gateways dividem a responsabilidade pela recorrência de forma oposta, e +`supports(Capability::MANAGES_RECURRENCE)` diz de que lado cada um fica: falso na Iugu (a +aplicação agenda), verdadeiro no Stripe (o gateway agenda). A regra é esta: - **Na Iugu, a aplicação é o motor de recorrência.** A API cria a recorrência junto com a fatura e devolve o identificador, mas não controla a periodicidade das cobranças. É a @@ -1077,12 +1076,15 @@ que possam ser reativados quando o ambiente passar a suportar o fluxo. #### Assinaturas e planos -Assinatura recorrente está disponível no gateway Iugu. No Stripe ela ainda **não está -implementada nesta lib** (planejada para uma versão futura; o Stripe Billing oferece o -recurso). O `StripeGateway` lista `SUBSCRIPTIONS` e `PLANS` em `notYetImplemented()`, então -`save()`, `get()`, os métodos de domínio (`suspend()`, `resume()`, `cancel()`, `changePlan()`, -`previewPlanChange()`) e `listSubscriptions()`/`listPlans()` lançam -`UnsupportedOperationException` com `reason` `not_implemented`, antes de qualquer requisição. +Assinatura recorrente e plano estão disponíveis nos dois gateways. Na Iugu o plano é o +recurso de plano nativo; no Stripe ele vira um par Product e Price recorrente (o id do plano +é o id do Price, com prefixo `price_`), e a assinatura é a Subscription do Stripe Billing. O +desconto de assinatura no Stripe (Coupon) está planejado para uma versão futura: um +`SubscriptionDiscount` no Stripe lança `UnsupportedOperationException` (`NATIVE_COUPONS`, +`not_implemented`) antes de criar a assinatura. Desconto percentual e desconto com `cycles` +maior que 1 são recusados antes de qualquer requisição; o desconto simples de valor +(`amountOff`) passa pela capability do model (a Iugu o entrega sem cupom), então a recusa vem +do driver, depois de o cliente novo que acompanha a assinatura ter sido criado. ```php use Potelo\MultiPayment\Models\Plan; @@ -1094,7 +1096,7 @@ $plan->identifier = 'plano_mensal'; $plan->amount = 10000; // centavos $plan->interval = PlanInterval::MONTH; // DAY, WEEK, MONTH ou YEAR (a Iugu recusa DAY) $plan->intervalCount = 1; -$plan->save('iugu'); +$plan->save('iugu'); // ou save('stripe'): cria o Product e o Price $subscription = (new \Potelo\MultiPayment\MultiPayment('iugu')) ->newSubscription() @@ -1170,9 +1172,9 @@ e cada gateway a traduz para o próprio parâmetro: | `ProrationBehavior` | O que acontece | Iugu | Stripe | |---|---|---|---| -| `CHARGE_DIFFERENCE` (padrão) | O plano novo é cobrado agora; o gateway decide o que abater do período já pago | `POST change_plan`: fatura emitida na hora, sem crédito do período anterior (a Iugu acrescenta ciclos no downgrade) | `proration_behavior: always_invoice` (planejado para uma versão futura) | -| `NONE` | Nada é cobrado nem creditado agora; o plano novo vale a partir da próxima cobrança do ciclo | `PUT` com `skip_charge`, mantendo a data de cobrança (`nextBillingAt` preenchido vai junto) | `proration_behavior: none` (planejado para uma versão futura) | -| `CREDIT` | O gateway calcula o crédito do período não usado e o aplica na próxima fatura | `UnsupportedOperationException` (`PLAN_CHANGE_PRORATION`, `gateway_limitation`), antes de qualquer requisição | `proration_behavior: create_prorations` (planejado para uma versão futura) | +| `CHARGE_DIFFERENCE` (padrão) | O plano novo é cobrado agora; o gateway decide o que abater do período já pago | `POST change_plan`: fatura emitida na hora, sem crédito do período anterior (a Iugu acrescenta ciclos no downgrade) | `proration_behavior: always_invoice`: as linhas de pró-rata (crédito do período não usado e cobrança do plano novo) são faturadas e cobradas na hora, e a fatura volta em `latestInvoice` | +| `NONE` | Nada é cobrado nem creditado agora; o plano novo vale a partir da próxima cobrança do ciclo | `PUT` com `skip_charge`, mantendo a data de cobrança (`nextBillingAt` preenchido vai junto) | `proration_behavior: none`; `nextBillingAt` diferente do lido é recusado (a Stripe não aceita mudar a data da próxima cobrança na troca) | +| `CREDIT` | O gateway calcula o crédito do período não usado e o aplica na próxima fatura | `UnsupportedOperationException` (`PLAN_CHANGE_PRORATION`, `gateway_limitation`), antes de qualquer requisição | `proration_behavior: create_prorations`: crédito e cobrança proporcionais entram na próxima fatura | Como a política que o gateway não oferece é recusada antes da rede, consulte `supports(Capability::PLAN_CHANGE_PRORATION)` antes de oferecer a opção de crédito no @@ -1188,13 +1190,18 @@ $subscription->changePlan('plano_anual', ProrationBehavior::CREDIT); // Iugu: - **`amount`**: o que a troca cobraria agora, em centavos; quando há linhas, é a soma de `items`. - **`items`**: as linhas da fatura que a troca geraria, como `InvoiceItem` (crédito com `price` - negativo). A lista nunca é nula: a Iugu não devolve linhas em `change_plan_simulation`, então + negativo). A lista nunca é nula. No Stripe as linhas vêm reais, da prévia de fatura + (`invoices.create_preview`) com `always_invoice`, o mesmo fluxo de `CHARGE_DIFFERENCE`. A + Iugu não devolve linhas em `change_plan_simulation`, então a lib monta uma linha de cobrança do plano novo (`Plano `, valendo `cost` mais `discount`) e, quando `discount` é maior que zero, uma linha negativa de crédito do plano - antigo. O payload cru (`cost`, `discount`, `cycles`, `expires_at`, `old_plan`, `new_plan`) - segue em `original`. -- **`effectiveAt`**: a data em que a próxima cobrança acontece após a troca. -- **`appliesImmediately`**: se o plano novo passa a valer assim que a troca for aplicada. Na + antigo. O payload cru (o Invoice da prévia no Stripe; `cost`, `discount`, `cycles`, + `expires_at`, `old_plan` e `new_plan` na Iugu) segue em `original`. +- **`effectiveAt`**: a data em que a próxima cobrança acontece após a troca. No Stripe é o fim + de período da linha mais distante da prévia. +- **`appliesImmediately`**: se o plano novo passa a valer assim que a troca for aplicada. No + Stripe é sempre verdadeiro (a Stripe aplica o plano novo na hora, independente do + pagamento). Na Iugu é verdadeiro quando a assinatura é paga só com cartão (o cartão padrão é cobrado na hora) e falso quando ela aceita boleto ou Pix, porque a Iugu só efetiva a troca depois do pagamento da fatura gerada; uma assinatura aberta a mais de um método também lê como falso. @@ -1318,6 +1325,61 @@ ao que veio na leitura — um `save()` que mexeu só nos itens não altera a dat > com `only_charge_on_due_date`. `Subscription::$paymentMethod` passou a ser escrito > (`payable_with`) e lido; até a 4.1.0 era ignorado nas duas direções. +Particularidades do Stripe: + +- **O plano é um Product mais um Price recorrente.** `createPlan()` cria os dois: o Product + guarda o nome e o identificador (`metadata.identifier`), o Price guarda o valor e o + intervalo, e o id do plano é o id do Price (`price_...`). O identificador vai também em + `lookup_key` do Price, que é como `getPlan()` o encontra (um identificador com o prefixo + `price_` é lido direto como id); identificador repetido é recusado pela Stripe. Todos os + intervalos de `PlanInterval` valem, inclusive `DAY`. `deactivatePlan()` arquiva o Price: + as assinaturas existentes continuam cobrando e assinatura nova com o plano é recusada pela + Stripe; o plano segue legível, com `active` falso, e aparece em `listPlans()`. +- **O cartão fica na assinatura.** `setCreditCard()` vira o `default_payment_method` da + Subscription; o cartão padrão do cliente não muda (na Iugu muda, porque lá a assinatura não + tem cartão próprio). Cartão sem id é salvo antes pelo fluxo de SetupIntent: se o emissor + exigir autenticação do pagador, a assinatura não é criada e sobe `ChargingException` com + `DeclineCode::AUTHENTICATION_REQUIRED` e o SetupIntent em `chargeResponse`; conclua com + `confirmCreditCardSetup()` e crie a assinatura com o id do cartão salvo. +- **Com cartão, a primeira fatura é cobrada na criação** (`payment_behavior` + `error_if_incomplete`): a recusa do cartão sobe como `ChargingException` e a assinatura não + é criada. Com trial não há cobrança e a assinatura nasce `TRIALING` (a fatura de valor zero + do trial volta em `latestInvoice`). Os dias de `setTrialDays()` vão como + `trial_period_days`, que não muda entre tentativas com a mesma chave de idempotência. +- **Com Pix, a assinatura nasce com a primeira fatura em aberto** (`payment_behavior` + `default_incomplete`, status `PENDING`): `latestInvoice` traz a fatura para o pagador + quitar (`url` é a página hospedada). O método `pix` em assinatura depende de habilitação na + conta Stripe; sem ela, a criação é recusada pelo gateway com `ValidationException` + ("The payment method type `pix` is invalid"). Boleto em assinatura ainda não está + implementado nesta lib. +- **Itens extras criam Prices no Stripe.** Cada `SubscriptionItem` vira um subscription item + com um Product e um Price próprios, criados na hora (o item precisa de `description` e + `amount`); item com `recurring` falso vai como item avulso da primeira fatura. No update + declarativo, item novo cria Price, item com `id` tem a quantidade atualizada e mantém o + Price, item que saiu da lista é removido, e a troca não gera pró-rata. +- **`suspend()` pausa a cobrança** (`pause_collection` com `behavior` `void`) e a assinatura + lê como `PAUSED` (na Iugu, `SUSPENDED`); as faturas dos ciclos pausados são anuladas. + `resume()` desfaz a pausa e também o cancelamento agendado por `cancel(atPeriodEnd: true)`. + Assinatura cancelada de vez (`CANCELED`) não volta na Stripe: `resume()` é recusado pelo + gateway com `ValidationException` (na Iugu, `resume()` reativa a cancelada). +- **`cancel(atPeriodEnd: true)` é nativo**: a assinatura segue ativa até o fim do período + pago, com `cancelAtPeriodEnd` e `canceledAt` preenchidos no model. `cancel()` sem o + argumento cancela na hora, sem estorno nem fatura final. +- **`nextBillingAt` vale só na criação** (`billing_cycle_anchor`). Na troca de plano e na + atualização, um `nextBillingAt` diferente do que veio do gateway é recusado com + `UnsupportedOperationException` (restrição consultável de `SUBSCRIPTIONS`): a Stripe não + aceita uma data arbitrária de próxima cobrança fora do ciclo. Na leitura, `nextBillingAt` é + o fim do período corrente do item do plano. +- **Leituras custam requisições a mais.** `getSubscription()` (e a criação e a troca com + cobrança) relê a fatura mais recente para preencher `latestInvoice` por inteiro; + `listSubscriptions()` e `listPlans()` paginam por cursor, então uma página além da primeira + custa uma requisição por página anterior; `listSubscriptions()` traz assinaturas em + qualquer status, sem `latestInvoice`. +- **A escrita sobre a fatura de assinatura (`in_`) ainda não existe nesta lib**: estorno e + cobrança manual de uma fatura de assinatura lançam `UnsupportedOperationException` + (`SUBSCRIPTIONS`, `not_implemented`); a leitura por `getInvoice()` e o cancelamento por + `cancelInvoice()` (`void`) estão disponíveis. + Confira `src/Builders/SubscriptionBuilder.php` para saber quais métodos estão disponíveis. #### CustomerBuilder diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 584a1f6..ef6456e 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -4,11 +4,13 @@ use Carbon\Carbon; use Stripe\StripeClient; +use Stripe\Price as StripePrice; use Stripe\Invoice as StripeInvoice; use Stripe\Customer as StripeCustomer; use Stripe\PaymentIntent as StripePaymentIntent; use Stripe\PaymentMethod as StripePaymentMethod; use Stripe\SetupIntent as StripeSetupIntent; +use Stripe\Subscription as StripeSubscription; use Stripe\Exception\CardException; use Stripe\Exception\ApiErrorException; use Stripe\Exception\PermissionException; @@ -20,6 +22,7 @@ use Stripe\Exception\AuthenticationException as StripeAuthenticationException; use Illuminate\Support\Facades\Config; use Potelo\MultiPayment\Models\Pix; +use Potelo\MultiPayment\Models\Plan; use Potelo\MultiPayment\Models\Model; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Refund; @@ -28,20 +31,29 @@ use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\InvoiceItem; use Potelo\MultiPayment\Models\AutomaticPix; +use Potelo\MultiPayment\Models\Subscription; +use Potelo\MultiPayment\Models\SubscriptionItem; use Potelo\MultiPayment\Models\AutomaticPixCharge; +use Potelo\MultiPayment\Models\SubscriptionPlanChange; use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Enums\Capability; +use Potelo\MultiPayment\Enums\PlanInterval; use Potelo\MultiPayment\Enums\InvoiceStatus; use Potelo\MultiPayment\Enums\InvoiceOriginType; use Potelo\MultiPayment\Enums\RefundStatus; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Enums\DeclineCode; +use Potelo\MultiPayment\Enums\ProrationBehavior; use Potelo\MultiPayment\Helpers\LogHelper; use Potelo\MultiPayment\Capabilities\CapabilityRestriction; +use Potelo\MultiPayment\Contracts\PlanContract; use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Contracts\SubscriptionContract; use Potelo\MultiPayment\Gateways\Concerns\ChecksCapabilities; use Potelo\MultiPayment\Gateways\Concerns\ResolvesIdempotencyKey; use Potelo\MultiPayment\Gateways\Stripe\DeclineCodes as StripeDeclineCodes; +use Potelo\MultiPayment\Gateways\Stripe\ProrationBehaviors; +use Potelo\MultiPayment\Gateways\Stripe\SubscriptionStatuses; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; use Potelo\MultiPayment\Exceptions\NotFoundException; @@ -55,7 +67,7 @@ use Potelo\MultiPayment\Exceptions\IdempotencyConflictException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; -class StripeGateway implements GatewayContract +class StripeGateway implements GatewayContract, SubscriptionContract, PlanContract { use ChecksCapabilities; use ResolvesIdempotencyKey; @@ -99,6 +111,18 @@ class StripeGateway implements GatewayContract /** Prefixo do id de um objeto Invoice da Stripe; o de PaymentIntent é `pi_`. */ private const STRIPE_INVOICE_ID_PREFIX = 'in_'; + /** Prefixo do id de um Price da Stripe, que é o id do plano neste driver. */ + private const STRIPE_PRICE_ID_PREFIX = 'price_'; + + /** + * Expand de toda leitura ou escrita de Subscription: o PaymentMethod padrão diz com que + * método a assinatura é cobrada, e o Product de cada item dá a descrição dos itens. + */ + private const SUBSCRIPTION_EXPAND = ['default_payment_method', 'items.data.price.product']; + + /** Expand na leitura ou criação de um Price: o Product dá nome e identificador ao plano. */ + private const PRICE_EXPAND = ['product']; + /** Tipo de InvoicePayment cujo pagamento é um PaymentIntent. */ private const INVOICE_PAYMENT_TYPE_PAYMENT_INTENT = 'payment_intent'; @@ -171,6 +195,12 @@ public function capabilities(): array Capability::INVOICE_CANCELLATION, Capability::IDEMPOTENCY, Capability::IDEMPOTENCY_ALL_ENDPOINTS, + Capability::SUBSCRIPTIONS, + Capability::PLANS, + Capability::PLAN_DEACTIVATION, + Capability::CANCEL_AT_PERIOD_END, + Capability::PLAN_CHANGE_PRORATION, + Capability::MANAGES_RECURRENCE, ]; } @@ -184,13 +214,7 @@ public function notYetImplemented(): array Capability::AUTOMATIC_PIX, Capability::MULTIPLE_PAYMENT_METHODS, Capability::DELAYED_CAPTURE, - Capability::SUBSCRIPTIONS, - Capability::PLANS, - Capability::PLAN_DEACTIVATION, - Capability::CANCEL_AT_PERIOD_END, Capability::NATIVE_COUPONS, - Capability::PLAN_CHANGE_PRORATION, - Capability::MANAGES_RECURRENCE, ]; } @@ -201,6 +225,8 @@ public function notYetImplemented(): array * é recusada na cobrança com `DeclineCode::BRAND_NOT_SUPPORTED`. `INVOICE_DUPLICATION`: * só fatura Pix pendente de venda avulsa. `INVOICE_CANCELLATION`: a fatura de assinatura * (`in_`) só é anulada depois de finalizada pela Stripe; rascunho é recusado. + * `SUBSCRIPTIONS`: a Stripe só aceita `nextBillingAt` na criação da assinatura; na troca de + * plano e na atualização a data da próxima cobrança segue o ciclo. */ public function restrictions(): array { @@ -219,6 +245,10 @@ public function restrictions(): array description: 'A fatura de assinatura (objeto Invoice) só é anulada depois de finalizada' . ' pela Stripe; rascunho é recusado.', ), + Capability::SUBSCRIPTIONS->value => new CapabilityRestriction( + description: 'nextBillingAt vale só na criação da assinatura; na troca de plano e na' + . ' atualização a Stripe não aceita uma data arbitrária de próxima cobrança.', + ), ]; } @@ -1083,11 +1113,14 @@ private function retrieveStripeInvoice(string $id): StripeInvoice private function assertPaymentIntentOrigin(Invoice $invoice, string $operation): void { if (self::isStripeInvoiceId($invoice->id)) { - throw UnsupportedOperationException::forGateway( - $this, - Capability::SUBSCRIPTIONS, + // reason fixado em not_implemented: SUBSCRIPTIONS está em capabilities(), mas a + // escrita sobre a fatura de assinatura ainda não foi construída nesta lib + throw new UnsupportedOperationException( "A operação {$operation} sobre a fatura de assinatura [{$invoice->id}] (objeto Invoice da Stripe)" - . ' ainda não está implementada nesta lib; a leitura por getInvoice() está disponível.' + . ' ainda não está implementada nesta lib; a leitura por getInvoice() está disponível.', + (string) $this, + Capability::SUBSCRIPTIONS, + UnsupportedOperationException::REASON_NOT_IMPLEMENTED ); } } @@ -2541,6 +2574,1182 @@ private function fillCardFields(CreditCard $creditCard, ?object $card): void $creditCard->year = isset($card->exp_year) ? (string) $card->exp_year : null; } + /** + * @inheritDoc + * + * O plano vira um par Product e Price recorrente: o Product guarda o nome e o + * identificador (`metadata.identifier`), o Price guarda o valor e o intervalo, e o id do + * Price é o id do plano. O identificador vai também em `lookup_key` do Price, que é como + * `getPlan()` o encontra; identificador repetido é recusado pela Stripe. A chave de + * idempotência vai no cabeçalho `Idempotency-Key` da criação do Price; o Product usa a + * derivada `{chave}:product`. + */ + public function createPlan(Plan $plan, ?string $idempotencyKey = null): Plan + { + if (is_null($plan->interval)) { + throw ModelAttributeValidationException::required('Plan', 'interval'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $plan); + $identifier = $plan->identifier ?? $plan->name; + + $stripeProduct = $this->stripeRequest(function () use ($plan, $identifier, $idempotencyKey) { + return $this->client->products->create([ + 'name' => $plan->name, + 'metadata' => ['identifier' => $identifier], + ], self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'product'))); + }); + + $stripePriceData = [ + 'product' => $stripeProduct->id, + 'unit_amount' => $plan->amount, + 'currency' => strtolower($plan->currency ?? 'brl'), + 'recurring' => [ + 'interval' => $plan->interval->value, + 'interval_count' => $plan->intervalCount ?? 1, + ], + 'lookup_key' => $identifier, + 'nickname' => $plan->name, + ]; + $stripePriceData = $this->mergeGatewayOptions($stripePriceData, $plan); + + $stripePrice = $this->stripeRequest(function () use ($stripePriceData, $idempotencyKey) { + return $this->client->prices->create( + $this->withExpand($stripePriceData, self::PRICE_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseStripePlan($stripePrice, $plan); + } + + /** + * @inheritDoc + * + * Busca pelo `id` (id de Price, prefixo `price_`) ou pelo `identifier` (`lookup_key` do + * Price). Um `identifier` com o prefixo `price_` é tratado como id, o que poupa a segunda + * busca de `MultiPayment::getPlan()`; identificador sem Price correspondente lança + * `NotFoundException`. + */ + public function getPlan(Plan $plan): Plan + { + if (!empty($plan->id)) { + $priceId = $plan->id; + } elseif (!empty($plan->identifier)) { + if (!str_starts_with($plan->identifier, self::STRIPE_PRICE_ID_PREFIX)) { + return $this->parseStripePlan($this->findStripePriceByLookupKey($plan->identifier), $plan); + } + $priceId = $plan->identifier; + } else { + throw ModelAttributeValidationException::required('Plan', 'id or identifier'); + } + + $stripePrice = $this->stripeRequest(function () use ($priceId) { + return $this->client->prices->retrieve($priceId, ['expand' => self::PRICE_EXPAND]); + }); + + return $this->parseStripePlan($stripePrice, $plan); + } + + /** + * @inheritDoc + * + * Lista os Prices recorrentes, ativos e arquivados. A paginação da Stripe é por cursor, + * então uma página além da primeira custa uma requisição por página anterior; página além + * do fim devolve lista vazia. + */ + public function listPlans(int $page = 1, int $limit = 100): array + { + if ($page < 1) { + throw ModelAttributeValidationException::invalid('Plan', 'page', 'Plan page must be at least 1'); + } + + if ($limit < 1 || $limit > 100) { + throw ModelAttributeValidationException::invalid('Plan', 'limit', 'Plan limit must be between 1 and 100'); + } + + $stripePrices = $this->stripeListPage( + fn (array $params) => $this->client->prices->all($params), + ['type' => 'recurring', 'limit' => $limit, 'expand' => ['data.product']], + $page + ); + + return array_map(fn ($stripePrice) => $this->parseStripePlan($stripePrice), $stripePrices); + } + + /** + * @inheritDoc + * + * Arquiva o Price (`active` falso): as assinaturas existentes continuam cobrando e uma + * assinatura nova com esse plano é recusada pela Stripe; o Product fica ativo. A chave de + * idempotência vai no cabeçalho `Idempotency-Key` da atualização. + */ + public function deactivatePlan(Plan $plan, ?string $idempotencyKey = null): Plan + { + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $plan); + + if (!empty($plan->id)) { + $priceId = $plan->id; + } elseif (!empty($plan->identifier)) { + $priceId = $this->resolveStripePriceId($plan->identifier); + } else { + throw ModelAttributeValidationException::required('Plan', 'id or identifier'); + } + + $stripePrice = $this->stripeRequest(function () use ($priceId, $idempotencyKey) { + return $this->client->prices->update( + $priceId, + ['active' => false, 'expand' => self::PRICE_EXPAND], + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseStripePlan($stripePrice, $plan); + } + + /** + * Converte um Price da Stripe (com o Product expandido, quando veio) num plano do + * MultiPayment. O id do plano é o do Price; `identifier` vem de `lookup_key`, senão de + * `metadata.identifier` do Product; a moeda volta em maiúsculas, como na leitura da Iugu. + * + * @param \Stripe\Price $stripePrice + * @param Plan|null $plan + * @return Plan + */ + private function parseStripePlan(StripePrice $stripePrice, ?Plan $plan = null): Plan + { + $plan = $plan ?? new Plan(); + $stripeProduct = is_object($stripePrice->product ?? null) ? $stripePrice->product : null; + + $plan->id = $stripePrice->id; + $plan->identifier = $stripePrice->lookup_key + ?? $stripeProduct?->metadata['identifier'] + ?? $plan->identifier; + $plan->name = $stripeProduct?->name ?? $stripePrice->nickname ?? $plan->name; + $plan->amount = $stripePrice->unit_amount ?? $plan->amount; + $plan->interval = PlanInterval::tryFrom($stripePrice->recurring?->interval ?? '') ?? $plan->interval; + $plan->intervalCount = $stripePrice->recurring?->interval_count ?? $plan->intervalCount; + $plan->currency = isset($stripePrice->currency) ? strtoupper($stripePrice->currency) : $plan->currency; + $plan->active = $stripePrice->active ?? $plan->active; + $plan->gateway = 'stripe'; + $plan->original = $stripePrice; + + return $plan; + } + + /** + * Busca o Price de um `lookup_key`, com o Product expandido. A Stripe responde 200 com a + * lista vazia quando o identificador não existe; a lista vazia é traduzida em + * `NotFoundException`, como um 404 seria. + * + * @param string $lookupKey + * @return \Stripe\Price + * @throws NotFoundException|GatewayException|GatewayNotAvailableException + */ + private function findStripePriceByLookupKey(string $lookupKey): StripePrice + { + $stripePrices = $this->stripeRequest(function () use ($lookupKey) { + return $this->client->prices->all([ + 'lookup_keys' => [$lookupKey], + 'limit' => 1, + 'expand' => ['data.product'], + ]); + }); + + $stripePrice = $stripePrices->data[0] ?? null; + if (is_null($stripePrice)) { + throw new NotFoundException("No plan found with identifier [{$lookupKey}] on stripe."); + } + + return $stripePrice; + } + + /** + * Resolve o plano apontado pelo consumidor para um id de Price: um valor com o prefixo + * `price_` já é o id; outro valor é procurado como `lookup_key`. + * + * @param string $planId + * @return string + * @throws NotFoundException|GatewayException|GatewayNotAvailableException + */ + private function resolveStripePriceId(string $planId): string + { + if (str_starts_with($planId, self::STRIPE_PRICE_ID_PREFIX)) { + return $planId; + } + + return $this->findStripePriceByLookupKey($planId)->id; + } + + /** + * Uma página de uma lista da Stripe no modelo página e limite do pacote. A Stripe pagina + * por cursor (`starting_after`), então as páginas anteriores à pedida são percorridas, uma + * requisição por página. Devolve os objetos da página, vazia quando a lista acabou antes. + * + * @param callable $fetch recebe os parâmetros da listagem e devolve a `\Stripe\Collection` + * @param array $params + * @param int $page + * @return array + */ + private function stripeListPage(callable $fetch, array $params, int $page): array + { + for ($current = 1; ; $current++) { + $collection = $this->stripeRequest(fn () => $fetch($params)); + $data = $collection->data ?? []; + if ($current === $page) { + return $data; + } + if (empty($data) || empty($collection->has_more)) { + return []; + } + $params['starting_after'] = end($data)->id; + } + } + + /** + * @inheritDoc + * + * O plano (`planId`) é o `lookup_key` ou o id de um Price. Itens extras viram subscription + * items com Price criado sob demanda no intervalo do plano (um Product e um Price novos + * por item); item com `recurring` falso vai como item avulso da primeira fatura. O cartão + * de `creditCard` vira o `default_payment_method` da assinatura, sem mudar o cartão + * padrão do cliente; cartão sem `id` é salvo antes pelo fluxo de SetupIntent, e um + * emissor que exija autenticação interrompe a criação com `ChargingException` + * (`AUTHENTICATION_REQUIRED`). Com cartão ou sem método informado, a primeira fatura é + * cobrada na criação e a recusa sobe como `ChargingException` (`payment_behavior` + * `error_if_incomplete`); com Pix a assinatura nasce com a primeira fatura em aberto até o + * pagamento (`default_incomplete`), lida em `latestInvoice`, com a página hospedada em + * `url` para o pagador quitar. Boleto em + * assinatura ainda não está implementado neste driver, e desconto em `discounts` é + * recusado antes da rede (`NATIVE_COUPONS`). Os dias de `trialDays` vão como + * `trial_period_days`, que não muda entre tentativas com a mesma chave de idempotência; + * o model devolvido traz a data em `trialEndsAt` e `trialDays` zerado. `nextBillingAt` + * vira `billing_cycle_anchor`. + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` da criação; as requisições + * secundárias usam derivadas (`{chave}:card` no cartão salvo antes, + * `{chave}:item{N}_product` no Product de cada item extra). + * + * @throws ChargingException|NotFoundException|UnsupportedOperationException + */ + public function createSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription + { + $this->assertSupportsAll($subscription->requiredCapabilities()); + if (empty($subscription->customer) || empty($subscription->customer->id)) { + throw ModelAttributeValidationException::required('Subscription', 'customer'); + } + if (empty($subscription->planId)) { + throw ModelAttributeValidationException::required('Subscription', 'planId'); + } + $this->assertSubscriptionHasNoDiscounts($subscription); + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $paymentMethod = $this->subscriptionPaymentMethod($subscription); + $priceId = $this->resolveStripePriceId($subscription->planId); + + $stripeSubscriptionData = [ + 'customer' => $subscription->customer->id, + 'items' => [['price' => $priceId]], + 'collection_method' => 'charge_automatically', + 'payment_behavior' => $paymentMethod === PaymentMethod::PIX ? 'default_incomplete' : 'error_if_incomplete', + ]; + + if (!is_null($paymentMethod)) { + $stripeSubscriptionData['payment_settings'] = [ + 'payment_method_types' => [self::paymentMethodToStripeType($paymentMethod)], + ]; + } + + $defaultPaymentMethodId = $this->applyStripeSubscriptionCard($subscription, $idempotencyKey); + if (!is_null($defaultPaymentMethodId)) { + $stripeSubscriptionData['default_payment_method'] = $defaultPaymentMethodId; + } + + $trialDays = null; + if (empty($subscription->trialEndsAt) && !empty($subscription->trialDays)) { + $trialDays = $subscription->trialDays; + $stripeSubscriptionData['trial_period_days'] = $trialDays; + } elseif (!empty($subscription->trialEndsAt)) { + $stripeSubscriptionData['trial_end'] = $subscription->trialEndsAt->getTimestamp(); + } + + if (!empty($subscription->nextBillingAt)) { + $stripeSubscriptionData['billing_cycle_anchor'] = $subscription->nextBillingAt->getTimestamp(); + } + + $itemsData = $this->subscriptionItemsData($subscription->items ?? [], $priceId, $idempotencyKey); + $stripeSubscriptionData['items'] = array_merge($stripeSubscriptionData['items'], $itemsData['items']); + if (!empty($itemsData['add_invoice_items'])) { + $stripeSubscriptionData['add_invoice_items'] = $itemsData['add_invoice_items']; + } + + if (!empty($subscription->metadata)) { + $stripeSubscriptionData['metadata'] = $subscription->metadata; + } + + $stripeSubscriptionData = $this->mergeGatewayOptions($stripeSubscriptionData, $subscription); + + $stripeSubscription = $this->stripeRequest(function () use ($stripeSubscriptionData, $idempotencyKey) { + return $this->client->subscriptions->create( + $this->withExpand($stripeSubscriptionData, self::SUBSCRIPTION_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + if (!is_null($trialDays)) { + $subscription->trialDays = null; + } + + return $this->parseStripeSubscription($stripeSubscription, $subscription, true); + } + + /** + * @inheritDoc + * + * `latestInvoice` volta lido por inteiro (`parseFromStripeInvoice()`), o que custa a + * leitura da fatura além da assinatura. + */ + public function getSubscription(Subscription $subscription): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $stripeSubscription = $this->stripeRequest(function () use ($subscription) { + return $this->client->subscriptions->retrieve( + $subscription->id, + ['expand' => self::SUBSCRIPTION_EXPAND] + ); + }); + + return $this->parseStripeSubscription($stripeSubscription, $subscription, true); + } + + /** + * @inheritDoc + * + * Escreve o cartão (`default_payment_method`), o método de pagamento + * (`payment_settings`), o trial (`trial_end`; os dias de `trialDays` viram a data agora), + * `metadata` e os itens. `nextBillingAt` diferente do que veio do gateway é recusado: a + * Stripe não aceita mudar a data da próxima cobrança fora do ciclo. Nos itens, item novo + * cria Price sob demanda, item com `id` tem a quantidade atualizada e mantém o Price, e a + * troca não gera pró-rata (`proration_behavior` `none`). Desconto é recusado antes da + * rede (`NATIVE_COUPONS`). A chave de idempotência vai no cabeçalho da atualização e, + * derivada, nas requisições que a antecedem (`{chave}:card`, `{chave}:item{N}_product`). + * + * @throws ChargingException|UnsupportedOperationException + */ + public function updateSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + $this->assertSubscriptionHasNoDiscounts($subscription); + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $data = []; + + // o método é validado antes de o cartão ser salvo, para a recusa (boleto, mais de um + // método, cartão fora da lista) não deixar um cartão anexado ao cliente + $paymentMethod = $this->subscriptionPaymentMethod($subscription); + + $defaultPaymentMethodId = $this->applyStripeSubscriptionCard($subscription, $idempotencyKey); + if (!is_null($defaultPaymentMethodId)) { + $data['default_payment_method'] = $defaultPaymentMethodId; + } + + if (!is_null($paymentMethod) && !$this->isOriginalStripePaymentMethod($subscription, $paymentMethod)) { + $data['payment_settings'] = [ + 'payment_method_types' => [self::paymentMethodToStripeType($paymentMethod)], + ]; + } + + // a atualização só aceita a data do fim do trial, então os dias viram a data agora + if (empty($subscription->trialEndsAt) && !empty($subscription->trialDays)) { + $subscription->trialEndsAt = Carbon::now()->addDays($subscription->trialDays); + $subscription->trialDays = null; + } + if (!empty($subscription->trialEndsAt) && !$this->isOriginalStripeTrialEnd($subscription)) { + $data['trial_end'] = $subscription->trialEndsAt->getTimestamp(); + } + + $this->assertNextBillingAtIsUnchanged($subscription); + + if (!is_null($subscription->items)) { + $declared = $this->declarativeStripeItems($subscription, $idempotencyKey); + if (!empty($declared['items'])) { + $data['items'] = $declared['items']; + $data['proration_behavior'] = 'none'; + } + if (!empty($declared['add_invoice_items'])) { + $data['add_invoice_items'] = $declared['add_invoice_items']; + } + } + + if (!empty($subscription->metadata)) { + $data['metadata'] = $subscription->metadata; + } + + $data = $this->mergeGatewayOptions($data, $subscription); + + $stripeSubscription = $this->stripeRequest(function () use ($subscription, $data, $idempotencyKey) { + return $this->client->subscriptions->update( + $subscription->id, + $this->withExpand($data, self::SUBSCRIPTION_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseStripeSubscription($stripeSubscription, $subscription); + } + + /** + * @inheritDoc + * + * Pausa a cobrança (`pause_collection` com `behavior` `void`): a assinatura continua + * existindo na Stripe e lê como `PAUSED` nesta lib, e as faturas dos ciclos pausados são + * anuladas. A chave de idempotência vai no cabeçalho `Idempotency-Key` da atualização. + */ + public function suspendSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $stripeSubscription = $this->stripeRequest(function () use ($subscription, $idempotencyKey) { + return $this->client->subscriptions->update( + $subscription->id, + $this->withExpand(['pause_collection' => ['behavior' => 'void']], self::SUBSCRIPTION_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseStripeSubscription($stripeSubscription, $subscription); + } + + /** + * @inheritDoc + * + * Desfaz a pausa (`pause_collection`) e o cancelamento agendado (`cancel_at_period_end`) + * numa única atualização. Assinatura cancelada de vez (`CANCELED`) não volta na Stripe: a + * atualização é recusada pelo gateway e chega como `ValidationException`. A chave de + * idempotência vai no cabeçalho `Idempotency-Key` da atualização. + */ + public function resumeSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $stripeSubscription = $this->stripeRequest(function () use ($subscription, $idempotencyKey) { + return $this->client->subscriptions->update( + $subscription->id, + $this->withExpand([ + 'pause_collection' => '', + 'cancel_at_period_end' => false, + ], self::SUBSCRIPTION_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseStripeSubscription($stripeSubscription, $subscription); + } + + /** + * @inheritDoc + * + * Sem `atPeriodEnd`, cancela na hora (a assinatura lê como `CANCELED` e não volta). Com + * `atPeriodEnd`, grava `cancel_at_period_end`: a assinatura segue ativa até o fim do + * período pago e o model volta com `cancelAtPeriodEnd` e `canceledAt` preenchidos; + * `resumeSubscription()` desfaz. A chave de idempotência vai no cabeçalho das duas formas + * (no cancelamento imediato, um `DELETE`, a Stripe a ignora). + */ + public function cancelSubscription( + Subscription $subscription, + bool $atPeriodEnd = false, + ?string $idempotencyKey = null + ): Subscription { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $stripeSubscription = $this->stripeRequest(function () use ($subscription, $atPeriodEnd, $idempotencyKey) { + if ($atPeriodEnd) { + return $this->client->subscriptions->update( + $subscription->id, + $this->withExpand(['cancel_at_period_end' => true], self::SUBSCRIPTION_EXPAND), + self::stripeOptions($idempotencyKey) + ); + } + + return $this->client->subscriptions->cancel( + $subscription->id, + ['expand' => self::SUBSCRIPTION_EXPAND], + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseStripeSubscription($stripeSubscription, $subscription); + } + + /** + * @inheritDoc + * + * A troca escreve o Price novo no item do plano. `CHARGE_DIFFERENCE` vai como + * `always_invoice` (a pró-rata é faturada e cobrada na hora, e a fatura volta em + * `latestInvoice`); `NONE` como `none`; `CREDIT` como `create_prorations` (crédito e + * cobrança proporcionais ficam para a próxima fatura). `nextBillingAt` diferente do que + * veio do gateway é recusado: a Stripe não aceita mudar a data da próxima cobrança na + * troca. A chave de idempotência vai no cabeçalho da atualização; a leitura que acha o + * item do plano não a usa. + * + * @throws NotFoundException|UnsupportedOperationException + */ + public function changeSubscriptionPlan( + Subscription $subscription, + string $planId, + ProrationBehavior|bool $proration = ProrationBehavior::CHARGE_DIFFERENCE, + ?string $idempotencyKey = null, + ?bool $charge = null + ): Subscription { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + $proration = ProrationBehavior::resolve($charge ?? $proration); + if (!is_null($proration->requiredCapability())) { + $this->assertSupports($proration->requiredCapability()); + } + $this->assertNextBillingAtIsUnchanged($subscription); + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $priceId = $this->resolveStripePriceId($planId); + $planItem = $this->currentStripePlanItem($subscription); + + $stripeSubscription = $this->stripeRequest(function () use ($subscription, $planItem, $priceId, $proration, $idempotencyKey) { + return $this->client->subscriptions->update( + $subscription->id, + $this->withExpand([ + 'items' => [['id' => $planItem->id, 'price' => $priceId]], + 'proration_behavior' => ProrationBehaviors::toStripe($proration), + ], self::SUBSCRIPTION_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + $subscription->planId = $planId; + + return $this->parseStripeSubscription( + $stripeSubscription, + $subscription, + $proration === ProrationBehavior::CHARGE_DIFFERENCE + ); + } + + /** + * @inheritDoc + * + * Usa a prévia de fatura da Stripe (`invoices.create_preview`) com o item do plano + * apontando o Price novo e `always_invoice`, o mesmo fluxo de + * `ProrationBehavior::CHARGE_DIFFERENCE`; as linhas voltam reais em `items`, com o + * crédito do período não usado em `price` negativo. `effectiveAt` é o fim de período da + * linha mais distante, quando acontece a próxima cobrança normal; `appliesImmediately` é + * verdadeiro, porque a Stripe aplica o plano novo na hora, independente do pagamento. + * Além da prévia, custa a leitura da assinatura (o item do plano) e, quando `planId` é um + * identificador, a busca do Price. + */ + public function previewSubscriptionPlanChange(Subscription $subscription, string $planId): SubscriptionPlanChange + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $priceId = $this->resolveStripePriceId($planId); + $planItem = $this->currentStripePlanItem($subscription); + + $preview = $this->stripeRequest(function () use ($subscription, $planItem, $priceId) { + return $this->client->invoices->createPreview([ + 'subscription' => $subscription->id, + 'subscription_details' => [ + 'items' => [['id' => $planItem->id, 'price' => $priceId]], + 'proration_behavior' => 'always_invoice', + ], + ]); + }); + + $planChange = new SubscriptionPlanChange(); + $planChange->amount = $preview->total ?? null; + $items = []; + $effectiveAt = null; + foreach ($preview->lines->data ?? [] as $line) { + $invoiceItem = new InvoiceItem(); + $invoiceItem->description = $line->description ?? null; + $invoiceItem->quantity = isset($line->quantity) ? (int) $line->quantity : 1; + // usa o valor total da linha, que carrega o sinal: a linha de crédito vem negativa + $amount = isset($line->amount) ? (int) $line->amount : null; + $invoiceItem->price = $amount; + if (!is_null($amount) && $invoiceItem->quantity > 1) { + if ($amount % $invoiceItem->quantity === 0) { + $invoiceItem->price = intdiv($amount, $invoiceItem->quantity); + } else { + // valor que não divide pela quantidade vira uma linha de valor total, + // para a soma dos itens continuar igual a amount + $invoiceItem->quantity = 1; + } + } + $items[] = $invoiceItem; + + $periodEnd = $line->period->end ?? null; + if (!empty($periodEnd) && (is_null($effectiveAt) || $periodEnd > $effectiveAt)) { + $effectiveAt = $periodEnd; + } + } + $planChange->items = $items; + if (!is_null($effectiveAt)) { + $planChange->effectiveAt = Carbon::createFromTimestamp($effectiveAt); + } + $planChange->appliesImmediately = true; + $planChange->gateway = 'stripe'; + $planChange->original = $preview; + + return $planChange; + } + + /** + * @inheritDoc + * + * Traz assinaturas em qualquer status (`status` `all`), sem `latestInvoice` (use + * `getSubscription()` para a fatura). A paginação da Stripe é por cursor, então uma + * página além da primeira custa uma requisição por página anterior. + */ + public function listSubscriptions(Customer $customer, int $page = 1, int $limit = 100): array + { + if (empty($customer->id)) { + throw ModelAttributeValidationException::required('Customer', 'id'); + } + + if ($page < 1) { + throw ModelAttributeValidationException::invalid('Subscription', 'page', 'Subscription page must be at least 1'); + } + + if ($limit < 1 || $limit > 100) { + throw ModelAttributeValidationException::invalid('Subscription', 'limit', 'Subscription limit must be between 1 and 100'); + } + + $stripeSubscriptions = $this->stripeListPage( + fn (array $params) => $this->client->subscriptions->all($params), + [ + 'customer' => $customer->id, + 'status' => 'all', + 'limit' => $limit, + 'expand' => ['data.default_payment_method'], + ], + $page + ); + + return array_map( + fn ($stripeSubscription) => $this->parseStripeSubscription($stripeSubscription), + $stripeSubscriptions + ); + } + + /** + * Resolve o único método de pagamento da assinatura, como `invoicePaymentMethod()` faz + * para a fatura; nulo quando o model não aponta método (a Stripe cobra o método padrão do + * cliente). Mais de um método é recusado (`MULTIPLE_PAYMENT_METHODS`), e um método que o + * driver ainda não cobre em assinatura (boleto) também (`BANK_SLIP`). + * + * @param Subscription $subscription + * @return PaymentMethod|null + * @throws ModelAttributeValidationException|UnsupportedOperationException + */ + private function subscriptionPaymentMethod(Subscription $subscription): ?PaymentMethod + { + $methods = $subscription->resolvedPaymentMethods(); + + if (count($methods) > 1) { + throw UnsupportedOperationException::forGateway( + $this, + Capability::MULTIPLE_PAYMENT_METHODS, + 'Informe exatamente um método em availablePaymentMethods.' + ); + } + + $method = empty($methods) ? null : reset($methods); + if (!is_null($method) && $method !== PaymentMethod::CREDIT_CARD && $method !== PaymentMethod::PIX) { + throw UnsupportedOperationException::forGateway($this, Capability::forPaymentMethod($method)); + } + + return $method; + } + + /** + * Tipo de PaymentMethod da Stripe para um método do pacote (o inverso de + * `PAYMENT_METHOD_TYPES`). + * + * @param PaymentMethod $paymentMethod + * @return string + */ + private static function paymentMethodToStripeType(PaymentMethod $paymentMethod): string + { + return (string) array_search($paymentMethod, self::PAYMENT_METHOD_TYPES, true); + } + + /** + * Recusa antes da rede uma assinatura com descontos: o cupom da Stripe (Coupon) está + * planejado para uma versão futura desta lib (`NATIVE_COUPONS`). + * + * @param Subscription $subscription + * @return void + * @throws UnsupportedOperationException + */ + private function assertSubscriptionHasNoDiscounts(Subscription $subscription): void + { + if (!empty($subscription->discounts)) { + throw UnsupportedOperationException::forGateway( + $this, + Capability::NATIVE_COUPONS, + 'Desconto de assinatura no Stripe está planejado para uma versão futura desta lib.' + ); + } + } + + /** + * Cartão que a assinatura cobra: devolve o id do PaymentMethod para + * `default_payment_method`. Cartão sem `id` é salvo antes por `createCreditCard()` (chave + * derivada `{chave}:card`); um emissor que exija autenticação do pagador interrompe a + * operação com `ChargingException` (`AUTHENTICATION_REQUIRED`), com o SetupIntent em + * `chargeResponse`; conclua com `confirmCreditCardSetup()` e use o id do cartão salvo. O + * cartão padrão do cliente não muda (na Iugu muda, porque lá a assinatura não tem cartão + * próprio). Sem cartão no model devolve nulo. + * + * @param Subscription $subscription + * @param string|null $idempotencyKey + * @return string|null + * @throws ChargingException|ModelAttributeValidationException + */ + private function applyStripeSubscriptionCard(Subscription $subscription, ?string $idempotencyKey): ?string + { + $creditCard = $subscription->creditCard; + if (empty($creditCard)) { + return null; + } + if (!empty($creditCard->id)) { + return $creditCard->id; + } + + if (empty($creditCard->customer)) { + $creditCard->customer = $subscription->customer; + } + $subscription->creditCard = $this->createCreditCard( + $creditCard, + self::derivedIdempotencyKey($idempotencyKey, 'card') + ); + if ($subscription->creditCard->requiresAction) { + $exception = ChargingException::declined( + 'stripe', + DeclineCode::AUTHENTICATION_REQUIRED, + 'authentication_required', + 'O emissor exige autenticação do pagador para este cartão; salve-o com createCreditCard(),' + . ' conclua a autenticação com confirmCreditCardSetup() e use o id do cartão salvo na assinatura.' + ); + $exception->chargeResponse = $subscription->creditCard->original?->toArray(); + + throw $exception; + } + + return $subscription->creditCard->id; + } + + /** + * Payload dos itens extras da assinatura: item com `recurring` verdadeiro vira um + * subscription item com Price recorrente criado sob demanda no intervalo do plano; item + * com `recurring` falso vira um item avulso da primeira fatura (`add_invoice_items`). + * Cada item cria um Product no Stripe (`price_data` exige um Product existente), com a + * chave derivada `{chave}:item{N}_product`. + * + * @param SubscriptionItem[] $items + * @param string $planPriceId + * @param string|null $idempotencyKey + * @return array{items: array, add_invoice_items: array} + * @throws ModelAttributeValidationException + */ + private function subscriptionItemsData(array $items, string $planPriceId, ?string $idempotencyKey): array + { + $data = ['items' => [], 'add_invoice_items' => []]; + $recurring = null; + + foreach (array_values($items) as $index => $item) { + if (empty($item->description)) { + throw ModelAttributeValidationException::required('SubscriptionItem', 'description'); + } + if (is_null($item->amount)) { + throw ModelAttributeValidationException::required('SubscriptionItem', 'amount'); + } + + $stripeProduct = $this->stripeRequest(function () use ($item, $index, $idempotencyKey) { + return $this->client->products->create( + ['name' => $item->description], + self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, "item{$index}_product")) + ); + }); + + $priceData = [ + 'currency' => 'brl', + 'product' => $stripeProduct->id, + 'unit_amount' => $item->amount, + ]; + + if ($item->recurring) { + // todo item recorrente precisa do mesmo intervalo de cobrança do plano + $recurring = $recurring ?? $this->stripePriceRecurring($planPriceId); + $priceData['recurring'] = $recurring; + $data['items'][] = ['price_data' => $priceData, 'quantity' => $item->quantity ?? 1]; + } else { + $data['add_invoice_items'][] = ['price_data' => $priceData, 'quantity' => $item->quantity ?? 1]; + } + } + + return $data; + } + + /** + * Intervalo de cobrança de um Price, no formato de `price_data.recurring`. + * + * @param string $priceId + * @return array{interval: string, interval_count: int} + */ + private function stripePriceRecurring(string $priceId): array + { + $stripePrice = $this->stripeRequest(function () use ($priceId) { + return $this->client->prices->retrieve($priceId); + }); + + return [ + 'interval' => $stripePrice->recurring->interval ?? 'month', + 'interval_count' => $stripePrice->recurring->interval_count ?? 1, + ]; + } + + /** + * Itens da atualização declarativa: os subscription items atuais fora da lista desejada + * são removidos (o item do plano fica), item com `id` tem a quantidade atualizada e item + * novo cria Price sob demanda no intervalo do plano; item com `recurring` falso vai como + * item avulso da próxima fatura. Faz um GET na assinatura para conhecer o estado atual. + * + * @param Subscription $subscription + * @param string|null $idempotencyKey + * @return array{items: array, add_invoice_items: array} + */ + private function declarativeStripeItems(Subscription $subscription, ?string $idempotencyKey): array + { + $current = $this->stripeRequest(function () use ($subscription) { + return $this->client->subscriptions->retrieve($subscription->id); + }); + $currentItems = $current->items->data ?? []; + $planItem = self::stripePlanItem($currentItems, $subscription->planId); + if (is_null($planItem) || empty($planItem->price->id ?? null)) { + throw new NotFoundException("No plan item found on subscription [{$subscription->id}] on stripe."); + } + + $keptIds = []; + foreach ($subscription->items as $item) { + if (!empty($item->id)) { + $keptIds[] = (string) $item->id; + } + } + + $entries = []; + foreach ($currentItems as $stripeItem) { + $id = $stripeItem->id ?? null; + if (empty($id) || $id === ($planItem->id ?? null) || in_array((string) $id, $keptIds, true)) { + continue; + } + $entries[] = ['id' => $id, 'deleted' => true]; + } + + $newItems = []; + foreach ($subscription->items as $item) { + if (!empty($item->id)) { + if (!is_null($item->quantity)) { + $entries[] = ['id' => $item->id, 'quantity' => $item->quantity]; + } + continue; + } + $newItems[] = $item; + } + + $created = $this->subscriptionItemsData($newItems, $planItem->price->id, $idempotencyKey); + + return [ + 'items' => array_merge($entries, $created['items']), + 'add_invoice_items' => $created['add_invoice_items'], + ]; + } + + /** + * Item do plano da assinatura, lido do gateway. + * + * @param Subscription $subscription + * @return object + * @throws NotFoundException|GatewayException|GatewayNotAvailableException + */ + private function currentStripePlanItem(Subscription $subscription): object + { + $current = $this->stripeRequest(function () use ($subscription) { + return $this->client->subscriptions->retrieve($subscription->id); + }); + + $planItem = self::stripePlanItem($current->items->data ?? [], $subscription->planId); + if (is_null($planItem) || empty($planItem->id)) { + throw new NotFoundException("No plan item found on subscription [{$subscription->id}] on stripe."); + } + + return $planItem; + } + + /** + * Item do plano entre os subscription items: o que aponta o Price cujo `lookup_key` ou id + * é o `planId` conhecido. Sem correspondência, o único item cujo Price tem `lookup_key` + * (os Prices criados sob demanda para itens extras não têm um); em último caso, o item + * mais antigo, porque o do plano nasce com a assinatura e os extras entram depois. + * + * @param array $stripeItems + * @param string|null $planId + * @return object|null + */ + private static function stripePlanItem(array $stripeItems, ?string $planId): ?object + { + if (!is_null($planId)) { + foreach ($stripeItems as $stripeItem) { + $price = $stripeItem->price ?? null; + if (($price->lookup_key ?? null) === $planId || ($price->id ?? null) === $planId) { + return $stripeItem; + } + } + } + + $withLookupKey = array_values(array_filter( + $stripeItems, + static fn ($stripeItem) => !empty($stripeItem->price->lookup_key ?? null) + )); + if (count($withLookupKey) === 1) { + return $withLookupKey[0]; + } + + $oldest = null; + foreach ($stripeItems as $stripeItem) { + if (is_null($oldest) || ($stripeItem->created ?? PHP_INT_MAX) < ($oldest->created ?? PHP_INT_MAX)) { + $oldest = $stripeItem; + } + } + + return $oldest; + } + + /** + * Recusa `nextBillingAt` diferente do que veio do gateway: fora da criação, a Stripe não + * aceita uma data arbitrária de próxima cobrança (a restrição consultável de + * `SUBSCRIPTIONS`). Um model lido do gateway, com a data que ele mesmo devolveu, passa. + * + * @param Subscription $subscription + * @return void + * @throws UnsupportedOperationException + */ + private function assertNextBillingAtIsUnchanged(Subscription $subscription): void + { + if (empty($subscription->nextBillingAt)) { + return; + } + + $original = $subscription->original->items->data ?? []; + $planItem = self::stripePlanItem(is_array($original) ? $original : [], $subscription->planId); + $originalPeriodEnd = $planItem->current_period_end ?? null; + // igualdade exata: a leitura preenche nextBillingAt com este mesmo timestamp, então + // qualquer diferença é uma mudança pedida pelo consumidor + if (!empty($originalPeriodEnd) && (int) $originalPeriodEnd === $subscription->nextBillingAt->getTimestamp()) { + return; + } + + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::SUBSCRIPTIONS, + 'A Stripe não aceita definir a data da próxima cobrança de uma assinatura existente;' + . ' nextBillingAt vale só na criação (billing_cycle_anchor).' + ); + } + + /** + * Diz se o método informado é o mesmo que veio do gateway na leitura + * (`payment_settings.payment_method_types`). + * + * @param Subscription $subscription + * @param PaymentMethod $paymentMethod + * @return bool + */ + private function isOriginalStripePaymentMethod(Subscription $subscription, PaymentMethod $paymentMethod): bool + { + $original = $subscription->original->payment_settings->payment_method_types ?? null; + + return is_array($original) && $original === [self::paymentMethodToStripeType($paymentMethod)]; + } + + /** + * Diz se o fim do trial informado é o mesmo que veio do gateway na leitura (`trial_end`). + * + * @param Subscription $subscription + * @return bool + */ + private function isOriginalStripeTrialEnd(Subscription $subscription): bool + { + $original = $subscription->original->trial_end ?? null; + + return !empty($original) && (int) $original === $subscription->trialEndsAt->getTimestamp(); + } + + /** + * Converte a Subscription da Stripe numa assinatura do MultiPayment. + * + * O item cujo Price é o plano (`stripePlanItem()`) dá o `planId` (`lookup_key`, senão o + * id do Price) e a próxima cobrança (`current_period_end`); os demais itens viram + * `items`, e `amount` é a soma dos itens por ciclo. `paymentMethod` vem do PaymentMethod + * padrão expandido, senão do único tipo em `payment_settings`. Com $withLatestInvoice, a + * fatura mais recente é lida por inteiro (`parseFromStripeInvoice()`), uma leitura a + * mais. + * + * @param \Stripe\Subscription $stripeSubscription + * @param Subscription|null $subscription + * @param bool $withLatestInvoice + * @return Subscription + * @throws GatewayException|GatewayNotAvailableException + */ + private function parseStripeSubscription( + StripeSubscription $stripeSubscription, + ?Subscription $subscription = null, + bool $withLatestInvoice = false + ): Subscription { + $subscription = $subscription ?? new Subscription(); + + $subscription->id = $stripeSubscription->id ?? $subscription->id; + $subscription->status = SubscriptionStatuses::toSubscriptionStatus($stripeSubscription); + + $stripeItems = $stripeSubscription->items->data ?? []; + $planItem = self::stripePlanItem($stripeItems, $subscription->planId); + $planPrice = $planItem->price ?? null; + $subscription->planId = $planPrice->lookup_key ?? $planPrice->id ?? $subscription->planId; + + $amount = 0; + $hasAmount = false; + $items = []; + foreach ($stripeItems as $stripeItem) { + $unitAmount = $stripeItem->price->unit_amount ?? null; + $quantity = (int) ($stripeItem->quantity ?? 1); + if (!is_null($unitAmount)) { + $amount += $unitAmount * $quantity; + $hasAmount = true; + } + if (($stripeItem->id ?? null) === ($planItem->id ?? null)) { + continue; + } + $items[] = $this->parseStripeSubscriptionItem($stripeItem); + } + if ($hasAmount) { + $subscription->amount = $amount; + } + if (!empty($stripeItems)) { + $subscription->items = $items; + } + + $customerId = is_object($stripeSubscription->customer ?? null) + ? $stripeSubscription->customer->id + : ($stripeSubscription->customer ?? null); + if (!empty($customerId)) { + // com um id diferente, manter os atributos antigos produziria um Customer com id + // de um e documento de outro + if (is_null($subscription->customer) || $subscription->customer->id !== $customerId) { + $subscription->customer = new Customer(); + } + $subscription->customer->id = $customerId; + } + + if (!empty($planItem->current_period_end ?? null)) { + $subscription->nextBillingAt = Carbon::createFromTimestamp($planItem->current_period_end); + } + if (!empty($stripeSubscription->trial_end ?? null)) { + $subscription->trialEndsAt = Carbon::createFromTimestamp($stripeSubscription->trial_end); + } + $subscription->cancelAtPeriodEnd = (bool) ($stripeSubscription->cancel_at_period_end ?? false); + $subscription->canceledAt = !empty($stripeSubscription->canceled_at ?? null) + ? Carbon::createFromTimestamp($stripeSubscription->canceled_at) + : null; + if (!empty($stripeSubscription->created ?? null)) { + $subscription->createdAt = Carbon::createFromTimestamp($stripeSubscription->created); + } + + if (isset($stripeSubscription->metadata)) { + $metadata = $stripeSubscription->metadata; + $subscription->metadata = is_object($metadata) && method_exists($metadata, 'toArray') + ? $metadata->toArray() + : (array) $metadata; + } + + $defaultPaymentMethod = $stripeSubscription->default_payment_method ?? null; + $method = is_object($defaultPaymentMethod) + ? (self::PAYMENT_METHOD_TYPES[$defaultPaymentMethod->type ?? ''] ?? null) + : null; + $types = $stripeSubscription->payment_settings->payment_method_types ?? null; + if (is_array($types)) { + $methods = array_values(array_filter(array_map( + static fn ($type) => self::PAYMENT_METHOD_TYPES[$type] ?? null, + $types + ))); + if (!empty($methods)) { + $subscription->availablePaymentMethods = $methods; + $method = $method ?? (count($methods) === 1 ? $methods[0] : null); + } + } + if (!is_null($method)) { + $subscription->paymentMethod = $method; + } + + if ($withLatestInvoice) { + $latestInvoiceId = is_object($stripeSubscription->latest_invoice ?? null) + ? $stripeSubscription->latest_invoice->id + : ($stripeSubscription->latest_invoice ?? null); + if (!empty($latestInvoiceId)) { + $subscription->latestInvoice = $this->parseInvoice($this->retrieveStripeInvoice($latestInvoiceId)); + } + } + + $subscription->gateway = 'stripe'; + $subscription->original = $stripeSubscription; + + return $subscription; + } + + /** + * Converte um subscription item da Stripe (fora o do plano) num item de assinatura. A + * descrição vem do Product expandido, senão do apelido do Price. + * + * @param object $stripeItem + * @return SubscriptionItem + */ + private function parseStripeSubscriptionItem(object $stripeItem): SubscriptionItem + { + $price = $stripeItem->price ?? null; + $product = is_object($price->product ?? null) ? $price->product : null; + + $item = new SubscriptionItem(); + $item->id = $stripeItem->id ?? null; + $item->description = $product?->name ?? $price->nickname ?? null; + $item->amount = $price->unit_amount ?? null; + $item->quantity = isset($stripeItem->quantity) ? (int) $stripeItem->quantity : null; + $item->recurring = true; + + return $item; + } + /** * @inheritDoc */ diff --git a/tests/Integration/StripeSubscriptionTest.php b/tests/Integration/StripeSubscriptionTest.php new file mode 100644 index 0000000..831a220 --- /dev/null +++ b/tests/Integration/StripeSubscriptionTest.php @@ -0,0 +1,213 @@ + Config::get('multi-payment.gateways.stripe.api_key'), + 'stripe_version' => StripeGateway::STRIPE_API_VERSION, + ]); + + foreach ($this->subscriptionsCriadas as $id) { + try { + $client->subscriptions->cancel($id); + } catch (\Throwable $e) { + // limpeza é best effort: assinatura já cancelada pelo teste + } + } + + foreach ($this->pricesCriados as $id) { + try { + $client->prices->update($id, ['active' => false]); + } catch (\Throwable $e) { + // limpeza é best effort: falha ao arquivar não invalida o teste + } + } + + parent::tearDown(); + } + + private function createPlan(string $gateway, int $amount, string $sufixo, PlanInterval $interval = PlanInterval::MONTH): Plan + { + $plan = new Plan(); + $plan->name = 'MultiPayment teste ' . $sufixo; + $plan->identifier = 'multipayment-teste-' . $sufixo . '-' . now()->format('YmdHisu'); + $plan->amount = $amount; + $plan->interval = $interval; + $plan->save($gateway); + $this->pricesCriados[] = $plan->id; + + return $plan; + } + + /** + * Deve criar o plano como Price recorrente, buscá-lo pelo identificador e arquivá-lo. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCreateGetAndDeactivateAPlan($gateway) + { + $plan = $this->createPlan($gateway, 10000, 'plano'); + + $this->assertStringStartsWith('price_', $plan->id); + $this->assertSame(PlanInterval::MONTH, $plan->interval); + $this->assertSame('BRL', $plan->currency); + $this->assertTrue($plan->active); + + $found = MultiPayment::setGateway($gateway)->getPlan($plan->identifier); + $this->assertSame($plan->id, $found->id); + $this->assertSame($plan->identifier, $found->identifier); + $this->assertSame(10000, $found->amount); + } + + /** + * Deve arquivar o plano por deactivatePlan, mantendo-o legível. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldDeactivateAPlan($gateway) + { + $plan = $this->createPlan($gateway, 10000, 'arquivar'); + + $deactivated = MultiPayment::setGateway($gateway)->gateway()->deactivatePlan($plan); + + $this->assertFalse($deactivated->active); + + $found = MultiPayment::setGateway($gateway)->getPlan($plan->id); + $this->assertFalse($found->active); + } + + /** + * Deve criar a assinatura com trial em dias no cartão de teste, trocar o plano com + * crédito, agendar o cancelamento ao fim do período, desfazê-lo e cancelar de vez. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldRunTheSubscriptionLifecycleWithTrialAndCredit($gateway) + { + $mensal = $this->createPlan($gateway, 10000, 'mensal'); + $anual = $this->createPlan($gateway, 90000, 'anual', PlanInterval::YEAR); + + $customerData = self::customerWithoutAddress(); + $customer = new Customer(); + $customer->name = $customerData['name']; + $customer->email = $customerData['email']; + $customer->taxDocument = $customerData['taxDocument']; + + $creditCard = new CreditCard(); + $creditCard->token = 'pm_card_visa'; + + $subscription = MultiPayment::setGateway($gateway)->newSubscription() + ->setPlanId($mensal->identifier) + ->setCustomer($customer) + ->setCreditCard($creditCard) + ->setTrialDays(7) + ->create(); + $this->subscriptionsCriadas[] = $subscription->id; + + $this->assertSame(SubscriptionStatus::TRIALING, $subscription->status); + $this->assertNull($subscription->trialDays); + // uma hora de tolerância, para o teste não depender de fuso nem da virada do dia + $this->assertEqualsWithDelta( + now()->addDays(7)->getTimestamp(), + $subscription->trialEndsAt->getTimestamp(), + 3600 + ); + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->paymentMethod); + $this->assertSame($mensal->identifier, $subscription->planId); + + // troca com crédito: a pró-rata fica para a próxima fatura, nada é cobrado agora + $subscription = $subscription->changePlan($anual->identifier, ProrationBehavior::CREDIT, $gateway); + $this->assertSame($anual->identifier, $subscription->planId); + + $subscription = $subscription->cancel(true, $gateway); + $this->assertTrue($subscription->cancelAtPeriodEnd); + $this->assertNotNull($subscription->canceledAt); + $this->assertNotSame(SubscriptionStatus::CANCELED, $subscription->status); + + $subscription = $subscription->resume($gateway); + $this->assertFalse($subscription->cancelAtPeriodEnd); + $this->assertNull($subscription->canceledAt); + + $subscription = $subscription->cancel(false, $gateway); + $this->assertSame(SubscriptionStatus::CANCELED, $subscription->status); + $this->assertNotNull($subscription->canceledAt); + } + + /** + * Deve simular a troca de plano com as linhas reais de pró-rata da Stripe. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldPreviewAPlanChangeWithRealLines($gateway) + { + $mensal = $this->createPlan($gateway, 10000, 'mensal-preview'); + $anual = $this->createPlan($gateway, 90000, 'anual-preview', PlanInterval::YEAR); + + $customerData = self::customerWithoutAddress(); + $customer = new Customer(); + $customer->name = $customerData['name']; + $customer->email = $customerData['email']; + $customer->taxDocument = $customerData['taxDocument']; + + $creditCard = new CreditCard(); + $creditCard->token = 'pm_card_visa'; + + $subscription = MultiPayment::setGateway($gateway)->newSubscription() + ->setPlanId($mensal->identifier) + ->setCustomer($customer) + ->setCreditCard($creditCard) + ->create(); + $this->subscriptionsCriadas[] = $subscription->id; + + $preview = $subscription->previewPlanChange($anual->identifier, $gateway); + + $this->assertNotEmpty($preview->items); + $this->assertTrue($preview->appliesImmediately); + $this->assertSame($preview->amount, array_sum(array_map( + static fn ($item) => $item->price * ($item->quantity ?? 1), + $preview->items + ))); + $credito = array_filter($preview->items, static fn ($item) => $item->price < 0); + $this->assertNotEmpty($credito, 'a prévia deve trazer a linha de crédito do período não usado'); + } +} diff --git a/tests/Unit/CapabilityGuardsTest.php b/tests/Unit/CapabilityGuardsTest.php index 6336cc9..c5e1264 100644 --- a/tests/Unit/CapabilityGuardsTest.php +++ b/tests/Unit/CapabilityGuardsTest.php @@ -8,14 +8,12 @@ use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; use Potelo\MultiPayment\MultiPayment; -use Potelo\MultiPayment\Models\Plan; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Models\Subscription; +use Potelo\MultiPayment\Models\SubscriptionDiscount; use Potelo\MultiPayment\Enums\Capability; -use Potelo\MultiPayment\Enums\PlanInterval; -use Potelo\MultiPayment\Enums\ProrationBehavior; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Gateways\StripeGateway; @@ -61,7 +59,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testSubscriptionCreationOnStripeFailsBeforeCreatingTheCustomer(): void + public function testPercentDiscountSubscriptionOnStripeFailsBeforeCreatingTheCustomer(): void { $customer = new Customer(); $customer->name = 'Fulano'; @@ -69,72 +67,88 @@ public function testSubscriptionCreationOnStripeFailsBeforeCreatingTheCustomer() $builder = (new MultiPayment('stripe'))->newSubscription() ->setPlanId('plano_mensal') - ->setCustomer($customer); + ->setCustomer($customer) + ->addPercentDiscount('Promo', 10.0); - $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $builder->create()); + $this->assertNotImplemented(Capability::NATIVE_COUPONS, fn () => $builder->create()); } - public function testSubscriptionDomainMethodsOnStripeFailBeforeTheNetwork(): void + public function testBankSlipSubscriptionOnStripeFailsBeforeCreatingTheCustomer(): void { - $subscription = new Subscription(); - $subscription->id = 'sub_1'; + $customer = new Customer(); + $customer->name = 'Fulano'; + $customer->email = 'fulano@exemplo.com'; - $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->get('stripe')); - $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->suspend('stripe')); - foreach (ProrationBehavior::cases() as $proration) { - $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->changePlan('plano_anual', $proration, 'stripe')); - } - $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->previewPlanChange('plano_anual', 'stripe')); - $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => (new MultiPayment('stripe'))->getSubscription('sub_1')); - $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->resume('stripe')); - $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->cancel(false, 'stripe')); - $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => (new MultiPayment('stripe'))->listSubscriptions('cus_1')); - - $existing = new Subscription(); - $existing->id = 'sub_1'; - $existing->metadata = ['origem' => 'teste']; - $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $existing->save('stripe')); + $builder = (new MultiPayment('stripe'))->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomer($customer) + ->setAvailablePaymentMethods([PaymentMethod::BANK_SLIP]); + + $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $builder->create()); + } + + /** + * Desconto simples (`amountOff`) passa pela capability do model (a Iugu o entrega sem + * cupom), então o driver Stripe o recusa por conta própria, antes de qualquer requisição. + */ + public function testSubscriptionDiscountsOnStripeAreRefusedBeforeTheNetwork(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + + $gateway = new StripeGateway(); + + $creating = new Subscription(); + $creating->customer = new Customer(); + $creating->customer->id = 'cus_1'; + $creating->planId = 'plano_mensal'; + $creating->discounts = [$discount]; + $this->assertNotImplemented(Capability::NATIVE_COUPONS, fn () => $gateway->createSubscription($creating)); + + $updating = new Subscription(); + $updating->id = 'sub_1'; + $updating->discounts = [$discount]; + $this->assertNotImplemented(Capability::NATIVE_COUPONS, fn () => $gateway->updateSubscription($updating)); } /** - * No update, o gateway gravado no model prevalece sobre o informado, como em `Model::save()`. + * No update, o gateway gravado no model prevalece sobre o informado, como em `Model::save()`: + * a recusa vem do stripe gravado no model, com o motivo dele, e a instância da Iugu não é + * tocada. */ public function testUpdateUsesTheGatewayStoredInTheModel(): void { $api = new QueuedIuguApiRequest([]); + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->percentOff = 10.0; + $subscription = new Subscription(); $subscription->id = 'sub_1'; $subscription->gateway = 'stripe'; + $subscription->discounts = [$discount]; - $this->assertNotImplemented(Capability::SUBSCRIPTIONS, fn () => $subscription->save(new IuguGateway($api))); + $this->assertNotImplemented(Capability::NATIVE_COUPONS, fn () => $subscription->save(new IuguGateway($api))); $this->assertCount(0, $api->calls); } /** - * `Model::delete()` confere a capability antes de procurar o método de despacho. + * `Model::delete()` confere a capability antes de procurar o método de despacho: sem a + * guarda, o despacho falharia com `ConfigurationException` por não existir + * `deleteSubscription` no driver. */ public function testDeleteChecksTheCapabilityBeforeTheDispatchMethod(): void { - $plan = new Plan(); - $plan->id = 'plan_1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->percentOff = 10.0; - $this->assertNotImplemented(Capability::PLANS, fn () => $plan->delete('stripe')); - } + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->discounts = [$discount]; - public function testPlanOperationsOnStripeFailBeforeTheNetwork(): void - { - $plan = new Plan(); - $plan->name = 'Mensal'; - $plan->amount = 10000; - $plan->interval = PlanInterval::MONTH; - - $this->assertNotImplemented(Capability::PLANS, fn () => $plan->save('stripe')); - - $existing = new Plan(); - $existing->id = 'plan_1'; - $this->assertNotImplemented(Capability::PLANS, fn () => $existing->get('stripe')); - $this->assertNotImplemented(Capability::PLANS, fn () => (new MultiPayment('stripe'))->listPlans()); - $this->assertNotImplemented(Capability::PLANS, fn () => (new MultiPayment('stripe'))->getPlan('plano_mensal')); + $this->assertNotImplemented(Capability::NATIVE_COUPONS, fn () => $subscription->delete('stripe')); } public function testBankSlipChargeOnStripeFailsBeforeCreatingTheCustomer(): void diff --git a/tests/Unit/Gateways/GatewayCapabilitiesTest.php b/tests/Unit/Gateways/GatewayCapabilitiesTest.php index 4741c67..0ab9818 100644 --- a/tests/Unit/Gateways/GatewayCapabilitiesTest.php +++ b/tests/Unit/Gateways/GatewayCapabilitiesTest.php @@ -81,14 +81,14 @@ public static function matrixProvider(): array Capability::INVOICE_CANCELLATION->name => [self::SUPPORTED, self::SUPPORTED], Capability::IDEMPOTENCY->name => [self::SUPPORTED, self::SUPPORTED], Capability::IDEMPOTENCY_ALL_ENDPOINTS->name => [self::LIMITATION, self::SUPPORTED], - Capability::SUBSCRIPTIONS->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], - Capability::PLANS->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], - Capability::PLAN_DEACTIVATION->name => [self::LIMITATION, self::NOT_IMPLEMENTED], - Capability::CANCEL_AT_PERIOD_END->name => [self::LIMITATION, self::NOT_IMPLEMENTED], + Capability::SUBSCRIPTIONS->name => [self::SUPPORTED, self::SUPPORTED], + Capability::PLANS->name => [self::SUPPORTED, self::SUPPORTED], + Capability::PLAN_DEACTIVATION->name => [self::LIMITATION, self::SUPPORTED], + Capability::CANCEL_AT_PERIOD_END->name => [self::LIMITATION, self::SUPPORTED], Capability::NATIVE_COUPONS->name => [self::LIMITATION, self::NOT_IMPLEMENTED], - Capability::PLAN_CHANGE_PRORATION->name => [self::LIMITATION, self::NOT_IMPLEMENTED], + Capability::PLAN_CHANGE_PRORATION->name => [self::LIMITATION, self::SUPPORTED], Capability::SUBSCRIPTION_CREDITS->name => [self::NOT_IMPLEMENTED, self::LIMITATION], - Capability::MANAGES_RECURRENCE->name => [self::LIMITATION, self::NOT_IMPLEMENTED], + Capability::MANAGES_RECURRENCE->name => [self::LIMITATION, self::SUPPORTED], ]; $cases = []; @@ -202,6 +202,7 @@ public static function restrictionProvider(): array 'stripe bandeiras' => ['stripe', Capability::CREDIT_CARD, ['brands' => ['visa', 'mastercard']]], 'stripe duplicação só pix' => ['stripe', Capability::INVOICE_DUPLICATION, ['payment_methods' => [PaymentMethod::PIX]]], 'stripe cancelamento de rascunho' => ['stripe', Capability::INVOICE_CANCELLATION, []], + 'stripe nextBillingAt só na criação' => ['stripe', Capability::SUBSCRIPTIONS, []], 'stripe pix sem restrição' => ['stripe', Capability::PIX, null], ]; } diff --git a/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php index d5318eb..c03a5cb 100644 --- a/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php +++ b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php @@ -10,11 +10,15 @@ use Illuminate\Support\Facades\Facade; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use Potelo\MultiPayment\Models\Plan; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\InvoiceItem; +use Potelo\MultiPayment\Models\Subscription; +use Potelo\MultiPayment\Models\SubscriptionItem; use Potelo\MultiPayment\Gateways\StripeGateway; +use Potelo\MultiPayment\Enums\PlanInterval; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; @@ -463,6 +467,127 @@ function (StripeGateway $g, ?string $key) { 'post /v1/payment_methods/pm_fake123/detach' => 'chave-1', ], ], + 'createPlan' => [ + fn (StripeGateway $g, ?string $key) => $g->createPlan(self::planModel(), $key), + [self::productResponse(), self::priceResponse()], + [ + 'post /v1/products' => 'chave-1:product', + 'post /v1/prices' => 'chave-1', + ], + ], + 'deactivatePlan' => [ + function (StripeGateway $g, ?string $key) { + $plan = new Plan(); + $plan->id = 'price_fake1'; + + return $g->deactivatePlan($plan, $key); + }, + [self::priceResponse()], + ['post /v1/prices/price_fake1' => 'chave-1'], + ], + 'createSubscription com cartão salvo' => [ + fn (StripeGateway $g, ?string $key) => $g->createSubscription(self::subscriptionModel('pm_fake123'), $key), + [ + self::subscriptionFixture(), + self::stripeInvoiceFixture(), + self::fixture('payment_intents/paid'), + ], + [ + 'post /v1/subscriptions' => 'chave-1', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1' => null, + 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi' => null, + ], + ], + 'createSubscription salvando o cartão antes' => [ + function (StripeGateway $g, ?string $key) { + $subscription = self::subscriptionModel(); + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->token = 'pm_fake123'; + + return $g->createSubscription($subscription, $key); + }, + [ + self::setupIntentResponse(), + self::subscriptionFixture(), + self::stripeInvoiceFixture(), + self::fixture('payment_intents/paid'), + ], + [ + 'post /v1/setup_intents' => 'chave-1:card', + 'post /v1/subscriptions' => 'chave-1', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1' => null, + 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi' => null, + ], + ], + 'createSubscription com item extra' => [ + function (StripeGateway $g, ?string $key) { + $subscription = self::subscriptionModel('pm_fake123'); + $item = new SubscriptionItem(); + $item->description = 'Consultas extras'; + $item->amount = 2500; + $subscription->items = [$item]; + + return $g->createSubscription($subscription, $key); + }, + [ + self::productResponse(), + self::priceResponse(), + self::subscriptionFixture(), + self::stripeInvoiceFixture(), + self::fixture('payment_intents/paid'), + ], + [ + 'post /v1/products' => 'chave-1:item0_product', + 'get /v1/prices/price_fake1' => null, + 'post /v1/subscriptions' => 'chave-1', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1' => null, + 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi' => null, + ], + ], + 'updateSubscription' => [ + function (StripeGateway $g, ?string $key) { + $subscription = new Subscription(); + $subscription->id = 'sub_fake1'; + $subscription->metadata = ['origem' => 'teste']; + + return $g->updateSubscription($subscription, $key); + }, + [self::subscriptionFixture()], + ['post /v1/subscriptions/sub_fake1' => 'chave-1'], + ], + 'suspendSubscription' => [ + fn (StripeGateway $g, ?string $key) => $g->suspendSubscription(self::subscriptionWithId(), $key), + [self::subscriptionFixture()], + ['post /v1/subscriptions/sub_fake1' => 'chave-1'], + ], + 'resumeSubscription' => [ + fn (StripeGateway $g, ?string $key) => $g->resumeSubscription(self::subscriptionWithId(), $key), + [self::subscriptionFixture()], + ['post /v1/subscriptions/sub_fake1' => 'chave-1'], + ], + 'cancelSubscription imediato' => [ + fn (StripeGateway $g, ?string $key) => $g->cancelSubscription(self::subscriptionWithId(), false, $key), + [self::subscriptionFixture()], + ['delete /v1/subscriptions/sub_fake1' => 'chave-1'], + ], + 'cancelSubscription ao fim do período' => [ + fn (StripeGateway $g, ?string $key) => $g->cancelSubscription(self::subscriptionWithId(), true, $key), + [self::subscriptionFixture()], + ['post /v1/subscriptions/sub_fake1' => 'chave-1'], + ], + 'changeSubscriptionPlan sem cobrança' => [ + fn (StripeGateway $g, ?string $key) => $g->changeSubscriptionPlan( + self::subscriptionWithId(), + 'price_fake2', + \Potelo\MultiPayment\Enums\ProrationBehavior::NONE, + $key + ), + [self::subscriptionFixture(), self::subscriptionFixture()], + [ + 'get /v1/subscriptions/sub_fake1' => null, + 'post /v1/subscriptions/sub_fake1' => 'chave-1', + ], + ], ]; } @@ -511,6 +636,81 @@ private static function invoiceWithId(): Invoice return $invoice; } + private static function planModel(): Plan + { + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->identifier = 'plano_mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + return $plan; + } + + /** + * Assinatura pronta para criação, com o plano pelo id de Price (a busca por `lookup_key` + * é leitura e ficaria fora das asserções de cabeçalho). + */ + private static function subscriptionModel(?string $cardId = null): Subscription + { + $subscription = new Subscription(); + $subscription->customer = new Customer(); + $subscription->customer->id = 'cus_fake123'; + $subscription->planId = 'price_fake1'; + if (!is_null($cardId)) { + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->id = $cardId; + } + + return $subscription; + } + + private static function subscriptionWithId(): Subscription + { + $subscription = new Subscription(); + $subscription->id = 'sub_fake1'; + + return $subscription; + } + + private static function productResponse(): array + { + return ['id' => 'prod_fake1', 'object' => 'product', 'name' => 'Mensal', 'active' => true, 'created' => 1786700000, 'metadata' => []]; + } + + private static function priceResponse(): array + { + return [ + 'id' => 'price_fake1', + 'object' => 'price', + 'active' => true, + 'currency' => 'brl', + 'lookup_key' => 'plano_mensal', + 'nickname' => 'Mensal', + 'created' => 1786700000, + 'product' => self::productResponse(), + 'recurring' => ['interval' => 'month', 'interval_count' => 1, 'usage_type' => 'licensed'], + 'type' => 'recurring', + 'unit_amount' => 10000, + 'unit_amount_decimal' => '10000', + ]; + } + + private static function subscriptionFixture(): array + { + return self::fixture('subscriptions/active'); + } + + private static function stripeInvoiceFixture(): array + { + return self::fixture('invoices/paid'); + } + + private static function fixture(string $path): array + { + return json_decode(file_get_contents(__DIR__ . "/../../fixtures/stripe/{$path}.json"), true); + } + private static function customerModel(): Customer { $customer = new Customer(); diff --git a/tests/Unit/Gateways/StripeGatewayPlanTest.php b/tests/Unit/Gateways/StripeGatewayPlanTest.php new file mode 100644 index 0000000..fbe8360 --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayPlanTest.php @@ -0,0 +1,348 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreatePlanCreatesAProductAndARecurringPriceAndParsesThePrice(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::productResponse(), + self::priceResponse(), + ]); + + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->identifier = 'plano_mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + $result = (new StripeGateway())->createPlan($plan); + + $this->assertSame(['post /v1/products', 'post /v1/prices'], self::calledPaths($httpClient)); + $this->assertSame(['name' => 'Mensal', 'metadata' => ['identifier' => 'plano_mensal']], $httpClient->calls[0][2]); + $priceParams = $httpClient->calls[1][2]; + $this->assertSame('prod_fake1', $priceParams['product']); + $this->assertSame(10000, $priceParams['unit_amount']); + $this->assertSame('brl', $priceParams['currency']); + $this->assertSame(['interval' => 'month', 'interval_count' => 1], $priceParams['recurring']); + $this->assertSame('plano_mensal', $priceParams['lookup_key']); + $this->assertSame('Mensal', $priceParams['nickname']); + $this->assertContains('product', $priceParams['expand']); + + $this->assertSame('price_fake1', $result->id); + $this->assertSame('plano_mensal', $result->identifier); + $this->assertSame('Mensal', $result->name); + $this->assertSame(10000, $result->amount); + $this->assertSame(PlanInterval::MONTH, $result->interval); + $this->assertSame(1, $result->intervalCount); + $this->assertSame('BRL', $result->currency); + $this->assertTrue($result->active); + $this->assertSame('stripe', $result->gateway); + } + + public function testCreatePlanUsesTheNameAsIdentifierWhenNoneIsGiven(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::productResponse(), + self::priceResponse(), + ]); + + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + (new StripeGateway())->createPlan($plan); + + $this->assertSame(['identifier' => 'Mensal'], $httpClient->calls[0][2]['metadata']); + $this->assertSame('Mensal', $httpClient->calls[1][2]['lookup_key']); + } + + public function testCreatePlanTranslatesYearAndDayIntervals(): void + { + foreach ([[PlanInterval::YEAR, 2, 'year'], [PlanInterval::DAY, 15, 'day']] as [$interval, $count, $expected]) { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::productResponse(), + self::priceResponse(), + ]); + + $plan = new Plan(); + $plan->name = 'Plano'; + $plan->amount = 10000; + $plan->interval = $interval; + $plan->intervalCount = $count; + + (new StripeGateway())->createPlan($plan); + + $this->assertSame(['interval' => $expected, 'interval_count' => $count], $httpClient->calls[1][2]['recurring']); + } + } + + public function testCreatePlanWithoutAnIntervalFailsBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + + $this->expectException(ModelAttributeValidationException::class); + + try { + (new StripeGateway())->createPlan($plan); + } finally { + $this->assertSame([], $httpClient->calls); + } + } + + public function testGetPlanByIdRetrievesThePriceWithTheProductExpanded(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::priceResponse()]); + + $plan = new Plan(); + $plan->id = 'price_fake1'; + + $result = (new StripeGateway())->getPlan($plan); + + $this->assertSame(['get /v1/prices/price_fake1'], self::calledPaths($httpClient)); + $this->assertSame(['expand' => ['product']], $httpClient->calls[0][2]); + $this->assertSame('plano_mensal', $result->identifier); + } + + public function testGetPlanByIdentifierSearchesTheLookupKey(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::priceListResponse([self::priceResponse()])]); + + $plan = new Plan(); + $plan->identifier = 'plano_mensal'; + + $result = (new StripeGateway())->getPlan($plan); + + $this->assertSame(['get /v1/prices'], self::calledPaths($httpClient)); + $this->assertSame(['plano_mensal'], $httpClient->calls[0][2]['lookup_keys']); + $this->assertContains('data.product', $httpClient->calls[0][2]['expand']); + $this->assertSame('price_fake1', $result->id); + } + + public function testGetPlanByAnIdentifierWithThePricePrefixReadsItAsAnId(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::priceResponse()]); + + $plan = new Plan(); + $plan->identifier = 'price_fake1'; + + (new StripeGateway())->getPlan($plan); + + $this->assertSame(['get /v1/prices/price_fake1'], self::calledPaths($httpClient)); + } + + public function testGetPlanByAnUnknownIdentifierRaisesNotFound(): void + { + RecordingStripeHttpClient::withResponses([self::priceListResponse([])]); + + $plan = new Plan(); + $plan->identifier = 'inexistente'; + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessageMatches('/inexistente/'); + + (new StripeGateway())->getPlan($plan); + } + + public function testGetPlanWithoutIdOrIdentifierIsRefusedBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $this->expectException(ModelAttributeValidationException::class); + + try { + (new StripeGateway())->getPlan(new Plan()); + } finally { + $this->assertSame([], $httpClient->calls); + } + } + + public function testListPlansListsRecurringPrices(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceListResponse([self::priceResponse(), self::priceResponse(['id' => 'price_fake2', 'lookup_key' => 'plano_anual'])]), + ]); + + $plans = (new StripeGateway())->listPlans(1, 50); + + $this->assertSame(['get /v1/prices'], self::calledPaths($httpClient)); + $this->assertSame('recurring', $httpClient->calls[0][2]['type']); + $this->assertSame(50, $httpClient->calls[0][2]['limit']); + $this->assertCount(2, $plans); + $this->assertSame('plano_anual', $plans[1]->identifier); + } + + public function testListPlansWalksTheCursorToReachALaterPage(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceListResponse([self::priceResponse()], true), + self::priceListResponse([self::priceResponse(['id' => 'price_fake2'])]), + ]); + + $plans = (new StripeGateway())->listPlans(2, 1); + + $this->assertCount(2, $httpClient->calls); + $this->assertSame('price_fake1', $httpClient->calls[1][2]['starting_after']); + $this->assertCount(1, $plans); + $this->assertSame('price_fake2', $plans[0]->id); + } + + public function testListPlansBeyondTheEndReturnsAnEmptyList(): void + { + RecordingStripeHttpClient::withResponses([self::priceListResponse([self::priceResponse()])]); + + $this->assertSame([], (new StripeGateway())->listPlans(2, 1)); + } + + public function testListPlansValidatesPageAndLimitBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $gateway = new StripeGateway(); + + foreach ([[0, 10], [1, 0], [1, 101]] as [$page, $limit]) { + try { + $gateway->listPlans($page, $limit); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException) { + } + } + $this->assertSame([], $httpClient->calls); + } + + public function testDeactivatePlanArchivesThePrice(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::priceResponse(['active' => false])]); + + $plan = new Plan(); + $plan->id = 'price_fake1'; + + $result = (new StripeGateway())->deactivatePlan($plan); + + $this->assertSame(['post /v1/prices/price_fake1'], self::calledPaths($httpClient)); + // o encoder do stripe-php envia booleano como a string 'false' + $this->assertSame('false', $httpClient->calls[0][2]['active']); + $this->assertFalse($result->active); + } + + public function testDeactivatePlanWithoutIdOrIdentifierIsRefusedBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $this->expectException(ModelAttributeValidationException::class); + + try { + (new StripeGateway())->deactivatePlan(new Plan()); + } finally { + $this->assertSame([], $httpClient->calls); + } + } + + public function testDeactivatePlanByIdentifierResolvesThePriceFirst(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceListResponse([self::priceResponse()]), + self::priceResponse(['active' => false]), + ]); + + $plan = new Plan(); + $plan->identifier = 'plano_mensal'; + + $result = (new StripeGateway())->deactivatePlan($plan); + + $this->assertSame(['get /v1/prices', 'post /v1/prices/price_fake1'], self::calledPaths($httpClient)); + $this->assertFalse($result->active); + } + + /** + * @return string[] `método caminho` de cada chamada gravada + */ + private static function calledPaths(RecordingStripeHttpClient $httpClient): array + { + return array_map( + static fn (array $call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), + $httpClient->calls + ); + } + + private static function productResponse(): array + { + return [ + 'id' => 'prod_fake1', + 'object' => 'product', + 'name' => 'Mensal', + 'active' => true, + 'created' => 1786700000, + 'metadata' => ['identifier' => 'plano_mensal'], + ]; + } + + private static function priceResponse(array $overrides = []): array + { + return array_merge([ + 'id' => 'price_fake1', + 'object' => 'price', + 'active' => true, + 'currency' => 'brl', + 'lookup_key' => 'plano_mensal', + 'nickname' => 'Mensal', + 'created' => 1786700000, + 'product' => self::productResponse(), + 'recurring' => ['interval' => 'month', 'interval_count' => 1, 'usage_type' => 'licensed'], + 'type' => 'recurring', + 'unit_amount' => 10000, + 'unit_amount_decimal' => '10000', + ], $overrides); + } + + private static function priceListResponse(array $prices, bool $hasMore = false): array + { + return [ + 'object' => 'list', + 'url' => '/v1/prices', + 'has_more' => $hasMore, + 'data' => $prices, + ]; + } +} diff --git a/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php b/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php new file mode 100644 index 0000000..2b0925f --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php @@ -0,0 +1,933 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreateSubscriptionWithASavedCardChargesTheFirstInvoiceAndReadsIt(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceListResponse(), + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->id = 'pm_fake123'; + + $result = (new StripeGateway())->createSubscription($subscription); + + $this->assertSame([ + 'get /v1/prices', + 'post /v1/subscriptions', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1', + 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi', + ], self::calledPaths($httpClient)); + + $params = $httpClient->calls[1][2]; + $this->assertSame('cus_fake123', $params['customer']); + $this->assertSame([['price' => 'price_fake1']], $params['items']); + $this->assertSame('charge_automatically', $params['collection_method']); + $this->assertSame('error_if_incomplete', $params['payment_behavior']); + $this->assertSame(['payment_method_types' => ['card']], $params['payment_settings']); + $this->assertSame('pm_fake123', $params['default_payment_method']); + $this->assertSame(self::SUBSCRIPTION_EXPAND, $params['expand']); + + $this->assertSame('sub_1UBJmkPjx0CusuMr3KQ2wXyZ', $result->id); + $this->assertSame(SubscriptionStatus::ACTIVE, $result->status); + $this->assertSame('plano_mensal', $result->planId); + $this->assertSame(10000, $result->amount); + $this->assertSame([], $result->items); + $this->assertSame('stripe', $result->gateway); + $this->assertSame(InvoiceStatus::PAID, $result->latestInvoice->status); + } + + public function testCreateSubscriptionWithTrialDaysSendsThePeriodAndReturnsTheDate(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/trialing'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $subscription->trialDays = 7; + + $result = (new StripeGateway())->createSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame(7, $params['trial_period_days']); + $this->assertArrayNotHasKey('trial_end', $params); + + $this->assertNull($result->trialDays); + $this->assertSame(1789170793, $result->trialEndsAt->getTimestamp()); + $this->assertSame(SubscriptionStatus::TRIALING, $result->status); + } + + public function testCreateSubscriptionWithPixLeavesTheFirstInvoiceOpen(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/incomplete'), + self::fixture('invoices/open_requires_payment_method'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $subscription->availablePaymentMethods = [PaymentMethod::PIX]; + + $result = (new StripeGateway())->createSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame('default_incomplete', $params['payment_behavior']); + $this->assertSame(['payment_method_types' => ['pix']], $params['payment_settings']); + + $this->assertSame(SubscriptionStatus::PENDING, $result->status); + $this->assertSame(InvoiceStatus::PENDING, $result->latestInvoice->status); + } + + public function testCreateSubscriptionWithExtraItemsCreatesPricesOnDemand(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::productResponse('prod_item0'), + self::priceResponse(), + self::productResponse('prod_item1'), + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $recurring = new SubscriptionItem(); + $recurring->description = 'Consultas extras'; + $recurring->amount = 2500; + $recurring->quantity = 2; + + $oneTime = new SubscriptionItem(); + $oneTime->description = 'Taxa de adesão'; + $oneTime->amount = 900; + $oneTime->recurring = false; + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $subscription->items = [$recurring, $oneTime]; + + (new StripeGateway())->createSubscription($subscription); + + $this->assertSame([ + 'post /v1/products', + 'get /v1/prices/price_fake1', + 'post /v1/products', + 'post /v1/subscriptions', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1', + 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi', + ], self::calledPaths($httpClient)); + $this->assertSame(['name' => 'Consultas extras'], $httpClient->calls[0][2]); + $this->assertSame(['name' => 'Taxa de adesão'], $httpClient->calls[2][2]); + + $params = $httpClient->calls[3][2]; + $this->assertSame([ + ['price' => 'price_fake1'], + [ + 'price_data' => [ + 'currency' => 'brl', + 'product' => 'prod_item0', + 'unit_amount' => 2500, + 'recurring' => ['interval' => 'month', 'interval_count' => 1], + ], + 'quantity' => 2, + ], + ], $params['items']); + $this->assertSame([ + [ + 'price_data' => ['currency' => 'brl', 'product' => 'prod_item1', 'unit_amount' => 900], + 'quantity' => 1, + ], + ], $params['add_invoice_items']); + } + + public function testCreateSubscriptionWithACardThatRequiresAuthenticationIsInterrupted(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('setup_intents/requires_action')]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->token = 'pm_fake123'; + + try { + (new StripeGateway())->createSubscription($subscription); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::AUTHENTICATION_REQUIRED, $e->declineCode); + $this->assertNotNull($e->chargeResponse); + } + + $this->assertSame(['post /v1/setup_intents'], self::calledPaths($httpClient)); + } + + public function testCreateSubscriptionSendsTrialEndAndBillingCycleAnchor(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/trialing'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $subscription->trialEndsAt = Carbon::createFromTimestamp(1789170793); + $subscription->nextBillingAt = Carbon::createFromTimestamp(1791157981); + + (new StripeGateway())->createSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame(1789170793, $params['trial_end']); + $this->assertSame(1791157981, $params['billing_cycle_anchor']); + $this->assertArrayNotHasKey('trial_period_days', $params); + } + + public function testCreateSubscriptionRequiresCustomerAndPlanBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $gateway = new StripeGateway(); + + $withoutCustomer = new Subscription(); + $withoutCustomer->planId = 'plano_mensal'; + try { + $gateway->createSubscription($withoutCustomer); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException) { + } + + $withoutPlan = new Subscription(); + $withoutPlan->customer = new Customer(); + $withoutPlan->customer->id = 'cus_fake123'; + try { + $gateway->createSubscription($withoutPlan); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException) { + } + + $this->assertSame([], $httpClient->calls); + } + + #[DataProvider('statusFixtureProvider')] + public function testGetSubscriptionMapsEachStatus(string $fixture, SubscriptionStatus $expected): void + { + $response = self::fixture("subscriptions/{$fixture}"); + $responses = [$response]; + if (!empty($response['latest_invoice'])) { + $responses[] = self::fixture('invoices/paid'); + $responses[] = self::fixture('payment_intents/paid'); + } + RecordingStripeHttpClient::withResponses($responses); + + $result = $this->getSubscription($response['id']); + + $this->assertSame($expected, $result->status); + } + + public static function statusFixtureProvider(): array + { + return [ + 'incomplete' => ['incomplete', SubscriptionStatus::PENDING], + 'incomplete_expired' => ['incomplete_expired', SubscriptionStatus::EXPIRED], + 'trialing' => ['trialing', SubscriptionStatus::TRIALING], + 'active' => ['active', SubscriptionStatus::ACTIVE], + 'past_due' => ['past_due', SubscriptionStatus::PAST_DUE], + 'unpaid' => ['unpaid', SubscriptionStatus::PAST_DUE], + 'canceled' => ['canceled', SubscriptionStatus::CANCELED], + 'paused' => ['paused', SubscriptionStatus::PAUSED], + 'pause_collection' => ['active_pause_collection', SubscriptionStatus::PAUSED], + ]; + } + + public function testGetSubscriptionParsesTheModelAndReadsTheLatestInvoice(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $result = $this->getSubscription('sub_1UBJmkPjx0CusuMr3KQ2wXyZ'); + + $this->assertSame(['expand' => self::SUBSCRIPTION_EXPAND], $httpClient->calls[0][2]); + $this->assertSame('sub_1UBJmkPjx0CusuMr3KQ2wXyZ', $result->id); + $this->assertSame('plano_mensal', $result->planId); + $this->assertSame(10000, $result->amount); + $this->assertSame('cus_VBen1v8T4Qa6XX', $result->customer->id); + $this->assertSame(1791157981, $result->nextBillingAt->getTimestamp()); + $this->assertFalse($result->cancelAtPeriodEnd); + $this->assertNull($result->canceledAt); + $this->assertSame(1788565981, $result->createdAt->getTimestamp()); + $this->assertSame([], $result->metadata); + $this->assertSame(PaymentMethod::CREDIT_CARD, $result->paymentMethod); + $this->assertSame([PaymentMethod::CREDIT_CARD], $result->availablePaymentMethods); + $this->assertSame(InvoiceStatus::PAID, $result->latestInvoice->status); + $this->assertSame('stripe', $result->latestInvoice->gateway); + } + + public function testGetSubscriptionOnACanceledSubscriptionFillsCanceledAt(): void + { + $response = self::fixture('subscriptions/canceled'); + RecordingStripeHttpClient::withResponses([ + $response, + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $result = $this->getSubscription($response['id']); + + $this->assertSame(SubscriptionStatus::CANCELED, $result->status); + $this->assertSame(1788565992, $result->canceledAt->getTimestamp()); + } + + public function testSuspendPausesTheCollection(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active_pause_collection')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->suspendSubscription($subscription); + + $this->assertSame(['post /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ'], self::calledPaths($httpClient)); + $this->assertSame(['behavior' => 'void'], $httpClient->calls[0][2]['pause_collection']); + $this->assertSame(SubscriptionStatus::PAUSED, $result->status); + } + + public function testResumeClearsThePauseAndTheScheduledCancellation(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->resumeSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame('', $params['pause_collection']); + // o encoder do stripe-php envia booleano como a string 'false' + $this->assertSame('false', $params['cancel_at_period_end']); + $this->assertSame(SubscriptionStatus::ACTIVE, $result->status); + } + + public function testCancelImmediatelyDeletesTheSubscription(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/canceled')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->cancelSubscription($subscription); + + $this->assertSame(['delete /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ'], self::calledPaths($httpClient)); + $this->assertSame(SubscriptionStatus::CANCELED, $result->status); + $this->assertSame(1788565992, $result->canceledAt->getTimestamp()); + } + + public function testCancelAtPeriodEndKeepsTheSubscriptionActiveAndFillsTheModel(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active_cancel_at_period_end')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->cancelSubscription($subscription, true); + + $this->assertSame(['post /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ'], self::calledPaths($httpClient)); + $this->assertSame('true', $httpClient->calls[0][2]['cancel_at_period_end']); + $this->assertSame(SubscriptionStatus::ACTIVE, $result->status); + $this->assertTrue($result->cancelAtPeriodEnd); + $this->assertSame(1788565991, $result->canceledAt->getTimestamp()); + } + + #[DataProvider('prorationProvider')] + public function testChangePlanTranslatesTheProrationPolicy(ProrationBehavior $proration, string $expected, bool $readsInvoice): void + { + $changed = self::fixture('subscriptions/active'); + $changed['items']['data'][0]['price']['id'] = 'price_fake2'; + $changed['items']['data'][0]['price']['lookup_key'] = 'plano_anual'; + $responses = [ + self::priceListResponse('price_fake2', 'plano_anual'), + self::fixture('subscriptions/active'), + $changed, + ]; + if ($readsInvoice) { + $responses[] = self::fixture('invoices/paid'); + $responses[] = self::fixture('payment_intents/paid'); + } + $httpClient = RecordingStripeHttpClient::withResponses($responses); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->changeSubscriptionPlan($subscription, 'plano_anual', $proration); + + $params = $httpClient->calls[2][2]; + $this->assertSame([['id' => 'si_UBJmkPjx0CusuMrLq0v9Yb2', 'price' => 'price_fake2']], $params['items']); + $this->assertSame($expected, $params['proration_behavior']); + $this->assertSame('plano_anual', $result->planId); + $this->assertSame($readsInvoice, !is_null($result->latestInvoice)); + } + + public static function prorationProvider(): array + { + return [ + 'CHARGE_DIFFERENCE fatura na hora' => [ProrationBehavior::CHARGE_DIFFERENCE, 'always_invoice', true], + 'NONE não gera pró-rata' => [ProrationBehavior::NONE, 'none', false], + 'CREDIT deixa a pró-rata para a próxima fatura' => [ProrationBehavior::CREDIT, 'create_prorations', false], + ]; + } + + public function testChangePlanWithADifferentNextBillingAtIsRefusedBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->nextBillingAt = Carbon::parse('2030-01-01'); + + try { + (new StripeGateway())->changeSubscriptionPlan($subscription, 'plano_anual', ProrationBehavior::NONE); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + } + + // outra hora do mesmo dia também é mudança: a comparação com o lido é exata + $sameDay = new Subscription(); + $sameDay->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $sameDay->nextBillingAt = Carbon::createFromTimestamp(1791157981 + 3600); + $sameDay->original = json_decode(json_encode(self::fixture('subscriptions/active'))); + + try { + (new StripeGateway())->changeSubscriptionPlan($sameDay, 'plano_anual', ProrationBehavior::NONE); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + } + $this->assertSame([], $httpClient->calls); + } + + public function testChangePlanWithTheNextBillingAtReadFromTheGatewayIsAccepted(): void + { + $changed = self::fixture('subscriptions/active'); + RecordingStripeHttpClient::withResponses([ + self::priceListResponse('price_fake2', 'plano_anual'), + self::fixture('subscriptions/active'), + $changed, + ]); + + // um model lido do gateway traz nextBillingAt preenchido com o current_period_end + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->nextBillingAt = Carbon::createFromTimestamp(1791157981); + $subscription->original = json_decode(json_encode(self::fixture('subscriptions/active'))); + + $result = (new StripeGateway())->changeSubscriptionPlan($subscription, 'plano_anual', ProrationBehavior::NONE); + + $this->assertSame(SubscriptionStatus::ACTIVE, $result->status); + } + + public function testPreviewPlanChangeReturnsTheRealProrationLines(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceListResponse('price_fake2', 'plano_anual'), + self::fixture('subscriptions/active'), + self::previewInvoiceResponse(), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $preview = (new StripeGateway())->previewSubscriptionPlanChange($subscription, 'plano_anual'); + + $this->assertSame([ + 'get /v1/prices', + 'get /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ', + 'post /v1/invoices/create_preview', + ], self::calledPaths($httpClient)); + $params = $httpClient->calls[2][2]; + $this->assertSame('sub_1UBJmkPjx0CusuMr3KQ2wXyZ', $params['subscription']); + $this->assertSame( + [ + 'items' => [['id' => 'si_UBJmkPjx0CusuMrLq0v9Yb2', 'price' => 'price_fake2']], + 'proration_behavior' => 'always_invoice', + ], + $params['subscription_details'] + ); + + $this->assertSame(25005, $preview->amount); + $this->assertCount(3, $preview->items); + $this->assertSame('Unused time on Mensal', $preview->items[0]->description); + $this->assertSame(-5000, $preview->items[0]->price); + $this->assertSame('Anual', $preview->items[1]->description); + // linha com quantidade 2: price é o valor unitário (30000 dividido por 2) + $this->assertSame(2, $preview->items[1]->quantity); + $this->assertSame(15000, $preview->items[1]->price); + // valor que não divide pela quantidade vira linha de valor total, e a soma dos itens + // continua igual a amount + $this->assertSame(1, $preview->items[2]->quantity); + $this->assertSame(5, $preview->items[2]->price); + $this->assertSame( + $preview->amount, + array_sum(array_map(static fn ($item) => $item->price * $item->quantity, $preview->items)) + ); + $this->assertSame(1819904400, $preview->effectiveAt->getTimestamp()); + $this->assertTrue($preview->appliesImmediately); + $this->assertSame('stripe', $preview->gateway); + } + + public function testUpdateSubscriptionReplacesTheItemsDeclaratively(): void + { + $current = self::fixture('subscriptions/active'); + $current['items']['data'][] = self::extraItemResponse(); + $httpClient = RecordingStripeHttpClient::withResponses([ + $current, + self::fixture('subscriptions/active'), + ]); + + // lista desejada vazia: o item extra sai, o item do plano fica + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->items = []; + + (new StripeGateway())->updateSubscription($subscription); + + $this->assertSame([ + 'get /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ', + 'post /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ', + ], self::calledPaths($httpClient)); + $params = $httpClient->calls[1][2]; + $this->assertSame([['id' => 'si_extra1', 'deleted' => 'true']], $params['items']); + $this->assertSame('none', $params['proration_behavior']); + } + + public function testUpdateSubscriptionWritesCardTrialAndMetadata(): void + { + Carbon::setTestNow('2026-09-04 12:00:00'); + try { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->id = 'pm_fake456'; + $subscription->trialDays = 7; + $subscription->metadata = ['origem' => 'teste']; + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame('pm_fake456', $params['default_payment_method']); + $this->assertSame(Carbon::now()->addDays(7)->getTimestamp(), $params['trial_end']); + $this->assertSame(['origem' => 'teste'], $params['metadata']); + $this->assertNull($subscription->trialDays); + } finally { + Carbon::setTestNow(); + } + } + + public function testUpdateSubscriptionWritesThePaymentSettingsWhenTheMethodChanged(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->paymentMethod = PaymentMethod::PIX; + + (new StripeGateway())->updateSubscription($subscription); + + $this->assertSame( + ['payment_method_types' => ['pix']], + $httpClient->calls[0][2]['payment_settings'] + ); + } + + /** + * Método e trial iguais aos lidos do gateway ficam fora do payload: um update que mexeu em + * outra coisa não reescreve o que já está na assinatura. + */ + public function testUpdateSubscriptionSkipsThePaymentMethodAndTrialReadFromTheGateway(): void + { + $original = json_decode(json_encode(self::fixture('subscriptions/trialing'))); + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/trialing')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->availablePaymentMethods = [PaymentMethod::CREDIT_CARD]; + $subscription->trialEndsAt = Carbon::createFromTimestamp($original->trial_end); + $subscription->metadata = ['origem' => 'teste']; + $subscription->original = $original; + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertArrayNotHasKey('payment_settings', $params); + $this->assertArrayNotHasKey('trial_end', $params); + $this->assertSame(['origem' => 'teste'], $params['metadata']); + } + + public function testUpdateSubscriptionUpdatesKeptItemsAndCreatesNewOnes(): void + { + $current = self::fixture('subscriptions/active'); + $current['items']['data'][] = self::extraItemResponse(); + $httpClient = RecordingStripeHttpClient::withResponses([ + $current, + self::productResponse('prod_item0'), + self::priceResponse(), + self::fixture('subscriptions/active'), + ]); + + $kept = new SubscriptionItem(); + $kept->id = 'si_extra1'; + $kept->quantity = 3; + + $new = new SubscriptionItem(); + $new->description = 'Consultas extras'; + $new->amount = 2500; + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->items = [$kept, $new]; + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[3][2]; + $this->assertSame([ + ['id' => 'si_extra1', 'quantity' => 3], + [ + 'price_data' => [ + 'currency' => 'brl', + 'product' => 'prod_item0', + 'unit_amount' => 2500, + 'recurring' => ['interval' => 'month', 'interval_count' => 1], + ], + 'quantity' => 1, + ], + ], $params['items']); + $this->assertSame('none', $params['proration_behavior']); + } + + /** + * A recusa do método vem antes de o cartão ser salvo: um cartão por token com uma lista + * sem cartão não pode ficar anexado ao cliente na Stripe. + */ + public function testUpdateSubscriptionValidatesTheMethodBeforeSavingTheCard(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->availablePaymentMethods = [PaymentMethod::PIX]; + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->token = 'pm_fake123'; + + try { + (new StripeGateway())->updateSubscription($subscription); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException) { + } + $this->assertSame([], $httpClient->calls); + } + + /** + * Sem `planId` no model, o item do plano é o único cujo Price tem `lookup_key`: os Prices + * de itens extras criados pela lib não têm um, e a ordem da lista da Stripe não decide. + */ + public function testParseElectsThePlanItemByLookupKeyWhenThePlanIdIsUnknown(): void + { + $response = self::fixture('subscriptions/active'); + $extra = self::extraItemResponse(); + $extra['created'] = $response['items']['data'][0]['created']; + $extra['price']['lookup_key'] = null; + array_unshift($response['items']['data'], $extra); + RecordingStripeHttpClient::withResponses([ + $response, + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $result = $this->getSubscription('sub_1UBJmkPjx0CusuMr3KQ2wXyZ'); + + $this->assertSame('plano_mensal', $result->planId); + $this->assertCount(1, $result->items); + $this->assertSame('si_extra1', $result->items[0]->id); + } + + public function testUpdateSubscriptionRefusesMethodsTheDriverDoesNotCoverBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $gateway = new StripeGateway(); + + $multi = new Subscription(); + $multi->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $multi->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::CREDIT_CARD]; + try { + $gateway->updateSubscription($multi); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::MULTIPLE_PAYMENT_METHODS, $e->capability); + } + + $boleto = new Subscription(); + $boleto->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $boleto->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + try { + $gateway->updateSubscription($boleto); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::BANK_SLIP, $e->capability); + } + + $this->assertSame([], $httpClient->calls); + } + + public function testUpdateSubscriptionWithADifferentNextBillingAtIsRefusedBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->nextBillingAt = Carbon::parse('2030-01-01'); + + try { + (new StripeGateway())->updateSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + } + $this->assertSame([], $httpClient->calls); + } + + public function testListSubscriptionsValidatesInputBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $gateway = new StripeGateway(); + $customer = new Customer(); + $customer->id = 'cus_fake123'; + + foreach ([ + [new Customer(), 1, 10], + [$customer, 0, 10], + [$customer, 1, 0], + [$customer, 1, 101], + ] as [$who, $page, $limit]) { + try { + $gateway->listSubscriptions($who, $page, $limit); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException) { + } + } + $this->assertSame([], $httpClient->calls); + } + + public function testListSubscriptionsListsEveryStatusWithoutTheLatestInvoice(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + [ + 'object' => 'list', + 'url' => '/v1/subscriptions', + 'has_more' => false, + 'data' => [self::fixture('subscriptions/active'), self::fixture('subscriptions/canceled')], + ], + ]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + + $subscriptions = (new StripeGateway())->listSubscriptions($customer); + + $params = $httpClient->calls[0][2]; + $this->assertSame('cus_fake123', $params['customer']); + $this->assertSame('all', $params['status']); + $this->assertSame(100, $params['limit']); + $this->assertCount(2, $subscriptions); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscriptions[0]->status); + $this->assertSame(SubscriptionStatus::CANCELED, $subscriptions[1]->status); + $this->assertNull($subscriptions[0]->latestInvoice); + } + + private function getSubscription(string $id): Subscription + { + $subscription = new Subscription(); + $subscription->id = $id; + + return (new StripeGateway())->getSubscription($subscription); + } + + private static function subscriptionModel(): Subscription + { + $subscription = new Subscription(); + $subscription->customer = new Customer(); + $subscription->customer->id = 'cus_fake123'; + $subscription->planId = 'plano_mensal'; + + return $subscription; + } + + /** + * @return string[] `método caminho` de cada chamada gravada + */ + private static function calledPaths(RecordingStripeHttpClient $httpClient): array + { + return array_map( + static fn (array $call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), + $httpClient->calls + ); + } + + private static function fixture(string $path): array + { + return json_decode(file_get_contents(__DIR__ . "/../../fixtures/stripe/{$path}.json"), true); + } + + private static function productResponse(string $id): array + { + return ['id' => $id, 'object' => 'product', 'name' => 'Produto', 'active' => true, 'created' => 1786700000, 'metadata' => []]; + } + + private static function priceResponse(string $id = 'price_fake1', string $lookupKey = 'plano_mensal'): array + { + return [ + 'id' => $id, + 'object' => 'price', + 'active' => true, + 'currency' => 'brl', + 'lookup_key' => $lookupKey, + 'nickname' => null, + 'created' => 1786700000, + 'product' => 'prod_fake1', + 'recurring' => ['interval' => 'month', 'interval_count' => 1, 'usage_type' => 'licensed'], + 'type' => 'recurring', + 'unit_amount' => 10000, + 'unit_amount_decimal' => '10000', + ]; + } + + private static function priceListResponse(string $id = 'price_fake1', string $lookupKey = 'plano_mensal'): array + { + return [ + 'object' => 'list', + 'url' => '/v1/prices', + 'has_more' => false, + 'data' => [self::priceResponse($id, $lookupKey)], + ]; + } + + private static function extraItemResponse(): array + { + return [ + 'id' => 'si_extra1', + 'object' => 'subscription_item', + // criado depois do item do plano: o mais antigo é lido como o do plano + 'created' => 1788566500, + 'quantity' => 1, + 'subscription' => 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ', + 'price' => self::priceResponse('price_extra1', ''), + ]; + } + + /** + * Prévia de fatura da troca de plano: crédito do período não usado e cobrança do plano + * novo, no formato de `invoices.create_preview`. + */ + private static function previewInvoiceResponse(): array + { + return [ + 'id' => 'upcoming_in_fake1', + 'object' => 'invoice', + 'total' => 25005, + 'currency' => 'brl', + 'lines' => [ + 'object' => 'list', + 'has_more' => false, + 'data' => [ + [ + 'id' => 'il_fake1', + 'object' => 'line_item', + 'description' => 'Unused time on Mensal', + 'amount' => -5000, + 'quantity' => 1, + 'period' => ['start' => 1788368400, 'end' => 1791157981], + ], + [ + 'id' => 'il_fake2', + 'object' => 'line_item', + 'description' => 'Anual', + 'amount' => 30000, + 'quantity' => 2, + 'period' => ['start' => 1788368400, 'end' => 1819904400], + ], + [ + 'id' => 'il_fake3', + 'object' => 'line_item', + 'description' => 'Ajuste', + 'amount' => 5, + 'quantity' => 3, + 'period' => ['start' => 1788368400, 'end' => 1790960400], + ], + ], + ], + ]; + } +} diff --git a/tests/fixtures/stripe/README.md b/tests/fixtures/stripe/README.md index 4d81c78..5bda15f 100644 --- a/tests/fixtures/stripe/README.md +++ b/tests/fixtures/stripe/README.md @@ -60,11 +60,33 @@ O `client_secret` de todas foi substituído por um placeholder. ## `subscriptions/` -Montadas a partir do objeto Subscription documentado para a API `2026-07-29.dahlia` (a -sessão de sandbox não criou assinaturas): `active.json` é a base, com um item de preço -recorrente mensal, e as demais trocam `status` e os campos que acompanham cada estado -(`trial_start`/`trial_end` em `trialing` e `paused`, `canceled_at`/`ended_at` em `canceled`, -`ended_at` em `incomplete_expired`). `active_pause_collection.json` é a base com -`pause_collection` preenchido. Servem ao mapa de status -(`Gateways\Stripe\SubscriptionStatuses`); quando o driver ler assinatura, regravar a partir da -sandbox. +Gravadas na sandbox em 2026-09-04, pelo próprio driver e com o `expand` que ele usa +(`default_payment_method` e `items.data.price.product`); ids, `lookup_key`, nomes de plano e +e-mail foram renomeados para os valores estáveis das fixtures (`sub_1UBJmk...`, +`plano_mensal`...), sem tocar no restante do payload: + +| Arquivo | Como foi produzida | +|---|---| +| `active.json` | assinatura criada com cartão salvo (`pm_card_visa`) e plano mensal | +| `active_pause_collection.json` | a mesma assinatura depois de `suspendSubscription()` | +| `active_cancel_at_period_end.json` | a mesma depois de `cancelSubscription(atPeriodEnd: true)` | +| `canceled.json` | a mesma depois do cancelamento imediato (já no plano anual, pela troca) | +| `trialing.json` | assinatura criada com `trialDays` 7 no cartão salvo | + +Montadas sobre `active.json` (ou `trialing.json`), porque a sandbox não produz o estado: + +| Arquivo | Diferença | +|---|---| +| `incomplete.json` | status | +| `incomplete_expired.json` | status e `ended_at` | +| `past_due.json` | status | +| `unpaid.json` | status | +| `paused.json` | status, sobre `trialing.json` (trial que terminou sem método de pagamento) | + +## `webhooks/` + +Eventos entregues por `stripe listen` (CLI 1.50.10) numa sessão de sandbox em 2026-09-04, um +arquivo por tipo, com o corpo cru byte a byte como recebido (o `Stripe-Signature` de cada +entrega e o signing secret do listener ficam em arquivo fora do git, para o teste de +verificação de assinatura de uma versão futura validar o corpo exato). `mandate.updated` não +foi gravado: exige Pix Automático, que a conta ainda não tem liberado. diff --git a/tests/fixtures/stripe/subscriptions/active.json b/tests/fixtures/stripe/subscriptions/active.json index 6cec291..400a3d8 100644 --- a/tests/fixtures/stripe/subscriptions/active.json +++ b/tests/fixtures/stripe/subscriptions/active.json @@ -8,12 +8,16 @@ "enabled": false, "liability": null }, - "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor": 1788565981, "billing_cycle_anchor_config": null, "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, "type": "flexible", - "updated_at": 1788368400 + "updated_at": 1788565981 }, + "billing_schedules": [], "billing_thresholds": null, "cancel_at": null, "cancel_at_period_end": false, @@ -21,14 +25,68 @@ "cancellation_details": { "comment": null, "feedback": null, + "feedback_option": null, "reason": null }, "collection_method": "charge_automatically", - "created": 1788368400, + "created": 1788565981, "currency": "brl", "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, "days_until_due": null, - "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, "default_source": null, "default_tax_rates": [], "description": null, @@ -36,6 +94,9 @@ "ended_at": null, "invoice_settings": { "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, "issuer": { "type": "self" } @@ -47,9 +108,10 @@ "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", "object": "subscription_item", "billing_thresholds": null, - "created": 1788368400, - "current_period_end": 1790960400, - "current_period_start": 1788368400, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, "discounts": [], "metadata": {}, "plan": { @@ -59,14 +121,14 @@ "amount": 10000, "amount_decimal": "10000", "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "interval": "month", "interval_count": 1, "livemode": false, "metadata": {}, "meter": null, - "nickname": null, + "nickname": "Plano Mensal", "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", "tiers_mode": null, "transform_usage": null, @@ -78,14 +140,38 @@ "object": "price", "active": true, "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "custom_unit_amount": null, "livemode": false, "lookup_key": "plano_mensal", "metadata": {}, - "nickname": null, - "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, "recurring": { "interval": "month", "interval_count": 1, @@ -111,26 +197,54 @@ }, "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", "livemode": false, + "managed_payments": { + "enabled": false + }, "metadata": {}, "next_pending_invoice_item_invoice": null, "on_behalf_of": null, "pause_collection": null, "payment_settings": { "payment_method_options": null, - "payment_method_types": null, + "payment_method_types": [ + "card" + ], "save_default_payment_method": "off" }, "pending_invoice_item_interval": null, "pending_setup_intent": null, "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, "schedule": null, - "start_date": 1788368400, + "start_date": 1788565981, "status": "active", "test_clock": null, "transfer_data": null, "trial_end": null, "trial_settings": { "end_behavior": { + "billing_cycle_anchor": null, "missing_payment_method": "create_invoice" } }, diff --git a/tests/fixtures/stripe/subscriptions/active_cancel_at_period_end.json b/tests/fixtures/stripe/subscriptions/active_cancel_at_period_end.json new file mode 100644 index 0000000..16a8b2a --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/active_cancel_at_period_end.json @@ -0,0 +1,252 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": 1820101981, + "cancel_at_period_end": true, + "canceled_at": 1788565991, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": "cancellation_requested" + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1820101981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Anual", + "product": "prod_UBJmiPjx0CusuMr7Wq3An2", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_anual", + "metadata": {}, + "nickname": "Plano Anual", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3An2", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565978, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_anual" + }, + "name": "Plano Anual", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565978, + "url": null + }, + "recurring": { + "interval": "year", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 90000, + "unit_amount_decimal": "90000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Anual", + "product": "prod_UBJmiPjx0CusuMr7Wq3An2", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/active_pause_collection.json b/tests/fixtures/stripe/subscriptions/active_pause_collection.json index d851e6b..65cbfed 100644 --- a/tests/fixtures/stripe/subscriptions/active_pause_collection.json +++ b/tests/fixtures/stripe/subscriptions/active_pause_collection.json @@ -8,12 +8,16 @@ "enabled": false, "liability": null }, - "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor": 1788565981, "billing_cycle_anchor_config": null, "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, "type": "flexible", - "updated_at": 1788368400 + "updated_at": 1788565981 }, + "billing_schedules": [], "billing_thresholds": null, "cancel_at": null, "cancel_at_period_end": false, @@ -21,14 +25,68 @@ "cancellation_details": { "comment": null, "feedback": null, + "feedback_option": null, "reason": null }, "collection_method": "charge_automatically", - "created": 1788368400, + "created": 1788565981, "currency": "brl", "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, "days_until_due": null, - "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, "default_source": null, "default_tax_rates": [], "description": null, @@ -36,6 +94,9 @@ "ended_at": null, "invoice_settings": { "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, "issuer": { "type": "self" } @@ -47,47 +108,72 @@ "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", "object": "subscription_item", "billing_thresholds": null, - "created": 1788368400, - "current_period_end": 1790960400, - "current_period_start": 1788368400, + "created": 1788565981, + "current_period_end": 1820101981, + "current_period_start": 1788565981, + "current_trial": null, "discounts": [], "metadata": {}, "plan": { - "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", "object": "plan", "active": true, - "amount": 10000, - "amount_decimal": "10000", + "amount": 90000, + "amount_decimal": "90000", "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", - "interval": "month", + "interval": "year", "interval_count": 1, "livemode": false, "metadata": {}, "meter": null, - "nickname": null, - "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "nickname": "Plano Anual", + "product": "prod_UBJmiPjx0CusuMr7Wq3An2", "tiers_mode": null, "transform_usage": null, "trial_period_days": null, "usage_type": "licensed" }, "price": { - "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", "object": "price", "active": true, "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "custom_unit_amount": null, "livemode": false, - "lookup_key": "plano_mensal", + "lookup_key": "plano_anual", "metadata": {}, - "nickname": null, - "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "nickname": "Plano Anual", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3An2", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565978, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_anual" + }, + "name": "Plano Anual", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565978, + "url": null + }, "recurring": { - "interval": "month", + "interval": "year", "interval_count": 1, "meter": null, "trial_period_days": null, @@ -97,8 +183,8 @@ "tiers_mode": null, "transform_quantity": null, "type": "recurring", - "unit_amount": 10000, - "unit_amount_decimal": "10000" + "unit_amount": 90000, + "unit_amount_decimal": "90000" }, "quantity": 1, "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", @@ -111,6 +197,9 @@ }, "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", "livemode": false, + "managed_payments": { + "enabled": false + }, "metadata": {}, "next_pending_invoice_item_invoice": null, "on_behalf_of": null, @@ -120,20 +209,45 @@ }, "payment_settings": { "payment_method_options": null, - "payment_method_types": null, + "payment_method_types": [ + "card" + ], "save_default_payment_method": "off" }, "pending_invoice_item_interval": null, "pending_setup_intent": null, "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Anual", + "product": "prod_UBJmiPjx0CusuMr7Wq3An2", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, "schedule": null, - "start_date": 1788368400, + "start_date": 1788565981, "status": "active", "test_clock": null, "transfer_data": null, "trial_end": null, "trial_settings": { "end_behavior": { + "billing_cycle_anchor": null, "missing_payment_method": "create_invoice" } }, diff --git a/tests/fixtures/stripe/subscriptions/canceled.json b/tests/fixtures/stripe/subscriptions/canceled.json index 6933e85..c66658a 100644 --- a/tests/fixtures/stripe/subscriptions/canceled.json +++ b/tests/fixtures/stripe/subscriptions/canceled.json @@ -8,34 +8,95 @@ "enabled": false, "liability": null }, - "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor": 1788565981, "billing_cycle_anchor_config": null, "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, "type": "flexible", - "updated_at": 1788368400 + "updated_at": 1788565981 }, + "billing_schedules": [], "billing_thresholds": null, "cancel_at": null, "cancel_at_period_end": false, - "canceled_at": 1789059600, + "canceled_at": 1788565992, "cancellation_details": { "comment": null, "feedback": null, + "feedback_option": null, "reason": "cancellation_requested" }, "collection_method": "charge_automatically", - "created": 1788368400, + "created": 1788565981, "currency": "brl", "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, "days_until_due": null, - "default_payment_method": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, "default_source": null, "default_tax_rates": [], "description": null, "discounts": [], - "ended_at": 1789059600, + "ended_at": 1788565992, "invoice_settings": { "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, "issuer": { "type": "self" } @@ -47,47 +108,72 @@ "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", "object": "subscription_item", "billing_thresholds": null, - "created": 1788368400, - "current_period_end": 1790960400, - "current_period_start": 1788368400, + "created": 1788565981, + "current_period_end": 1820101981, + "current_period_start": 1788565981, + "current_trial": null, "discounts": [], "metadata": {}, "plan": { - "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", "object": "plan", "active": true, - "amount": 10000, - "amount_decimal": "10000", + "amount": 90000, + "amount_decimal": "90000", "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", - "interval": "month", + "interval": "year", "interval_count": 1, "livemode": false, "metadata": {}, "meter": null, - "nickname": null, - "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "nickname": "Plano Anual", + "product": "prod_UBJmiPjx0CusuMr7Wq3An2", "tiers_mode": null, "transform_usage": null, "trial_period_days": null, "usage_type": "licensed" }, "price": { - "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", "object": "price", "active": true, "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "custom_unit_amount": null, "livemode": false, - "lookup_key": "plano_mensal", + "lookup_key": "plano_anual", "metadata": {}, - "nickname": null, - "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "nickname": "Plano Anual", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3An2", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565978, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_anual" + }, + "name": "Plano Anual", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565978, + "url": null + }, "recurring": { - "interval": "month", + "interval": "year", "interval_count": 1, "meter": null, "trial_period_days": null, @@ -97,8 +183,8 @@ "tiers_mode": null, "transform_quantity": null, "type": "recurring", - "unit_amount": 10000, - "unit_amount_decimal": "10000" + "unit_amount": 90000, + "unit_amount_decimal": "90000" }, "quantity": 1, "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", @@ -111,26 +197,54 @@ }, "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", "livemode": false, + "managed_payments": { + "enabled": false + }, "metadata": {}, "next_pending_invoice_item_invoice": null, "on_behalf_of": null, "pause_collection": null, "payment_settings": { "payment_method_options": null, - "payment_method_types": null, + "payment_method_types": [ + "card" + ], "save_default_payment_method": "off" }, "pending_invoice_item_interval": null, "pending_setup_intent": null, "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Anual", + "product": "prod_UBJmiPjx0CusuMr7Wq3An2", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, "schedule": null, - "start_date": 1788368400, + "start_date": 1788565981, "status": "canceled", "test_clock": null, "transfer_data": null, "trial_end": null, "trial_settings": { "end_behavior": { + "billing_cycle_anchor": null, "missing_payment_method": "create_invoice" } }, diff --git a/tests/fixtures/stripe/subscriptions/incomplete.json b/tests/fixtures/stripe/subscriptions/incomplete.json index b5724e2..e842c4d 100644 --- a/tests/fixtures/stripe/subscriptions/incomplete.json +++ b/tests/fixtures/stripe/subscriptions/incomplete.json @@ -8,12 +8,16 @@ "enabled": false, "liability": null }, - "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor": 1788565981, "billing_cycle_anchor_config": null, "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, "type": "flexible", - "updated_at": 1788368400 + "updated_at": 1788565981 }, + "billing_schedules": [], "billing_thresholds": null, "cancel_at": null, "cancel_at_period_end": false, @@ -21,14 +25,68 @@ "cancellation_details": { "comment": null, "feedback": null, + "feedback_option": null, "reason": null }, "collection_method": "charge_automatically", - "created": 1788368400, + "created": 1788565981, "currency": "brl", "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, "days_until_due": null, - "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, "default_source": null, "default_tax_rates": [], "description": null, @@ -36,6 +94,9 @@ "ended_at": null, "invoice_settings": { "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, "issuer": { "type": "self" } @@ -47,9 +108,10 @@ "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", "object": "subscription_item", "billing_thresholds": null, - "created": 1788368400, - "current_period_end": 1790960400, - "current_period_start": 1788368400, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, "discounts": [], "metadata": {}, "plan": { @@ -59,14 +121,14 @@ "amount": 10000, "amount_decimal": "10000", "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "interval": "month", "interval_count": 1, "livemode": false, "metadata": {}, "meter": null, - "nickname": null, + "nickname": "Plano Mensal", "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", "tiers_mode": null, "transform_usage": null, @@ -78,14 +140,38 @@ "object": "price", "active": true, "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "custom_unit_amount": null, "livemode": false, "lookup_key": "plano_mensal", "metadata": {}, - "nickname": null, - "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, "recurring": { "interval": "month", "interval_count": 1, @@ -111,26 +197,54 @@ }, "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", "livemode": false, + "managed_payments": { + "enabled": false + }, "metadata": {}, "next_pending_invoice_item_invoice": null, "on_behalf_of": null, "pause_collection": null, "payment_settings": { "payment_method_options": null, - "payment_method_types": null, + "payment_method_types": [ + "card" + ], "save_default_payment_method": "off" }, "pending_invoice_item_interval": null, "pending_setup_intent": null, "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, "schedule": null, - "start_date": 1788368400, + "start_date": 1788565981, "status": "incomplete", "test_clock": null, "transfer_data": null, "trial_end": null, "trial_settings": { "end_behavior": { + "billing_cycle_anchor": null, "missing_payment_method": "create_invoice" } }, diff --git a/tests/fixtures/stripe/subscriptions/incomplete_expired.json b/tests/fixtures/stripe/subscriptions/incomplete_expired.json index 8d0e549..b20c93e 100644 --- a/tests/fixtures/stripe/subscriptions/incomplete_expired.json +++ b/tests/fixtures/stripe/subscriptions/incomplete_expired.json @@ -8,12 +8,16 @@ "enabled": false, "liability": null }, - "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor": 1788565981, "billing_cycle_anchor_config": null, "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, "type": "flexible", - "updated_at": 1788368400 + "updated_at": 1788565981 }, + "billing_schedules": [], "billing_thresholds": null, "cancel_at": null, "cancel_at_period_end": false, @@ -21,21 +25,78 @@ "cancellation_details": { "comment": null, "feedback": null, + "feedback_option": null, "reason": null }, "collection_method": "charge_automatically", - "created": 1788368400, + "created": 1788565981, "currency": "brl", "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, "days_until_due": null, - "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, "default_source": null, "default_tax_rates": [], "description": null, "discounts": [], - "ended_at": 1788454800, + "ended_at": 1788648781, "invoice_settings": { "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, "issuer": { "type": "self" } @@ -47,9 +108,10 @@ "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", "object": "subscription_item", "billing_thresholds": null, - "created": 1788368400, - "current_period_end": 1790960400, - "current_period_start": 1788368400, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, "discounts": [], "metadata": {}, "plan": { @@ -59,14 +121,14 @@ "amount": 10000, "amount_decimal": "10000", "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "interval": "month", "interval_count": 1, "livemode": false, "metadata": {}, "meter": null, - "nickname": null, + "nickname": "Plano Mensal", "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", "tiers_mode": null, "transform_usage": null, @@ -78,14 +140,38 @@ "object": "price", "active": true, "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "custom_unit_amount": null, "livemode": false, "lookup_key": "plano_mensal", "metadata": {}, - "nickname": null, - "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, "recurring": { "interval": "month", "interval_count": 1, @@ -111,26 +197,54 @@ }, "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", "livemode": false, + "managed_payments": { + "enabled": false + }, "metadata": {}, "next_pending_invoice_item_invoice": null, "on_behalf_of": null, "pause_collection": null, "payment_settings": { "payment_method_options": null, - "payment_method_types": null, + "payment_method_types": [ + "card" + ], "save_default_payment_method": "off" }, "pending_invoice_item_interval": null, "pending_setup_intent": null, "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, "schedule": null, - "start_date": 1788368400, + "start_date": 1788565981, "status": "incomplete_expired", "test_clock": null, "transfer_data": null, "trial_end": null, "trial_settings": { "end_behavior": { + "billing_cycle_anchor": null, "missing_payment_method": "create_invoice" } }, diff --git a/tests/fixtures/stripe/subscriptions/past_due.json b/tests/fixtures/stripe/subscriptions/past_due.json index 69c044c..46b172d 100644 --- a/tests/fixtures/stripe/subscriptions/past_due.json +++ b/tests/fixtures/stripe/subscriptions/past_due.json @@ -8,12 +8,16 @@ "enabled": false, "liability": null }, - "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor": 1788565981, "billing_cycle_anchor_config": null, "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, "type": "flexible", - "updated_at": 1788368400 + "updated_at": 1788565981 }, + "billing_schedules": [], "billing_thresholds": null, "cancel_at": null, "cancel_at_period_end": false, @@ -21,14 +25,68 @@ "cancellation_details": { "comment": null, "feedback": null, + "feedback_option": null, "reason": null }, "collection_method": "charge_automatically", - "created": 1788368400, + "created": 1788565981, "currency": "brl", "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, "days_until_due": null, - "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, "default_source": null, "default_tax_rates": [], "description": null, @@ -36,6 +94,9 @@ "ended_at": null, "invoice_settings": { "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, "issuer": { "type": "self" } @@ -47,9 +108,10 @@ "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", "object": "subscription_item", "billing_thresholds": null, - "created": 1788368400, - "current_period_end": 1790960400, - "current_period_start": 1788368400, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, "discounts": [], "metadata": {}, "plan": { @@ -59,14 +121,14 @@ "amount": 10000, "amount_decimal": "10000", "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "interval": "month", "interval_count": 1, "livemode": false, "metadata": {}, "meter": null, - "nickname": null, + "nickname": "Plano Mensal", "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", "tiers_mode": null, "transform_usage": null, @@ -78,14 +140,38 @@ "object": "price", "active": true, "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "custom_unit_amount": null, "livemode": false, "lookup_key": "plano_mensal", "metadata": {}, - "nickname": null, - "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, "recurring": { "interval": "month", "interval_count": 1, @@ -111,26 +197,54 @@ }, "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", "livemode": false, + "managed_payments": { + "enabled": false + }, "metadata": {}, "next_pending_invoice_item_invoice": null, "on_behalf_of": null, "pause_collection": null, "payment_settings": { "payment_method_options": null, - "payment_method_types": null, + "payment_method_types": [ + "card" + ], "save_default_payment_method": "off" }, "pending_invoice_item_interval": null, "pending_setup_intent": null, "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, "schedule": null, - "start_date": 1788368400, + "start_date": 1788565981, "status": "past_due", "test_clock": null, "transfer_data": null, "trial_end": null, "trial_settings": { "end_behavior": { + "billing_cycle_anchor": null, "missing_payment_method": "create_invoice" } }, diff --git a/tests/fixtures/stripe/subscriptions/paused.json b/tests/fixtures/stripe/subscriptions/paused.json index 08131d2..f056aef 100644 --- a/tests/fixtures/stripe/subscriptions/paused.json +++ b/tests/fixtures/stripe/subscriptions/paused.json @@ -8,12 +8,16 @@ "enabled": false, "liability": null }, - "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor": 1789170793, "billing_cycle_anchor_config": null, "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, "type": "flexible", - "updated_at": 1788368400 + "updated_at": 1788565993 }, + "billing_schedules": [], "billing_thresholds": null, "cancel_at": null, "cancel_at_period_end": false, @@ -21,14 +25,68 @@ "cancellation_details": { "comment": null, "feedback": null, + "feedback_option": null, "reason": null }, "collection_method": "charge_automatically", - "created": 1788368400, + "created": 1788565993, "currency": "brl", "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, "days_until_due": null, - "default_payment_method": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, "default_source": null, "default_tax_rates": [], "description": null, @@ -36,6 +94,9 @@ "ended_at": null, "invoice_settings": { "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, "issuer": { "type": "self" } @@ -47,9 +108,10 @@ "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", "object": "subscription_item", "billing_thresholds": null, - "created": 1788368400, - "current_period_end": 1790960400, - "current_period_start": 1788368400, + "created": 1788565993, + "current_period_end": 1789170793, + "current_period_start": 1788565993, + "current_trial": null, "discounts": [], "metadata": {}, "plan": { @@ -59,14 +121,14 @@ "amount": 10000, "amount_decimal": "10000", "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "interval": "month", "interval_count": 1, "livemode": false, "metadata": {}, "meter": null, - "nickname": null, + "nickname": "Plano Mensal", "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", "tiers_mode": null, "transform_usage": null, @@ -78,14 +140,38 @@ "object": "price", "active": true, "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "custom_unit_amount": null, "livemode": false, "lookup_key": "plano_mensal", "metadata": {}, - "nickname": null, - "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, "recurring": { "interval": "month", "interval_count": 1, @@ -111,28 +197,56 @@ }, "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", "livemode": false, + "managed_payments": { + "enabled": false + }, "metadata": {}, "next_pending_invoice_item_invoice": null, "on_behalf_of": null, "pause_collection": null, "payment_settings": { "payment_method_options": null, - "payment_method_types": null, + "payment_method_types": [ + "card" + ], "save_default_payment_method": "off" }, "pending_invoice_item_interval": null, "pending_setup_intent": null, "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, "schedule": null, - "start_date": 1788368400, + "start_date": 1788565993, "status": "paused", "test_clock": null, "transfer_data": null, - "trial_end": 1788973200, + "trial_end": 1789170793, "trial_settings": { "end_behavior": { - "missing_payment_method": "pause" + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" } }, - "trial_start": 1788368400 + "trial_start": 1788565993 } diff --git a/tests/fixtures/stripe/subscriptions/trialing.json b/tests/fixtures/stripe/subscriptions/trialing.json index 6c2ad82..e2ed81b 100644 --- a/tests/fixtures/stripe/subscriptions/trialing.json +++ b/tests/fixtures/stripe/subscriptions/trialing.json @@ -8,12 +8,16 @@ "enabled": false, "liability": null }, - "billing_cycle_anchor": 1788973200, + "billing_cycle_anchor": 1789170793, "billing_cycle_anchor_config": null, "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, "type": "flexible", - "updated_at": 1788368400 + "updated_at": 1788565993 }, + "billing_schedules": [], "billing_thresholds": null, "cancel_at": null, "cancel_at_period_end": false, @@ -21,14 +25,68 @@ "cancellation_details": { "comment": null, "feedback": null, + "feedback_option": null, "reason": null }, "collection_method": "charge_automatically", - "created": 1788368400, + "created": 1788565993, "currency": "brl", "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, "days_until_due": null, - "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, "default_source": null, "default_tax_rates": [], "description": null, @@ -36,6 +94,9 @@ "ended_at": null, "invoice_settings": { "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, "issuer": { "type": "self" } @@ -47,9 +108,10 @@ "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", "object": "subscription_item", "billing_thresholds": null, - "created": 1788368400, - "current_period_end": 1790960400, - "current_period_start": 1788368400, + "created": 1788565993, + "current_period_end": 1789170793, + "current_period_start": 1788565993, + "current_trial": null, "discounts": [], "metadata": {}, "plan": { @@ -59,14 +121,14 @@ "amount": 10000, "amount_decimal": "10000", "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "interval": "month", "interval_count": 1, "livemode": false, "metadata": {}, "meter": null, - "nickname": null, + "nickname": "Plano Mensal", "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", "tiers_mode": null, "transform_usage": null, @@ -78,14 +140,38 @@ "object": "price", "active": true, "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "custom_unit_amount": null, "livemode": false, "lookup_key": "plano_mensal", "metadata": {}, - "nickname": null, - "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, "recurring": { "interval": "month", "interval_count": 1, @@ -111,28 +197,56 @@ }, "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", "livemode": false, + "managed_payments": { + "enabled": false + }, "metadata": {}, "next_pending_invoice_item_invoice": null, "on_behalf_of": null, "pause_collection": null, "payment_settings": { "payment_method_options": null, - "payment_method_types": null, + "payment_method_types": [ + "card" + ], "save_default_payment_method": "off" }, "pending_invoice_item_interval": null, "pending_setup_intent": null, "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, "schedule": null, - "start_date": 1788368400, + "start_date": 1788565993, "status": "trialing", "test_clock": null, "transfer_data": null, - "trial_end": 1788973200, + "trial_end": 1789170793, "trial_settings": { "end_behavior": { + "billing_cycle_anchor": null, "missing_payment_method": "create_invoice" } }, - "trial_start": 1788368400 + "trial_start": 1788565993 } diff --git a/tests/fixtures/stripe/subscriptions/unpaid.json b/tests/fixtures/stripe/subscriptions/unpaid.json index 18cdd1e..d410f39 100644 --- a/tests/fixtures/stripe/subscriptions/unpaid.json +++ b/tests/fixtures/stripe/subscriptions/unpaid.json @@ -8,12 +8,16 @@ "enabled": false, "liability": null }, - "billing_cycle_anchor": 1788368400, + "billing_cycle_anchor": 1788565981, "billing_cycle_anchor_config": null, "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, "type": "flexible", - "updated_at": 1788368400 + "updated_at": 1788565981 }, + "billing_schedules": [], "billing_thresholds": null, "cancel_at": null, "cancel_at_period_end": false, @@ -21,14 +25,68 @@ "cancellation_details": { "comment": null, "feedback": null, + "feedback_option": null, "reason": null }, "collection_method": "charge_automatically", - "created": 1788368400, + "created": 1788565981, "currency": "brl", "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, "days_until_due": null, - "default_payment_method": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, "default_source": null, "default_tax_rates": [], "description": null, @@ -36,6 +94,9 @@ "ended_at": null, "invoice_settings": { "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, "issuer": { "type": "self" } @@ -47,9 +108,10 @@ "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", "object": "subscription_item", "billing_thresholds": null, - "created": 1788368400, - "current_period_end": 1790960400, - "current_period_start": 1788368400, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, "discounts": [], "metadata": {}, "plan": { @@ -59,14 +121,14 @@ "amount": 10000, "amount_decimal": "10000", "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "interval": "month", "interval_count": 1, "livemode": false, "metadata": {}, "meter": null, - "nickname": null, + "nickname": "Plano Mensal", "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", "tiers_mode": null, "transform_usage": null, @@ -78,14 +140,38 @@ "object": "price", "active": true, "billing_scheme": "per_unit", - "created": 1788368399, + "created": 1788565978, "currency": "brl", "custom_unit_amount": null, "livemode": false, "lookup_key": "plano_mensal", "metadata": {}, - "nickname": null, - "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, "recurring": { "interval": "month", "interval_count": 1, @@ -111,26 +197,54 @@ }, "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", "livemode": false, + "managed_payments": { + "enabled": false + }, "metadata": {}, "next_pending_invoice_item_invoice": null, "on_behalf_of": null, "pause_collection": null, "payment_settings": { "payment_method_options": null, - "payment_method_types": null, + "payment_method_types": [ + "card" + ], "save_default_payment_method": "off" }, "pending_invoice_item_interval": null, "pending_setup_intent": null, "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, "schedule": null, - "start_date": 1788368400, + "start_date": 1788565981, "status": "unpaid", "test_clock": null, "transfer_data": null, "trial_end": null, "trial_settings": { "end_behavior": { + "billing_cycle_anchor": null, "missing_payment_method": "create_invoice" } }, diff --git a/tests/fixtures/stripe/webhooks/charge.dispute.created.json b/tests/fixtures/stripe/webhooks/charge.dispute.created.json new file mode 100644 index 0000000..98c5b8a --- /dev/null +++ b/tests/fixtures/stripe/webhooks/charge.dispute.created.json @@ -0,0 +1,105 @@ +{ + "id": "evt_1UC6vwPjx0CusuMr3RQZkHeD", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788566052, + "data": { + "object": { + "id": "du_1UC6vvPjx0CusuMrFCR9SiV0", + "object": "dispute", + "amount": 7000, + "balance_transaction": "txn_1UC6vwPjx0CusuMrDKJHpKUT", + "balance_transactions": [ + { + "available_on": 1788566051, + "created": 1788566051, + "net": -12500, + "currency": "brl", + "source": "du_1UC6vvPjx0CusuMrFCR9SiV0", + "reporting_category": "dispute", + "fee_details": [ + { + "application": null, + "amount": 5500, + "type": "stripe_fee", + "description": "Dispute fee", + "currency": "brl" + } + ], + "amount": -7000, + "status": "available", + "balance_type": "payments", + "object": "balance_transaction", + "id": "txn_1UC6vwPjx0CusuMrDKJHpKUT", + "exchange_rate": null, + "type": "adjustment", + "description": "Chargeback withdrawal for ch_3UC6vvPjx0CusuMr1eTM0Zbf", + "fee": 5500 + } + ], + "charge": "ch_3UC6vvPjx0CusuMr1eTM0Zbf", + "created": 1788566051, + "currency": "brl", + "enhanced_eligibility_types": [], + "evidence": { + "access_activity_log": null, + "billing_address": null, + "cancellation_policy": null, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_communication": null, + "customer_email_address": null, + "customer_name": null, + "customer_purchase_ip": null, + "customer_signature": null, + "duplicate_charge_documentation": null, + "duplicate_charge_explanation": null, + "duplicate_charge_id": null, + "enhanced_evidence": {}, + "product_description": null, + "receipt": null, + "refund_policy": null, + "refund_policy_disclosure": null, + "refund_refusal_explanation": null, + "service_date": null, + "service_documentation": null, + "shipping_address": null, + "shipping_carrier": null, + "shipping_date": null, + "shipping_documentation": null, + "shipping_tracking_number": null, + "uncategorized_file": null, + "uncategorized_text": null + }, + "evidence_details": { + "due_by": 1789257599, + "enhanced_eligibility": {}, + "has_evidence": false, + "past_due": false, + "submission_count": 0 + }, + "is_charge_refundable": false, + "livemode": false, + "metadata": {}, + "payment_intent": "pi_3UC6vvPjx0CusuMr1QDzJkyL", + "payment_method_details": { + "card": { + "brand": "visa", + "case_type": "chargeback", + "network": "visa", + "network_reason_code": "10.4" + }, + "type": "card" + }, + "reason": "fraudulent", + "status": "needs_response" + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_Ux6mKfmUFyegOW", + "idempotency_key": "173f01d9-c5e5-4b28-bdb4-01d4b66eac6b" + }, + "type": "charge.dispute.created" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/charge.refunded.json b/tests/fixtures/stripe/webhooks/charge.refunded.json new file mode 100644 index 0000000..08c2b83 --- /dev/null +++ b/tests/fixtures/stripe/webhooks/charge.refunded.json @@ -0,0 +1,137 @@ +{ + "id": "evt_3UC6vrPjx0CusuMr1Dl0qeMF", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788566048, + "data": { + "object": { + "id": "ch_3UC6vrPjx0CusuMr1kgZJyoO", + "object": "charge", + "amount": 5000, + "amount_captured": 5000, + "amount_refunded": 5000, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3UC6vrPjx0CusuMr1NBdNIDz", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": true, + "created": 1788566047, + "currency": "brl", + "customer": "cus_VCVxAZQMCHXOt8", + "description": null, + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "livemode": false, + "metadata": { + "item_0_description": "Cobranca para estorno", + "item_0_price": "5000", + "item_0_quantity": "1" + }, + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 25, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3UC6vrPjx0CusuMr1p8wB8OF", + "payment_method": "pm_1UC6vpPjx0CusuMr1aZb0Cwt", + "payment_method_details": { + "card": { + "amount_authorized": 5000, + "authorization_code": "951116", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "4242", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "666611471566978", + "overcapture": { + "maximum_amount_capturable": 5000, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": {}, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xVHpLa1RQangwQ3VzdU1yKKG07dQGMgbSlvgGNGY6LBbYyqpucoFdj3ANQs4FnFI50cTWK_HL1akSX2iD4p7LzjlGrK7Ltqtsf66e", + "refunded": true, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "previous_attributes": { + "amount_refunded": 0, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xVHpLa1RQangwQ3VzdU1yKKC07dQGMgYW4HfsvuQ6LBad9Q3eqcxuoRv3D8BRcW43HwPhLRwQbZQ3DmMX_OdkvrrSUsROV5yD362l", + "refunded": false + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_F2cFBYuedq08e7", + "idempotency_key": "cd0bd68c-622a-409d-9906-1966de360394" + }, + "type": "charge.refunded" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/customer.subscription.created.json b/tests/fixtures/stripe/webhooks/customer.subscription.created.json new file mode 100644 index 0000000..2672158 --- /dev/null +++ b/tests/fixtures/stripe/webhooks/customer.subscription.created.json @@ -0,0 +1,191 @@ +{ + "id": "evt_1UC6upPjx0CusuMrLVt80Lm2", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565983, + "data": { + "object": { + "id": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "days_until_due": null, + "default_payment_method": "pm_1UC6ulPjx0CusuMrE28WKacN", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_VCVwoG3IX26J0b", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Mensal 607a71f6", + "product": "prod_VCVwrMO5ms21iL", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "mp-p06-mensal-607a71f6", + "metadata": {}, + "nickname": "MP P06 Mensal 607a71f6", + "product": "prod_VCVwrMO5ms21iL", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UC6unPjx0CusuMrH0YMAWK6" + }, + "latest_invoice": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Mensal 607a71f6", + "product": "prod_VCVwrMO5ms21iL", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_3E7Ws12zN5HMSB", + "idempotency_key": "p06-607a71f6-card" + }, + "type": "customer.subscription.created" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/customer.subscription.deleted.json b/tests/fixtures/stripe/webhooks/customer.subscription.deleted.json new file mode 100644 index 0000000..06886a3 --- /dev/null +++ b/tests/fixtures/stripe/webhooks/customer.subscription.deleted.json @@ -0,0 +1,191 @@ +{ + "id": "evt_1UC6uyPjx0CusuMrQ3S6u2dQ", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565992, + "data": { + "object": { + "id": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": 1788565992, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": "cancellation_requested" + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "days_until_due": null, + "default_payment_method": "pm_1UC6ulPjx0CusuMrE28WKacN", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": 1788565992, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_VCVwoG3IX26J0b", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1820101981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UC6ukPjx0CusuMrfiJshCLw", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Anual 607a71f6", + "product": "prod_VCVw3GaYGFkGe4", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UC6ukPjx0CusuMrfiJshCLw", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "mp-p06-anual-607a71f6", + "metadata": {}, + "nickname": "MP P06 Anual 607a71f6", + "product": "prod_VCVw3GaYGFkGe4", + "recurring": { + "interval": "year", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 90000, + "unit_amount_decimal": "90000" + }, + "quantity": 1, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UC6unPjx0CusuMrH0YMAWK6" + }, + "latest_invoice": "in_1UC6usPjx0CusuMrgAijCrPZ", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UC6ukPjx0CusuMrfiJshCLw", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Anual 607a71f6", + "product": "prod_VCVw3GaYGFkGe4", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "canceled", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_RRIkVKQEkJ4AjN", + "idempotency_key": "p06-607a71f6-cancel" + }, + "type": "customer.subscription.deleted" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/customer.subscription.updated.json b/tests/fixtures/stripe/webhooks/customer.subscription.updated.json new file mode 100644 index 0000000..e46eddc --- /dev/null +++ b/tests/fixtures/stripe/webhooks/customer.subscription.updated.json @@ -0,0 +1,268 @@ +{ + "id": "evt_1UC6uuPjx0CusuMrsiKlht8v", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565988, + "data": { + "object": { + "id": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "days_until_due": null, + "default_payment_method": "pm_1UC6ulPjx0CusuMrE28WKacN", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_VCVwoG3IX26J0b", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1820101981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UC6ukPjx0CusuMrfiJshCLw", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Anual 607a71f6", + "product": "prod_VCVw3GaYGFkGe4", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UC6ukPjx0CusuMrfiJshCLw", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "mp-p06-anual-607a71f6", + "metadata": {}, + "nickname": "MP P06 Anual 607a71f6", + "product": "prod_VCVw3GaYGFkGe4", + "recurring": { + "interval": "year", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 90000, + "unit_amount_decimal": "90000" + }, + "quantity": 1, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UC6unPjx0CusuMrH0YMAWK6" + }, + "latest_invoice": "in_1UC6usPjx0CusuMrgAijCrPZ", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UC6ukPjx0CusuMrfiJshCLw", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Anual 607a71f6", + "product": "prod_VCVw3GaYGFkGe4", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null + }, + "previous_attributes": { + "items": { + "data": [ + { + "id": "si_VCVwoG3IX26J0b", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Mensal 607a71f6", + "product": "prod_VCVwrMO5ms21iL", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "mp-p06-mensal-607a71f6", + "metadata": {}, + "nickname": "MP P06 Mensal 607a71f6", + "product": "prod_VCVwrMO5ms21iL", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "tax_rates": [] + } + ] + }, + "latest_invoice": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "plan": { + "id": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "amount": 10000, + "amount_decimal": "10000", + "interval": "month", + "nickname": "MP P06 Mensal 607a71f6", + "product": "prod_VCVwrMO5ms21iL" + } + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_ooVw7ohCghBXbH", + "idempotency_key": "p06-607a71f6-change" + }, + "type": "customer.subscription.updated" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/invoice.created.json b/tests/fixtures/stripe/webhooks/invoice.created.json new file mode 100644 index 0000000..c6ee6b4 --- /dev/null +++ b/tests/fixtures/stripe/webhooks/invoice.created.json @@ -0,0 +1,172 @@ +{ + "id": "evt_1UC6upPjx0CusuMrbh1kV6t8", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565983, + "data": { + "object": { + "id": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 10000, + "amount_overpaid": 0, + "amount_paid": 10000, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "subscription_create", + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "customer_address": null, + "customer_email": "mp-p06-607a71f6@exemplo.com", + "customer_name": "MP Teste Prompt06", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788565981, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z3aVdHUTY5N2REVXcySFU0WnNMeld4dU55aGRsLDE3OTEwNjc4Mw0200EdI3V4M5?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z3aVdHUTY5N2REVXcySFU0WnNMeld4dU55aGRsLDE3OTEwNjc4Mw0200EdI3V4M5/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UC6unPjx0CusuMrxYHjaaU6", + "object": "line_item", + "amount": 10000, + "currency": "brl", + "description": "1 × MP P06 Mensal 607a71f6 (a R$ 100.00 / month)", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "livemode": false, + "metadata": {}, + "parent": { + "invoice_item_details": null, + "subscription_item_details": { + "invoice_item": null, + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "subscription_item": "si_VCVwoG3IX26J0b" + }, + "type": "subscription_item_details" + }, + "period": { + "end": 1791157981, + "start": 1788565981 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "product": "prod_VCVwrMO5ms21iL" + }, + "type": "price_details", + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 10000, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UC6unPjx0CusuMr2Jkqq5Fb/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "QO3ETNSL-0001", + "on_behalf_of": null, + "parent": { + "quote_details": null, + "subscription_details": { + "metadata": {}, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6" + }, + "type": "subscription_details" + }, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": [ + "card" + ] + }, + "period_end": 1788565981, + "period_start": 1788565981, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788565981, + "marked_uncollectible_at": null, + "paid_at": 1788565981, + "voided_at": null + }, + "subtotal": 10000, + "subtotal_excluding_tax": 10000, + "test_clock": null, + "total": 10000, + "total_discount_amounts": [], + "total_excluding_tax": 10000, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_3E7Ws12zN5HMSB", + "idempotency_key": "p06-607a71f6-card" + }, + "type": "invoice.created" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/invoice.finalized.json b/tests/fixtures/stripe/webhooks/invoice.finalized.json new file mode 100644 index 0000000..5435d18 --- /dev/null +++ b/tests/fixtures/stripe/webhooks/invoice.finalized.json @@ -0,0 +1,172 @@ +{ + "id": "evt_1UC6upPjx0CusuMrsM7ryUqb", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565983, + "data": { + "object": { + "id": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 10000, + "amount_overpaid": 0, + "amount_paid": 10000, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "subscription_create", + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "customer_address": null, + "customer_email": "mp-p06-607a71f6@exemplo.com", + "customer_name": "MP Teste Prompt06", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788565981, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z3aVdHUTY5N2REVXcySFU0WnNMeld4dU55aGRsLDE3OTEwNjc4Mw0200EdI3V4M5?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z3aVdHUTY5N2REVXcySFU0WnNMeld4dU55aGRsLDE3OTEwNjc4Mw0200EdI3V4M5/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UC6unPjx0CusuMrxYHjaaU6", + "object": "line_item", + "amount": 10000, + "currency": "brl", + "description": "1 × MP P06 Mensal 607a71f6 (a R$ 100.00 / month)", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "livemode": false, + "metadata": {}, + "parent": { + "invoice_item_details": null, + "subscription_item_details": { + "invoice_item": null, + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "subscription_item": "si_VCVwoG3IX26J0b" + }, + "type": "subscription_item_details" + }, + "period": { + "end": 1791157981, + "start": 1788565981 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "product": "prod_VCVwrMO5ms21iL" + }, + "type": "price_details", + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 10000, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UC6unPjx0CusuMr2Jkqq5Fb/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "QO3ETNSL-0001", + "on_behalf_of": null, + "parent": { + "quote_details": null, + "subscription_details": { + "metadata": {}, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6" + }, + "type": "subscription_details" + }, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": [ + "card" + ] + }, + "period_end": 1788565981, + "period_start": 1788565981, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788565981, + "marked_uncollectible_at": null, + "paid_at": 1788565981, + "voided_at": null + }, + "subtotal": 10000, + "subtotal_excluding_tax": 10000, + "test_clock": null, + "total": 10000, + "total_discount_amounts": [], + "total_excluding_tax": 10000, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_3E7Ws12zN5HMSB", + "idempotency_key": "p06-607a71f6-card" + }, + "type": "invoice.finalized" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/invoice.paid.json b/tests/fixtures/stripe/webhooks/invoice.paid.json new file mode 100644 index 0000000..b093eec --- /dev/null +++ b/tests/fixtures/stripe/webhooks/invoice.paid.json @@ -0,0 +1,172 @@ +{ + "id": "evt_1UC6upPjx0CusuMr7t76P2p5", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565983, + "data": { + "object": { + "id": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 10000, + "amount_overpaid": 0, + "amount_paid": 10000, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "subscription_create", + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "customer_address": null, + "customer_email": "mp-p06-607a71f6@exemplo.com", + "customer_name": "MP Teste Prompt06", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788565981, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z3aVdHUTY5N2REVXcySFU0WnNMeld4dU55aGRsLDE3OTEwNjc4Mw0200EdI3V4M5?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z3aVdHUTY5N2REVXcySFU0WnNMeld4dU55aGRsLDE3OTEwNjc4Mw0200EdI3V4M5/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UC6unPjx0CusuMrxYHjaaU6", + "object": "line_item", + "amount": 10000, + "currency": "brl", + "description": "1 × MP P06 Mensal 607a71f6 (a R$ 100.00 / month)", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "livemode": false, + "metadata": {}, + "parent": { + "invoice_item_details": null, + "subscription_item_details": { + "invoice_item": null, + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "subscription_item": "si_VCVwoG3IX26J0b" + }, + "type": "subscription_item_details" + }, + "period": { + "end": 1791157981, + "start": 1788565981 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "product": "prod_VCVwrMO5ms21iL" + }, + "type": "price_details", + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 10000, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UC6unPjx0CusuMr2Jkqq5Fb/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "QO3ETNSL-0001", + "on_behalf_of": null, + "parent": { + "quote_details": null, + "subscription_details": { + "metadata": {}, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6" + }, + "type": "subscription_details" + }, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": [ + "card" + ] + }, + "period_end": 1788565981, + "period_start": 1788565981, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788565981, + "marked_uncollectible_at": null, + "paid_at": 1788565981, + "voided_at": null + }, + "subtotal": 10000, + "subtotal_excluding_tax": 10000, + "test_clock": null, + "total": 10000, + "total_discount_amounts": [], + "total_excluding_tax": 10000, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_3E7Ws12zN5HMSB", + "idempotency_key": "p06-607a71f6-card" + }, + "type": "invoice.paid" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/invoice.payment_failed.json b/tests/fixtures/stripe/webhooks/invoice.payment_failed.json new file mode 100644 index 0000000..e57e81f --- /dev/null +++ b/tests/fixtures/stripe/webhooks/invoice.payment_failed.json @@ -0,0 +1,170 @@ +{ + "id": "evt_1UC6wTPjx0CusuMrGqqAySXf", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788566084, + "data": { + "object": { + "id": "in_1UC6wQPjx0CusuMrikar2pXa", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 4000, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 4000, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "subscription_create", + "collection_method": "charge_automatically", + "created": 1788566082, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VCVyPodH6sGSXc", + "customer_account": null, + "customer_address": null, + "customer_email": "mp-p06wh2-f7c5dae9@exemplo.com", + "customer_name": "MP Teste Webhooks 2", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788566082, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z5YUJXVEc3UlhZT0liQzZJMEFvQjV1QVdjNHVHLDE3OTEwNjg4NQ02008pZrz1Rx?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z5YUJXVEc3UlhZT0liQzZJMEFvQjV1QVdjNHVHLDE3OTEwNjg4NQ02008pZrz1Rx/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UC6wQPjx0CusuMrGC9rtmRb", + "object": "line_item", + "amount": 4000, + "currency": "brl", + "description": "1 × MP P06 WH2 f7c5dae9 (a R$ 40.00 / month)", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UC6wQPjx0CusuMrikar2pXa", + "livemode": false, + "metadata": {}, + "parent": { + "invoice_item_details": null, + "subscription_item_details": { + "invoice_item": null, + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": "sub_1UC6wQPjx0CusuMrsmhkXBow", + "subscription_item": "si_VCVyEb0uEFOyhM" + }, + "type": "subscription_item_details" + }, + "period": { + "end": 1791158082, + "start": 1788566082 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UC6wQPjx0CusuMr1OSsR2oV", + "product": "prod_VCVyrNe2mwrugX" + }, + "type": "price_details", + "unit_amount_decimal": "4000" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 4000, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UC6wQPjx0CusuMrikar2pXa/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "MNHMQAKN-0001", + "on_behalf_of": null, + "parent": { + "quote_details": null, + "subscription_details": { + "metadata": {}, + "subscription": "sub_1UC6wQPjx0CusuMrsmhkXBow" + }, + "type": "subscription_details" + }, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "period_end": 1788566082, + "period_start": 1788566082, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788566082, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 4000, + "subtotal_excluding_tax": 4000, + "test_clock": null, + "total": 4000, + "total_discount_amounts": [], + "total_excluding_tax": 4000, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_VFpL6enekjx7eH", + "idempotency_key": "8cd25e88-b3c8-4657-8ad1-7178e86f5b81" + }, + "type": "invoice.payment_failed" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/payment_intent.payment_failed.json b/tests/fixtures/stripe/webhooks/payment_intent.payment_failed.json new file mode 100644 index 0000000..aa31b2f --- /dev/null +++ b/tests/fixtures/stripe/webhooks/payment_intent.payment_failed.json @@ -0,0 +1,141 @@ +{ + "id": "evt_3UC6wQPjx0CusuMr0ZdMD3mh", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788566084, + "data": { + "object": { + "id": "pi_3UC6wQPjx0CusuMr0U8FJLrn", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 4000, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UC6wQPjx0CusuMr0U8FJLrn_secret_g00fFKr2LgN29mA0LkZ11EI5j", + "confirmation_method": "automatic", + "created": 1788566082, + "currency": "brl", + "customer": "cus_VCVyPodH6sGSXc", + "customer_account": null, + "description": "Subscription creation", + "excluded_payment_method_types": null, + "last_payment_error": { + "advice_code": "try_again_later", + "charge": "ch_3UC6wQPjx0CusuMr0ukM1IF1", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "network_decline_code": "01", + "payment_method": { + "id": "pm_1UC6wOPjx0CusuMrnMR6nM9I", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "sryrMZCxUxxxIzqq", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788566080, + "customer": "cus_VCVyPodH6sGSXc", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": "ch_3UC6wQPjx0CusuMr0ukM1IF1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UC6wQPjx0CusuMrikar2pXa" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "payment_record": null, + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_VFpL6enekjx7eH", + "idempotency_key": "8cd25e88-b3c8-4657-8ad1-7178e86f5b81" + }, + "type": "payment_intent.payment_failed" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/payment_intent.succeeded.json b/tests/fixtures/stripe/webhooks/payment_intent.succeeded.json new file mode 100644 index 0000000..ebd1d47 --- /dev/null +++ b/tests/fixtures/stripe/webhooks/payment_intent.succeeded.json @@ -0,0 +1,79 @@ +{ + "id": "evt_3UC6unPjx0CusuMr1YVY85MW", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565982, + "data": { + "object": { + "id": "pi_3UC6unPjx0CusuMr1L8mQPNj", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 10000, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 10000, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UC6unPjx0CusuMr1L8mQPNj_secret_YmueIkpVbYHfn98l1A5mlOGTX", + "confirmation_method": "automatic", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "description": "Subscription creation", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": "ch_3UC6unPjx0CusuMr12E30a16", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UC6unPjx0CusuMr2Jkqq5Fb" + }, + "payment_method": "pm_1UC6ulPjx0CusuMrE28WKacN", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "payment_record": null, + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": "off_session", + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_3E7Ws12zN5HMSB", + "idempotency_key": "p06-607a71f6-card" + }, + "type": "payment_intent.succeeded" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/setup_intent.succeeded.json b/tests/fixtures/stripe/webhooks/setup_intent.succeeded.json new file mode 100644 index 0000000..c11d71c --- /dev/null +++ b/tests/fixtures/stripe/webhooks/setup_intent.succeeded.json @@ -0,0 +1,55 @@ +{ + "id": "evt_1UC6umPjx0CusuMrGf4gkinv", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565980, + "data": { + "object": { + "id": "seti_1UC6ulPjx0CusuMrIz23i4LU", + "object": "setup_intent", + "allowed_payment_method_types": null, + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1UC6ulPjx0CusuMrIz23i4LU_secret_VCVw5uZoL7ik5vbkWno6DUaVZSQ6PyC", + "created": 1788565979, + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1UC6umPjx0CusuMrr3p7NAqf", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "mandate": null, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1UC6ulPjx0CusuMrE28WKacN", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "single_use_mandate": null, + "status": "succeeded", + "usage": "off_session" + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_hZLvxAEjK0tthv", + "idempotency_key": "3916aebd-c1f6-4804-b691-d04b0db2d6cf" + }, + "type": "setup_intent.succeeded" +} \ No newline at end of file From 17b84a08903fee5dd98db21dd0dd917b35894998 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Fri, 4 Sep 2026 22:19:04 -0300 Subject: [PATCH 30/32] feat(subscription): cupom com validade e cancelamento ao fim do ciclo, nativos no Stripe e emulados na Iugu via custom_variables --- README.md | 130 +++- composer.json | 1 + composer.lock | 2 +- src/Builders/SubscriptionBuilder.php | 14 +- src/Console/SyncSubscriptionsCommand.php | 93 +++ src/Contracts/DeclaresCapabilities.php | 28 +- src/Contracts/SubscriptionSyncContract.php | 29 + src/Enums/Capability.php | 15 +- src/Facades/MultiPayment.php | 2 + src/Gateways/Concerns/ChecksCapabilities.php | 25 +- src/Gateways/IuguGateway.php | 687 +++++++++++++++++- src/Gateways/StripeGateway.php | 277 ++++++- src/Helpers/CapabilitiesTable.php | 9 +- src/Models/Subscription.php | 31 +- src/Models/SubscriptionDiscount.php | 42 +- src/MultiPayment.php | 27 + src/Providers/MultiPaymentServiceProvider.php | 5 + tests/Integration/StripeSubscriptionTest.php | 59 ++ tests/Integration/SubscriptionTest.php | 72 +- tests/Unit/CapabilityGuardsTest.php | 69 +- .../Console/SyncSubscriptionsCommandTest.php | 177 +++++ .../Unit/Gateways/GatewayCapabilitiesTest.php | 44 +- .../Gateways/IuguGatewayIdempotencyTest.php | 13 + .../Gateways/IuguGatewaySubscriptionTest.php | 480 ++++++++++-- .../IuguGatewaySyncSubscriptionsTest.php | 278 +++++++ .../Gateways/StripeGatewayIdempotencyTest.php | 56 ++ .../StripeGatewaySubscriptionTest.php | 317 +++++++- tests/Unit/SubscriptionTest.php | 44 ++ 28 files changed, 2807 insertions(+), 219 deletions(-) create mode 100644 src/Console/SyncSubscriptionsCommand.php create mode 100644 src/Contracts/SubscriptionSyncContract.php create mode 100644 tests/Unit/Console/SyncSubscriptionsCommandTest.php create mode 100644 tests/Unit/Gateways/IuguGatewaySyncSubscriptionsTest.php diff --git a/README.md b/README.md index e3a9f23..9d34288 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atu - [Pix Automático](#pix-automático) - [Pix Automático: quem agenda a cobrança](#pix-automático-quem-agenda-a-cobrança) - [Assinaturas e planos](#assinaturas-e-planos) + - [Emulações na Iugu](#emulações-na-iugu) - [CustomerBuilder](#customerbuilder) - [Salvar cartão (CreditCardBuilder)](#salvar-cartão-creditcardbuilder) - [getInvoice](#getinvoice) @@ -110,12 +111,15 @@ Também é possível utilizar o Facade: ### Capabilities -Cada driver declara o que suporta em dois níveis, pelo contract `DeclaresCapabilities`: +Cada driver declara o que suporta em três níveis, pelo contract `DeclaresCapabilities`: `capabilities()` lista o que o gateway oferece e a lib implementa; `notYetImplemented()` lista o -que o gateway oferece mas a lib ainda não construiu (planejado para uma versão futura). O que não -aparece em nenhuma das duas listas é limitação do gateway. `supports(Capability $c)` responde -sobre a primeira lista e `supportsAll(Capability ...$c)` exige todas de uma vez. Os valores são -o enum `Potelo\MultiPayment\Enums\Capability`. +que o gateway oferece mas a lib ainda não construiu (planejado para uma versão futura); e +`emulated()` lista o que o gateway não oferece mas a lib entrega por conta própria (ver +[Emulações na Iugu](#emulações-na-iugu)). O que não aparece em nenhuma das três listas é +limitação do gateway. `supports(Capability $c)` responde verdadeiro para capability +implementada ou emulada, `isEmulated(Capability $c)` diz se ela roda na lib, e +`supportsAll(Capability ...$c)` exige todas de uma vez. Os valores são o enum +`Potelo\MultiPayment\Enums\Capability`. Uma capability suportada pode valer só numa parte dos casos. `restriction(Capability $c)` devolve um `CapabilityRestriction` (`Potelo\MultiPayment\Capabilities\CapabilityRestriction`) com a @@ -138,6 +142,8 @@ MultiPayment::supports(Capability::INSTALLMENTS, 'iugu'); // true MultiPayment::supportsAll(Capability::PIX, Capability::INVOICE_DUPLICATION); // no gateway da instância MultiPayment::capabilities('stripe'); // Capability[] que a lib implementa MultiPayment::notYetImplemented('stripe'); // Capability[] planejadas +MultiPayment::emulated('iugu'); // [Capability::COUPONS, Capability::CANCEL_AT_PERIOD_END] +MultiPayment::isEmulated(Capability::COUPONS, 'iugu'); // true: exige o comando de sincronização agendado // restrições dentro de um "sim", consultáveis antes de tokenizar ou de exibir a opção $brands = MultiPayment::restriction(Capability::CREDIT_CARD, 'stripe'); @@ -179,8 +185,9 @@ coluna "Restrições" é o que `restriction()` devolve para cada gateway. | `SUBSCRIPTIONS` | Assinatura recorrente: criar, buscar, atualizar, suspender, retomar, cancelar, trocar de plano e listar. | sim | sim | Stripe: nextBillingAt vale só na criação da assinatura; na troca de plano e na atualização a Stripe não aceita uma data arbitrária de próxima cobrança. | | `PLANS` | Plano de assinatura: criar, buscar e listar. | sim | sim | | | `PLAN_DEACTIVATION` | Desativar um plano sem apagá-lo (`deactivatePlan`). | limitação do gateway | sim | | -| `CANCEL_AT_PERIOD_END` | Cancelar a assinatura só no fim do período já pago (`cancel(atPeriodEnd: true)`). | limitação do gateway | sim | | -| `NATIVE_COUPONS` | Cupom de primeira classe na assinatura: desconto percentual e desconto limitado a vários ciclos. | limitação do gateway | não implementado | | +| `CANCEL_AT_PERIOD_END` | Cancelar a assinatura só no fim do período já pago (`cancel(atPeriodEnd: true)`). | emulado | sim | | +| `COUPONS` | Cupom de assinatura com prazo: desconto limitado a um número de ciclos ou válido até uma data (`validUntil`). | emulado | sim | Stripe: O cupom da Stripe dura meses inteiros (duration_in_months): cycles maior que 1 exige plano com intervalo mensal ou anual, e validUntil vira meses inteiros contados da aplicação, arredondados para cima. | +| `PERCENT_DISCOUNT` | Desconto percentual (`percentOff`) sobre o valor da assinatura. | limitação do gateway | sim | | | `PLAN_CHANGE_PRORATION` | Crédito proporcional do período não usado, calculado pelo gateway, ao trocar de plano (`changePlan()` com `ProrationBehavior::CREDIT`). | limitação do gateway | sim | | | `SUBSCRIPTION_CREDITS` | Assinatura com saldo de créditos consumíveis, abatidos a cada uso. | não implementado | limitação do gateway | | | `MANAGES_RECURRENCE` | O gateway agenda as cobranças do Pix Automático por conta própria; sem ela, a aplicação é o motor de recorrência e chama as operações de `AutomaticPixContract` na periodicidade certa. | limitação do gateway | sim | | @@ -1078,13 +1085,18 @@ que possam ser reativados quando o ambiente passar a suportar o fluxo. Assinatura recorrente e plano estão disponíveis nos dois gateways. Na Iugu o plano é o recurso de plano nativo; no Stripe ele vira um par Product e Price recorrente (o id do plano -é o id do Price, com prefixo `price_`), e a assinatura é a Subscription do Stripe Billing. O -desconto de assinatura no Stripe (Coupon) está planejado para uma versão futura: um -`SubscriptionDiscount` no Stripe lança `UnsupportedOperationException` (`NATIVE_COUPONS`, -`not_implemented`) antes de criar a assinatura. Desconto percentual e desconto com `cycles` -maior que 1 são recusados antes de qualquer requisição; o desconto simples de valor -(`amountOff`) passa pela capability do model (a Iugu o entrega sem cupom), então a recusa vem -do driver, depois de o cliente novo que acompanha a assinatura ter sido criado. +é o id do Price, com prefixo `price_`), e a assinatura é a Subscription do Stripe Billing. + +O desconto (`SubscriptionDiscount`) funciona nos dois gateways. No Stripe cada desconto vira +um Coupon criado na hora e aplicado à assinatura: `cycles` 1 é `duration` `once`, `cycles` +acima de 1 e `validUntil` viram `repeating` com `duration_in_months` (o cupom da Stripe dura +meses inteiros: `cycles` acima de 1 exige plano mensal ou anual, e `validUntil` vira meses +contados da aplicação, arredondados para cima), e desconto sem prazo é `forever`; na leitura +o desconto volta com o id do Coupon e, no `repeating`, com o fim em `validUntil`. Na Iugu o +desconto é um subitem recorrente de valor negativo, e a validade (`validUntil`, ou a +calculada de `cycles` pelo intervalo do plano) é emulada pela lib (ver +[Emulações na Iugu](#emulações-na-iugu)). Desconto percentual (`percentOff`) só existe no +Stripe (`PERCENT_DISCOUNT`); a Iugu o recusa antes de qualquer requisição. ```php use Potelo\MultiPayment\Models\Plan; @@ -1145,8 +1157,9 @@ Operações sobre a assinatura: use Potelo\MultiPayment\Enums\ProrationBehavior; $subscription->suspend(); -$subscription->resume(); +$subscription->resume(); // desfaz a suspensão e o cancelamento agendado $subscription->cancel(); // CANCELED; na Iugu, suspende e grava a marca de cancelamento +$subscription->cancel(atPeriodEnd: true); // segue ativa até o fim do período pago, com cancelAtPeriodEnd $subscription->changePlan('plano_anual'); // ProrationBehavior::CHARGE_DIFFERENCE: cobra o plano novo agora $subscription->changePlan('plano_anual', ProrationBehavior::NONE); // nada é cobrado agora $preview = $subscription->previewPlanChange('plano_anual'); // simula sem aplicar (ver "Troca de plano") @@ -1229,20 +1242,23 @@ Particularidades da Iugu: - **Cancelar é suspender com uma marca.** A Iugu só suspende, então `cancel()` faz duas requisições: `POST /suspend` e um `PUT` que grava `mp_canceled_at` (data e hora, ISO 8601) em `custom_variables`. A assinatura lê como `CANCELED` enquanto estiver suspensa com a marca, - `canceledAt` é preenchido a partir dela, e ela também aparece em `metadata`. `resume()` de + e `canceledAt` é preenchido a partir dela. `resume()` de uma assinatura cancelada reativa e remove a marca (`PUT` com `_destroy`), voltando a `ACTIVE`. Chamar `cancel()` de novo numa assinatura já cancelada só repete a suspensão e mantém a data original. Se a segunda requisição de `cancel()` ou de `resume()` falhar, a exceção sobe com a assinatura no estado intermediário (suspensa sem marca, ou ativa com a marca); repita a chamada, de preferência com a mesma chave de idempotência, que a Iugu aceita - `suspend` e `activate` repetidos. O prefixo `mp_` em `custom_variables` é reservado à lib: não - use chaves com esse prefixo em `metadata`; uma marca `mp_canceled_at` que não seja uma data lê - como ausente, com aviso no log. `cancel(atPeriodEnd: true)` lança `UnsupportedOperationException` - (`CANCEL_AT_PERIOD_END`, `gateway_limitation`); para encerrar ao fim do período, suspenda na - data (a emulação está planejada para uma versão futura). -- **Desconto é sempre valor fixo.** `percentOff` e `cycles` maior que `1` lançam - `UnsupportedOperationException` (`NATIVE_COUPONS`, `gateway_limitation`); `cycles` aceita `1` - (uma fatura) ou `null` (até ser removido). + `suspend` e `activate` repetidos. Uma marca `mp_canceled_at` que não seja uma data lê + como ausente, com aviso no log. `cancel(atPeriodEnd: true)` é emulado pela lib e depende do + comando de sincronização agendado (ver [Emulações na Iugu](#emulações-na-iugu)). +- **O prefixo `mp_` em `custom_variables` é reservado à lib** para o estado das emulações: + `metadata` com uma chave assim é recusado com `ModelAttributeValidationException`, e na + leitura as variáveis `mp_` não aparecem em `metadata` (viram os campos tipados: + `canceledAt`, `cancelAtPeriodEnd`, `validUntil` do desconto). +- **Desconto é sempre valor fixo.** `percentOff` lança `UnsupportedOperationException` + (`PERCENT_DISCOUNT`, `gateway_limitation`); `cycles` aceita `1` (uma fatura, subitem sem + recorrência) ou `null` (até ser removido), e `cycles` maior que `1` ou `validUntil` são + emulados pela lib (ver [Emulações na Iugu](#emulações-na-iugu)). - **Plano anual é 12 meses, e plano diário não existe.** A Iugu só tem intervalos em semanas e meses, então `PlanInterval::YEAR` é enviado como `12 * intervalCount` meses e `PlanInterval::DAY` lança `ModelAttributeValidationException` antes de chamar a API. Na leitura vale a @@ -1285,9 +1301,10 @@ Particularidades da Iugu: subitens na mesma chamada, então o pacote lê a assinatura, envia a remoção sozinha e só depois a atualização — até três requisições. Entre a remoção e a atualização a assinatura fica sem os itens removidos, e se a segunda falhar eles não voltam sozinhos. -- **`cancelAtPeriodEnd` não é mapeado** na Iugu, nas duas direções. `canceledAt` vem da marca - `mp_canceled_at` gravada por `cancel()`. `paymentMethod` vai como `payable_with` e volta - quando a assinatura aceita um único método (`all` e listas com mais de um leem como nulo). +- **`cancelAtPeriodEnd` e `canceledAt` vêm das marcas da lib** (`mp_cancel_at_period_end` e + `mp_canceled_at` em `custom_variables`), gravadas por `cancel()`. `paymentMethod` vai como + `payable_with` e volta quando a assinatura aceita um único método (`all` e listas com mais + de um leem como nulo). - **Reativar exige data de cobrança.** Assinatura criada sem `nextBillingAt` fica sem data no gateway e, por isso, não volta com `resume()`: a Iugu responde sem erro e sem mudar nada, e o `status` devolvido segue `SUSPENDED`. @@ -1323,7 +1340,57 @@ ao que veio na leitura — um `save()` que mexeu só nos itens não altera a dat > **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, `trialEndsAt` ia só como > `expires_at`, e com cartão padrão a Iugu cobrava o primeiro ciclo na criação; agora o trial vai > com `only_charge_on_due_date`. `Subscription::$paymentMethod` passou a ser escrito -> (`payable_with`) e lido; até a 4.1.0 era ignorado nas duas direções. +> (`payable_with`) e lido; até a 4.1.0 era ignorado nas duas direções. O desconto de assinatura +> no Stripe deixou de ser recusado e vira Coupon; desconto percentual passou a ser recusado na +> Iugu com a capability `PERCENT_DISCOUNT` (antes `NATIVE_COUPONS`, hoje `COUPONS`); e as +> variáveis `mp_` da Iugu saíram de `metadata` (a marca de cancelamento aparecia lá) e passaram +> a ser recusadas na escrita. + +#### Emulações na Iugu + +A Iugu não oferece cupom com prazo nem cancelamento ao fim do ciclo; a lib emula os dois (a +célula da [tabela de capabilities](#capabilities) diz "emulado", e `isEmulated()` responde +verdadeiro). O estado da emulação vive em `custom_variables` da própria assinatura na Iugu, +com o prefixo reservado `mp_`, então qualquer instância da lib lê o mesmo estado sem banco +próprio: + +| Variável | Conteúdo | +|---|---| +| `mp_discount__until` | Data (`Y-m-d`) até a qual o subitem de desconto vale | +| `mp_cancel_at_period_end` | `1` quando há cancelamento agendado | +| `mp_cancel_scheduled_for` | Data (`Y-m-d`) em que a assinatura deve ser suspensa | +| `mp_canceled_at` | Data em que a lib cancelou (gravada pelo `cancel()` imediato e pelo comando ao aplicar o agendado) | + +Como funciona cada emulação: + +- **Cupom com prazo.** O desconto continua sendo o subitem negativo; a validade (`validUntil`, + ou a data calculada de `cycles` pelo intervalo do plano, contada da primeira cobrança) é + gravada na variável do subitem numa segunda requisição depois da escrita. Na leitura o + desconto volta com `validUntil` preenchido. Se essa segunda requisição falhar na criação, a + exceção sobe com a assinatura já criada e o desconto sem prazo; reaplique a validade com um + `save()` da lista de descontos, que o update regrava a variável. +- **Cancelamento ao fim do ciclo.** `cancel(atPeriodEnd: true)` não suspende: um único `PUT` + grava a intenção e a data programada (a data da próxima cobrança), a assinatura segue + `ACTIVE` com `cancelAtPeriodEnd` verdadeiro, e `resume()` limpa o agendamento. + +**A emulação só funciona se o comando `multipayment:sync-subscriptions` roda.** Ele percorre +as assinaturas do gateway, remove o subitem de desconto cuja validade passou e suspende, +gravando `mp_canceled_at`, a assinatura cujo cancelamento agendado chegou à data. Agende-o na +aplicação: + +```php +// app/Console/Kernel.php (ou routes/console.php) +$schedule->command('multipayment:sync-subscriptions')->hourly(); +``` + +O comando é idempotente (rodar duas vezes não muda nada na segunda), escreve cada ação na +saída e no log, aceita `--gateway=iugu` para um gateway só e `--dry-run` para inspecionar sem +escrever; num gateway que gerencia os dois recursos sozinho (Stripe), ele não faz nada e diz +isso no log, e a varredura sem `--gateway` pula gateway configurado sem `api_key`. Entre o vencimento (do desconto ou do agendamento) e a execução seguinte do +comando existe uma janela em que a Iugu ainda tem o subitem, ou a assinatura ativa: uma +fatura gerada nessa janela sai com o desconto vencido, e a frequência do agendamento limita a +janela. Uma aplicação que já emulava esses recursos por conta própria precisa desligar a +lógica ao migrar, no mesmo deploy, para não remover o desconto nem suspender duas vezes. Particularidades do Stripe: @@ -1357,6 +1424,13 @@ Particularidades do Stripe: `amount`); item com `recurring` falso vai como item avulso da primeira fatura. No update declarativo, item novo cria Price, item com `id` tem a quantidade atualizada e mantém o Price, item que saiu da lista é removido, e a troca não gera pró-rata. +- **Descontos criam Coupons no Stripe.** Cada `SubscriptionDiscount` novo vira um Coupon + criado na hora, aplicado via `discounts` da Subscription; a duração vem de `cycles` e + `validUntil` (ver [Assinaturas e planos](#assinaturas-e-planos)). No update a lista + informada substitui a da assinatura: desconto com `id` (o id do Coupon, como veio na + leitura) mantém o Coupon, desconto novo cria um, lista vazia remove todos, e a lista igual + à lida não é reenviada. O Coupon criado não é apagado quando sai da assinatura: ele fica na + conta, reutilizável pelo id. - **`suspend()` pausa a cobrança** (`pause_collection` com `behavior` `void`) e a assinatura lê como `PAUSED` (na Iugu, `SUSPENDED`); as faturas dos ciclos pausados são anuladas. `resume()` desfaz a pausa e também o cancelamento agendado por `cancel(atPeriodEnd: true)`. diff --git a/composer.json b/composer.json index 1ebce96..95ba054 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "require": { "php": "^8.3", "illuminate/config": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0", "illuminate/support": "^10.0|^11.0|^12.0", "illuminate/cache": "^10.0|^11.0|^12.0", "illuminate/contracts": "^10.0|^11.0|^12.0", diff --git a/composer.lock b/composer.lock index 338d3fb..e2778a1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e397f1b2e4575d158bc685d6ef8d036a", + "content-hash": "bf4cfaeca671ab935e649bc6319d99e4", "packages": [ { "name": "brick/math", diff --git a/src/Builders/SubscriptionBuilder.php b/src/Builders/SubscriptionBuilder.php index 73c29af..2174ae3 100644 --- a/src/Builders/SubscriptionBuilder.php +++ b/src/Builders/SubscriptionBuilder.php @@ -240,19 +240,22 @@ public function setDiscounts(array $discounts): SubscriptionBuilder * * @param string $description * @param int $amountOff Valor abatido, em centavos e positivo - * @param int|null $cycles null enquanto não for removido, 1 só na próxima fatura + * @param int|null $cycles null enquanto não for removido, 1 só na próxima fatura; exclusivo com $validUntil + * @param Carbon|null $validUntil data até a qual o desconto vale, inclusive * * @return $this */ public function addAmountDiscount( string $description, int $amountOff, - ?int $cycles = null + ?int $cycles = null, + ?Carbon $validUntil = null ): SubscriptionBuilder { $discount = new SubscriptionDiscount(); $discount->description = $description; $discount->amountOff = $amountOff; $discount->cycles = $cycles; + $discount->validUntil = $validUntil; $this->model->discounts[] = $discount; return $this; @@ -263,19 +266,22 @@ public function addAmountDiscount( * * @param string $description * @param float $percentOff Percentual abatido, entre 0 e 100 - * @param int|null $cycles null enquanto não for removido, 1 só na próxima fatura + * @param int|null $cycles null enquanto não for removido, 1 só na próxima fatura; exclusivo com $validUntil + * @param Carbon|null $validUntil data até a qual o desconto vale, inclusive * * @return $this */ public function addPercentDiscount( string $description, float $percentOff, - ?int $cycles = null + ?int $cycles = null, + ?Carbon $validUntil = null ): SubscriptionBuilder { $discount = new SubscriptionDiscount(); $discount->description = $description; $discount->percentOff = $percentOff; $discount->cycles = $cycles; + $discount->validUntil = $validUntil; $this->model->discounts[] = $discount; return $this; diff --git a/src/Console/SyncSubscriptionsCommand.php b/src/Console/SyncSubscriptionsCommand.php new file mode 100644 index 0000000..e8a1e37 --- /dev/null +++ b/src/Console/SyncSubscriptionsCommand.php @@ -0,0 +1,93 @@ +option('gateway') + ? [$this->option('gateway')] + : array_keys(Config::get('multi-payment.gateways', [])); + $dryRun = (bool) $this->option('dry-run'); + $prefix = $dryRun ? '[dry-run] ' : ''; + + foreach ($names as $name) { + if (!$this->option('gateway') && empty(Config::get("multi-payment.gateways.{$name}.api_key"))) { + $this->info("[{$name}] sem api_key configurada; gateway pulado."); + continue; + } + + $gateway = ConfigurationHelper::resolveGateway($name); + + $emulatesSubscriptions = $gateway->isEmulated(Capability::COUPONS) + || $gateway->isEmulated(Capability::CANCEL_AT_PERIOD_END); + if (!$emulatesSubscriptions) { + $this->info("[{$name}] o gateway gerencia cupom e cancelamento ao fim do ciclo; nada a sincronizar."); + continue; + } + + if (!$gateway instanceof SubscriptionSyncContract) { + throw ConfigurationException::GatewayMissingContract( + $gateway, + $gateway->isEmulated(Capability::COUPONS) ? Capability::COUPONS : Capability::CANCEL_AT_PERIOD_END, + SubscriptionSyncContract::class + ); + } + + $actions = $gateway->syncSubscriptions($dryRun); + foreach ($actions as $action) { + $line = "[{$name}] assinatura {$action['subscription']}: {$action['detail']}"; + $this->line($prefix . $line); + LogHelper::info("multipayment:sync-subscriptions {$prefix}{$line}", [ + 'gateway' => $name, + 'subscription' => $action['subscription'], + 'action' => $action['action'], + 'dry_run' => $dryRun, + ]); + } + + if ($actions === []) { + $this->info("[{$name}] nada a aplicar."); + } + } + + return self::SUCCESS; + } +} diff --git a/src/Contracts/DeclaresCapabilities.php b/src/Contracts/DeclaresCapabilities.php index c4ef2b6..8dce506 100644 --- a/src/Contracts/DeclaresCapabilities.php +++ b/src/Contracts/DeclaresCapabilities.php @@ -6,10 +6,11 @@ use Potelo\MultiPayment\Capabilities\CapabilityRestriction; /** - * Declaração do que um gateway suporta, em dois níveis: o que o gateway oferece e a lib - * implementa, e o que o gateway oferece mas a lib ainda não construiu. Uma capability fora - * das duas listas é limitação do gateway. Uma capability suportada pode ainda ter uma - * restrição (`restriction()`), que descreve em que parte dos casos ela vale. + * Declaração do que um gateway suporta, em três níveis: o que o gateway oferece e a lib + * implementa, o que o gateway oferece mas a lib ainda não construiu, e o que o gateway não + * oferece mas a lib entrega por emulação. Uma capability fora das três listas é limitação do + * gateway. Uma capability suportada pode ainda ter uma restrição (`restriction()`), que + * descreve em que parte dos casos ela vale. */ interface DeclaresCapabilities { @@ -28,7 +29,24 @@ public function capabilities(): array; public function notYetImplemented(): array; /** - * Diz se a capability está em `capabilities()`. + * Capabilities que o gateway não oferece e este driver entrega por conta própria. A + * emulação de assinatura depende do comando `multipayment:sync-subscriptions` agendado + * pela aplicação. + * + * @return Capability[] + */ + public function emulated(): array; + + /** + * Diz se a capability está em `emulated()`: a lib a entrega por conta própria. + * + * @param Capability $capability + * @return bool + */ + public function isEmulated(Capability $capability): bool; + + /** + * Diz se a capability está em `capabilities()` ou em `emulated()`. * * @param Capability $capability * @return bool diff --git a/src/Contracts/SubscriptionSyncContract.php b/src/Contracts/SubscriptionSyncContract.php new file mode 100644 index 0000000..0187ef6 --- /dev/null +++ b/src/Contracts/SubscriptionSyncContract.php @@ -0,0 +1,29 @@ + + * @throws GatewayException|GatewayNotAvailableException + */ + public function syncSubscriptions(bool $dryRun = false): array; +} diff --git a/src/Enums/Capability.php b/src/Enums/Capability.php index bed5c26..b6a8891 100644 --- a/src/Enums/Capability.php +++ b/src/Enums/Capability.php @@ -4,11 +4,15 @@ /** * Recurso que um gateway pode oferecer e a lib pode ter implementado. Cada driver declara, por - * `DeclaresCapabilities`, o que suporta e o que o gateway oferece mas a lib ainda não construiu; - * o que não aparece em nenhuma das duas listas é limitação do gateway. + * `DeclaresCapabilities`, o que suporta, o que o gateway oferece mas a lib ainda não construiu + * e o que o gateway não oferece mas a lib entrega por emulação (`emulated()`); o que não + * aparece em nenhuma das três listas é limitação do gateway. */ enum Capability: string { + /** @deprecated desde 2026-09-04, use `Capability::COUPONS`. */ + public const NATIVE_COUPONS = self::COUPONS; + /** Fatura paga com cartão de crédito. */ case CREDIT_CARD = 'credit_card'; @@ -85,8 +89,11 @@ enum Capability: string /** Cancelar a assinatura só no fim do período já pago (`cancel(atPeriodEnd: true)`). */ case CANCEL_AT_PERIOD_END = 'cancel_at_period_end'; - /** Cupom de primeira classe na assinatura: desconto percentual e desconto limitado a vários ciclos. */ - case NATIVE_COUPONS = 'native_coupons'; + /** Cupom de assinatura com prazo: desconto limitado a um número de ciclos ou válido até uma data (`validUntil`). */ + case COUPONS = 'coupons'; + + /** Desconto percentual (`percentOff`) sobre o valor da assinatura. */ + case PERCENT_DISCOUNT = 'percent_discount'; /** Crédito proporcional do período não usado, calculado pelo gateway, ao trocar de plano (`changePlan()` com `ProrationBehavior::CREDIT`). */ case PLAN_CHANGE_PRORATION = 'plan_change_proration'; diff --git a/src/Facades/MultiPayment.php b/src/Facades/MultiPayment.php index 095c995..9267b2a 100644 --- a/src/Facades/MultiPayment.php +++ b/src/Facades/MultiPayment.php @@ -33,6 +33,8 @@ * @method static bool supports(\Potelo\MultiPayment\Enums\Capability $capability, $gateway = null) * @method static \Potelo\MultiPayment\Enums\Capability[] capabilities($gateway = null) * @method static \Potelo\MultiPayment\Enums\Capability[] notYetImplemented($gateway = null) + * @method static \Potelo\MultiPayment\Enums\Capability[] emulated($gateway = null) + * @method static bool isEmulated(\Potelo\MultiPayment\Enums\Capability $capability, $gateway = null) * @method static bool supportsAll(\Potelo\MultiPayment\Enums\Capability ...$capabilities) * @method static \Potelo\MultiPayment\Capabilities\CapabilityRestriction|null restriction(\Potelo\MultiPayment\Enums\Capability $capability, $gateway = null) * @method static array restrictions($gateway = null) diff --git a/src/Gateways/Concerns/ChecksCapabilities.php b/src/Gateways/Concerns/ChecksCapabilities.php index 5eb6b25..f02e1a6 100644 --- a/src/Gateways/Concerns/ChecksCapabilities.php +++ b/src/Gateways/Concerns/ChecksCapabilities.php @@ -7,9 +7,10 @@ use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; /** - * Implementa `supports()`, `supportsAll()` e `restriction()` de `DeclaresCapabilities` sobre - * as listas do driver e oferece as guardas que os drivers chamam antes de qualquer requisição. - * `restrictions()` devolve lista vazia; o driver que tem restrição a sobrescreve. + * Implementa `supports()`, `supportsAll()`, `isEmulated()` e `restriction()` de + * `DeclaresCapabilities` sobre as listas do driver e oferece as guardas que os drivers chamam + * antes de qualquer requisição. `emulated()` e `restrictions()` devolvem lista vazia; o driver + * que emula ou restringe as sobrescreve. */ trait ChecksCapabilities { @@ -23,12 +24,28 @@ abstract public function capabilities(): array; */ abstract public function notYetImplemented(): array; + /** + * @inheritDoc + */ + public function emulated(): array + { + return []; + } + + /** + * @inheritDoc + */ + public function isEmulated(Capability $capability): bool + { + return in_array($capability, $this->emulated(), true); + } + /** * @inheritDoc */ public function supports(Capability $capability): bool { - return in_array($capability, $this->capabilities(), true); + return in_array($capability, $this->capabilities(), true) || $this->isEmulated($capability); } /** diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index 255b348..5f170e2 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -40,6 +40,7 @@ use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Contracts\IdempotencyStore; use Potelo\MultiPayment\Contracts\SubscriptionContract; +use Potelo\MultiPayment\Contracts\SubscriptionSyncContract; use Potelo\MultiPayment\Gateways\Concerns\ChecksCapabilities; use Potelo\MultiPayment\Gateways\Concerns\ResolvesIdempotencyKey; use Potelo\MultiPayment\Gateways\Iugu\DeclineCodes as IuguDeclineCodes; @@ -56,7 +57,7 @@ use Potelo\MultiPayment\Exceptions\IdempotencyConflictException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; -class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract +class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract, SubscriptionSyncContract { use ChecksCapabilities; use ResolvesIdempotencyKey; @@ -75,6 +76,24 @@ class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract private const STATUS_CHARGEBACK = 'chargeback'; private const STATUS_AUTHORIZED = 'authorized'; + /** + * Prefixo reservado em `custom_variables` da assinatura para o estado que a lib grava + * (cancelamento e validade de desconto); `metadata` com uma chave assim é recusado. + */ + private const RESERVED_VARIABLE_PREFIX = 'mp_'; + + /** Variável que marca o cancelamento agendado para o fim do período (`1` quando há). */ + private const CANCEL_AT_PERIOD_END_VARIABLE = 'mp_cancel_at_period_end'; + + /** Variável com a data (`Y-m-d`) em que a assinatura agendada deve ser suspensa. */ + private const CANCEL_SCHEDULED_FOR_VARIABLE = 'mp_cancel_scheduled_for'; + + /** Prefixo da variável de validade de um desconto: `mp_discount__until`. */ + private const DISCOUNT_UNTIL_PREFIX = 'mp_discount_'; + + /** Sufixo da variável de validade de um desconto: `mp_discount__until`. */ + private const DISCOUNT_UNTIL_SUFFIX = '_until'; + /** * Variável de `custom_variables` da assinatura em que a lib grava a data do cancelamento. * A Iugu só suspende, então é essa marca que distingue `CANCELED` de `SUSPENDED`. O @@ -150,6 +169,21 @@ public function notYetImplemented(): array ]; } + /** + * @inheritDoc + * + * A Iugu não tem cupom com prazo nem cancelamento ao fim do ciclo; a lib emula os dois com + * estado em `custom_variables` da assinatura, aplicado pelo comando + * `multipayment:sync-subscriptions` agendado pela aplicação. + */ + public function emulated(): array + { + return [ + Capability::COUPONS, + Capability::CANCEL_AT_PERIOD_END, + ]; + } + /** * @inheritDoc * @@ -1885,7 +1919,11 @@ private function parseIuguCard(mixed $iuguCreditCard, ?CreditCard $creditCard = * cliente antes da criação, porque a assinatura da Iugu cobra o cartão padrão. `trialDays` * vira `trialEndsAt` contado de hoje, e um trial (`trialEndsAt`) vai como `expires_at` com * `only_charge_on_due_date`, para o primeiro ciclo só ser cobrado no fim do teste; - * `nextBillingAt` sozinho vai só como `expires_at`. A chave de idempotência vai no cabeçalho + * `nextBillingAt` sozinho vai só como `expires_at`. Desconto com validade (`validUntil`, + * ou `cycles` acima de 1, convertido pela duração do plano) grava + * `mp_discount__until` em `custom_variables` numa segunda requisição (`PUT`, + * chave derivada `{chave}:discounts`); o comando `multipayment:sync-subscriptions` remove + * o subitem quando a data passa. A chave de idempotência vai no cabeçalho * `Idempotency-Key` de `POST /subscriptions`. Na reutilização da chave a Iugu responde 409 * sem o id da assinatura original (`resource_id: processing`), que chega como * `IdempotencyConflictException`. @@ -1894,6 +1932,7 @@ public function createSubscription(Subscription $subscription, ?string $idempote { $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + $requestedDiscounts = $subscription->discounts ?? []; $data = array_merge( $this->subscriptionToIuguData($subscription), self::withoutIdempotencyKey($subscription->gatewayOptions) @@ -1910,7 +1949,11 @@ public function createSubscription(Subscription $subscription, ?string $idempote true ); - return $this->parseIuguSubscription($response, $subscription); + $parsed = $this->parseIuguSubscription($response, $subscription); + + return empty($requestedDiscounts) + ? $parsed + : $this->applyIuguDiscountValidities($requestedDiscounts, $parsed, true, $idempotencyKey); } /** @@ -1980,8 +2023,10 @@ public function getSubscription(Subscription $subscription): Subscription * @inheritDoc * * A chave de idempotência passa pela `IdempotencyStore`: a informada no `PUT` da - * atualização, `{chave}:remove` na remoção de subitens que a antecede e `{chave}:card` ou - * `{chave}:default` no cartão que passa a ser o padrão do cliente. + * atualização, `{chave}:remove` na remoção de subitens que a antecede, `{chave}:card` ou + * `{chave}:default` no cartão que passa a ser o padrão do cliente e `{chave}:discounts` + * na escrita da validade dos descontos (`mp_discount__until`), que também + * remove a variável de desconto que saiu da lista. */ public function updateSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription { @@ -1990,6 +2035,7 @@ public function updateSubscription(Subscription $subscription, ?string $idempote } $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + $requestedDiscounts = $subscription->discounts; $data = array_merge( $this->subscriptionToIuguData($subscription, false), self::withoutIdempotencyKey($subscription->gatewayOptions) @@ -2032,7 +2078,11 @@ public function updateSubscription(Subscription $subscription, ?string $idempote $idempotencyKey ); - return $this->parseIuguSubscription($response, $subscription); + $parsed = $this->parseIuguSubscription($response, $subscription); + + return is_null($requestedDiscounts) + ? $parsed + : $this->applyIuguDiscountValidities($requestedDiscounts, $parsed, false, $idempotencyKey); } /** @@ -2055,11 +2105,12 @@ public function suspendSubscription(Subscription $subscription, ?string $idempot /** * @inheritDoc * - * Reativa também uma assinatura cancelada por `cancelSubscription()`, que na Iugu é uma - * assinatura suspensa com a marca `mp_canceled_at`: a marca é removida de + * Reativa também uma assinatura cancelada por `cancelSubscription()`, imediato ou + * agendado: a marca de cancelamento (`mp_canceled_at`) e o agendamento + * (`mp_cancel_at_period_end`, `mp_cancel_scheduled_for`) são removidos de * `custom_variables` numa segunda requisição (`PUT` com `_destroy`, chave derivada - * `{chave}:uncancel`), para a assinatura voltar a ler como `ACTIVE`. A chave de - * idempotência passa pela `IdempotencyStore`. + * `{chave}:uncancel`), para a assinatura voltar a ler como `ACTIVE` sem cancelamento + * pendente. A chave de idempotência passa pela `IdempotencyStore`. */ public function resumeSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription { @@ -2068,14 +2119,18 @@ public function resumeSubscription(Subscription $subscription, ?string $idempote $response = $this->iuguSubscriptionAction($subscription, 'activate', 'resuming subscription', $idempotencyKey); $resumed = $this->parseIuguSubscription($response, $subscription); - if (is_null($resumed->canceledAt)) { + if (is_null($resumed->canceledAt) && !$resumed->cancelAtPeriodEnd) { return $resumed; } $response = $this->iuguIdempotentRequest( 'PUT', $this->subscriptionUrl($subscription->id), - ['custom_variables' => [['name' => self::CANCELED_AT_VARIABLE, '_destroy' => true]]], + ['custom_variables' => [ + ['name' => self::CANCELED_AT_VARIABLE, '_destroy' => true], + ['name' => self::CANCEL_AT_PERIOD_END_VARIABLE, '_destroy' => true], + ['name' => self::CANCEL_SCHEDULED_FOR_VARIABLE, '_destroy' => true], + ]], 'clearing the subscription cancellation', self::derivedIdempotencyKey($idempotencyKey, 'uncancel') ); @@ -2090,18 +2145,23 @@ public function resumeSubscription(Subscription $subscription, ?string $idempote * data do cancelamento em `custom_variables` (`mp_canceled_at`), numa segunda requisição * (`PUT`, chave derivada `{chave}:cancel`); é essa marca que faz a leitura devolver * `CANCELED`. Assinatura que já tem a marca é só suspensa de novo, e a data original fica. - * `resumeSubscription()` desfaz as duas coisas. A chave de idempotência passa pela - * `IdempotencyStore`. + * Com `$atPeriodEnd`, a assinatura não é suspensa: um único `PUT` grava + * `mp_cancel_at_period_end` e `mp_cancel_scheduled_for` (a data da próxima cobrança), ela + * segue ativa com `cancelAtPeriodEnd` verdadeiro, e o comando + * `multipayment:sync-subscriptions` a suspende quando a data chega, gravando + * `mp_canceled_at`. `resumeSubscription()` desfaz as duas formas. A chave de idempotência + * passa pela `IdempotencyStore`. */ public function cancelSubscription( Subscription $subscription, bool $atPeriodEnd = false, ?string $idempotencyKey = null ): Subscription { + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + if ($atPeriodEnd) { - $this->assertSupports(Capability::CANCEL_AT_PERIOD_END, 'Suspenda a assinatura na data desejada.'); + return $this->scheduleIuguCancellation($subscription, $idempotencyKey); } - $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); $response = $this->iuguSubscriptionAction($subscription, 'suspend', 'suspending subscription', $idempotencyKey); $suspended = $this->parseIuguSubscription($response, $subscription); @@ -2124,6 +2184,248 @@ public function cancelSubscription( return $this->parseIuguSubscription($response, $suspended); } + /** + * Agenda o cancelamento para o fim do período corrente: grava em `custom_variables` a + * intenção (`mp_cancel_at_period_end`) e a data programada (`mp_cancel_scheduled_for`, a + * data da próxima cobrança), sem suspender. A data vem de `nextBillingAt` do model ou de + * uma leitura da assinatura; sem data de cobrança não há fim de período e a operação é + * recusada. Chamada repetida atualiza a data programada para a próxima cobrança atual. + * + * @param Subscription $subscription + * @param string|null $idempotencyKey chave já resolvida; passa pela `IdempotencyStore` + * + * @return Subscription + * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException + */ + private function scheduleIuguCancellation(Subscription $subscription, ?string $idempotencyKey): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $scheduledFor = $subscription->nextBillingAt; + if (empty($scheduledFor)) { + $current = $this->iuguRequest( + 'GET', + $this->subscriptionUrl($subscription->id), + [], + 'getting subscription' + ); + $expiresAt = ((object) $current)->expires_at ?? null; + $scheduledFor = empty($expiresAt) ? null : new Carbon($expiresAt); + } + + if (empty($scheduledFor)) { + throw ModelAttributeValidationException::invalid( + 'Subscription', + 'nextBillingAt', + 'the subscription has no billing date, so there is no period end to schedule' + . ' the cancellation at; cancel it immediately instead.' + ); + } + + $response = $this->iuguIdempotentRequest( + 'PUT', + $this->subscriptionUrl($subscription->id), + ['custom_variables' => [ + ['name' => self::CANCEL_AT_PERIOD_END_VARIABLE, 'value' => '1'], + ['name' => self::CANCEL_SCHEDULED_FOR_VARIABLE, 'value' => $scheduledFor->format('Y-m-d')], + ]], + 'scheduling the subscription cancellation', + $idempotencyKey + ); + + return $this->parseIuguSubscription($response, $subscription); + } + + /** + * @inheritDoc + * + * Percorre todas as assinaturas da conta em páginas de 100. Assinatura suspensa é pulada: + * ela não gera fatura, e a que o comando suspendeu na rodada anterior já está aplicada. O + * desconto vencido e a variável dele saem num único `PUT`; a variável de desconto sem + * subitem correspondente (sobra de uma escrita interrompida) também é removida. O + * cancelamento agendado que chegou à data grava a marca `mp_canceled_at` e suspende + * (`POST /suspend`), nessa ordem, e o agendamento fica gravado. + */ + public function syncSubscriptions(bool $dryRun = false): array + { + $actions = []; + $limit = 100; + $start = 0; + + do { + $query = http_build_query(['limit' => $limit, 'start' => $start], '', '&', PHP_QUERY_RFC3986); + $response = $this->iuguRequest( + 'GET', + Iugu::getBaseURI() . '/subscriptions?' . $query, + [], + 'listing subscriptions' + ); + $items = (array) (is_array($response) ? $response : ($response->items ?? [])); + + foreach ($items as $item) { + $item = (object) $item; + // uma assinatura com problema não derruba a varredura das demais; a rodada + // seguinte tenta de novo + try { + array_push($actions, ...$this->syncIuguSubscription($item, $dryRun)); + } catch (MultiPaymentException $e) { + LogHelper::warning( + 'Sincronização da assinatura [' . ($item->id ?? '?') . '] da Iugu falhou: ' + . $e->getMessage(), + ['subscription' => $item->id ?? null, 'gateway' => 'iugu'] + ); + } + } + + $start += $limit; + } while (count($items) === $limit); + + return $actions; + } + + /** + * Aplica numa assinatura o que a emulação deixou agendado e devolve as ações, no formato + * de `SubscriptionSyncContract::syncSubscriptions()`. Com `$dryRun`, só as devolve. + * + * @param object $iuguSubscription + * @param bool $dryRun + * + * @return array + * @throws GatewayException|GatewayNotAvailableException + */ + private function syncIuguSubscription(object $iuguSubscription, bool $dryRun): array + { + if (!empty($iuguSubscription->suspended) || empty($iuguSubscription->id)) { + return []; + } + $id = (string) $iuguSubscription->id; + + $discountSubitemIds = []; + foreach ((array) ($iuguSubscription->subitems ?? []) as $subitem) { + $subitem = (object) $subitem; + if (($subitem->price_cents ?? 0) < 0 && !empty($subitem->id)) { + $discountSubitemIds[] = (string) $subitem->id; + } + } + + $actions = []; + $subitemDestroys = []; + $variableDestroys = []; + foreach (array_keys($this->iuguCustomVariables($iuguSubscription)) as $name) { + $subitemId = self::discountSubitemIdFromVariable((string) $name); + if (is_null($subitemId)) { + continue; + } + + if (!in_array($subitemId, $discountSubitemIds, true)) { + $variableDestroys[] = ['name' => (string) $name, '_destroy' => true]; + $actions[] = [ + 'subscription' => $id, + 'action' => 'remove_orphan_discount_variable', + 'detail' => "variável {$name} sem subitem de desconto correspondente removida", + ]; + continue; + } + + $until = $this->iuguDiscountUntil($iuguSubscription, $subitemId); + if (is_null($until) || !$until->copy()->endOfDay()->isPast()) { + continue; + } + + $subitemDestroys[] = ['id' => $subitemId, '_destroy' => true]; + $variableDestroys[] = ['name' => (string) $name, '_destroy' => true]; + $actions[] = [ + 'subscription' => $id, + 'action' => 'remove_discount', + 'detail' => "desconto {$subitemId} vencido em {$until->format('Y-m-d')} removido", + ]; + } + + $cancelDue = $this->iuguScheduledCancellationDue($iuguSubscription); + if (!is_null($cancelDue)) { + $actions[] = [ + 'subscription' => $id, + 'action' => 'cancel', + 'detail' => "cancelamento agendado para {$cancelDue->format('Y-m-d')} aplicado:" + . ' assinatura suspensa e marcada como cancelada', + ]; + } + + if ($dryRun || $actions === []) { + return $actions; + } + + if ($variableDestroys !== []) { + $data = ['custom_variables' => $variableDestroys]; + if ($subitemDestroys !== []) { + $data['subitems'] = $subitemDestroys; + } + $this->iuguRequest( + 'PUT', + $this->subscriptionUrl($id), + $data, + 'removing expired subscription discounts' + ); + } + + if (!is_null($cancelDue)) { + // a marca vai antes da suspensão: uma falha entre as duas deixa a assinatura + // ativa com a marca, que a rodada seguinte reprocessa (suspensa sem a marca + // seria pulada e leria SUSPENDED em vez de CANCELED para sempre) + $this->iuguRequest( + 'PUT', + $this->subscriptionUrl($id), + ['custom_variables' => [[ + 'name' => self::CANCELED_AT_VARIABLE, + 'value' => Carbon::now()->toIso8601String(), + ]]], + 'marking the subscription as canceled' + ); + $this->iuguRequest('POST', $this->subscriptionUrl($id) . '/suspend', [], 'suspending subscription'); + } + + return $actions; + } + + /** + * Data do cancelamento agendado que já chegou (`mp_cancel_at_period_end` com + * `mp_cancel_scheduled_for` de hoje ou anterior); nulo quando não há agendamento, a data + * ainda não chegou ou a data gravada não é legível (com aviso no log). + * + * @param object $iuguSubscription + * + * @return Carbon|null + */ + private function iuguScheduledCancellationDue(object $iuguSubscription): ?Carbon + { + $flag = $this->iuguCustomVariable($iuguSubscription, self::CANCEL_AT_PERIOD_END_VARIABLE); + if (is_null($flag) || $flag === '0') { + return null; + } + + $scheduled = $this->iuguCustomVariable($iuguSubscription, self::CANCEL_SCHEDULED_FOR_VARIABLE); + if (is_null($scheduled)) { + return null; + } + + try { + $date = new Carbon($scheduled); + } catch (\Throwable) { + LogHelper::warning( + 'Data de cancelamento agendado [' . self::CANCEL_SCHEDULED_FOR_VARIABLE + . "] ilegível [{$scheduled}] na assinatura [" . ($iuguSubscription->id ?? '?') + . '] da Iugu, tratada como ausente', + ['subscription' => $iuguSubscription->id ?? null, 'value' => $scheduled, 'gateway' => 'iugu'] + ); + + return null; + } + + return $date->copy()->startOfDay()->lte(Carbon::now()) ? $date : null; + } + /** * Faz o `POST` de uma ação da assinatura (`suspend`, `activate`) e devolve a resposta crua. * @@ -2443,6 +2745,17 @@ private function subscriptionToIuguData(Subscription $subscription, bool $creati } if (!empty($subscription->metadata)) { + foreach (array_keys($subscription->metadata) as $name) { + if (str_starts_with((string) $name, self::RESERVED_VARIABLE_PREFIX)) { + throw ModelAttributeValidationException::invalid( + 'Subscription', + 'metadata', + 'metadata keys with the mp_ prefix are reserved for the library state' + . ' in the Iugu custom_variables.' + ); + } + } + $data['custom_variables'] = array_map( fn($name, $value) => ['name' => $name, 'value' => $value], array_keys($subscription->metadata), @@ -2535,7 +2848,11 @@ private function subscriptionItemToIuguData(SubscriptionItem $item): array /** * Monta um subitem da Iugu a partir de um desconto de assinatura. * - * Desconto percentual e desconto limitado a mais de um ciclo não têm equivalente na Iugu. + * Desconto percentual não tem equivalente na Iugu. `cycles` 1 vira um subitem sem + * recorrência (vale só para a próxima fatura); os demais casos vão como subitem + * recorrente, e a validade (`validUntil`, ou a calculada de `cycles`) é gravada em + * `custom_variables` depois da criação, para o comando de sincronização remover o subitem + * vencido. * * @param SubscriptionDiscount $discount * @@ -2547,7 +2864,7 @@ private function subscriptionDiscountToIuguData(SubscriptionDiscount $discount): if (!is_null($discount->percentOff)) { throw UnsupportedOperationException::forGateway( $this, - Capability::NATIVE_COUPONS, + Capability::PERCENT_DISCOUNT, 'A Iugu não tem desconto percentual em assinatura; use amountOff.' ); } @@ -2556,19 +2873,11 @@ private function subscriptionDiscountToIuguData(SubscriptionDiscount $discount): throw ModelAttributeValidationException::required('SubscriptionDiscount', 'amountOff'); } - if (!is_null($discount->cycles) && $discount->cycles > 1) { - throw UnsupportedOperationException::forGateway( - $this, - Capability::NATIVE_COUPONS, - 'Na Iugu o desconto vale para uma fatura ou até ser removido; use cycles 1 ou nulo.' - ); - } - $data = [ 'description' => $discount->description, 'price_cents' => -abs($discount->amountOff), 'quantity' => 1, - 'recurrent' => (int) is_null($discount->cycles), + 'recurrent' => (int) ($discount->cycles !== 1), ]; if (!empty($discount->id)) { @@ -2689,7 +2998,9 @@ private function parseIuguSubscription($iuguSubscription, ?Subscription $subscri $iuguSubitem = (object) $iuguSubitem; if (($iuguSubitem->price_cents ?? 0) < 0) { - $subscription->discounts[] = $this->parseIuguSubscriptionDiscount($iuguSubitem); + $discount = $this->parseIuguSubscriptionDiscount($iuguSubitem); + $discount->validUntil = $this->iuguDiscountUntil($iuguSubscription, $discount->id); + $subscription->discounts[] = $discount; } else { $subscription->items[] = $this->parseIuguSubscriptionItem($iuguSubitem); } @@ -2707,10 +3018,18 @@ private function parseIuguSubscription($iuguSubscription, ?Subscription $subscri : null; } - // lista vazia também conta: é o que a Iugu devolve depois de remover a última variável + // lista vazia também conta: é o que a Iugu devolve depois de remover a última variável; + // as variáveis mp_ são estado da lib e viram os campos tipados, fora de metadata if (isset($iuguSubscription->custom_variables)) { - $subscription->metadata = $this->iuguCustomVariables($iuguSubscription); + $subscription->metadata = array_filter( + $this->iuguCustomVariables($iuguSubscription), + static fn ($name) => !str_starts_with((string) $name, self::RESERVED_VARIABLE_PREFIX), + ARRAY_FILTER_USE_KEY + ); $subscription->canceledAt = $this->iuguCanceledAt($iuguSubscription); + // `0` conta como sem agendamento, a mesma leitura do comando de sincronização + $flag = $this->iuguCustomVariable($iuguSubscription, self::CANCEL_AT_PERIOD_END_VARIABLE); + $subscription->cancelAtPeriodEnd = !is_null($flag) && $flag !== '0'; } $subscription->gateway = 'iugu'; @@ -2785,6 +3104,314 @@ private function iuguCustomVariable(object $iuguSubscription, string $name): ?st return is_scalar($value) && (string) $value !== '' ? (string) $value : null; } + /** + * Nome da variável de validade de um subitem de desconto (`mp_discount__until`). + * + * @param string $subitemId + * + * @return string + */ + private static function discountUntilVariable(string $subitemId): string + { + return self::DISCOUNT_UNTIL_PREFIX . $subitemId . self::DISCOUNT_UNTIL_SUFFIX; + } + + /** + * Id do subitem de desconto de uma variável `mp_discount__until`; nulo quando + * o nome não segue o padrão. + * + * @param string $name + * + * @return string|null + */ + private static function discountSubitemIdFromVariable(string $name): ?string + { + if ( + !str_starts_with($name, self::DISCOUNT_UNTIL_PREFIX) + || !str_ends_with($name, self::DISCOUNT_UNTIL_SUFFIX) + ) { + return null; + } + + $id = substr( + $name, + strlen(self::DISCOUNT_UNTIL_PREFIX), + -strlen(self::DISCOUNT_UNTIL_SUFFIX) + ); + + return $id === '' ? null : $id; + } + + /** + * Validade gravada pela lib para um subitem de desconto + * (`mp_discount__until`). Nulo quando a variável não existe ou não é uma data + * legível; neste último caso registra um aviso no log e a variável é tratada como ausente. + * + * @param object $iuguSubscription + * @param string|null $subitemId + * + * @return Carbon|null + */ + private function iuguDiscountUntil(object $iuguSubscription, ?string $subitemId): ?Carbon + { + if (empty($subitemId)) { + return null; + } + + $name = self::discountUntilVariable($subitemId); + $value = $this->iuguCustomVariable($iuguSubscription, $name); + if (is_null($value)) { + return null; + } + + try { + return new Carbon($value); + } catch (\Throwable) { + LogHelper::warning( + "Validade de desconto [{$name}] ilegível [{$value}] na assinatura [" + . ($iuguSubscription->id ?? '?') . '] da Iugu, tratada como ausente', + ['subscription' => $iuguSubscription->id ?? null, 'value' => $value, 'gateway' => 'iugu'] + ); + + return null; + } + } + + /** + * Grava em `custom_variables` a validade dos descontos que acabaram de ser escritos + * (`mp_discount__until`) e remove a variável de desconto que saiu da lista, + * num único `PUT` (chave derivada `{chave}:discounts`). Sem mudança a fazer, nenhuma + * requisição sai. O model devolvido traz `validUntil` aplicado em cada desconto. + * + * @param SubscriptionDiscount[] $requestedDiscounts descontos como o chamador os informou + * @param Subscription $parsed model já preenchido com a resposta da escrita + * @param bool $creating + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * + * @return Subscription + * @throws GatewayException|GatewayNotAvailableException + */ + private function applyIuguDiscountValidities( + array $requestedDiscounts, + Subscription $parsed, + bool $creating, + ?string $idempotencyKey + ): Subscription { + $changes = $this->iuguDiscountVariableChanges($requestedDiscounts, $parsed, $creating); + if ($changes === []) { + return $parsed; + } + + $response = $this->iuguIdempotentRequest( + 'PUT', + $this->subscriptionUrl($parsed->id), + ['custom_variables' => $changes], + 'writing the subscription discount validity', + self::derivedIdempotencyKey($idempotencyKey, 'discounts') + ); + + return $this->parseIuguSubscription($response, $parsed); + } + + /** + * Mudanças de `custom_variables` que deixam as validades de desconto iguais às pedidas: + * uma escrita por desconto com validade nova ou diferente da gravada, e um `_destroy` por + * variável de desconto que não corresponde mais a um desconto com validade. O desconto + * pedido é casado com o subitem da resposta pelo `id`; os sem `id` casam pela descrição e + * pelo valor e, sem par assim, na ordem em que foram enviados. + * + * @param SubscriptionDiscount[] $requestedDiscounts + * @param Subscription $parsed + * @param bool $creating + * + * @return array + * @throws GatewayException|GatewayNotAvailableException + */ + private function iuguDiscountVariableChanges(array $requestedDiscounts, Subscription $parsed, bool $creating): array + { + $existing = $this->iuguCustomVariables((object) ($parsed->original ?? new \stdClass())); + + $requestedIds = array_map('strval', array_filter(array_column($requestedDiscounts, 'id'))); + $byId = []; + $unmatched = []; + foreach ($parsed->discounts ?? [] as $parsedDiscount) { + if (!empty($parsedDiscount->id) && in_array((string) $parsedDiscount->id, $requestedIds, true)) { + $byId[(string) $parsedDiscount->id] = $parsedDiscount; + } else { + $unmatched[] = $parsedDiscount; + } + } + + $planCycle = null; + $planCycleResolver = function () use (&$planCycle, $parsed): array { + return $planCycle ??= $this->iuguPlanCycle($parsed->planId); + }; + + $desired = []; + foreach ($requestedDiscounts as $requested) { + if (!$requested instanceof SubscriptionDiscount) { + continue; + } + + $target = !empty($requested->id) + ? ($byId[(string) $requested->id] ?? null) + : $this->shiftMatchingDiscount($unmatched, $requested); + if (is_null($target) || empty($target->id)) { + continue; + } + + $until = $this->iuguDiscountValidUntil($requested, $parsed, $creating, $planCycleResolver); + if (is_null($until)) { + continue; + } + + $desired[self::discountUntilVariable((string) $target->id)] = $until->format('Y-m-d'); + $target->validUntil = $until; + } + + $changes = []; + foreach ($desired as $name => $value) { + if (($existing[$name] ?? null) !== $value) { + $changes[] = ['name' => $name, 'value' => $value]; + } + } + foreach (array_keys($existing) as $name) { + if ( + !is_null(self::discountSubitemIdFromVariable((string) $name)) + && !array_key_exists($name, $desired) + ) { + $changes[] = ['name' => (string) $name, '_destroy' => true]; + } + } + + return $changes; + } + + /** + * Tira da lista o primeiro desconto da resposta com a mesma descrição e o mesmo valor do + * pedido; sem um par assim, o primeiro da lista. A ordem dos subitens na resposta da Iugu + * não é garantida, então a igualdade de conteúdo vem antes da posição. + * + * @param SubscriptionDiscount[] $unmatched descontos da resposta ainda sem par; o escolhido sai da lista + * @param SubscriptionDiscount $requested + * + * @return SubscriptionDiscount|null + */ + private function shiftMatchingDiscount(array &$unmatched, SubscriptionDiscount $requested): ?SubscriptionDiscount + { + foreach ($unmatched as $index => $candidate) { + if ( + $candidate->description === $requested->description + && $candidate->amountOff === abs((int) $requested->amountOff) + ) { + unset($unmatched[$index]); + $unmatched = array_values($unmatched); + + return $candidate; + } + } + + return array_shift($unmatched); + } + + /** + * Data até a qual um desconto pedido vale: `validUntil` quando informado; com `cycles` + * acima de 1, a data da fatura de número `cycles`, com um intervalo do plano entre + * faturas. A contagem parte da próxima cobrança lida da resposta: numa criação sem trial + * a primeira fatura já foi cobrada e a segunda sai na próxima cobrança, então faltam + * `cycles - 2` intervalos; com trial, e no update, a próxima cobrança é a primeira fatura + * coberta e faltam `cycles - 1`. Nulo quando o desconto não tem prazo (`cycles` 1 vale só + * para a próxima fatura e é o próprio subitem sem recorrência). + * + * @param SubscriptionDiscount $discount + * @param Subscription $parsed + * @param bool $creating + * @param callable $planCycle devolve `[PlanInterval, int]` do plano, lido sob demanda + * + * @return Carbon|null + */ + private function iuguDiscountValidUntil( + SubscriptionDiscount $discount, + Subscription $parsed, + bool $creating, + callable $planCycle + ): ?Carbon { + if (!empty($discount->validUntil)) { + return $discount->validUntil->copy(); + } + + if (is_null($discount->cycles) || $discount->cycles <= 1) { + return null; + } + + [$interval, $intervalCount] = $planCycle(); + + if ($creating && !empty($parsed->trialEndsAt)) { + $base = $parsed->trialEndsAt; + $remaining = $discount->cycles - 1; + } elseif ($creating && !empty($parsed->nextBillingAt)) { + $base = $parsed->nextBillingAt; + $remaining = $discount->cycles - 2; + } elseif (!$creating && !empty($parsed->nextBillingAt)) { + $base = $parsed->nextBillingAt; + $remaining = $discount->cycles - 1; + } else { + $base = Carbon::now(); + $remaining = $discount->cycles - 1; + } + + return self::addPlanCycles($base, $interval, $intervalCount, max(0, $remaining)); + } + + /** + * Intervalo de cobrança do plano, para converter `cycles` em data. Sem plano legível + * (identificador vazio, plano removido ou sem intervalo), assume mensal. + * + * @param string|null $planId + * + * @return array{0: PlanInterval, 1: int} + * @throws GatewayException|GatewayNotAvailableException + */ + private function iuguPlanCycle(?string $planId): array + { + if (empty($planId)) { + return [PlanInterval::MONTH, 1]; + } + + $plan = new Plan(); + $plan->identifier = $planId; + + try { + $plan = $this->getPlan($plan); + } catch (NotFoundException) { + return [PlanInterval::MONTH, 1]; + } + + return [$plan->interval ?? PlanInterval::MONTH, $plan->intervalCount ?? 1]; + } + + /** + * Soma ciclos do plano a uma data. + * + * @param Carbon $date + * @param PlanInterval $interval + * @param int $intervalCount + * @param int $cycles + * + * @return Carbon + */ + private static function addPlanCycles(Carbon $date, PlanInterval $interval, int $intervalCount, int $cycles): Carbon + { + $units = $intervalCount * $cycles; + + return match ($interval) { + PlanInterval::DAY => $date->copy()->addDays($units), + PlanInterval::WEEK => $date->copy()->addWeeks($units), + PlanInterval::MONTH => $date->copy()->addMonths($units), + PlanInterval::YEAR => $date->copy()->addYears($units), + }; + } + /** * Converte um subitem de valor não negativo da Iugu num item de assinatura. * diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index ef6456e..5fce9a3 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -33,6 +33,7 @@ use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Models\SubscriptionItem; +use Potelo\MultiPayment\Models\SubscriptionDiscount; use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Models\SubscriptionPlanChange; use Potelo\MultiPayment\Models\AutomaticPixCancellation; @@ -118,7 +119,7 @@ class StripeGateway implements GatewayContract, SubscriptionContract, PlanContra * Expand de toda leitura ou escrita de Subscription: o PaymentMethod padrão diz com que * método a assinatura é cobrada, e o Product de cada item dá a descrição dos itens. */ - private const SUBSCRIPTION_EXPAND = ['default_payment_method', 'items.data.price.product']; + private const SUBSCRIPTION_EXPAND = ['default_payment_method', 'discounts.source.coupon', 'items.data.price.product']; /** Expand na leitura ou criação de um Price: o Product dá nome e identificador ao plano. */ private const PRICE_EXPAND = ['product']; @@ -199,6 +200,8 @@ public function capabilities(): array Capability::PLANS, Capability::PLAN_DEACTIVATION, Capability::CANCEL_AT_PERIOD_END, + Capability::COUPONS, + Capability::PERCENT_DISCOUNT, Capability::PLAN_CHANGE_PRORATION, Capability::MANAGES_RECURRENCE, ]; @@ -214,7 +217,6 @@ public function notYetImplemented(): array Capability::AUTOMATIC_PIX, Capability::MULTIPLE_PAYMENT_METHODS, Capability::DELAYED_CAPTURE, - Capability::NATIVE_COUPONS, ]; } @@ -226,11 +228,19 @@ public function notYetImplemented(): array * só fatura Pix pendente de venda avulsa. `INVOICE_CANCELLATION`: a fatura de assinatura * (`in_`) só é anulada depois de finalizada pela Stripe; rascunho é recusado. * `SUBSCRIPTIONS`: a Stripe só aceita `nextBillingAt` na criação da assinatura; na troca de - * plano e na atualização a data da próxima cobrança segue o ciclo. + * plano e na atualização a data da próxima cobrança segue o ciclo. `COUPONS`: o cupom da + * Stripe dura meses inteiros (`duration_in_months`), então `cycles` maior que 1 exige plano + * com intervalo mensal ou anual, e `validUntil` vira meses inteiros contados da aplicação, + * arredondados para cima. */ public function restrictions(): array { return [ + Capability::COUPONS->value => new CapabilityRestriction( + description: 'O cupom da Stripe dura meses inteiros (duration_in_months): cycles maior que 1' + . ' exige plano com intervalo mensal ou anual, e validUntil vira meses inteiros contados' + . ' da aplicação, arredondados para cima.', + ), Capability::CREDIT_CARD->value => new CapabilityRestriction( description: 'Na conta brasileira só cartão de crédito Visa e Mastercard; outra bandeira é' . ' recusada na cobrança com DeclineCode::BRAND_NOT_SUPPORTED.', @@ -2819,15 +2829,18 @@ private function stripeListPage(callable $fetch, array $params, int $page): arra * `error_if_incomplete`); com Pix a assinatura nasce com a primeira fatura em aberto até o * pagamento (`default_incomplete`), lida em `latestInvoice`, com a página hospedada em * `url` para o pagador quitar. Boleto em - * assinatura ainda não está implementado neste driver, e desconto em `discounts` é - * recusado antes da rede (`NATIVE_COUPONS`). Os dias de `trialDays` vão como + * assinatura ainda não está implementado neste driver. Cada desconto de `discounts` vira + * um Coupon criado na hora e aplicado à assinatura: `cycles` 1 é `duration` `once`, mais + * de um ciclo ou `validUntil` viram `repeating` com `duration_in_months`, e desconto sem + * prazo é `forever`. Os dias de `trialDays` vão como * `trial_period_days`, que não muda entre tentativas com a mesma chave de idempotência; * o model devolvido traz a data em `trialEndsAt` e `trialDays` zerado. `nextBillingAt` * vira `billing_cycle_anchor`. * * A chave de idempotência vai no cabeçalho `Idempotency-Key` da criação; as requisições * secundárias usam derivadas (`{chave}:card` no cartão salvo antes, - * `{chave}:item{N}_product` no Product de cada item extra). + * `{chave}:item{N}_product` no Product de cada item extra, `{chave}:discount{N}_coupon` + * no Coupon de cada desconto). * * @throws ChargingException|NotFoundException|UnsupportedOperationException */ @@ -2840,7 +2853,6 @@ public function createSubscription(Subscription $subscription, ?string $idempote if (empty($subscription->planId)) { throw ModelAttributeValidationException::required('Subscription', 'planId'); } - $this->assertSubscriptionHasNoDiscounts($subscription); $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); $paymentMethod = $this->subscriptionPaymentMethod($subscription); @@ -2882,6 +2894,14 @@ public function createSubscription(Subscription $subscription, ?string $idempote $stripeSubscriptionData['add_invoice_items'] = $itemsData['add_invoice_items']; } + if (!empty($subscription->discounts)) { + $stripeSubscriptionData['discounts'] = $this->stripeSubscriptionDiscountsData( + $subscription->discounts, + fn () => $this->stripePriceRecurring($priceId), + $idempotencyKey + ); + } + if (!empty($subscription->metadata)) { $stripeSubscriptionData['metadata'] = $subscription->metadata; } @@ -2932,9 +2952,11 @@ public function getSubscription(Subscription $subscription): Subscription * `metadata` e os itens. `nextBillingAt` diferente do que veio do gateway é recusado: a * Stripe não aceita mudar a data da próxima cobrança fora do ciclo. Nos itens, item novo * cria Price sob demanda, item com `id` tem a quantidade atualizada e mantém o Price, e a - * troca não gera pró-rata (`proration_behavior` `none`). Desconto é recusado antes da - * rede (`NATIVE_COUPONS`). A chave de idempotência vai no cabeçalho da atualização e, - * derivada, nas requisições que a antecedem (`{chave}:card`, `{chave}:item{N}_product`). + * troca não gera pró-rata (`proration_behavior` `none`). Nos descontos, a lista informada + * substitui a da assinatura: desconto com `id` mantém o Coupon, desconto novo cria um, e + * lista vazia remove todos. A chave de idempotência vai no cabeçalho da atualização e, + * derivada, nas requisições que a antecedem (`{chave}:card`, `{chave}:item{N}_product`, + * `{chave}:discount{N}_coupon`). * * @throws ChargingException|UnsupportedOperationException */ @@ -2943,7 +2965,6 @@ public function updateSubscription(Subscription $subscription, ?string $idempote if (empty($subscription->id)) { throw ModelAttributeValidationException::required('Subscription', 'id'); } - $this->assertSubscriptionHasNoDiscounts($subscription); $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); $data = []; @@ -2985,6 +3006,17 @@ public function updateSubscription(Subscription $subscription, ?string $idempote } } + if (!is_null($subscription->discounts) && !$this->isOriginalStripeDiscounts($subscription)) { + // lista vazia remove todos os descontos; a Stripe limpa o campo com string vazia + $data['discounts'] = empty($subscription->discounts) + ? '' + : $this->stripeSubscriptionDiscountsData( + $subscription->discounts, + fn () => $this->stripePlanRecurringForUpdate($subscription), + $idempotencyKey + ); + } + if (!empty($subscription->metadata)) { $data['metadata'] = $subscription->metadata; } @@ -3243,7 +3275,7 @@ public function listSubscriptions(Customer $customer, int $page = 1, int $limit 'customer' => $customer->id, 'status' => 'all', 'limit' => $limit, - 'expand' => ['data.default_payment_method'], + 'expand' => ['data.default_payment_method', 'data.discounts.source.coupon'], ], $page ); @@ -3297,22 +3329,199 @@ private static function paymentMethodToStripeType(PaymentMethod $paymentMethod): } /** - * Recusa antes da rede uma assinatura com descontos: o cupom da Stripe (Coupon) está - * planejado para uma versão futura desta lib (`NATIVE_COUPONS`). + * Payload de `discounts` da assinatura: desconto com `id` mantém o Coupon existente; + * desconto novo cria um Coupon (`{chave}:discount{N}_coupon`) com a duração derivada de + * `cycles` e `validUntil`. + * + * @param SubscriptionDiscount[] $discounts + * @param callable $planRecurring devolve `{interval, interval_count}` do plano, lido sob demanda + * @param string|null $idempotencyKey + * @return array + * @throws ModelAttributeValidationException|UnsupportedOperationException + */ + private function stripeSubscriptionDiscountsData(array $discounts, callable $planRecurring, ?string $idempotencyKey): array + { + $data = []; + foreach (array_values($discounts) as $index => $discount) { + if (!empty($discount->id)) { + $data[] = ['coupon' => $discount->id]; + continue; + } + + $couponData = $this->stripeCouponData($discount, $planRecurring); + $stripeCoupon = $this->stripeRequest(function () use ($couponData, $index, $idempotencyKey) { + return $this->client->coupons->create( + $couponData, + self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, "discount{$index}_coupon")) + ); + }); + + $data[] = ['coupon' => $stripeCoupon->id]; + } + + return $data; + } + + /** + * Payload de um Coupon a partir de um desconto de assinatura: `percent_off` ou + * `amount_off` em `brl`, e a duração de `stripeCouponDuration()`. + * + * @param SubscriptionDiscount $discount + * @param callable $planRecurring devolve `{interval, interval_count}` do plano, lido sob demanda + * @return array + * @throws ModelAttributeValidationException|UnsupportedOperationException + */ + private function stripeCouponData(SubscriptionDiscount $discount, callable $planRecurring): array + { + if (empty($discount->description)) { + throw ModelAttributeValidationException::required('SubscriptionDiscount', 'description'); + } + if (is_null($discount->percentOff) && is_null($discount->amountOff)) { + throw ModelAttributeValidationException::required('SubscriptionDiscount', 'amountOff or percentOff'); + } + + $couponData = ['name' => $discount->description]; + if (!is_null($discount->percentOff)) { + $couponData['percent_off'] = $discount->percentOff; + } else { + $couponData['amount_off'] = $discount->amountOff; + $couponData['currency'] = 'brl'; + } + + return array_merge($couponData, $this->stripeCouponDuration($discount, $planRecurring)); + } + + /** + * Duração do Coupon: `cycles` 1 é `once`; desconto sem prazo é `forever`; `validUntil` e + * `cycles` acima de 1 viram `repeating` com `duration_in_months` (meses até `validUntil`, + * arredondados para cima, ou os meses de `cycles` ciclos do plano). `validUntil` no + * passado é recusado com `ModelAttributeValidationException`, e plano com intervalo + * fora de mês e ano com `cycles` acima de 1 é recusado, porque a duração do Coupon só + * conta em meses. + * + * @param SubscriptionDiscount $discount + * @param callable $planRecurring devolve `{interval, interval_count}` do plano, lido sob demanda + * @return array + * @throws ModelAttributeValidationException|UnsupportedOperationException + */ + private function stripeCouponDuration(SubscriptionDiscount $discount, callable $planRecurring): array + { + if ($discount->cycles === 1) { + return ['duration' => 'once']; + } + + if (!empty($discount->validUntil)) { + $now = Carbon::now(); + if ($discount->validUntil->lessThanOrEqualTo($now)) { + throw ModelAttributeValidationException::invalid( + 'SubscriptionDiscount', + 'validUntil', + 'validUntil must be a future date to create the coupon.' + ); + } + + // diffInMonths trunca em algumas versões do Carbon; o mês parcial conta inteiro + $months = (int) $now->diffInMonths($discount->validUntil); + if ($now->copy()->addMonths($months)->lessThan($discount->validUntil)) { + $months++; + } + + return ['duration' => 'repeating', 'duration_in_months' => max(1, $months)]; + } + + if (is_null($discount->cycles)) { + return ['duration' => 'forever']; + } + + $recurring = $planRecurring(); + $monthsPerCycle = match ($recurring['interval'] ?? null) { + 'month' => (int) ($recurring['interval_count'] ?? 1), + 'year' => 12 * (int) ($recurring['interval_count'] ?? 1), + default => throw UnsupportedOperationException::restricted( + (string) $this, + Capability::COUPONS, + 'O cupom da Stripe dura meses inteiros, então cycles acima de 1 exige plano com' + . ' intervalo mensal ou anual; use validUntil.' + ), + }; + + return ['duration' => 'repeating', 'duration_in_months' => $discount->cycles * $monthsPerCycle]; + } + + /** + * Intervalo de cobrança do plano de uma assinatura existente, no formato de + * `price_data.recurring`: lido de `original` quando a assinatura veio do gateway, senão do + * item do plano na Stripe (uma leitura). * * @param Subscription $subscription - * @return void - * @throws UnsupportedOperationException + * @return array{interval: string|null, interval_count: int} + * @throws GatewayException|NotFoundException */ - private function assertSubscriptionHasNoDiscounts(Subscription $subscription): void + private function stripePlanRecurringForUpdate(Subscription $subscription): array { - if (!empty($subscription->discounts)) { - throw UnsupportedOperationException::forGateway( - $this, - Capability::NATIVE_COUPONS, - 'Desconto de assinatura no Stripe está planejado para uma versão futura desta lib.' - ); + $stripeItems = $subscription->original->items->data ?? null; + $planItem = is_null($stripeItems) + ? $this->currentStripePlanItem($subscription) + : self::stripePlanItem((array) $stripeItems, $subscription->planId); + $recurring = $planItem->price->recurring ?? null; + + return [ + 'interval' => $recurring->interval ?? null, + 'interval_count' => (int) ($recurring->interval_count ?? 1), + ]; + } + + /** + * Diz se os descontos informados são os mesmos que vieram do gateway na leitura: todos com + * `id` e na mesma ordem dos Coupons da assinatura. + * + * @param Subscription $subscription + * @return bool + */ + private function isOriginalStripeDiscounts(Subscription $subscription): bool + { + $original = $subscription->original->discounts ?? null; + if (!is_array($original) && !$original instanceof \Traversable) { + return false; + } + + $originalCoupons = []; + foreach ($original as $stripeDiscount) { + $coupon = is_object($stripeDiscount) ? ($stripeDiscount->source->coupon ?? null) : null; + $originalCoupons[] = is_object($coupon) ? ($coupon->id ?? null) : (is_string($coupon) ? $coupon : null); } + + $modelCoupons = array_map( + static fn (SubscriptionDiscount $discount) => $discount->id, + array_values($subscription->discounts) + ); + + return $modelCoupons === $originalCoupons && !in_array(null, $modelCoupons, true); + } + + /** + * Converte um discount da Stripe (com o Coupon expandido em `source.coupon`) num desconto + * de assinatura. O `id` é o do Coupon; `cycles` volta 1 para `once` e nulo nos demais + * casos, e a duração `repeating` traz o fim em `validUntil`. + * + * @param object $stripeDiscount + * @return SubscriptionDiscount + */ + private function parseStripeSubscriptionDiscount(object $stripeDiscount): SubscriptionDiscount + { + $coupon = is_object($stripeDiscount->source->coupon ?? null) ? $stripeDiscount->source->coupon : null; + + $discount = new SubscriptionDiscount(); + $discount->id = $coupon->id ?? null; + $discount->description = $coupon->name ?? null; + $discount->amountOff = isset($coupon->amount_off) ? (int) $coupon->amount_off : null; + $discount->percentOff = isset($coupon->percent_off) ? (float) $coupon->percent_off : null; + $discount->cycles = ($coupon->duration ?? null) === 'once' ? 1 : null; + $discount->validUntil = !empty($stripeDiscount->end ?? null) + ? Carbon::createFromTimestamp($stripeDiscount->end) + : null; + + return $discount; } /** @@ -3613,7 +3822,8 @@ private function isOriginalStripeTrialEnd(Subscription $subscription): bool * * O item cujo Price é o plano (`stripePlanItem()`) dá o `planId` (`lookup_key`, senão o * id do Price) e a próxima cobrança (`current_period_end`); os demais itens viram - * `items`, e `amount` é a soma dos itens por ciclo. `paymentMethod` vem do PaymentMethod + * `items`, e `amount` é a soma dos itens por ciclo. Os discounts expandidos viram + * `discounts`, com o id do Coupon. `paymentMethod` vem do PaymentMethod * padrão expandido, senão do único tipo em `payment_settings`. Com $withLatestInvoice, a * fatura mais recente é lida por inteiro (`parseFromStripeInvoice()`), uma leitura a * mais. @@ -3661,6 +3871,25 @@ private function parseStripeSubscription( $subscription->items = $items; } + $stripeDiscounts = $stripeSubscription->discounts ?? null; + if (is_array($stripeDiscounts) || $stripeDiscounts instanceof \Traversable) { + $discounts = []; + $unexpanded = false; + foreach ($stripeDiscounts as $stripeDiscount) { + // sem expand o discount vem como id (string), que não tem o Coupon para ler + if (is_object($stripeDiscount)) { + $discounts[] = $this->parseStripeSubscriptionDiscount($stripeDiscount); + } else { + $unexpanded = true; + } + } + // com um discount ilegível, a lista fica como está: gravar uma lista incompleta + // faria um save() posterior remover da assinatura os descontos que existem + if (!$unexpanded) { + $subscription->discounts = $discounts; + } + } + $customerId = is_object($stripeSubscription->customer ?? null) ? $stripeSubscription->customer->id : ($stripeSubscription->customer ?? null); diff --git a/src/Helpers/CapabilitiesTable.php b/src/Helpers/CapabilitiesTable.php index 20b9a7f..357b2cd 100644 --- a/src/Helpers/CapabilitiesTable.php +++ b/src/Helpers/CapabilitiesTable.php @@ -14,6 +14,9 @@ class CapabilitiesTable /** Célula de capability que o gateway oferece e a lib implementa. */ public const SUPPORTED = 'sim'; + /** Célula de capability que o gateway não oferece e a lib entrega por emulação. */ + public const EMULATED = 'emulado'; + /** Célula de capability que o gateway oferece mas a lib ainda não implementa. */ public const NOT_IMPLEMENTED = 'não implementado'; @@ -58,10 +61,14 @@ public static function markdown(array $gateways): string */ public static function cell(DeclaresCapabilities $gateway, Capability $capability): string { - if ($gateway->supports($capability)) { + if (in_array($capability, $gateway->capabilities(), true)) { return self::SUPPORTED; } + if ($gateway->isEmulated($capability)) { + return self::EMULATED; + } + if (in_array($capability, $gateway->notYetImplemented(), true)) { return self::NOT_IMPLEMENTED; } diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index 2cbad8f..91da775 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -60,8 +60,9 @@ class Subscription extends Model /** * Além de `SUBSCRIPTIONS`, a assinatura precisa da capability de cada método de * `resolvedPaymentMethods()` (e de `MULTIPLE_PAYMENT_METHODS` quando há mais de um), de - * `RAW_CARD_DATA` quando o cartão vem com os dados crus (sem `id` nem `token`) e de - * `NATIVE_COUPONS` quando algum desconto é percentual ou limitado a mais de um ciclo. + * `RAW_CARD_DATA` quando o cartão vem com os dados crus (sem `id` nem `token`), de + * `PERCENT_DISCOUNT` quando algum desconto é percentual e de `COUPONS` quando algum + * desconto é limitado a mais de um ciclo ou tem data de validade. * * @return Capability[] * @throws ModelAttributeValidationException método de pagamento fora de `PaymentMethod::selectable()` @@ -82,12 +83,15 @@ public function requiredCapabilities(): array } foreach ($this->discounts ?? [] as $discount) { - if ( - $discount instanceof SubscriptionDiscount - && (!is_null($discount->percentOff) || (!is_null($discount->cycles) && $discount->cycles > 1)) - ) { - $capabilities[] = Capability::NATIVE_COUPONS; - break; + if (!$discount instanceof SubscriptionDiscount) { + continue; + } + if (!is_null($discount->percentOff) && !in_array(Capability::PERCENT_DISCOUNT, $capabilities, true)) { + $capabilities[] = Capability::PERCENT_DISCOUNT; + } + $hasTerm = !empty($discount->validUntil) || (!is_null($discount->cycles) && $discount->cycles > 1); + if ($hasTerm && !in_array(Capability::COUPONS, $capabilities, true)) { + $capabilities[] = Capability::COUPONS; } } @@ -179,8 +183,10 @@ public function requiredCapabilities(): array public ?Carbon $nextBillingAt = null; /** - * Diz se há cancelamento agendado para o fim do período corrente. Preenchido na leitura - * por gateway que oferece o recurso; na Iugu fica nulo. + * Diz se há cancelamento agendado para o fim do período corrente. No Stripe vem do + * gateway; na Iugu vem da marca `mp_cancel_at_period_end` que `cancel(atPeriodEnd: true)` + * grava em `custom_variables`, aplicada na data pelo comando + * `multipayment:sync-subscriptions`. * * @var bool|null */ @@ -586,8 +592,9 @@ public function suspend(GatewayContract|string|null $gateway = null, ?string $id } /** - * Volta a cobrar uma assinatura suspensa; na Iugu, também uma cancelada por `cancel()` (a - * marca de cancelamento é removida). + * Volta a cobrar uma assinatura suspensa e desfaz um cancelamento agendado + * (`cancel(atPeriodEnd: true)`); na Iugu, também reativa uma cancelada por `cancel()` (as + * marcas de cancelamento são removidas de `custom_variables`). * * @param GatewayContract|string|null $gateway * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica diff --git a/src/Models/SubscriptionDiscount.php b/src/Models/SubscriptionDiscount.php index 565430e..725ce24 100644 --- a/src/Models/SubscriptionDiscount.php +++ b/src/Models/SubscriptionDiscount.php @@ -2,6 +2,7 @@ namespace Potelo\MultiPayment\Models; +use Carbon\Carbon; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; /** @@ -36,12 +37,38 @@ class SubscriptionDiscount extends Model /** * Quantos ciclos o desconto vale: null enquanto não for removido, 1 só na próxima fatura, - * N para N ciclos. + * N para N ciclos. Mutuamente exclusivo com validUntil. * * @var int|null */ public ?int $cycles = null; + /** + * Data até a qual o desconto vale, inclusive. Mutuamente exclusivo com cycles. Na leitura, + * um desconto criado com `cycles` volta com a data equivalente aqui. + * + * @var Carbon|null + */ + public ?Carbon $validUntil = null; + + /** + * @inheritDoc + */ + public function fill(array $data): void + { + // valor vazio conta como ausente, para não cair como string na propriedade de data + if (array_key_exists('valid_until', $data)) { + if (!empty($data['valid_until'])) { + $this->validUntil = $data['valid_until'] instanceof Carbon + ? $data['valid_until'] + : Carbon::parse($data['valid_until']); + } + unset($data['valid_until']); + } + + parent::fill($data); + } + /** * @return void * @throws ModelAttributeValidationException @@ -106,5 +133,18 @@ protected function attributesExtraValidation(array $attributes): void 'cycles must be null or at least 1.' ); } + + if ( + in_array('cycles', $attributes) + && in_array('validUntil', $attributes) + && !is_null($this->cycles) + && !empty($this->validUntil) + ) { + throw ModelAttributeValidationException::invalid( + $model, + 'cycles', + 'cycles and validUntil are mutually exclusive.' + ); + } } } diff --git a/src/MultiPayment.php b/src/MultiPayment.php index c3add45..7be25f5 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -108,6 +108,33 @@ public function notYetImplemented($gateway = null): array return $this->gateway($gateway)->notYetImplemented(); } + /** + * Capabilities que o gateway (o desta instância, por padrão) não oferece mas a lib entrega + * por emulação. + * + * @param GatewayContract|string|null $gateway + * @return Capability[] + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function emulated($gateway = null): array + { + return $this->gateway($gateway)->emulated(); + } + + /** + * Diz se a lib entrega a capability por emulação no gateway (o desta instância, por + * padrão). + * + * @param Capability $capability + * @param GatewayContract|string|null $gateway + * @return bool + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function isEmulated(Capability $capability, $gateway = null): bool + { + return $this->gateway($gateway)->isEmulated($capability); + } + /** * Diz se o gateway desta instância suporta todas as capabilities informadas. Para outro * gateway, use `gateway($nome)->supportsAll(...)`. diff --git a/src/Providers/MultiPaymentServiceProvider.php b/src/Providers/MultiPaymentServiceProvider.php index d5a278f..400eeca 100644 --- a/src/Providers/MultiPaymentServiceProvider.php +++ b/src/Providers/MultiPaymentServiceProvider.php @@ -5,6 +5,7 @@ use Potelo\MultiPayment\MultiPayment; use Illuminate\Support\ServiceProvider; use Potelo\MultiPayment\Contracts\IdempotencyStore; +use Potelo\MultiPayment\Console\SyncSubscriptionsCommand; use Potelo\MultiPayment\Idempotency\CacheIdempotencyStore; class MultiPaymentServiceProvider extends ServiceProvider @@ -28,6 +29,10 @@ public function boot() $this->publishes([ $configFile => config_path('multi-payment.php'), ], 'config'); + + if ($this->app->runningInConsole()) { + $this->commands([SyncSubscriptionsCommand::class]); + } } /** diff --git a/tests/Integration/StripeSubscriptionTest.php b/tests/Integration/StripeSubscriptionTest.php index 831a220..434b36e 100644 --- a/tests/Integration/StripeSubscriptionTest.php +++ b/tests/Integration/StripeSubscriptionTest.php @@ -29,6 +29,9 @@ class StripeSubscriptionTest extends TestCase /** @var string[] ids de assinatura criados, cancelados no tearDown */ private array $subscriptionsCriadas = []; + /** @var string[] ids de Coupon criados, apagados no tearDown */ + private array $couponsCriados = []; + /** * Data provider de gateway: mantém o padrão por gateway da suíte e dispensa o sleep da Iugu. * @@ -67,6 +70,14 @@ protected function tearDown(): void } } + foreach ($this->couponsCriados as $id) { + try { + $client->coupons->delete($id); + } catch (\Throwable $e) { + // limpeza é best effort, como acima + } + } + parent::tearDown(); } @@ -174,6 +185,54 @@ public function testShouldRunTheSubscriptionLifecycleWithTrialAndCredit($gateway $this->assertNotNull($subscription->canceledAt); } + /** + * O desconto vira um Coupon aplicado à assinatura: `cycles` acima de 1 num plano mensal é + * `repeating` com o fim em `validUntil`, a primeira fatura sai com o abatimento e a + * leitura devolve o desconto com o id do Coupon. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCreateASubscriptionWithACouponDiscount($gateway) + { + $mensal = $this->createPlan($gateway, 10000, 'cupom'); + + $customerData = self::customerWithoutAddress(); + $customer = new Customer(); + $customer->name = $customerData['name']; + $customer->email = $customerData['email']; + $customer->taxDocument = $customerData['taxDocument']; + + $creditCard = new CreditCard(); + $creditCard->token = 'pm_card_visa'; + + $subscription = MultiPayment::setGateway($gateway)->newSubscription() + ->setPlanId($mensal->identifier) + ->setCustomer($customer) + ->setCreditCard($creditCard) + ->addAmountDiscount('Promo', 500, 3) + ->create(); + $this->subscriptionsCriadas[] = $subscription->id; + + $this->assertCount(1, $subscription->discounts); + $discount = $subscription->discounts[0]; + $this->couponsCriados[] = $discount->id; + $this->assertNotEmpty($discount->id); + $this->assertSame('Promo', $discount->description); + $this->assertSame(500, $discount->amountOff); + // duração repeating de 3 meses: a Stripe informa o fim do desconto + $this->assertNotNull($discount->validUntil); + $this->assertEqualsWithDelta( + now()->addMonths(3)->getTimestamp(), + $discount->validUntil->getTimestamp(), + 86400 * 4 + ); + + $this->assertSame(9500, $subscription->latestInvoice->paidAmount); + + $lida = MultiPayment::setGateway($gateway)->getSubscription($subscription->id); + $this->assertSame($discount->id, $lida->discounts[0]->id); + $this->assertSame(500, $lida->discounts[0]->amountOff); + } + /** * Deve simular a troca de plano com as linhas reais de pró-rata da Stripe. */ diff --git a/tests/Integration/SubscriptionTest.php b/tests/Integration/SubscriptionTest.php index 082ef34..0d59e6b 100644 --- a/tests/Integration/SubscriptionTest.php +++ b/tests/Integration/SubscriptionTest.php @@ -200,10 +200,9 @@ public function testShouldRunTheSubscriptionLifecycle(): void $this->assertSame(SubscriptionStatus::CANCELED, $cancelada->status); $this->assertNotNull($cancelada->canceledAt); $this->assertLessThan(5, abs(now()->diffInMinutes($cancelada->canceledAt))); - $this->assertSame( - $cancelada->canceledAt->toIso8601String(), - $cancelada->metadata['mp_canceled_at'] ?? null - ); + // a marca mp_canceled_at vive em custom_variables e vira o campo tipado; metadata + // não expõe as variáveis reservadas da lib + $this->assertArrayNotHasKey('mp_canceled_at', $cancelada->metadata ?? []); $relida = new Subscription(); $relida->id = $subscription->id; @@ -221,6 +220,71 @@ public function testShouldRunTheSubscriptionLifecycle(): void $this->assertSame($subscription->id, $doCliente[0]->id); } + /** + * O cancelamento ao fim do ciclo grava a intenção em `custom_variables` sem suspender, a + * leitura devolve `cancelAtPeriodEnd` e `resume()` desfaz o agendamento. + * + * @return void + */ + public function testShouldScheduleAndUndoACancellationAtPeriodEnd(): void + { + $plan = $this->createPlan(10000, 'agendado'); + $subscription = $this->createSubscription($plan, now()->addMonth()); + + $agendada = $subscription->cancel(true, self::GATEWAY); + $this->assertSame(SubscriptionStatus::ACTIVE, $agendada->status); + $this->assertTrue($agendada->cancelAtPeriodEnd); + $this->assertNull($agendada->canceledAt); + $this->assertArrayNotHasKey('mp_cancel_at_period_end', $agendada->metadata ?? []); + + $lida = MultiPayment::setGateway(self::GATEWAY)->getSubscription($subscription->id); + $this->assertTrue($lida->cancelAtPeriodEnd); + $this->assertSame(SubscriptionStatus::ACTIVE, $lida->status); + + $reativada = $lida->resume(self::GATEWAY); + $this->assertFalse($reativada->cancelAtPeriodEnd); + $this->assertSame(SubscriptionStatus::ACTIVE, $reativada->status); + } + + /** + * A validade de um desconto vira a variável `mp_discount__until` na Iugu, e a + * leitura a devolve em `validUntil`, fora de `metadata`. + * + * @return void + */ + public function testShouldStoreTheDiscountValidityInCustomVariables(): void + { + $plan = $this->createPlan(10000, 'validade'); + $customer = $this->createCustomer(self::GATEWAY, $this->customerWithoutAddress()); + $validUntil = now()->addMonths(2)->startOfDay(); + + $subscription = MultiPayment::setGateway(self::GATEWAY)->newSubscription() + ->setPlanId($plan->identifier) + ->setCustomerId($customer->id) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) + ->setNextBillingAt(now()->addMonth()) + ->addAmountDiscount('Promo', 500, null, $validUntil) + ->create(); + $this->criados['subscriptions'][] = $subscription->id; + + $this->assertCount(1, $subscription->discounts); + $this->assertNotEmpty($subscription->discounts[0]->id); + $this->assertSame( + $validUntil->format('Y-m-d'), + $subscription->discounts[0]->validUntil->format('Y-m-d') + ); + + $lida = MultiPayment::setGateway(self::GATEWAY)->getSubscription($subscription->id); + $this->assertSame( + $validUntil->format('Y-m-d'), + $lida->discounts[0]->validUntil->format('Y-m-d') + ); + $this->assertArrayNotHasKey( + 'mp_discount_' . $lida->discounts[0]->id . '_until', + $lida->metadata ?? [] + ); + } + /** * Assinatura sem data de cobrança não volta com resume(): a Iugu responde sem erro e sem * mudar o estado. diff --git a/tests/Unit/CapabilityGuardsTest.php b/tests/Unit/CapabilityGuardsTest.php index c5e1264..b8ec5d7 100644 --- a/tests/Unit/CapabilityGuardsTest.php +++ b/tests/Unit/CapabilityGuardsTest.php @@ -2,6 +2,7 @@ namespace Potelo\MultiPayment\Tests\Unit; +use Carbon\Carbon; use Stripe\ApiRequestor; use PHPUnit\Framework\TestCase; use Illuminate\Config\Repository; @@ -59,20 +60,6 @@ protected function tearDown(): void parent::tearDown(); } - public function testPercentDiscountSubscriptionOnStripeFailsBeforeCreatingTheCustomer(): void - { - $customer = new Customer(); - $customer->name = 'Fulano'; - $customer->email = 'fulano@exemplo.com'; - - $builder = (new MultiPayment('stripe'))->newSubscription() - ->setPlanId('plano_mensal') - ->setCustomer($customer) - ->addPercentDiscount('Promo', 10.0); - - $this->assertNotImplemented(Capability::NATIVE_COUPONS, fn () => $builder->create()); - } - public function testBankSlipSubscriptionOnStripeFailsBeforeCreatingTheCustomer(): void { $customer = new Customer(); @@ -87,31 +74,6 @@ public function testBankSlipSubscriptionOnStripeFailsBeforeCreatingTheCustomer() $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $builder->create()); } - /** - * Desconto simples (`amountOff`) passa pela capability do model (a Iugu o entrega sem - * cupom), então o driver Stripe o recusa por conta própria, antes de qualquer requisição. - */ - public function testSubscriptionDiscountsOnStripeAreRefusedBeforeTheNetwork(): void - { - $discount = new SubscriptionDiscount(); - $discount->description = 'Promo'; - $discount->amountOff = 500; - - $gateway = new StripeGateway(); - - $creating = new Subscription(); - $creating->customer = new Customer(); - $creating->customer->id = 'cus_1'; - $creating->planId = 'plano_mensal'; - $creating->discounts = [$discount]; - $this->assertNotImplemented(Capability::NATIVE_COUPONS, fn () => $gateway->createSubscription($creating)); - - $updating = new Subscription(); - $updating->id = 'sub_1'; - $updating->discounts = [$discount]; - $this->assertNotImplemented(Capability::NATIVE_COUPONS, fn () => $gateway->updateSubscription($updating)); - } - /** * No update, o gateway gravado no model prevalece sobre o informado, como em `Model::save()`: * a recusa vem do stripe gravado no model, com o motivo dele, e a instância da Iugu não é @@ -120,16 +82,13 @@ public function testSubscriptionDiscountsOnStripeAreRefusedBeforeTheNetwork(): v public function testUpdateUsesTheGatewayStoredInTheModel(): void { $api = new QueuedIuguApiRequest([]); - $discount = new SubscriptionDiscount(); - $discount->description = 'Promo'; - $discount->percentOff = 10.0; $subscription = new Subscription(); $subscription->id = 'sub_1'; $subscription->gateway = 'stripe'; - $subscription->discounts = [$discount]; + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; - $this->assertNotImplemented(Capability::NATIVE_COUPONS, fn () => $subscription->save(new IuguGateway($api))); + $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $subscription->save(new IuguGateway($api))); $this->assertCount(0, $api->calls); } @@ -140,15 +99,11 @@ public function testUpdateUsesTheGatewayStoredInTheModel(): void */ public function testDeleteChecksTheCapabilityBeforeTheDispatchMethod(): void { - $discount = new SubscriptionDiscount(); - $discount->description = 'Promo'; - $discount->percentOff = 10.0; - $subscription = new Subscription(); $subscription->id = 'sub_1'; - $subscription->discounts = [$discount]; + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; - $this->assertNotImplemented(Capability::NATIVE_COUPONS, fn () => $subscription->delete('stripe')); + $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $subscription->delete('stripe')); } public function testBankSlipChargeOnStripeFailsBeforeCreatingTheCustomer(): void @@ -206,7 +161,7 @@ public function testPercentDiscountSubscriptionOnIuguFailsBeforeCreatingTheCusto ->addPercentDiscount('Anual', 10.0); $this->assertUnsupported( - Capability::NATIVE_COUPONS, + Capability::PERCENT_DISCOUNT, UnsupportedOperationException::REASON_GATEWAY_LIMITATION, 'iugu', fn () => $builder->create() @@ -322,7 +277,17 @@ public function testInvoiceRequiredCapabilitiesDeriveFromTheAttributes(): void $discount = new \Potelo\MultiPayment\Models\SubscriptionDiscount(); $discount->percentOff = 10.0; $subscription->discounts = [$discount]; - $this->assertSame([Capability::SUBSCRIPTIONS, Capability::NATIVE_COUPONS], $subscription->requiredCapabilities()); + $this->assertSame([Capability::SUBSCRIPTIONS, Capability::PERCENT_DISCOUNT], $subscription->requiredCapabilities()); + + $withTerm = new Subscription(); + $limited = new \Potelo\MultiPayment\Models\SubscriptionDiscount(); + $limited->amountOff = 500; + $limited->cycles = 3; + $dated = new \Potelo\MultiPayment\Models\SubscriptionDiscount(); + $dated->amountOff = 500; + $dated->validUntil = Carbon::parse('2027-01-01'); + $withTerm->discounts = [$limited, $dated]; + $this->assertSame([Capability::SUBSCRIPTIONS, Capability::COUPONS], $withTerm->requiredCapabilities()); $existing = new Invoice(); $existing->id = 'inv_1'; diff --git a/tests/Unit/Console/SyncSubscriptionsCommandTest.php b/tests/Unit/Console/SyncSubscriptionsCommandTest.php new file mode 100644 index 0000000..ad7137a --- /dev/null +++ b/tests/Unit/Console/SyncSubscriptionsCommandTest.php @@ -0,0 +1,177 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => [ + 'iugu' => ['api_key' => 'test-api-key', 'class' => IuguGateway::class], + 'stripe' => ['api_key' => 'sk_test_fake', 'class' => StripeGateway::class], + ], + ], + ])); + $app->instance('log', $this->logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + QueuedIuguApiRequest::restoreSdkRequester(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + /** + * Executa o comando com o container mínimo dos testes unitários e devolve a saída. + * + * @param array $input + * @return string + */ + private function runCommand(array $input): string + { + $command = new SyncSubscriptionsCommand(); + $command->setLaravel(Facade::getFacadeApplication()); + $output = new BufferedOutput(); + + $this->assertSame(0, $command->run(new ArrayInput($input), $output)); + + return $output->fetch(); + } + + private static function listResponse(array $items): object + { + return (object) ['totalItems' => count($items), 'items' => $items]; + } + + private static function expiredDiscountSubscription(): object + { + return (object) [ + 'id' => 'sub_expirado', + 'plan_identifier' => 'plano_mensal', + 'expires_at' => '2026-10-01', + 'active' => true, + 'suspended' => false, + 'subitems' => [(object) ['id' => 'si_d1', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true]], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-09-03']], + ]; + } + + public function testTheCommandAppliesTheIuguEmulationsAndLogsEachAction(): void + { + $api = (new QueuedIuguApiRequest([ + self::listResponse([self::expiredDiscountSubscription()]), + (object) ['id' => 'sub_expirado'], + ]))->installAsSdkRequester(); + + $output = $this->runCommand(['--gateway' => 'iugu']); + + $this->assertStringContainsString('assinatura sub_expirado', $output); + $this->assertStringContainsString('desconto si_d1 vencido em 2026-09-03 removido', $output); + $this->assertCount(2, $api->calls); + $this->assertSame('PUT', $api->calls[1]['method']); + + $logged = array_filter( + $this->logger->records, + static fn (array $record) => str_contains($record['message'], 'multipayment:sync-subscriptions') + ); + $this->assertCount(1, $logged); + $record = array_values($logged)[0]; + $this->assertSame('info', $record['level']); + $this->assertSame('remove_discount', $record['context']['action']); + $this->assertSame('sub_expirado', $record['context']['subscription']); + } + + public function testDryRunReportsWithoutWriting(): void + { + $api = (new QueuedIuguApiRequest([ + self::listResponse([self::expiredDiscountSubscription()]), + ]))->installAsSdkRequester(); + + $output = $this->runCommand(['--gateway' => 'iugu', '--dry-run' => true]); + + $this->assertStringContainsString('[dry-run]', $output); + $this->assertStringContainsString('desconto si_d1 vencido', $output); + $this->assertCount(1, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + } + + public function testAGatewayThatManagesTheFeaturesItselfIsSkippedWithAMessage(): void + { + $output = $this->runCommand(['--gateway' => 'stripe']); + + $this->assertStringContainsString('[stripe] o gateway gerencia cupom e cancelamento ao fim do ciclo', $output); + } + + /** + * Sem `--gateway`, o comando percorre todos os gateways configurados: sincroniza a Iugu e + * pula o Stripe com a mensagem. + */ + public function testWithoutTheOptionEveryConfiguredGatewayIsVisited(): void + { + $api = (new QueuedIuguApiRequest([ + self::listResponse([]), + ]))->installAsSdkRequester(); + + $output = $this->runCommand([]); + + $this->assertStringContainsString('[iugu] nada a aplicar.', $output); + $this->assertStringContainsString('[stripe] o gateway gerencia', $output); + $this->assertCount(1, $api->calls); + } + + /** + * A configuração padrão do pacote registra os dois gateways mesmo quando a aplicação só + * usa um; o gateway sem `api_key` é pulado na varredura sem `--gateway`. + */ + public function testAGatewayWithoutAnApiKeyIsSkippedInTheSweep(): void + { + Facade::getFacadeApplication()['config']->set('multi-payment.gateways.iugu.api_key', null); + $api = (new QueuedIuguApiRequest([]))->installAsSdkRequester(); + + $output = $this->runCommand([]); + + $this->assertStringContainsString('[iugu] sem api_key configurada; gateway pulado.', $output); + $this->assertStringContainsString('[stripe] o gateway gerencia', $output); + $this->assertCount(0, $api->calls); + } +} diff --git a/tests/Unit/Gateways/GatewayCapabilitiesTest.php b/tests/Unit/Gateways/GatewayCapabilitiesTest.php index 0ab9818..7b83720 100644 --- a/tests/Unit/Gateways/GatewayCapabilitiesTest.php +++ b/tests/Unit/Gateways/GatewayCapabilitiesTest.php @@ -25,6 +25,7 @@ class GatewayCapabilitiesTest extends TestCase { private const SUPPORTED = CapabilitiesTable::SUPPORTED; + private const EMULATED = CapabilitiesTable::EMULATED; private const NOT_IMPLEMENTED = CapabilitiesTable::NOT_IMPLEMENTED; private const LIMITATION = CapabilitiesTable::GATEWAY_LIMITATION; @@ -54,7 +55,11 @@ public function testDriverDeclaresTheExpectedSupport(string $gateway, Capability $driver = self::driver($gateway); $this->assertSame($expected, CapabilitiesTable::cell($driver, $capability)); - $this->assertSame($expected === self::SUPPORTED, $driver->supports($capability)); + $this->assertSame( + in_array($expected, [self::SUPPORTED, self::EMULATED], true), + $driver->supports($capability) + ); + $this->assertSame($expected === self::EMULATED, $driver->isEmulated($capability)); } /** @@ -84,8 +89,9 @@ public static function matrixProvider(): array Capability::SUBSCRIPTIONS->name => [self::SUPPORTED, self::SUPPORTED], Capability::PLANS->name => [self::SUPPORTED, self::SUPPORTED], Capability::PLAN_DEACTIVATION->name => [self::LIMITATION, self::SUPPORTED], - Capability::CANCEL_AT_PERIOD_END->name => [self::LIMITATION, self::SUPPORTED], - Capability::NATIVE_COUPONS->name => [self::LIMITATION, self::NOT_IMPLEMENTED], + Capability::CANCEL_AT_PERIOD_END->name => [self::EMULATED, self::SUPPORTED], + Capability::COUPONS->name => [self::EMULATED, self::SUPPORTED], + Capability::PERCENT_DISCOUNT->name => [self::LIMITATION, self::SUPPORTED], Capability::PLAN_CHANGE_PRORATION->name => [self::LIMITATION, self::SUPPORTED], Capability::SUBSCRIPTION_CREDITS->name => [self::NOT_IMPLEMENTED, self::LIMITATION], Capability::MANAGES_RECURRENCE->name => [self::LIMITATION, self::SUPPORTED], @@ -107,16 +113,22 @@ public function testMatrixCoversEveryCapabilityForEveryDriver(): void } #[DataProvider('driverProvider')] - public function testCapabilitiesAndNotYetImplementedDoNotOverlap(string $gateway): void + public function testCapabilitiesNotYetImplementedAndEmulatedDoNotOverlap(string $gateway): void { $driver = self::driver($gateway); $overlap = array_filter( $driver->capabilities(), + static fn (Capability $capability) => in_array($capability, $driver->notYetImplemented(), true) + || in_array($capability, $driver->emulated(), true) + ); + $emulatedOverlap = array_filter( + $driver->emulated(), static fn (Capability $capability) => in_array($capability, $driver->notYetImplemented(), true) ); $this->assertSame([], $overlap); + $this->assertSame([], $emulatedOverlap); $this->assertSame($driver->capabilities(), array_values(array_unique($driver->capabilities(), SORT_REGULAR))); } @@ -203,6 +215,8 @@ public static function restrictionProvider(): array 'stripe duplicação só pix' => ['stripe', Capability::INVOICE_DUPLICATION, ['payment_methods' => [PaymentMethod::PIX]]], 'stripe cancelamento de rascunho' => ['stripe', Capability::INVOICE_CANCELLATION, []], 'stripe nextBillingAt só na criação' => ['stripe', Capability::SUBSCRIPTIONS, []], + 'stripe cupom dura meses inteiros' => ['stripe', Capability::COUPONS, []], + 'iugu cupom sem restrição' => ['iugu', Capability::COUPONS, null], 'stripe pix sem restrição' => ['stripe', Capability::PIX, null], ]; } @@ -265,6 +279,28 @@ public function testTheFacadeExposesSupportsAllAndTheRestrictions(): void $this->assertArrayHasKey(Capability::INSTALLMENTS->value, $payment->restrictions('iugu')); } + /** + * A fachada expõe `emulated()` e `isEmulated()` do gateway, e o nome antigo + * `Capability::NATIVE_COUPONS` resolve para o mesmo caso `COUPONS`. + */ + public function testTheFacadeExposesTheEmulatedListAndTheLegacyCouponsName(): void + { + Facade::getFacadeApplication()['config']->set('multi-payment.gateways.iugu.class', IuguGateway::class); + Facade::getFacadeApplication()['config']->set('multi-payment.gateways.stripe.class', StripeGateway::class); + + $payment = new MultiPayment('stripe'); + + $this->assertSame([], $payment->emulated()); + $this->assertFalse($payment->isEmulated(Capability::COUPONS)); + $this->assertSame( + [Capability::COUPONS, Capability::CANCEL_AT_PERIOD_END], + $payment->emulated('iugu') + ); + $this->assertTrue($payment->isEmulated(Capability::CANCEL_AT_PERIOD_END, 'iugu')); + $this->assertTrue($payment->supports(Capability::COUPONS, 'iugu')); + $this->assertSame(Capability::COUPONS, Capability::NATIVE_COUPONS); + } + private static function driver(string $gateway): GatewayContract&DeclaresCapabilities { return match ($gateway) { diff --git a/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php index d878b64..6806877 100644 --- a/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php +++ b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php @@ -284,6 +284,19 @@ function (IuguGateway $g, string $key) { ], 'PUT', '/subscriptions/sub_1', ], + 'cancelSubscription agendado (PUT das variáveis de agendamento)' => [ + function (IuguGateway $g, string $key) { + $subscription = self::subscriptionWithId(); + $subscription->nextBillingAt = Carbon::parse('2026-12-01'); + + return $g->cancelSubscription($subscription, true, $key); + }, + [self::subscriptionResponse(['custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-12-01'], + ]])], + 'PUT', '/subscriptions/sub_1', + ], 'changeSubscriptionPlan com cobrança (POST /change_plan, com a releitura repetida)' => [ fn (IuguGateway $g, string $key) => $g->changeSubscriptionPlan(self::subscriptionWithId(), 'plano_anual', ProrationBehavior::CHARGE_DIFFERENCE, $key), [(object) ['success' => true], self::subscriptionResponse(['plan_identifier' => 'plano_anual'])], diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index 6daf4bb..f332445 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -259,7 +259,7 @@ public static function statusProvider(): array ]; } - public function testParseReadsTheCancellationMarkIntoCanceledAtAndKeepsItInMetadata(): void + public function testParseReadsTheCancellationMarkIntoCanceledAtAndKeepsItOutOfMetadata(): void { $api = new QueuedIuguApiRequest([$this->subscriptionResponse([ 'suspended' => true, @@ -275,8 +275,8 @@ public function testParseReadsTheCancellationMarkIntoCanceledAtAndKeepsItInMetad $this->assertSame(SubscriptionStatus::CANCELED, $subscription->status); $this->assertSame('2026-09-10T10:00:00-03:00', $subscription->canceledAt->toIso8601String()); - $this->assertSame(['origem' => 'teste', 'mp_canceled_at' => '2026-09-10T10:00:00-03:00'], $subscription->metadata); - $this->assertNull($subscription->cancelAtPeriodEnd); + $this->assertSame(['origem' => 'teste'], $subscription->metadata); + $this->assertFalse($subscription->cancelAtPeriodEnd); } /** @@ -528,7 +528,7 @@ public function testAnUnreadableCancellationMarkIsIgnoredWithAWarning(): void $this->assertSame(SubscriptionStatus::SUSPENDED, $subscription->status); $this->assertNull($subscription->canceledAt); - $this->assertSame(['mp_canceled_at' => 'sim'], $subscription->metadata); + $this->assertSame([], $subscription->metadata); $this->assertCount(2, $logger->records, 'um aviso por leitura da marca (status e canceledAt)'); $this->assertSame('warning', $logger->records[0]['level']); $this->assertStringContainsString('mp_canceled_at', $logger->records[0]['message']); @@ -575,7 +575,11 @@ public function testResumeClearsTheCancellationMark(): void $this->assertSame('PUT', $api->calls[1]['method']); $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[1]['url']); $this->assertSame( - ['custom_variables' => [['name' => 'mp_canceled_at', '_destroy' => true]]], + ['custom_variables' => [ + ['name' => 'mp_canceled_at', '_destroy' => true], + ['name' => 'mp_cancel_at_period_end', '_destroy' => true], + ['name' => 'mp_cancel_scheduled_for', '_destroy' => true], + ]], $api->calls[1]['data'] ); $this->assertSame(SubscriptionStatus::ACTIVE, $resumed->status); @@ -651,24 +655,77 @@ public function testResumeOfASuspendedSubscriptionDoesNotTouchCustomVariables(): $this->assertSame(['origem' => 'teste'], $resumed->metadata); } - public function testCancelAtPeriodEndIsRejected(): void + public function testCancelAtPeriodEndSchedulesTheCancellationWithoutSuspending(): void { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse([ + 'custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-12-01'], + ], + ])]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'next_billing_at' => '2026-12-01']); + + $canceled = (new IuguGateway($api))->cancelSubscription($subscription, true); + + $this->assertCount(1, $api->calls); + $this->assertSame('PUT', $api->calls[0]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[0]['url']); + $this->assertSame( + ['custom_variables' => [ + ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-12-01'], + ]], + $api->calls[0]['data'] + ); + $this->assertSame(SubscriptionStatus::ACTIVE, $canceled->status); + $this->assertTrue($canceled->cancelAtPeriodEnd); + $this->assertNull($canceled->canceledAt); + } + + /** + * Sem `nextBillingAt` no model, a data programada vem de uma leitura da assinatura; sem + * data de cobrança na resposta, não há fim de período e a operação é recusada antes de + * qualquer escrita. + */ + public function testCancelAtPeriodEndReadsTheBillingDateWhenTheModelDoesNotHaveIt(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['expires_at' => '2026-11-15']), + $this->subscriptionResponse(), + ]); + $subscription = new Subscription(); $subscription->id = 'sub_1'; - $api = new QueuedIuguApiRequest([]); + (new IuguGateway($api))->cancelSubscription($subscription, true); + + $this->assertCount(2, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertSame( + ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-11-15'], + $api->calls[1]['data']['custom_variables'][1] + ); + } + + public function testCancelAtPeriodEndWithoutABillingDateIsRejected(): void + { + $response = $this->subscriptionResponse(); + unset($response->expires_at); + $api = new QueuedIuguApiRequest([$response]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; try { (new IuguGateway($api))->cancelSubscription($subscription, true); - $this->fail('Esperava UnsupportedOperationException'); - } catch (UnsupportedOperationException $e) { - $this->assertSame(Capability::CANCEL_AT_PERIOD_END, $e->capability); - $this->assertSame('iugu', $e->gateway); - $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); - $this->assertFalse($e->isNotImplemented()); - $this->assertStringContainsString('Suspenda a assinatura', $e->getMessage()); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('no billing date', $e->getMessage()); } - $this->assertCount(0, $api->calls); + $this->assertCount(1, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); } public function testChangePlanWithoutChargeSendsSkipChargeAndTheNewBillingDate(): void @@ -1143,41 +1200,13 @@ public function testPercentageDiscountIsRejected(): void (new IuguGateway($api))->createSubscription($subscription); $this->fail('Esperava UnsupportedOperationException'); } catch (UnsupportedOperationException $e) { - $this->assertSame(Capability::NATIVE_COUPONS, $e->capability); + $this->assertSame(Capability::PERCENT_DISCOUNT, $e->capability); $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); $this->assertStringContainsString('use amountOff', $e->getMessage()); } $this->assertCount(0, $api->calls); } - /** - * A Iugu só expressa desconto de uma fatura ou até ser removido, então guardar cycles maior - * que 1 devolveria um limite que o gateway não mantém. - */ - public function testDiscountLimitedToMoreThanOneCycleIsRejected(): void - { - $discount = new SubscriptionDiscount(); - $discount->description = 'Promo'; - $discount->amountOff = 500; - $discount->cycles = 3; - - $subscription = new Subscription(); - $subscription->fill(['plan_id' => 'plano', 'customer' => ['id' => 'cus_1']]); - $subscription->discounts = [$discount]; - - $api = new QueuedIuguApiRequest([]); - - try { - (new IuguGateway($api))->createSubscription($subscription); - $this->fail('Esperava UnsupportedOperationException'); - } catch (UnsupportedOperationException $e) { - $this->assertSame(Capability::NATIVE_COUPONS, $e->capability); - $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); - $this->assertStringContainsString('use cycles 1 ou nulo', $e->getMessage()); - } - $this->assertCount(0, $api->calls); - } - public function testDiscountWithoutAmountOffIsRejectedByTheMapper(): void { $discount = new SubscriptionDiscount(); @@ -2975,4 +3004,367 @@ public static function parsedPaymentMethodProvider(): array 'all' => ['all', null], ]; } + + private function discountSubitem(string $id = 'si_d1', int $priceCents = -500, bool $recurrent = true): object + { + return (object) [ + 'id' => $id, + 'description' => 'Promo', + 'price_cents' => $priceCents, + 'quantity' => 1, + 'recurrent' => $recurrent, + ]; + } + + public function testDiscountWithValidUntilWritesTheVariableAfterTheCreation(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => [$this->discountSubitem()]]), + $this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'valid_until' => '2026-12-31']], + ]); + + $created = (new IuguGateway($api))->createSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame( + [['description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => 1]], + $api->calls[0]['data']['subitems'] + ); + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[1]['url']); + $this->assertSame( + ['custom_variables' => [['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31']]], + $api->calls[1]['data'] + ); + $this->assertSame('2026-12-31', $created->discounts[0]->validUntil->format('Y-m-d')); + } + + /** + * `cycles` acima de 1 vira a data da fatura de número `cycles`. Na criação sem trial a + * primeira fatura é cobrada na hora e a segunda sai na próxima cobrança da resposta, então + * três ciclos terminam um intervalo do plano (lido por `getPlan()`) depois dela. + */ + public function testDiscountCyclesBecomeAValidUntilComputedFromThePlanInterval(): void + { + Carbon::setTestNow('2026-09-04 12:00:00'); + + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => [$this->discountSubitem()]]), + (object) ['identifier' => 'plano_mensal', 'interval_type' => 'months', 'interval' => 1, 'value_cents' => 10000], + $this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-11-01']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'cycles' => 3]], + ]); + + $created = (new IuguGateway($api))->createSubscription($subscription); + + $this->assertCount(3, $api->calls); + $this->assertStringEndsWith('/plans/identifier/plano_mensal', $api->calls[1]['url']); + $this->assertSame( + ['custom_variables' => [['name' => 'mp_discount_si_d1_until', 'value' => '2026-11-01']]], + $api->calls[2]['data'] + ); + $this->assertSame('2026-11-01', $created->discounts[0]->validUntil->format('Y-m-d')); + } + + /** + * Substituir a lista de descontos por uma vazia remove o subitem e a variável de validade + * que ficou sem desconto correspondente. + */ + public function testUpdateWithAnEmptyDiscountListRemovesTheStaleValidityVariable(): void + { + $current = $this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31']], + ]); + $api = new QueuedIuguApiRequest([ + $current, + $this->subscriptionResponse(['custom_variables' => [ + (object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31'], + ]]), + $this->subscriptionResponse(['custom_variables' => [ + (object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31'], + ]]), + $this->subscriptionResponse(['custom_variables' => []]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->discounts = []; + + $updated = (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(4, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertSame( + ['subitems' => [['id' => 'si_d1', '_destroy' => true]]], + $api->calls[1]['data'] + ); + $this->assertSame( + ['custom_variables' => [['name' => 'mp_discount_si_d1_until', '_destroy' => true]]], + $api->calls[3]['data'] + ); + $this->assertNull($updated->canceledAt); + $this->assertSame([], $updated->metadata); + } + + public function testParseReadsTheDiscountValidityAndTheScheduledCancellation(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [ + (object) ['name' => 'origem', 'value' => 'teste'], + (object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31'], + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-10-01'], + ], + ])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + $this->assertTrue($subscription->cancelAtPeriodEnd); + $this->assertNull($subscription->canceledAt); + $this->assertSame('2026-12-31', $subscription->discounts[0]->validUntil->format('Y-m-d')); + $this->assertNull($subscription->discounts[0]->cycles); + $this->assertSame(['origem' => 'teste'], $subscription->metadata); + } + + /** + * Com chave, o `PUT` da validade recebe `{chave}:discounts` pela store, e o retry só + * repete o `POST` (endpoint nativo, deduplicado pela própria Iugu). + */ + public function testCreateWithDiscountStoresTheValidityWriteUnderItsOwnKey(): void + { + $created = $this->subscriptionResponse(['subitems' => [$this->discountSubitem()]]); + $withVariable = $this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31']], + ]); + $api = new QueuedIuguApiRequest([$created, $withVariable, $created]); + $store = new \Potelo\MultiPayment\Idempotency\InMemoryIdempotencyStore(); + $gateway = new IuguGateway($api, $store); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'valid_until' => '2026-12-31']], + ]); + + $gateway->createSubscription($subscription, 'chave-1'); + + $this->assertCount(2, $api->calls); + $this->assertSame(['Idempotency-Key: chave-1'], $api->calls[0]['headers']); + $this->assertSame([], $api->calls[1]['headers']); + $this->assertTrue($store->has('iugu:chave-1:discounts')); + + $again = new Subscription(); + $again->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'valid_until' => '2026-12-31']], + ]); + $gateway->createSubscription($again, 'chave-1'); + + $this->assertCount(3, $api->calls, 'o retry repete só o POST; o PUT da validade sai da store'); + $this->assertSame('POST', $api->calls[2]['method']); + } + + /** + * No update, o desconto novo casa com o subitem da resposta e ganha a variável de + * validade; a variável do desconto que saiu da lista é removida no mesmo `PUT`. + */ + public function testUpdateReplacesTheDiscountAndRewritesItsValidityVariable(): void + { + $old = $this->discountSubitem('si_old'); + $oldVariable = (object) ['name' => 'mp_discount_si_old_until', 'value' => '2026-10-01']; + $new = $this->discountSubitem('si_new'); + + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => [$old], 'custom_variables' => [$oldVariable]]), + $this->subscriptionResponse(['subitems' => [], 'custom_variables' => [$oldVariable]]), + $this->subscriptionResponse(['subitems' => [$new], 'custom_variables' => [$oldVariable]]), + $this->subscriptionResponse([ + 'subitems' => [$new], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_new_until', 'value' => '2026-12-31']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'valid_until' => '2026-12-31']], + ]); + + $updated = (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(4, $api->calls); + $this->assertSame( + ['subitems' => [['id' => 'si_old', '_destroy' => true]]], + $api->calls[1]['data'] + ); + $this->assertSame( + ['custom_variables' => [ + ['name' => 'mp_discount_si_new_until', 'value' => '2026-12-31'], + ['name' => 'mp_discount_si_old_until', '_destroy' => true], + ]], + $api->calls[3]['data'] + ); + $this->assertSame('2026-12-31', $updated->discounts[0]->validUntil->format('Y-m-d')); + } + + /** + * Update com o mesmo desconto e a mesma validade não escreve a variável de novo: a + * operação termina no `PUT` da atualização. + */ + public function testUpdateWithTheSameDiscountValidityDoesNotWriteTheVariableAgain(): void + { + $withVariable = $this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31']], + ]); + $api = new QueuedIuguApiRequest([$withVariable, $withVariable]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'discounts' => [[ + 'id' => 'si_d1', + 'description' => 'Promo', + 'amount_off' => 500, + 'valid_until' => '2026-12-31', + ]], + ]); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertSame('PUT', $api->calls[1]['method']); + } + + /** + * Na criação com trial, a contagem de `cycles` parte do fim do teste, que é quando a + * primeira fatura é cobrada. + */ + public function testDiscountCyclesCountFromTheTrialEndWhenCreatingWithATrial(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => [$this->discountSubitem()], 'expires_at' => '2026-09-11']), + (object) ['identifier' => 'plano_mensal', 'interval_type' => 'months', 'interval' => 1, 'value_cents' => 10000], + $this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-10-11']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'trial_ends_at' => '2026-09-11', + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'cycles' => 2]], + ]); + + (new IuguGateway($api))->createSubscription($subscription); + + $this->assertSame( + ['name' => 'mp_discount_si_d1_until', 'value' => '2026-10-11'], + $api->calls[2]['data']['custom_variables'][0] + ); + } + + public function testAnUnreadableDiscountValidityIsIgnoredWithAWarning(): void + { + $app = \Illuminate\Support\Facades\Facade::getFacadeApplication(); + $app->instance('log', $logger = new RecordingLogger()); + + $api = new QueuedIuguApiRequest([$this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => 'sim']], + ])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertNull($subscription->discounts[0]->validUntil); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertStringContainsString('mp_discount_si_d1_until', $logger->records[0]['message']); + } + + /** + * `resume()` de uma assinatura ativa com cancelamento agendado remove as variáveis do + * agendamento, mesmo sem a marca `mp_canceled_at`. + */ + public function testResumeClearsAScheduledCancellation(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-10-01'], + ]]), + $this->subscriptionResponse(['custom_variables' => []]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $resumed = (new IuguGateway($api))->resumeSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertSame( + [ + ['name' => 'mp_canceled_at', '_destroy' => true], + ['name' => 'mp_cancel_at_period_end', '_destroy' => true], + ['name' => 'mp_cancel_scheduled_for', '_destroy' => true], + ], + $api->calls[1]['data']['custom_variables'] + ); + $this->assertFalse($resumed->cancelAtPeriodEnd); + } + + /** + * O prefixo `mp_` de `custom_variables` guarda o estado da emulação, então `metadata` com + * uma chave assim é recusado antes de qualquer requisição. + */ + public function testMetadataWithTheReservedPrefixIsRejected(): void + { + $api = new QueuedIuguApiRequest([]); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1']]); + $subscription->metadata = ['mp_canceled_at' => '2026-09-04']; + + try { + (new IuguGateway($api))->createSubscription($subscription); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('mp_ prefix', $e->getMessage()); + } + $this->assertCount(0, $api->calls); + } } diff --git a/tests/Unit/Gateways/IuguGatewaySyncSubscriptionsTest.php b/tests/Unit/Gateways/IuguGatewaySyncSubscriptionsTest.php new file mode 100644 index 0000000..5c3cbb9 --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewaySyncSubscriptionsTest.php @@ -0,0 +1,278 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => ['iugu' => ['api_key' => 'test-api-key', 'class' => IuguGateway::class]], + ], + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + private static function subscription(string $id, array $overrides = []): object + { + return (object) array_merge([ + 'id' => $id, + 'customer_id' => 'cus_1', + 'plan_identifier' => 'plano_mensal', + 'expires_at' => '2026-10-01', + 'active' => true, + 'suspended' => false, + 'in_trial' => false, + ], $overrides); + } + + private static function listResponse(array $items): object + { + return (object) ['totalItems' => count($items), 'items' => $items]; + } + + private static function expiredDiscountSubscription(): object + { + return self::subscription('sub_expirado', [ + 'subitems' => [(object) ['id' => 'si_d1', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true]], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-09-03']], + ]); + } + + private static function dueCancellationSubscription(): object + { + return self::subscription('sub_agendado', [ + 'custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-09-04'], + ], + ]); + } + + public function testSyncAppliesEachPendingEmulationAndSkipsWhatIsNotDue(): void + { + $api = new QueuedIuguApiRequest([ + self::listResponse([ + self::expiredDiscountSubscription(), + self::subscription('sub_vigente', [ + 'subitems' => [(object) ['id' => 'si_d2', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true]], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d2_until', 'value' => '2026-12-31']], + ]), + // validade inclusiva: o desconto que vale até hoje ainda não venceu + self::subscription('sub_no_limite', [ + 'subitems' => [(object) ['id' => 'si_d3', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true]], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d3_until', 'value' => '2026-09-04']], + ]), + self::subscription('sub_suspensa', [ + 'suspended' => true, + 'custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-09-01'], + ], + ]), + self::dueCancellationSubscription(), + self::subscription('sub_orfa', [ + 'custom_variables' => [(object) ['name' => 'mp_discount_si_x_until', 'value' => '2026-09-01']], + ]), + self::subscription('sub_futura', [ + 'custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-09-05'], + ], + ]), + ]), + self::subscription('sub_expirado'), + self::subscription('sub_agendado', ['suspended' => true]), + self::subscription('sub_agendado', ['suspended' => true]), + self::subscription('sub_orfa'), + ]); + + $actions = (new IuguGateway($api))->syncSubscriptions(); + + $this->assertSame( + [ + ['sub_expirado', 'remove_discount'], + ['sub_agendado', 'cancel'], + ['sub_orfa', 'remove_orphan_discount_variable'], + ], + array_map(static fn (array $action) => [$action['subscription'], $action['action']], $actions) + ); + + $this->assertCount(5, $api->calls); + $this->assertStringContainsString('limit=100', $api->calls[0]['url']); + $this->assertStringContainsString('start=0', $api->calls[0]['url']); + + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertStringEndsWith('/subscriptions/sub_expirado', $api->calls[1]['url']); + $this->assertSame([ + 'custom_variables' => [['name' => 'mp_discount_si_d1_until', '_destroy' => true]], + 'subitems' => [['id' => 'si_d1', '_destroy' => true]], + ], $api->calls[1]['data']); + + // a marca vai antes da suspensão, para uma falha no meio ser reprocessada na rodada + // seguinte (assinatura suspensa sem a marca seria pulada) + $this->assertSame('PUT', $api->calls[2]['method']); + $this->assertStringEndsWith('/subscriptions/sub_agendado', $api->calls[2]['url']); + $this->assertSame('mp_canceled_at', $api->calls[2]['data']['custom_variables'][0]['name']); + $this->assertSame('POST', $api->calls[3]['method']); + $this->assertStringEndsWith('/subscriptions/sub_agendado/suspend', $api->calls[3]['url']); + + $this->assertSame('PUT', $api->calls[4]['method']); + $this->assertStringEndsWith('/subscriptions/sub_orfa', $api->calls[4]['url']); + $this->assertSame( + ['custom_variables' => [['name' => 'mp_discount_si_x_until', '_destroy' => true]]], + $api->calls[4]['data'] + ); + } + + /** + * Sobre o estado que a primeira passada deixou (subitem e variável removidos, assinatura + * agendada suspensa com `mp_canceled_at`), a segunda passada não escreve nada. + */ + public function testSyncIsIdempotentOverTheResultingState(): void + { + $api = new QueuedIuguApiRequest([ + self::listResponse([ + self::subscription('sub_expirado'), + self::subscription('sub_agendado', [ + 'suspended' => true, + 'custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-09-04'], + (object) ['name' => 'mp_canceled_at', 'value' => '2026-09-04T12:00:00-03:00'], + ], + ]), + ]), + ]); + + $actions = (new IuguGateway($api))->syncSubscriptions(); + + $this->assertSame([], $actions); + $this->assertCount(1, $api->calls); + } + + public function testDryRunReportsTheActionsWithoutWriting(): void + { + $api = new QueuedIuguApiRequest([ + self::listResponse([self::expiredDiscountSubscription(), self::dueCancellationSubscription()]), + ]); + + $actions = (new IuguGateway($api))->syncSubscriptions(true); + + $this->assertCount(2, $actions); + $this->assertCount(1, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + } + + /** + * Uma página cheia dispara a leitura da página seguinte, e as assinaturas das duas são + * processadas. + */ + public function testSyncPagesThroughTheSubscriptions(): void + { + $fullPage = array_map( + static fn (int $i) => self::subscription("sub_{$i}"), + range(1, 100) + ); + + $api = new QueuedIuguApiRequest([ + self::listResponse($fullPage), + self::listResponse([self::expiredDiscountSubscription()]), + self::subscription('sub_expirado'), + ]); + + $actions = (new IuguGateway($api))->syncSubscriptions(); + + $this->assertCount(1, $actions); + $this->assertCount(3, $api->calls, 'a ação da segunda página precisa ser aplicada'); + $this->assertStringContainsString('start=0', $api->calls[0]['url']); + $this->assertStringContainsString('start=100', $api->calls[1]['url']); + $this->assertSame('PUT', $api->calls[2]['method']); + } + + /** + * Uma assinatura cuja escrita falha não derruba a varredura: o erro vai para o log e as + * assinaturas seguintes são sincronizadas. + */ + public function testAFailingSubscriptionDoesNotAbortTheSweep(): void + { + $app = Facade::getFacadeApplication(); + $app->instance('log', $logger = new \Potelo\MultiPayment\Tests\Unit\RecordingLogger()); + + $api = new QueuedIuguApiRequest([ + self::listResponse([ + self::expiredDiscountSubscription(), + self::dueCancellationSubscription(), + ]), + new \IuguObjectNotFound('not found'), + self::subscription('sub_agendado'), + self::subscription('sub_agendado', ['suspended' => true]), + ]); + + $actions = (new IuguGateway($api))->syncSubscriptions(); + + $this->assertSame(['sub_agendado'], array_column($actions, 'subscription')); + $this->assertCount(4, $api->calls, 'a segunda assinatura precisa ser sincronizada depois da falha'); + $failures = array_filter( + $logger->records, + static fn (array $record) => str_contains($record['message'], 'sub_expirado') + ); + $this->assertCount(1, $failures); + $this->assertSame('warning', array_values($failures)[0]['level']); + } + + /** + * Data de agendamento que não é uma data lê como sem agendamento, com aviso no log, e + * nada é aplicado. + */ + public function testAnUnreadableScheduledDateIsIgnoredWithAWarning(): void + { + $app = Facade::getFacadeApplication(); + $app->instance('log', $logger = new \Potelo\MultiPayment\Tests\Unit\RecordingLogger()); + + $api = new QueuedIuguApiRequest([ + self::listResponse([self::subscription('sub_ilegivel', [ + 'custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => 'amanha'], + ], + ])]), + ]); + + $actions = (new IuguGateway($api))->syncSubscriptions(); + + $this->assertSame([], $actions); + $this->assertCount(1, $api->calls); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertStringContainsString('mp_cancel_scheduled_for', $logger->records[0]['message']); + } +} diff --git a/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php index c03a5cb..2edc121 100644 --- a/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php +++ b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php @@ -17,6 +17,7 @@ use Potelo\MultiPayment\Models\InvoiceItem; use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Models\SubscriptionItem; +use Potelo\MultiPayment\Models\SubscriptionDiscount; use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Enums\PlanInterval; use Potelo\MultiPayment\Enums\PaymentMethod; @@ -544,6 +545,29 @@ function (StripeGateway $g, ?string $key) { 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi' => null, ], ], + 'createSubscription com desconto' => [ + function (StripeGateway $g, ?string $key) { + $subscription = self::subscriptionModel('pm_fake123'); + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $subscription->discounts = [$discount]; + + return $g->createSubscription($subscription, $key); + }, + [ + self::couponResponse(), + self::subscriptionFixture(), + self::stripeInvoiceFixture(), + self::fixture('payment_intents/paid'), + ], + [ + 'post /v1/coupons' => 'chave-1:discount0_coupon', + 'post /v1/subscriptions' => 'chave-1', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1' => null, + 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi' => null, + ], + ], 'updateSubscription' => [ function (StripeGateway $g, ?string $key) { $subscription = new Subscription(); @@ -555,6 +579,23 @@ function (StripeGateway $g, ?string $key) { [self::subscriptionFixture()], ['post /v1/subscriptions/sub_fake1' => 'chave-1'], ], + 'updateSubscription com desconto novo' => [ + function (StripeGateway $g, ?string $key) { + $subscription = new Subscription(); + $subscription->id = 'sub_fake1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $subscription->discounts = [$discount]; + + return $g->updateSubscription($subscription, $key); + }, + [self::couponResponse(), self::subscriptionFixture()], + [ + 'post /v1/coupons' => 'chave-1:discount0_coupon', + 'post /v1/subscriptions/sub_fake1' => 'chave-1', + ], + ], 'suspendSubscription' => [ fn (StripeGateway $g, ?string $key) => $g->suspendSubscription(self::subscriptionWithId(), $key), [self::subscriptionFixture()], @@ -696,6 +737,21 @@ private static function priceResponse(): array ]; } + private static function couponResponse(): array + { + return [ + 'id' => 'co_fake1', + 'object' => 'coupon', + 'amount_off' => 500, + 'currency' => 'brl', + 'duration' => 'forever', + 'name' => 'Promo', + 'valid' => true, + 'created' => 1786700000, + 'metadata' => [], + ]; + } + private static function subscriptionFixture(): array { return self::fixture('subscriptions/active'); diff --git a/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php b/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php index 2b0925f..a29f1d5 100644 --- a/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php @@ -13,6 +13,7 @@ use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Models\SubscriptionItem; +use Potelo\MultiPayment\Models\SubscriptionDiscount; use Potelo\MultiPayment\Gateways\StripeGateway; use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Enums\DeclineCode; @@ -33,7 +34,7 @@ */ class StripeGatewaySubscriptionTest extends TestCase { - private const SUBSCRIPTION_EXPAND = ['default_payment_method', 'items.data.price.product']; + private const SUBSCRIPTION_EXPAND = ['default_payment_method', 'discounts.source.coupon', 'items.data.price.product']; protected function setUp(): void { @@ -815,6 +816,320 @@ private function getSubscription(string $id): Subscription return (new StripeGateway())->getSubscription($subscription); } + public function testCreateSubscriptionWithADiscountCreatesTheCouponAndAppliesIt(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::couponResponse(), + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $subscription->discounts = [$discount]; + + (new StripeGateway())->createSubscription($subscription); + + $this->assertSame('post /v1/coupons', self::calledPaths($httpClient)[0]); + $this->assertSame( + ['name' => 'Promo', 'amount_off' => 500, 'currency' => 'brl', 'duration' => 'forever'], + $httpClient->calls[0][2] + ); + $this->assertSame([['coupon' => 'co_fake1']], $httpClient->calls[1][2]['discounts']); + } + + public function testDiscountWithOneCycleCreatesAOnceCouponWithPercentOff(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::couponResponse(['amount_off' => null, 'percent_off' => 10.0, 'duration' => 'once']), + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->percentOff = 10.0; + $discount->cycles = 1; + $subscription->discounts = [$discount]; + + (new StripeGateway())->createSubscription($subscription); + + $this->assertSame( + ['name' => 'Promo', 'percent_off' => 10.0, 'duration' => 'once'], + $httpClient->calls[0][2] + ); + } + + /** + * `cycles` acima de 1 vira `repeating` com os meses dos ciclos do plano, lido do Price + * quando o intervalo ainda não é conhecido. + */ + public function testDiscountCyclesBecomeTheMonthsOfTheMonthlyPlan(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceResponse(), + self::couponResponse(['duration' => 'repeating', 'duration_in_months' => 3]), + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->cycles = 3; + $subscription->discounts = [$discount]; + + (new StripeGateway())->createSubscription($subscription); + + $this->assertSame('get /v1/prices/price_fake1', self::calledPaths($httpClient)[0]); + $this->assertSame('repeating', $httpClient->calls[1][2]['duration']); + $this->assertSame(3, $httpClient->calls[1][2]['duration_in_months']); + } + + public function testDiscountValidUntilBecomesWholeMonthsRoundedUp(): void + { + Carbon::setTestNow('2026-09-04 12:00:00'); + try { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::couponResponse(['duration' => 'repeating', 'duration_in_months' => 3]), + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->validUntil = Carbon::parse('2026-11-10'); + $subscription->discounts = [$discount]; + + (new StripeGateway())->createSubscription($subscription); + + $this->assertSame('repeating', $httpClient->calls[0][2]['duration']); + $this->assertSame(3, $httpClient->calls[0][2]['duration_in_months']); + } finally { + Carbon::setTestNow(); + } + } + + /** + * O cupom da Stripe dura meses inteiros, então `cycles` acima de 1 num plano semanal não + * tem duração equivalente e é recusado antes de criar o cupom. + */ + public function testDiscountCyclesOnAWeeklyPlanAreRefused(): void + { + $weeklyPrice = self::priceResponse(); + $weeklyPrice['recurring']['interval'] = 'week'; + $httpClient = RecordingStripeHttpClient::withResponses([$weeklyPrice]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->cycles = 3; + $subscription->discounts = [$discount]; + + try { + (new StripeGateway())->createSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::COUPONS, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('meses inteiros', $e->getMessage()); + } + $this->assertSame(['get /v1/prices/price_fake1'], self::calledPaths($httpClient)); + } + + public function testGetSubscriptionParsesTheDiscounts(): void + { + $fixture = self::fixture('subscriptions/active'); + $once = self::discountResponse(['id' => 'co_fake2', 'duration' => 'once']); + $fixture['discounts'] = [ + self::discountResponse(['duration' => 'repeating', 'duration_in_months' => 3], 1796500000), + $once, + ]; + RecordingStripeHttpClient::withResponses([ + $fixture, + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $result = $this->getSubscription('sub_1UBJmkPjx0CusuMr3KQ2wXyZ'); + + $this->assertCount(2, $result->discounts); + $discount = $result->discounts[0]; + $this->assertSame('co_fake1', $discount->id); + $this->assertSame('Promo', $discount->description); + $this->assertSame(500, $discount->amountOff); + $this->assertNull($discount->percentOff); + $this->assertNull($discount->cycles); + $this->assertSame(1796500000, $discount->validUntil->getTimestamp()); + + $this->assertSame('co_fake2', $result->discounts[1]->id); + $this->assertSame(1, $result->discounts[1]->cycles); + $this->assertNull($result->discounts[1]->validUntil); + } + + /** + * Um discount que veio como id (sem expand) não tem o Coupon para ler: a lista fica nula, + * e um `save()` posterior não toca os descontos da assinatura. + */ + public function testUnexpandedDiscountsDoNotOverwriteTheList(): void + { + $fixture = self::fixture('subscriptions/active'); + $fixture['discounts'] = ['di_fake1']; + $httpClient = RecordingStripeHttpClient::withResponses([ + $fixture, + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + $fixture, + ]); + + $subscription = $this->getSubscription('sub_1UBJmkPjx0CusuMr3KQ2wXyZ'); + $this->assertNull($subscription->discounts); + + $subscription->items = null; + (new StripeGateway())->updateSubscription($subscription); + + $this->assertCount(4, $httpClient->calls); + $this->assertArrayNotHasKey('discounts', $httpClient->calls[3][2]); + } + + /** + * No update, os meses de `cycles` vêm do intervalo do plano lido em `original`, sem + * requisição extra ao Price. + */ + public function testUpdateDiscountCyclesReadThePlanIntervalFromTheOriginal(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + self::couponResponse(['duration' => 'repeating', 'duration_in_months' => 2]), + self::fixture('subscriptions/active'), + ]); + + $subscription = $this->getSubscription('sub_1UBJmkPjx0CusuMr3KQ2wXyZ'); + $subscription->items = null; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->cycles = 2; + $subscription->discounts = [$discount]; + + (new StripeGateway())->updateSubscription($subscription); + + $this->assertSame('post /v1/coupons', self::calledPaths($httpClient)[3]); + $this->assertSame(2, $httpClient->calls[3][2]['duration_in_months']); + $this->assertNotContains('get /v1/prices/price_fake1', self::calledPaths($httpClient)); + } + + public function testUpdateReplacesTheDiscounts(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::couponResponse(), + self::fixture('subscriptions/active'), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $subscription->discounts = [$discount]; + + (new StripeGateway())->updateSubscription($subscription); + + $this->assertSame([ + 'post /v1/coupons', + 'post /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ', + ], self::calledPaths($httpClient)); + $this->assertSame([['coupon' => 'co_fake1']], $httpClient->calls[1][2]['discounts']); + } + + public function testUpdateWithAnEmptyDiscountListClearsThem(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active'), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->discounts = []; + + (new StripeGateway())->updateSubscription($subscription); + + $this->assertSame('', $httpClient->calls[0][2]['discounts']); + } + + /** + * Um model lido do gateway traz os descontos com o id do Coupon; salvar sem mexer neles + * não recria cupom nem reenvia `discounts`. + */ + public function testUpdateKeepsTheDiscountsThatCameFromTheGateway(): void + { + $fixture = self::fixture('subscriptions/active'); + $fixture['discounts'] = [self::discountResponse()]; + $httpClient = RecordingStripeHttpClient::withResponses([ + $fixture, + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + $fixture, + ]); + + $subscription = $this->getSubscription('sub_1UBJmkPjx0CusuMr3KQ2wXyZ'); + $subscription->items = null; + + (new StripeGateway())->updateSubscription($subscription); + + $this->assertCount(4, $httpClient->calls); + $this->assertSame('post /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ', self::calledPaths($httpClient)[3]); + $this->assertArrayNotHasKey('discounts', $httpClient->calls[3][2]); + } + + private static function couponResponse(array $overrides = []): array + { + return array_filter(array_merge([ + 'id' => 'co_fake1', + 'object' => 'coupon', + 'amount_off' => 500, + 'currency' => 'brl', + 'percent_off' => null, + 'duration' => 'forever', + 'duration_in_months' => null, + 'name' => 'Promo', + 'valid' => true, + 'created' => 1786700000, + 'metadata' => [], + ], $overrides), static fn ($value) => !is_null($value)); + } + + private static function discountResponse(array $couponOverrides = [], ?int $end = null): array + { + return [ + 'id' => 'di_fake1', + 'object' => 'discount', + 'source' => ['coupon' => self::couponResponse($couponOverrides), 'type' => 'coupon'], + 'customer' => 'cus_VBen1v8T4Qa6XX', + 'subscription' => 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ', + 'start' => 1788565981, + 'end' => $end, + ]; + } + private static function subscriptionModel(): Subscription { $subscription = new Subscription(); diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php index 0bdb501..90564dd 100644 --- a/tests/Unit/SubscriptionTest.php +++ b/tests/Unit/SubscriptionTest.php @@ -332,6 +332,50 @@ public function testDiscountRejectsZeroCycles(): void $discount->validate(); } + public function testDiscountRejectsCyclesAndValidUntilTogether(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->cycles = 3; + $discount->validUntil = Carbon::parse('2026-12-31'); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/cycles and validUntil are mutually exclusive/'); + + $discount->validate(); + } + + public function testBuilderDiscountsAcceptAValidUntilDate(): void + { + $gateway = Mockery::mock(GatewayContract::class); + + $subscription = (new SubscriptionBuilder($gateway)) + ->addAmountDiscount('Promo', 300, null, Carbon::parse('2026-12-31')) + ->get(); + + $this->assertSame('2026-12-31', $subscription->discounts[0]->validUntil->format('Y-m-d')); + $this->assertNull($subscription->discounts[0]->cycles); + } + + public function testDiscountFillParsesValidUntil(): void + { + $discount = new SubscriptionDiscount(); + $discount->fill(['description' => 'Promo', 'amount_off' => 500, 'valid_until' => '2026-12-31']); + + $this->assertSame('2026-12-31', $discount->validUntil->format('Y-m-d')); + + $carbon = new SubscriptionDiscount(); + $carbon->fill(['valid_until' => Carbon::parse('2026-12-31 10:00:00')]); + + $this->assertSame('2026-12-31 10:00:00', $carbon->validUntil->format('Y-m-d H:i:s')); + + $empty = new SubscriptionDiscount(); + $empty->fill(['description' => 'Promo', 'valid_until' => '']); + + $this->assertNull($empty->validUntil); + } + public function testPlanRejectsUnknownIntervalOnWrite(): void { $plan = new Plan(); From abc1bd643bbbd53903b4eb06ea3c6f1bb796de5a Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Fri, 4 Sep 2026 22:52:25 -0300 Subject: [PATCH 31/32] feat(stripe): implementa boleto em venda avulsa e assinatura --- README.md | 60 +++- src/Gateways/StripeGateway.php | 295 ++++++++++++++++-- src/Models/Invoice.php | 5 +- tests/Integration/StripeGatewayTest.php | 54 +++- tests/Integration/StripeSubscriptionTest.php | 38 +++ tests/Unit/CapabilityGuardsTest.php | 45 +-- .../Unit/Gateways/GatewayCapabilitiesTest.php | 4 +- .../Gateways/StripeGatewayIdempotencyTest.php | 38 +++ .../Gateways/StripeGatewayInvoiceTest.php | 277 +++++++++++++++- .../StripeGatewaySubscriptionTest.php | 149 ++++++++- tests/fixtures/stripe/README.md | 17 +- .../invoices/open_boleto_send_invoice.json | 239 ++++++++++++++ .../boleto_requires_action.json | 69 ++++ .../active_send_invoice_boleto.json | 198 ++++++++++++ 14 files changed, 1392 insertions(+), 96 deletions(-) create mode 100644 tests/fixtures/stripe/invoices/open_boleto_send_invoice.json create mode 100644 tests/fixtures/stripe/payment_intents/boleto_requires_action.json create mode 100644 tests/fixtures/stripe/subscriptions/active_send_invoice_boleto.json diff --git a/README.md b/README.md index 9d34288..c9ea736 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ vez de capturar a exceção depois: use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Facades\MultiPayment; -if (!MultiPayment::gateway('stripe')->supports(Capability::BANK_SLIP)) { +if (!MultiPayment::gateway('stripe')->supports(Capability::AUTOMATIC_PIX)) { $gateway = 'iugu'; // roteia antes de exibir a opção } @@ -168,7 +168,7 @@ coluna "Restrições" é o que `restriction()` devolve para cada gateway. |---|---|---|---|---| | `CREDIT_CARD` | Fatura paga com cartão de crédito. | sim | sim | Stripe: Na conta brasileira só cartão de crédito Visa e Mastercard; outra bandeira é recusada na cobrança com DeclineCode::BRAND_NOT_SUPPORTED. | | `PIX` | Fatura paga com Pix avulso, com QR Code de pagamento único. | sim | sim | | -| `BANK_SLIP` | Fatura paga com boleto bancário. | sim | não implementado | | +| `BANK_SLIP` | Fatura paga com boleto bancário. | sim | sim | Stripe: A Stripe aceita boleto de R$ 5,00 a R$ 49.999,99, com vencimento de hoje a 60 dias; fora dessas janelas a criação é recusada antes da requisição. | | `AUTOMATIC_PIX` | Recorrência de Pix Automático criada junto com a fatura, com reagendamento e cancelamento pela lib. | sim | não implementado | | | `MULTIPLE_PAYMENT_METHODS` | Fatura aberta a mais de um método de pagamento, escolhido pelo pagador na hora de pagar. | sim | não implementado | | | `RAW_CARD_DATA` | Cartão informado com número e CVV pela API; sem ela, o cartão é tokenizado no navegador e só o token chega à lib. | sim | limitação do gateway | | @@ -178,8 +178,8 @@ coluna "Restrições" é o que `restriction()` devolve para cada gateway. | `PARTIAL_REFUND_CARD` | Estorno de parte do valor numa fatura paga com cartão. | sim | sim | | | `PARTIAL_REFUND_PIX` | Estorno de parte do valor numa fatura paga com Pix. | limitação do gateway | sim | | | `REFUND_BANK_SLIP` | Estorno pela API de uma fatura paga com boleto. | limitação do gateway | limitação do gateway | | -| `INVOICE_DUPLICATION` | Segunda via de uma fatura pendente com nova data de vencimento (`duplicateInvoice`). | sim | sim | Stripe: Só fatura Pix pendente de venda avulsa (PaymentIntent); cartão, outro estado ou fatura de assinatura são recusados. | -| `INVOICE_CANCELLATION` | Cancelamento de uma fatura ainda não paga (`cancelInvoice`). | sim | sim | Stripe: A fatura de assinatura (objeto Invoice) só é anulada depois de finalizada pela Stripe; rascunho é recusado. | +| `INVOICE_DUPLICATION` | Segunda via de uma fatura pendente com nova data de vencimento (`duplicateInvoice`). | sim | sim | Stripe: Só fatura Pix pendente de venda avulsa (PaymentIntent); cartão, boleto, outro estado ou fatura de assinatura são recusados. | +| `INVOICE_CANCELLATION` | Cancelamento de uma fatura ainda não paga (`cancelInvoice`). | sim | sim | Stripe: A fatura de assinatura (objeto Invoice) só é anulada depois de finalizada pela Stripe (rascunho é recusado), e o boleto pendente só depois de o voucher vencer. | | `IDEMPOTENCY` | Chave de idempotência (`idempotencyKey`) honrada em toda operação de escrita, pelo gateway ou pela deduplicação da lib (`IdempotencyStore`). | sim | sim | | | `IDEMPOTENCY_ALL_ENDPOINTS` | Chave de idempotência honrada pelo próprio gateway em toda operação de escrita, sem depender da deduplicação da lib. | limitação do gateway | sim | | | `SUBSCRIPTIONS` | Assinatura recorrente: criar, buscar, atualizar, suspender, retomar, cancelar, trocar de plano e listar. | sim | sim | Stripe: nextBillingAt vale só na criação da assinatura; na troca de plano e na atualização a Stripe não aceita uma data arbitrária de próxima cobrança. | @@ -196,8 +196,9 @@ Sobre as restrições e algumas células: - **Operação fora da restrição** lança `UnsupportedOperationException::restricted()`, com a capability, `reason` `gateway_limitation` e a mensagem que descreve a restrição: duplicação - fora de Pix pendente e cancelamento de rascunho de fatura de assinatura no Stripe, cartão que - pertence a outro cliente no Stripe (ver [Particularidades do Stripe](#particularidades-do-stripe)). + fora de Pix pendente, cancelamento de rascunho de fatura de assinatura ou de boleto com + voucher em aberto no Stripe, cartão que pertence a outro cliente no Stripe (ver + [Particularidades do Stripe](#particularidades-do-stripe)). A bandeira fora da restrição de `CREDIT_CARD` chega depois, na cobrança, como `CardDeclinedException` com `DeclineCode::BRAND_NOT_SUPPORTED`; por isso vale consultar `restriction()->allowsBrand()` antes de tokenizar. @@ -443,10 +444,29 @@ recusado na validação, porque a fatura com Pix Automático é criada com `PIX` - **Pix expirado continua pendente e re-cobrável.** Na Iugu, fatura expirada vira `canceled`; no Stripe ela volta a aguardar pagamento (`pending`) e pode ser paga com cartão via `chargeInvoiceWithCreditCard` ou duplicada com `duplicateInvoice` (nova expiração; - a original é cancelada). Só fatura Pix pendente de venda avulsa é duplicável: cartão, fatura - em outro estado ou fatura de assinatura lança `UnsupportedOperationException` + a original é cancelada). Só fatura Pix pendente de venda avulsa é duplicável: cartão, boleto, + fatura em outro estado ou fatura de assinatura lança `UnsupportedOperationException` (`INVOICE_DUPLICATION`, `gateway_limitation`); `restriction(Capability::INVOICE_DUPLICATION)` publica a regra. +- **Boleto exige `tax_document`, nome, e-mail e endereço do cliente.** O CPF/CNPJ vai no + voucher (`boleto.tax_id`) e os demais nos billing details (a sandbox aceita boleto sem + esses dados, a produção os exige); a falta de qualquer um, inclusive de rua, cidade, estado + ou CEP no endereço, lança `ModelAttributeValidationException` antes da rede. O valor deve + ficar entre R$ 5,00 e + R$ 49.999,99 e o vencimento (`dueDate`) de hoje a 60 dias, também validados antes da + requisição; sem `dueDate`, vale o prazo padrão da conta na Stripe (3 dias). A fatura volta + pendente com a página hospedada do voucher em `url`, a linha digitável em `bankSlip->number` + e o PDF em `bankSlip->url` (a Stripe só publica o número, então `barcode_data` e + `barcode_image` ficam vazios). A compensação leva até um dia útil depois do pagamento; + acompanhe por `getInvoice()`. +- **Boleto pendente não pode ser cancelado nem re-cobrado.** A Stripe não invalida o voucher + antes do vencimento: `cancelInvoice()` numa fatura com o voucher em aberto é recusado + (`UnsupportedOperationException` restrita de `INVOICE_CANCELLATION` quando o model traz o + voucher; `ValidationException` quando a recusa vem do gateway), e + `chargeInvoiceWithCreditCard()` também é recusado pelo gateway enquanto o voucher vale. + Vencido o voucher, a fatura volta a aguardar pagamento (`pending`) e pode ser cancelada ou + cobrada com cartão. Estorno de boleto fica fora da API nos dois gateways + (`REFUND_BANK_SLIP`). - **Cartão pertence a um único cliente.** Cobrar, buscar ou excluir um `pm_` informando outro cliente lança `UnsupportedOperationException` (`CREDIT_CARD`, `gateway_limitation`) antes da operação. @@ -875,8 +895,8 @@ o deixava nulo, traz o valor de `declineCode`. Compare com `declineCode`. > `GatewayNotAvailableException` deixa de repetir credencial errada; quem capturava > `GatewayException` para chave recusada na Iugu precisa capturar `AuthenticationException`. > -> Na mesma versão, operação não suportada ou ainda não implementada (boleto, Pix Automático, -> assinatura e plano no Stripe; cancelamento ao fim do período, desconto percentual e desativação +> Na mesma versão, operação não suportada ou ainda não implementada (Pix Automático no Stripe; +> desconto percentual e desativação > de plano na Iugu; cartão com dados crus e duplicação fora de Pix pendente no Stripe) deixou de > chegar como `GatewayException` (ou `GatewayException::methodNotFound`) e passou a lançar > `UnsupportedOperationException`, que herda de `MultiPaymentException`. Um @@ -955,15 +975,19 @@ $pix = $payment->newInvoice() $pix->pix->qrCodeText; $boleto = $payment->newInvoice() - ->setPaymentMethod(PaymentMethod::BANK_SLIP) // no Stripe: UnsupportedOperationException, antes da rede + ->setPaymentMethod(PaymentMethod::BANK_SLIP) ->addCustomer('Nome do cliente', 'email@example.com', '20176996915') ->addCustomerAddress('41820330', 'Rua', '123', null, 'Bairro', 'Salvador', 'BA') ->addItem('Mensalidade', 10000, 1) ->setDueDate(today()->addDays(3)) // vencimento ->create(); -$boleto->bankSlip->number; +$boleto->bankSlip->number; // linha digitável nos dois gateways ``` +No Stripe o boleto exige CPF/CNPJ e endereço do cliente, aceita valores de R$ 5,00 a +R$ 49.999,99 e vencimento de hoje a 60 dias, tudo validado antes da requisição (ver +[Particularidades do Stripe](#particularidades-do-stripe)). + `setPaymentMethod()` decide como a fatura é criada quando `availablePaymentMethods` fica vazia; `setAvailablePaymentMethods([...])` (ou `addAvailablePaymentMethod()`) abre a fatura a mais de um método na Iugu e tem precedência sobre `setPaymentMethod()`, que então precisa constar da @@ -987,7 +1011,7 @@ alternativa por array está em [charge](#charge-alternativa-por-array). | Propriedade | Array / builder | Iugu | Stripe | |---|---|---|---| -| `dueDate` | `due_date` / `setDueDate()` | `due_date` (o dia; a fatura vencida continua pagável) | `due_date` da fatura de assinatura; na venda avulsa por Pix sem `pixExpiresAt`, o fim desse dia vira a expiração do QR Code | +| `dueDate` | `due_date` / `setDueDate()` | `due_date` (o dia; a fatura vencida continua pagável) | `due_date` da fatura de assinatura; no boleto avulso vira `expires_after_days` (de hoje a 60 dias) e a leitura devolve o instante em que o voucher vence; na venda avulsa por Pix sem `pixExpiresAt`, o fim desse dia vira a expiração do QR Code | | `pixExpiresAt` | `pix_expires_at` / `setPixExpiresAt()` | `pix_qr_code_expires_at` (ISO 8601); sem `dueDate`, o vencimento é o dia em que o QR Code expira; a leitura só o preenche quando a fatura o devolve | `payment_method_options.pix.expires_at`, entre 10 segundos e 14 dias no futuro (sem ele, a Stripe usa 4 horas); volta na leitura | As duas aceitam `Carbon` (ou `CarbonImmutable`) e, no array, string em `Y-m-d` ou ISO 8601 com @@ -1417,8 +1441,14 @@ Particularidades do Stripe: `default_incomplete`, status `PENDING`): `latestInvoice` traz a fatura para o pagador quitar (`url` é a página hospedada). O método `pix` em assinatura depende de habilitação na conta Stripe; sem ela, a criação é recusada pelo gateway com `ValidationException` - ("The payment method type `pix` is invalid"). Boleto em assinatura ainda não está - implementado nesta lib. + ("The payment method type `pix` is invalid"). +- **Com boleto, a assinatura nasce ativa em modo de fatura enviada** (`collection_method` + `send_invoice`, com `days_until_due` de 3 dias, sobrescritível por + `gateway_options['days_until_due']`): a primeira fatura é finalizada na criação e volta em + `latestInvoice` aberta, com vencimento e com a página hospedada em `url`, onde o pagador + gera o voucher; as faturas dos ciclos seguintes são emitidas pela Stripe com o mesmo prazo. + A troca de método de uma assinatura existente para boleto muda o modo de cobrança para + `send_invoice`, e a troca de boleto para cartão ou Pix devolve a cobrança automática. - **Itens extras criam Prices no Stripe.** Cada `SubscriptionItem` vira um subscription item com um Product e um Price próprios, criados na hora (o item precisa de `description` e `amount`); item com `recurring` falso vai como item avulso da primeira fatura. No update diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 5fce9a3..d90121f 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -27,6 +27,7 @@ use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Refund; use Potelo\MultiPayment\Models\Address; +use Potelo\MultiPayment\Models\BankSlip; use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\InvoiceItem; @@ -124,6 +125,22 @@ class StripeGateway implements GatewayContract, SubscriptionContract, PlanContra /** Expand na leitura ou criação de um Price: o Product dá nome e identificador ao plano. */ private const PRICE_EXPAND = ['product']; + /** Valor mínimo de um boleto na Stripe, em centavos (R$ 5,00). */ + private const BOLETO_MIN_AMOUNT = 500; + + /** Valor máximo de um boleto na Stripe, em centavos (R$ 49.999,99). */ + private const BOLETO_MAX_AMOUNT = 4999999; + + /** Prazo máximo de vencimento de um boleto na Stripe, em dias corridos a partir de hoje. */ + private const BOLETO_MAX_EXPIRES_AFTER_DAYS = 60; + + /** + * Prazo de pagamento (`days_until_due`) da fatura de assinatura cobrada por boleto, + * alinhado ao vencimento padrão do voucher na Stripe (3 dias). Sobrescritível por + * `gatewayOptions['days_until_due']`. + */ + private const BOLETO_DAYS_UNTIL_DUE = 3; + /** Tipo de InvoicePayment cujo pagamento é um PaymentIntent. */ private const INVOICE_PAYMENT_TYPE_PAYMENT_INTENT = 'payment_intent'; @@ -189,6 +206,7 @@ public function capabilities(): array return [ Capability::CREDIT_CARD, Capability::PIX, + Capability::BANK_SLIP, Capability::CARD_SETUP_AUTHENTICATION, Capability::PARTIAL_REFUND_CARD, Capability::PARTIAL_REFUND_PIX, @@ -213,7 +231,6 @@ public function capabilities(): array public function notYetImplemented(): array { return [ - Capability::BANK_SLIP, Capability::AUTOMATIC_PIX, Capability::MULTIPLE_PAYMENT_METHODS, Capability::DELAYED_CAPTURE, @@ -224,9 +241,11 @@ public function notYetImplemented(): array * @inheritDoc * * `CREDIT_CARD`: a conta brasileira só aceita crédito Visa e Mastercard, e outra bandeira - * é recusada na cobrança com `DeclineCode::BRAND_NOT_SUPPORTED`. `INVOICE_DUPLICATION`: - * só fatura Pix pendente de venda avulsa. `INVOICE_CANCELLATION`: a fatura de assinatura - * (`in_`) só é anulada depois de finalizada pela Stripe; rascunho é recusado. + * é recusada na cobrança com `DeclineCode::BRAND_NOT_SUPPORTED`. `BANK_SLIP`: valor entre + * R$ 5,00 e R$ 49.999,99 e vencimento em até 60 dias, validados antes da requisição. + * `INVOICE_DUPLICATION`: só fatura Pix pendente de venda avulsa. `INVOICE_CANCELLATION`: a + * fatura de assinatura (`in_`) só é anulada depois de finalizada pela Stripe (rascunho é + * recusado), e o boleto pendente só depois de o voucher vencer. * `SUBSCRIPTIONS`: a Stripe só aceita `nextBillingAt` na criação da assinatura; na troca de * plano e na atualização a data da próxima cobrança segue o ciclo. `COUPONS`: o cupom da * Stripe dura meses inteiros (`duration_in_months`), então `cycles` maior que 1 exige plano @@ -246,14 +265,19 @@ public function restrictions(): array . ' recusada na cobrança com DeclineCode::BRAND_NOT_SUPPORTED.', allowedBrands: ['visa', 'mastercard'], ), + Capability::BANK_SLIP->value => new CapabilityRestriction( + description: 'A Stripe aceita boleto de R$ 5,00 a R$ 49.999,99, com vencimento de hoje a' + . ' 60 dias; fora dessas janelas a criação é recusada antes da requisição.', + ), Capability::INVOICE_DUPLICATION->value => new CapabilityRestriction( - description: 'Só fatura Pix pendente de venda avulsa (PaymentIntent); cartão, outro estado' - . ' ou fatura de assinatura são recusados.', + description: 'Só fatura Pix pendente de venda avulsa (PaymentIntent); cartão, boleto,' + . ' outro estado ou fatura de assinatura são recusados.', allowedPaymentMethods: [PaymentMethod::PIX], ), Capability::INVOICE_CANCELLATION->value => new CapabilityRestriction( description: 'A fatura de assinatura (objeto Invoice) só é anulada depois de finalizada' - . ' pela Stripe; rascunho é recusado.', + . ' pela Stripe (rascunho é recusado), e o boleto pendente só depois de o voucher' + . ' vencer.', ), Capability::SUBSCRIPTIONS->value => new CapabilityRestriction( description: 'nextBillingAt vale só na criação da assinatura; na troca de plano e na' @@ -810,6 +834,7 @@ public function createInvoice(Invoice $invoice, ?string $idempotencyKey = null): return match ($paymentMethod) { PaymentMethod::CREDIT_CARD => $this->createCreditCardInvoice($invoice, $idempotencyKey), PaymentMethod::PIX => $this->createPixInvoice($invoice, $idempotencyKey), + PaymentMethod::BANK_SLIP => $this->createBankSlipInvoice($invoice, $idempotencyKey), default => throw UnsupportedOperationException::forGateway($this, Capability::forPaymentMethod($paymentMethod)), }; } @@ -975,6 +1000,120 @@ private function pixExpiresAt(Invoice $invoice): ?Carbon return $expiresAt; } + /** + * Cria e confirma um PaymentIntent de boleto 100% server-side. A fatura volta pendente com + * o voucher em `next_action.boleto_display_details`: `Invoice::$url` é a página hospedada, + * `bankSlip->number` é a linha digitável e `bankSlip->url` é o PDF. O pagamento é + * assíncrono (a compensação leva até um dia útil; acompanhar via `getInvoice()`). + * + * A Stripe exige CPF/CNPJ (`boleto.tax_id`), nome, e-mail e endereço completo do pagador + * (`billing_details`), valor entre R$ 5,00 e R$ 49.999,99 e vencimento + * (`expires_after_days`, derivado de `dueDate`) de hoje a 60 dias; tudo é validado antes + * da requisição. Sem `dueDate`, vale o prazo padrão da conta na Stripe (3 dias). + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @param string|null $idempotencyKey + * @return \Potelo\MultiPayment\Models\Invoice + * @throws GatewayException|ModelAttributeValidationException + */ + private function createBankSlipInvoice(Invoice $invoice, ?string $idempotencyKey): Invoice + { + // a sandbox aceita boleto sem os dados do pagador, mas a produção exige documento, + // nome, e-mail e endereço completo; falhar cedo evita um erro obscuro da API + if (empty($invoice->customer) || empty($invoice->customer->taxDocument)) { + throw ModelAttributeValidationException::required('Customer', 'taxDocument'); + } + foreach (['name', 'email'] as $attribute) { + if (empty($invoice->customer->{$attribute})) { + throw ModelAttributeValidationException::required('Customer', $attribute); + } + } + $address = $invoice->customer->address; + if (empty($address) || empty($address->street) || empty($address->city) + || empty($address->state) || empty($address->zipCode)) { + throw ModelAttributeValidationException::required('Customer', 'address (street, city, state and zipCode)'); + } + + $stripePaymentIntentData = $this->invoiceToStripeData($invoice); + $amount = $stripePaymentIntentData['amount']; + if ($amount < self::BOLETO_MIN_AMOUNT || $amount > self::BOLETO_MAX_AMOUNT) { + throw ModelAttributeValidationException::invalid( + 'Invoice', + 'amount', + 'amount must be between ' . self::BOLETO_MIN_AMOUNT . ' and ' . self::BOLETO_MAX_AMOUNT + . ' cents for bank slip invoices on the stripe gateway' + ); + } + + $stripePaymentIntentData['payment_method_types'] = ['boleto']; + $stripePaymentIntentData['payment_method_data'] = [ + 'type' => 'boleto', + 'boleto' => ['tax_id' => $invoice->customer->taxDocument], + 'billing_details' => array_filter([ + 'name' => $invoice->customer->name, + 'email' => $invoice->customer->email, + 'address' => array_filter([ + 'line1' => trim(($address->street ?? '') . ', ' . ($address->number ?: 'S/N'), ', '), + 'line2' => $address->complement, + 'city' => $address->city, + 'state' => $address->state, + 'postal_code' => $address->zipCode, + // a Stripe exige o código ISO de duas letras, e o boleto é só do Brasil + 'country' => 'BR', + ], static fn ($value) => !is_null($value) && $value !== ''), + ]), + ]; + $stripePaymentIntentData['confirm'] = true; + $expiresAfterDays = $this->boletoExpiresAfterDays($invoice); + if (!is_null($expiresAfterDays)) { + $stripePaymentIntentData['payment_method_options']['boleto']['expires_after_days'] = $expiresAfterDays; + } + $stripePaymentIntentData = $this->mergeGatewayOptions($stripePaymentIntentData, $invoice); + + $stripePaymentIntent = $this->stripeRequest(function () use ($stripePaymentIntentData, $idempotencyKey) { + return $this->client->paymentIntents->create( + $this->withExpand($stripePaymentIntentData, self::PAYMENT_INTENT_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseInvoice($stripePaymentIntent, $invoice); + } + + /** + * Dias corridos até o vencimento do boleto (`expires_after_days`), derivados de `dueDate`: + * zero vence hoje às 23h59 (fuso de São Paulo) e o teto da Stripe é 60. Nulo quando a + * fatura não informa `dueDate` (vale o prazo padrão da conta). Vencimento no passado ou + * além do teto é recusado antes da requisição. A contagem parte da data corrente em São + * Paulo, o fuso em que a Stripe vira o dia do boleto, e trata `dueDate` como a data de + * calendário que o consumidor informou, qualquer que seja o fuso dela. + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @return int|null + * @throws ModelAttributeValidationException + */ + private function boletoExpiresAfterDays(Invoice $invoice): ?int + { + if (empty($invoice->dueDate)) { + return null; + } + + $days = Carbon::now('America/Sao_Paulo')->startOfDay()->diffInDays( + Carbon::parse($invoice->dueDate->format('Y-m-d'), 'America/Sao_Paulo'), + false + ); + if ($days < 0 || $days > self::BOLETO_MAX_EXPIRES_AFTER_DAYS) { + throw ModelAttributeValidationException::invalid( + 'Invoice', + 'dueDate', + 'dueDate must be between today and ' . self::BOLETO_MAX_EXPIRES_AFTER_DAYS + . ' days in the future for bank slip invoices on the stripe gateway' + ); + } + + return (int) $days; + } + /** * Recupera o CPF/CNPJ dos billing_details do PaymentMethod de um PaymentIntent pix — * o PaymentMethod pode já ter sido consumido (pix expirado), sobrando só a cópia @@ -1460,9 +1599,12 @@ private function parseFromPaymentIntent(StripePaymentIntent $stripePaymentIntent $this->parseCardDetails($invoice, $stripeCharge); - // sem next_action de pix não há QR utilizável: a página de instruções some junto, - // inclusive num model reutilizado (ex.: fatura pix expirada re-cobrada com cartão) - $invoice->url = $this->parsePixDisplay($invoice, $stripePaymentIntent); + // sem next_action não há QR nem voucher utilizável: a página hospedada some junto, + // inclusive num model reutilizado (ex.: fatura pix expirada re-cobrada com cartão); + // os dois parses rodam sempre, para limpar o que sobrou do outro método + $pixUrl = $this->parsePixDisplay($invoice, $stripePaymentIntent); + $boletoUrl = $this->parseBoletoDisplay($invoice, $stripePaymentIntent); + $invoice->url = $pixUrl ?? $boletoUrl; return $invoice; } @@ -1531,6 +1673,7 @@ private function parseFromStripeInvoice(StripeInvoice $stripeInvoice, ?Invoice $ $this->parseCardDetails($invoice, $stripeCharge); $this->parsePixDisplay($invoice, $stripePaymentIntent); + $this->parseBoletoDisplay($invoice, $stripePaymentIntent); return $invoice; } @@ -1746,6 +1889,40 @@ private function parsePixDisplay(Invoice $invoice, ?StripePaymentIntent $stripeP return $qrCode->hosted_instructions_url ?? null; } + /** + * Preenche `bankSlip` (linha digitável em `number`, PDF em `url`) a partir de + * `next_action.boleto_display_details` do PaymentIntent e devolve a página hospedada do + * voucher; `dueDate`, quando vazio, recebe o instante em que o voucher vence. Sem voucher + * (boleto pago, vencido ou outro método), limpa `bankSlip` e devolve nulo. + * + * @param Invoice $invoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @return string|null + */ + private function parseBoletoDisplay(Invoice $invoice, ?StripePaymentIntent $stripePaymentIntent): ?string + { + // isset() passa pelo __isset: um next_action de outro tipo não tem a chave e o + // StripeObject registraria "Undefined property" no log ao lê-la + $nextAction = $stripePaymentIntent?->next_action; + $voucher = isset($nextAction->boleto_display_details) ? $nextAction->boleto_display_details : null; + if (empty($voucher)) { + $invoice->bankSlip = null; + + return null; + } + + if (empty($invoice->bankSlip)) { + $invoice->bankSlip = new BankSlip(); + } + $invoice->bankSlip->number = $voucher->number ?? null; + $invoice->bankSlip->url = $voucher->pdf ?? null; + if (empty($invoice->dueDate) && !empty($voucher->expires_at)) { + $invoice->dueDate = Carbon::createFromTimestamp($voucher->expires_at); + } + + return $voucher->hosted_voucher_url ?? null; + } + /** * Monta a lista de estornos da fatura a partir de `refunds` do charge pago, um `Refund` * por estorno. Sem charge pago ou sem estorno a lista é vazia. Numa resposta em que a @@ -2152,10 +2329,14 @@ public function duplicateInvoice( * @inheritDoc * * Na origem `PAYMENT_INTENT` cancela o PaymentIntent; na origem `INVOICE` (id `in_`) anula o - * Invoice da Stripe (`void`), e a Stripe cancela sozinha o PaymentIntent padrão dele. A - * chave de idempotência vai no cabeçalho `Idempotency-Key` do cancelamento. + * Invoice da Stripe (`void`), e a Stripe cancela sozinha o PaymentIntent padrão dele. O + * boleto com voucher em aberto não pode ser cancelado na Stripe: quando o model traz o + * voucher (`bankSlip` numa fatura pendente), a recusa acontece antes da requisição; sem + * ele, a recusa da Stripe chega como `ValidationException`. Depois que o voucher vence, a + * fatura volta a ser cancelável. A chave de idempotência vai no cabeçalho + * `Idempotency-Key` do cancelamento. * - * @throws ModelAttributeValidationException + * @throws ModelAttributeValidationException|UnsupportedOperationException */ public function cancelInvoice(Invoice $invoice, ?string $idempotencyKey = null): Invoice { @@ -2168,6 +2349,17 @@ public function cancelInvoice(Invoice $invoice, ?string $idempotencyKey = null): return $this->voidStripeInvoice($invoice, $idempotencyKey); } + if ($invoice->paymentMethod === PaymentMethod::BANK_SLIP + && $invoice->status === InvoiceStatus::PENDING + && !empty($invoice->bankSlip)) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::INVOICE_CANCELLATION, + "O boleto pendente [{$invoice->id}] não pode ser cancelado na Stripe enquanto o voucher" + . ' não vence; aguarde o vencimento (a fatura volta a ser cancelável) ou o pagamento.' + ); + } + // só estados não-terminais são canceláveis; PaymentIntent pago recusa o cancel // com payment_intent_unexpected_state (vira GatewayException) $stripePaymentIntent = $this->stripeRequest(function () use ($invoice, $idempotencyKey) { @@ -2828,8 +3020,11 @@ private function stripeListPage(callable $fetch, array $params, int $page): arra * cobrada na criação e a recusa sobe como `ChargingException` (`payment_behavior` * `error_if_incomplete`); com Pix a assinatura nasce com a primeira fatura em aberto até o * pagamento (`default_incomplete`), lida em `latestInvoice`, com a página hospedada em - * `url` para o pagador quitar. Boleto em - * assinatura ainda não está implementado neste driver. Cada desconto de `discounts` vira + * `url` para o pagador quitar. Com boleto a assinatura nasce ativa em modo de fatura + * enviada (`send_invoice`, com `days_until_due` de 3 dias, sobrescritível por + * `gatewayOptions['days_until_due']`): a primeira fatura é finalizada na hora e volta em + * `latestInvoice` aberta, com a página hospedada em `url`, onde o pagador gera o voucher; + * as faturas dos ciclos seguintes seguem o mesmo prazo. Cada desconto de `discounts` vira * um Coupon criado na hora e aplicado à assinatura: `cycles` 1 é `duration` `once`, mais * de um ciclo ou `validUntil` viram `repeating` com `duration_in_months`, e desconto sem * prazo é `forever`. Os dias de `trialDays` vão como @@ -2840,7 +3035,7 @@ private function stripeListPage(callable $fetch, array $params, int $page): arra * A chave de idempotência vai no cabeçalho `Idempotency-Key` da criação; as requisições * secundárias usam derivadas (`{chave}:card` no cartão salvo antes, * `{chave}:item{N}_product` no Product de cada item extra, `{chave}:discount{N}_coupon` - * no Coupon de cada desconto). + * no Coupon de cada desconto, `{chave}:finalize` na finalização da fatura de boleto). * * @throws ChargingException|NotFoundException|UnsupportedOperationException */ @@ -2861,9 +3056,19 @@ public function createSubscription(Subscription $subscription, ?string $idempote $stripeSubscriptionData = [ 'customer' => $subscription->customer->id, 'items' => [['price' => $priceId]], - 'collection_method' => 'charge_automatically', - 'payment_behavior' => $paymentMethod === PaymentMethod::PIX ? 'default_incomplete' : 'error_if_incomplete', ]; + if ($paymentMethod === PaymentMethod::BANK_SLIP) { + // boleto é assíncrono demais para a cobrança automática na criação (a janela de + // 23 horas de `incomplete` venceria antes da compensação): a assinatura nasce + // ativa e cada ciclo emite uma fatura em aberto com prazo de pagamento + $stripeSubscriptionData['collection_method'] = 'send_invoice'; + $stripeSubscriptionData['days_until_due'] = self::BOLETO_DAYS_UNTIL_DUE; + } else { + $stripeSubscriptionData['collection_method'] = 'charge_automatically'; + $stripeSubscriptionData['payment_behavior'] = $paymentMethod === PaymentMethod::PIX + ? 'default_incomplete' + : 'error_if_incomplete'; + } if (!is_null($paymentMethod)) { $stripeSubscriptionData['payment_settings'] = [ @@ -2915,6 +3120,10 @@ public function createSubscription(Subscription $subscription, ?string $idempote ); }); + if ($paymentMethod === PaymentMethod::BANK_SLIP) { + $this->finalizeFirstBoletoInvoice($stripeSubscription, self::derivedIdempotencyKey($idempotencyKey, 'finalize')); + } + if (!is_null($trialDays)) { $subscription->trialDays = null; } @@ -2922,6 +3131,35 @@ public function createSubscription(Subscription $subscription, ?string $idempote return $this->parseStripeSubscription($stripeSubscription, $subscription, true); } + /** + * Finaliza a primeira fatura de uma assinatura de boleto: no modo `send_invoice` ela nasce + * rascunho, sem página hospedada, e a Stripe só a finalizaria sozinha cerca de uma hora + * depois. Sem fatura na assinatura (trial), o método não faz nada. Num retry com a mesma + * chave de idempotência a Stripe repete a finalização original. + * + * @param \Stripe\Subscription $stripeSubscription + * @param string|null $idempotencyKey + * @return void + * @throws GatewayException|GatewayNotAvailableException + */ + private function finalizeFirstBoletoInvoice(StripeSubscription $stripeSubscription, ?string $idempotencyKey): void + { + $latestInvoiceId = is_object($stripeSubscription->latest_invoice ?? null) + ? $stripeSubscription->latest_invoice->id + : ($stripeSubscription->latest_invoice ?? null); + if (empty($latestInvoiceId)) { + return; + } + + $this->stripeRequest(function () use ($latestInvoiceId, $idempotencyKey) { + return $this->client->invoices->finalizeInvoice( + $latestInvoiceId, + [], + self::stripeOptions($idempotencyKey) + ); + }); + } + /** * @inheritDoc * @@ -2948,7 +3186,9 @@ public function getSubscription(Subscription $subscription): Subscription * @inheritDoc * * Escreve o cartão (`default_payment_method`), o método de pagamento - * (`payment_settings`), o trial (`trial_end`; os dias de `trialDays` viram a data agora), + * (`payment_settings`; a troca para boleto muda a assinatura para o modo de fatura enviada + * com prazo de 3 dias, e a troca de boleto para outro método a devolve à cobrança + * automática), o trial (`trial_end`; os dias de `trialDays` viram a data agora), * `metadata` e os itens. `nextBillingAt` diferente do que veio do gateway é recusado: a * Stripe não aceita mudar a data da próxima cobrança fora do ciclo. Nos itens, item novo * cria Price sob demanda, item com `id` tem a quantidade atualizada e mantém o Price, e a @@ -2982,6 +3222,17 @@ public function updateSubscription(Subscription $subscription, ?string $idempote $data['payment_settings'] = [ 'payment_method_types' => [self::paymentMethodToStripeType($paymentMethod)], ]; + // a troca de método muda também o modo de cobrança: boleto exige fatura enviada + // com prazo (send_invoice), os demais cobram automaticamente. O modo vai sempre + // que o método muda, sem depender do estado lido do gateway: num model fresco + // (só o id) o driver não sabe o modo atual, e reescrever o mesmo modo na Stripe + // não tem efeito + if ($paymentMethod === PaymentMethod::BANK_SLIP) { + $data['collection_method'] = 'send_invoice'; + $data['days_until_due'] = self::BOLETO_DAYS_UNTIL_DUE; + } else { + $data['collection_method'] = 'charge_automatically'; + } } // a atualização só aceita a data do fim do trial, então os dias viram a data agora @@ -3289,8 +3540,8 @@ public function listSubscriptions(Customer $customer, int $page = 1, int $limit /** * Resolve o único método de pagamento da assinatura, como `invoicePaymentMethod()` faz * para a fatura; nulo quando o model não aponta método (a Stripe cobra o método padrão do - * cliente). Mais de um método é recusado (`MULTIPLE_PAYMENT_METHODS`), e um método que o - * driver ainda não cobre em assinatura (boleto) também (`BANK_SLIP`). + * cliente). Mais de um método é recusado (`MULTIPLE_PAYMENT_METHODS`), e um método fora + * do mapa do driver é recusado pela capability dele. * * @param Subscription $subscription * @return PaymentMethod|null @@ -3309,7 +3560,7 @@ private function subscriptionPaymentMethod(Subscription $subscription): ?Payment } $method = empty($methods) ? null : reset($methods); - if (!is_null($method) && $method !== PaymentMethod::CREDIT_CARD && $method !== PaymentMethod::PIX) { + if (!is_null($method) && !in_array($method, self::PAYMENT_METHOD_TYPES, true)) { throw UnsupportedOperationException::forGateway($this, Capability::forPaymentMethod($method)); } diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index b2995bd..8f71c5a 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -172,8 +172,9 @@ class Invoice extends Model /** * Data de vencimento da fatura. Na Iugu é o `due_date` (o dia; a fatura vencida continua - * pagável); no Stripe é o `due_date` da fatura de assinatura e, na venda avulsa por Pix sem - * `pixExpiresAt`, o fim desse dia vira a expiração do QR Code. + * pagável); no Stripe é o `due_date` da fatura de assinatura, no boleto avulso vira os + * dias até o vencimento do voucher (a leitura devolve o instante em que ele vence) e, na + * venda avulsa por Pix sem `pixExpiresAt`, o fim desse dia vira a expiração do QR Code. * * @var Carbon|null */ diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php index ad80349..b4fa91d 100644 --- a/tests/Integration/StripeGatewayTest.php +++ b/tests/Integration/StripeGatewayTest.php @@ -558,29 +558,65 @@ private function stripeClient(): StripeClient } /** - * Boleto está fora do escopo do gateway Stripe e deve falhar com mensagem específica. + * Deve criar uma fatura de boleto pendente com o voucher hospedado, a linha digitável e o + * PDF; o voucher em aberto não pode ser cancelado, e o estorno de boleto é recusado antes + * da rede. * * @return void */ #[DataProvider('stripeGatewayDataProvider')] - public function testShouldRejectBankSlipInvoice($gateway) + public function testShouldCreateBankSlipInvoiceWithHostedVoucher($gateway) { $customerData = self::customerWithoutAddress(); - $invoiceBuilder = MultiPayment::setGateway($gateway)->newInvoice() + $addressData = self::address(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() ->addCustomer( $customerData['name'], $customerData['email'], $customerData['taxDocument'] ) - ->addItem('Assinatura mensal', 9900, 1) - ->setAvailablePaymentMethods([PaymentMethod::BANK_SLIP]); + ->addCustomerAddress( + $addressData['zipCode'], + $addressData['street'], + $addressData['number'], + $addressData['complement'], + $addressData['district'], + $addressData['city'], + $addressData['state'], + $addressData['country'] + ) + ->addItem('Assinatura mensal', 12345, 1) + ->setAvailablePaymentMethods([PaymentMethod::BANK_SLIP]) + ->setDueDate(Carbon::today()->addDays(3)) + ->create(); + + $this->assertNotNull($invoice->id); + $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); + $this->assertEquals(PaymentMethod::BANK_SLIP, $invoice->paymentMethod); + $this->assertStringContainsString('boleto/voucher', $invoice->url); + $this->assertNotEmpty($invoice->bankSlip->number); + $this->assertStringEndsWith('/pdf', $invoice->bankSlip->url); + $this->assertSame(Carbon::today()->addDays(3)->format('Y-m-d'), $invoice->dueDate->format('Y-m-d')); + + $invoiceFetched = MultiPayment::setGateway($gateway)->getInvoice($invoice->id); + $this->assertEquals(InvoiceStatus::PENDING, $invoiceFetched->status); + $this->assertEquals(PaymentMethod::BANK_SLIP, $invoiceFetched->paymentMethod); + $this->assertEquals($invoice->bankSlip->number, $invoiceFetched->bankSlip->number); try { - $invoiceBuilder->create(); - $this->fail('Esperava UnsupportedOperationException'); + $invoiceFetched->cancel($gateway); + $this->fail('Esperava UnsupportedOperationException ao cancelar boleto pendente'); } catch (UnsupportedOperationException $e) { - $this->assertSame(Capability::BANK_SLIP, $e->capability); - $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); + $this->assertSame(Capability::INVOICE_CANCELLATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + } + + try { + MultiPayment::setGateway($gateway)->refundInvoice($invoice->id); + $this->fail('Esperava RefundNotSupportedException para estorno de boleto'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $e->reason); + $this->assertTrue($e->manualRefundRequired); } } diff --git a/tests/Integration/StripeSubscriptionTest.php b/tests/Integration/StripeSubscriptionTest.php index 434b36e..552ba34 100644 --- a/tests/Integration/StripeSubscriptionTest.php +++ b/tests/Integration/StripeSubscriptionTest.php @@ -269,4 +269,42 @@ public function testShouldPreviewAPlanChangeWithRealLines($gateway) $credito = array_filter($preview->items, static fn ($item) => $item->price < 0); $this->assertNotEmpty($credito, 'a prévia deve trazer a linha de crédito do período não usado'); } + + /** + * A assinatura de boleto nasce ativa em modo de fatura enviada, com a primeira fatura já + * finalizada: aberta, com vencimento e com a página hospedada onde o pagador gera o + * voucher. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCreateASubscriptionPaidWithBankSlip($gateway) + { + $mensal = $this->createPlan($gateway, 10000, 'boleto'); + + $customerData = self::customerWithoutAddress(); + $customer = new Customer(); + $customer->name = $customerData['name']; + $customer->email = $customerData['email']; + $customer->taxDocument = $customerData['taxDocument']; + + $subscription = MultiPayment::setGateway($gateway)->newSubscription() + ->setPlanId($mensal->identifier) + ->setCustomer($customer) + ->setAvailablePaymentMethods([PaymentMethod::BANK_SLIP]) + ->create(); + $this->subscriptionsCriadas[] = $subscription->id; + + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + $this->assertSame(PaymentMethod::BANK_SLIP, $subscription->paymentMethod); + + $latestInvoice = $subscription->latestInvoice; + $this->assertNotNull($latestInvoice); + $this->assertSame(\Potelo\MultiPayment\Enums\InvoiceStatus::PENDING, $latestInvoice->status); + $this->assertStringContainsString('invoice.stripe.com', $latestInvoice->url); + // days_until_due padrão de 3 dias, com uma hora de tolerância de fuso + $this->assertEqualsWithDelta( + now()->addDays(3)->getTimestamp(), + $latestInvoice->dueDate->getTimestamp(), + 3600 + ); + } } diff --git a/tests/Unit/CapabilityGuardsTest.php b/tests/Unit/CapabilityGuardsTest.php index b8ec5d7..ebb85d6 100644 --- a/tests/Unit/CapabilityGuardsTest.php +++ b/tests/Unit/CapabilityGuardsTest.php @@ -60,7 +60,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testBankSlipSubscriptionOnStripeFailsBeforeCreatingTheCustomer(): void + public function testMultiMethodSubscriptionOnStripeFailsBeforeCreatingTheCustomer(): void { $customer = new Customer(); $customer->name = 'Fulano'; @@ -69,9 +69,9 @@ public function testBankSlipSubscriptionOnStripeFailsBeforeCreatingTheCustomer() $builder = (new MultiPayment('stripe'))->newSubscription() ->setPlanId('plano_mensal') ->setCustomer($customer) - ->setAvailablePaymentMethods([PaymentMethod::BANK_SLIP]); + ->setAvailablePaymentMethods([PaymentMethod::PIX, PaymentMethod::CREDIT_CARD]); - $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $builder->create()); + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $builder->create()); } /** @@ -86,9 +86,9 @@ public function testUpdateUsesTheGatewayStoredInTheModel(): void $subscription = new Subscription(); $subscription->id = 'sub_1'; $subscription->gateway = 'stripe'; - $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $subscription->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::CREDIT_CARD]; - $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $subscription->save(new IuguGateway($api))); + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $subscription->save(new IuguGateway($api))); $this->assertCount(0, $api->calls); } @@ -101,18 +101,18 @@ public function testDeleteChecksTheCapabilityBeforeTheDispatchMethod(): void { $subscription = new Subscription(); $subscription->id = 'sub_1'; - $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $subscription->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::CREDIT_CARD]; - $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $subscription->delete('stripe')); + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $subscription->delete('stripe')); } - public function testBankSlipChargeOnStripeFailsBeforeCreatingTheCustomer(): void + public function testMultiMethodChargeOnStripeFailsBeforeCreatingTheCustomer(): void { $multiPayment = new MultiPayment('stripe'); - $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $multiPayment->charge([ + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $multiPayment->charge([ 'items' => [['description' => 'Mensalidade', 'price' => 10000, 'quantity' => 1]], - 'available_payment_methods' => [PaymentMethod::BANK_SLIP->value], + 'available_payment_methods' => [PaymentMethod::BANK_SLIP->value, PaymentMethod::PIX->value], 'customer' => ['name' => 'Fulano', 'email' => 'fulano@exemplo.com', 'tax_document' => '20176996915'], ])); } @@ -180,16 +180,18 @@ public function testMultiMethodInvoiceOnStripeFailsBeforeCreatingTheCustomer(): } /** - * A primeira capability recusada é a do método de pagamento, antes da de multi-método. + * A capability de multi-método é recusada antes da de cartão com dados crus, na ordem em + * que `requiredCapabilities()` as declara. */ - public function testTheFirstMissingCapabilityIsThePaymentMethod(): void + public function testTheFirstMissingCapabilityIsTheOneDeclaredFirst(): void { $builder = (new MultiPayment('stripe'))->newInvoice() ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') ->addItem('Mensalidade', 10000, 1) - ->setAvailablePaymentMethods([PaymentMethod::PIX, PaymentMethod::BANK_SLIP]); + ->setAvailablePaymentMethods([PaymentMethod::PIX, PaymentMethod::CREDIT_CARD]) + ->addCreditCard('4111111111111111', '12', '2030', '123', 'Fulano', 'Silva'); - $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $builder->create()); + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $builder->create()); } /** @@ -221,8 +223,8 @@ public function testInvoiceRequiredCapabilitiesRejectNonSelectableMethods(): voi } /** - * Com a lista vazia, `paymentMethod` decide a capability exigida, e a fatura de boleto no - * Stripe falha pelo array de `charge()` antes de criar o cliente. + * Com a lista vazia, `paymentMethod` decide a capability exigida, e a fatura multi-método + * no Stripe falha pelo array de `charge()` antes de criar o cliente. */ public function testInvoiceRequiredCapabilitiesDeriveFromPaymentMethodWhenTheListIsEmpty(): void { @@ -238,9 +240,9 @@ public function testInvoiceRequiredCapabilitiesDeriveFromPaymentMethodWhenTheLis ); $multiPayment = new MultiPayment('stripe'); - $this->assertNotImplemented(Capability::BANK_SLIP, fn () => $multiPayment->charge([ + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $multiPayment->charge([ 'items' => [['description' => 'Mensalidade', 'price' => 10000, 'quantity' => 1]], - 'payment_method' => 'bank_slip', + 'available_payment_methods' => ['pix', 'bank_slip'], 'customer' => ['name' => 'Fulano', 'email' => 'fulano@exemplo.com'], ])); $this->assertSame([], $this->stripeHttp->calls); @@ -381,8 +383,9 @@ public function testFacadeExposesTheDeclarations(): void $this->assertInstanceOf(IuguGateway::class, $multiPayment->gateway('iugu')); $this->assertSame($iugu, $multiPayment->gateway($iugu)); $this->assertTrue($multiPayment->supports(Capability::PIX)); - $this->assertFalse($multiPayment->supports(Capability::BANK_SLIP)); - $this->assertTrue($multiPayment->supports(Capability::BANK_SLIP, 'iugu')); + $this->assertTrue($multiPayment->supports(Capability::BANK_SLIP)); + $this->assertFalse($multiPayment->supports(Capability::AUTOMATIC_PIX)); + $this->assertTrue($multiPayment->supports(Capability::AUTOMATIC_PIX, 'iugu')); $this->assertTrue($multiPayment->gateway('iugu')->supports(Capability::INSTALLMENTS)); $this->assertSame((new StripeGateway())->capabilities(), $multiPayment->capabilities()); $this->assertSame((new StripeGateway())->notYetImplemented(), $multiPayment->notYetImplemented('stripe')); @@ -404,7 +407,7 @@ public function testLaravelFacadeResolvesTheCapabilityMethods(): void Facade::getFacadeApplication()->bind('multiPayment', fn () => new MultiPayment('stripe')); $this->assertTrue(\Potelo\MultiPayment\Facades\MultiPayment::supports(Capability::PIX)); - $this->assertFalse(\Potelo\MultiPayment\Facades\MultiPayment::supports(Capability::BANK_SLIP)); + $this->assertFalse(\Potelo\MultiPayment\Facades\MultiPayment::supports(Capability::AUTOMATIC_PIX)); $this->assertContains(Capability::SUBSCRIPTIONS, \Potelo\MultiPayment\Facades\MultiPayment::capabilities('iugu')); $this->assertInstanceOf(StripeGateway::class, \Potelo\MultiPayment\Facades\MultiPayment::gateway()); } diff --git a/tests/Unit/Gateways/GatewayCapabilitiesTest.php b/tests/Unit/Gateways/GatewayCapabilitiesTest.php index 7b83720..d83c564 100644 --- a/tests/Unit/Gateways/GatewayCapabilitiesTest.php +++ b/tests/Unit/Gateways/GatewayCapabilitiesTest.php @@ -72,7 +72,7 @@ public static function matrixProvider(): array // iugu stripe Capability::CREDIT_CARD->name => [self::SUPPORTED, self::SUPPORTED], Capability::PIX->name => [self::SUPPORTED, self::SUPPORTED], - Capability::BANK_SLIP->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], + Capability::BANK_SLIP->name => [self::SUPPORTED, self::SUPPORTED], Capability::AUTOMATIC_PIX->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], Capability::MULTIPLE_PAYMENT_METHODS->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], Capability::RAW_CARD_DATA->name => [self::SUPPORTED, self::LIMITATION], @@ -271,7 +271,7 @@ public function testTheFacadeExposesSupportsAllAndTheRestrictions(): void $payment = new MultiPayment('stripe'); $this->assertTrue($payment->supportsAll(Capability::CREDIT_CARD, Capability::PIX)); - $this->assertFalse($payment->supportsAll(Capability::CREDIT_CARD, Capability::BANK_SLIP)); + $this->assertFalse($payment->supportsAll(Capability::CREDIT_CARD, Capability::AUTOMATIC_PIX)); $this->assertSame(['visa', 'mastercard'], $payment->restriction(Capability::CREDIT_CARD)->allowedBrands); $this->assertNull($payment->restriction(Capability::PIX)); $this->assertSame(12, $payment->restriction(Capability::INSTALLMENTS, 'iugu')->maxInstallments); diff --git a/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php index 2edc121..d13db03 100644 --- a/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php +++ b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php @@ -325,6 +325,11 @@ function (StripeGateway $g, ?string $key) { [$pendingPix], ['post /v1/payment_intents' => 'chave-1'], ], + 'createInvoice boleto' => [ + fn (StripeGateway $g, ?string $key) => $g->createInvoice(self::bankSlipInvoiceModel(), $key), + [self::fixture('payment_intents/boleto_requires_action')], + ['post /v1/payment_intents' => 'chave-1'], + ], 'refundInvoice' => [ fn (StripeGateway $g, ?string $key) => $g->refundInvoice(self::invoiceWithId(), null, $key), [ @@ -568,6 +573,25 @@ function (StripeGateway $g, ?string $key) { 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi' => null, ], ], + 'createSubscription boleto' => [ + function (StripeGateway $g, ?string $key) { + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_1UC8LwPjx0CusuMrr3Vq7Hpk'; + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + + return $g->createSubscription($subscription, $key); + }, + [ + self::fixture('subscriptions/active_send_invoice_boleto'), + self::fixture('invoices/open_boleto_send_invoice'), + self::fixture('invoices/open_boleto_send_invoice'), + ], + [ + 'post /v1/subscriptions' => 'chave-1', + 'post /v1/invoices/in_1UC8LxPjx0CusuMr8L1JgWdN/finalize' => 'chave-1:finalize', + 'get /v1/invoices/in_1UC8LxPjx0CusuMr8L1JgWdN' => null, + ], + ], 'updateSubscription' => [ function (StripeGateway $g, ?string $key) { $subscription = new Subscription(); @@ -816,6 +840,20 @@ private static function pixInvoiceModel(): Invoice return $invoice; } + private static function bankSlipInvoiceModel(): Invoice + { + $invoice = self::pixInvoiceModel(); + $invoice->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $invoice->customer->address = new \Potelo\MultiPayment\Models\Address(); + $invoice->customer->address->street = 'Av Paulista'; + $invoice->customer->address->number = '1234'; + $invoice->customer->address->city = 'Sao Paulo'; + $invoice->customer->address->state = 'SP'; + $invoice->customer->address->zipCode = '01310000'; + + return $invoice; + } + private static function stripeCustomerResponse(): array { return [ diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php index 02f5d4b..f386023 100644 --- a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -154,26 +154,248 @@ public function testRejectsInvoiceWithMultiplePaymentMethods(): void $this->assertSame([], $httpClient->calls); } - public function testRejectsBankSlipInvoiceAttributingTheLimitationToTheLibrary(): void + public function testCreatesBankSlipInvoiceFullyServerSideAndParsesTheVoucher(): void + { + Carbon::setTestNow('2026-09-04 10:00:00'); + $httpClient = RecordingStripeHttpClient::withResponses([$this->boletoPaymentIntentResponse()]); + + $invoice = $this->bankSlipInvoiceModel(); + $invoice->dueDate = Carbon::parse('2026-09-07'); + $result = (new StripeGateway())->createInvoice($invoice); + + $this->assertCount(1, $httpClient->calls); + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/payment_intents', parse_url($url, PHP_URL_PATH)); + $this->assertSame(['boleto'], $params['payment_method_types']); + $this->assertSame([ + 'type' => 'boleto', + 'boleto' => ['tax_id' => '20176996915'], + 'billing_details' => [ + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'address' => [ + 'line1' => 'Av Paulista, 1234', + 'city' => 'Sao Paulo', + 'state' => 'SP', + 'postal_code' => '01310000', + 'country' => 'BR', + ], + ], + ], $params['payment_method_data']); + $this->assertSame('true', $params['confirm']); + $this->assertSame(['boleto' => ['expires_after_days' => 3]], $params['payment_method_options']); + + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertSame(PaymentMethod::BANK_SLIP, $result->paymentMethod); + $this->assertNull($result->paidAmount); + $this->assertStringContainsString('payments.stripe.com/boleto/voucher', $result->url); + $this->assertSame('01010101010101010101010101010101010101010101010', $result->bankSlip->number); + $this->assertStringEndsWith('/pdf', $result->bankSlip->url); + $this->assertNull($result->pix); + } + + /** + * Sem `dueDate` no model, o vencimento do voucher (fim do dia na Stripe) preenche a data. + */ + public function testGetBankSlipInvoiceFillsTheDueDateFromTheVoucherExpiry(): void + { + RecordingStripeHttpClient::withResponses([$this->boletoPaymentIntentResponse()]); + + $result = $this->getInvoice(); + + $this->assertSame(1788836340, $result->dueDate->getTimestamp()); + $this->assertSame(PaymentMethod::BANK_SLIP, $result->paymentMethod); + } + + public function testBankSlipInvoiceRequiresCustomerTaxDocument(): void + { + $invoice = $this->bankSlipInvoiceModel(); + $invoice->customer->taxDocument = null; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('taxDocument'); + + (new StripeGateway())->createInvoice($invoice); + } + + public function testBankSlipInvoiceRequiresCustomerAddress(): void + { + $invoice = $this->bankSlipInvoiceModel(); + $invoice->customer->address = null; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('address'); + + (new StripeGateway())->createInvoice($invoice); + } + + public function testBankSlipInvoiceRequiresCustomerEmail(): void + { + $invoice = $this->bankSlipInvoiceModel(); + $invoice->customer->email = null; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('email'); + + (new StripeGateway())->createInvoice($invoice); + } + + /** + * Endereço presente mas incompleto também é recusado: a Stripe exige rua, cidade, estado + * e CEP nos billing details do boleto. + */ + public function testBankSlipInvoiceRequiresACompleteAddress(): void { $httpClient = RecordingStripeHttpClient::withResponses([]); - $invoice = $this->creditCardInvoiceModel(); - $invoice->creditCard = null; - $invoice->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $invoice = $this->bankSlipInvoiceModel(); + $invoice->customer->address->zipCode = null; try { (new StripeGateway())->createInvoice($invoice); - $this->fail('Boleto no Stripe deveria lançar UnsupportedOperationException'); - } catch (UnsupportedOperationException $e) { - $this->assertSame(Capability::BANK_SLIP, $e->capability); - $this->assertSame('stripe', $e->gateway); - $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); - $this->assertStringContainsString('ainda não está implementada nesta lib', $e->getMessage()); - $this->assertStringNotContainsStringIgnoringCase('não oferece', $e->getMessage()); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('address (street, city, state and zipCode)', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + /** + * Limites de valor do boleto na Stripe: R$ 5,00 a R$ 49.999,99. + */ + #[DataProvider('bankSlipAmountOutsideLimitsProvider')] + public function testBankSlipInvoiceRejectsAmountOutsideStripeLimits(int $amount): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->bankSlipInvoiceModel(); + $invoice->items = null; + $invoice->amount = $amount; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('amount must be between 500 and 4999999', $e->getMessage()); } $this->assertSame([], $httpClient->calls); } + public static function bankSlipAmountOutsideLimitsProvider(): array + { + return [ + 'abaixo do minimo' => [499], + 'acima do maximo' => [5000000], + ]; + } + + #[DataProvider('bankSlipDueDateOutsideWindowProvider')] + public function testBankSlipInvoiceRejectsDueDateOutsideStripeWindow(string $dueDate): void + { + Carbon::setTestNow('2026-09-04 10:00:00'); + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->bankSlipInvoiceModel(); + $invoice->dueDate = Carbon::parse($dueDate); + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('dueDate must be between today and 60 days', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + public static function bankSlipDueDateOutsideWindowProvider(): array + { + return [ + 'vencida ontem' => ['2026-09-03'], + 'alem de 60 dias' => ['2026-11-04'], + ]; + } + + /** + * Vencimento hoje é aceito: `expires_after_days` zero vence às 23h59 de hoje na Stripe. + */ + public function testBankSlipInvoiceDueTodaySendsZeroExpiresAfterDays(): void + { + Carbon::setTestNow('2026-09-04 10:00:00'); + $httpClient = RecordingStripeHttpClient::withResponses([$this->boletoPaymentIntentResponse()]); + + $invoice = $this->bankSlipInvoiceModel(); + $invoice->dueDate = Carbon::parse('2026-09-04'); + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame(0, $httpClient->calls[0][2]['payment_method_options']['boleto']['expires_after_days']); + } + + /** + * O teto de 60 dias é aceito, e a contagem usa a data corrente no fuso de São Paulo, onde + * a Stripe vira o dia do boleto. + */ + public function testBankSlipInvoiceDueInExactlySixtyDaysSendsSixtyExpiresAfterDays(): void + { + // 01:00 UTC do dia 5 ainda é dia 4 em São Paulo: a contagem parte do dia 4 + Carbon::setTestNow('2026-09-05 01:00:00'); + $httpClient = RecordingStripeHttpClient::withResponses([$this->boletoPaymentIntentResponse()]); + + $invoice = $this->bankSlipInvoiceModel(); + $invoice->dueDate = Carbon::parse('2026-11-03'); + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame(60, $httpClient->calls[0][2]['payment_method_options']['boleto']['expires_after_days']); + } + + public function testBankSlipInvoiceWithoutDueDateOmitsExpiresAfterDays(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->boletoPaymentIntentResponse()]); + + (new StripeGateway())->createInvoice($this->bankSlipInvoiceModel()); + + $this->assertArrayNotHasKey('payment_method_options', $httpClient->calls[0][2]); + } + + /** + * A Stripe não cancela um boleto com voucher em aberto; com o voucher no model, a recusa + * acontece antes da requisição. + */ + public function testCancelPendingBankSlipInvoiceWithTheVoucherInHandIsRefusedBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->boletoPaymentIntentResponse()]); + $result = $this->getInvoice(); + + try { + (new StripeGateway())->cancelInvoice($result); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_CANCELLATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('voucher', $e->getMessage()); + } + $this->assertCount(1, $httpClient->calls, 'o cancelamento não pode chegar à rede'); + } + + /** + * Boleto pendente sem o voucher no model é cancelável: o cancel chega ao gateway e a + * fatura volta cancelada com `bankSlip` limpo. + */ + public function testCancelExpiredBankSlipInvoiceGoesToTheGateway(): void + { + $canceled = $this->boletoPaymentIntentResponse(); + $canceled['status'] = 'canceled'; + $canceled['next_action'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$canceled]); + + $expired = new Invoice(); + $expired->id = 'pi_fake123'; + $expired->paymentMethod = PaymentMethod::BANK_SLIP; + $expired->status = InvoiceStatus::PENDING; + $result = (new StripeGateway())->cancelInvoice($expired); + + $this->assertSame('/v1/payment_intents/pi_fake123/cancel', parse_url($httpClient->calls[0][1], PHP_URL_PATH)); + $this->assertSame(InvoiceStatus::CANCELED, $result->status); + $this->assertNull($result->bankSlip); + } + public function testRejectsUnknownPaymentMethodStringOnWriteWithoutHittingTheApi(): void { $httpClient = RecordingStripeHttpClient::withResponses([]); @@ -1147,9 +1369,6 @@ public function testRefundInvoiceRequiresId(): void (new StripeGateway())->refundInvoice(new Invoice()); } - /** - * Boleto ainda não existe neste driver; a guarda já nasce coberta para quando entrar. - */ public function testBoletoRefundWithThePaymentMethodInHandMakesNoRequest(): void { $httpClient = RecordingStripeHttpClient::withResponses([]); @@ -2024,6 +2243,36 @@ private function pixInvoiceModel(): Invoice return $invoice; } + private function bankSlipInvoiceModel(): Invoice + { + $invoice = $this->pixInvoiceModel(); + $invoice->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $invoice->pixExpiresAt = null; + $invoice->customer->address = new \Potelo\MultiPayment\Models\Address(); + $invoice->customer->address->street = 'Av Paulista'; + $invoice->customer->address->number = '1234'; + $invoice->customer->address->city = 'Sao Paulo'; + $invoice->customer->address->state = 'SP'; + $invoice->customer->address->zipCode = '01310000'; + + return $invoice; + } + + /** + * Resposta gravada na sandbox em 2026-09-04 (ver o README das fixtures): PaymentIntent de + * boleto confirmado, em `requires_action` com o voucher em `boleto_display_details`. + */ + private function boletoPaymentIntentResponse(): array + { + $response = json_decode( + file_get_contents(__DIR__ . '/../../fixtures/stripe/payment_intents/boleto_requires_action.json'), + true + ); + $response['id'] = 'pi_fake123'; + + return $response; + } + /** * O StripeObject registra "Undefined property" no logger da Stripe ao ler uma chave * ausente, e `?->` não protege contra isso porque o objeto pai existe. Em fatura pix diff --git a/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php b/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php index a29f1d5..344964c 100644 --- a/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php @@ -142,6 +142,141 @@ public function testCreateSubscriptionWithPixLeavesTheFirstInvoiceOpen(): void $this->assertSame(InvoiceStatus::PENDING, $result->latestInvoice->status); } + /** + * Com boleto a assinatura nasce ativa em modo de fatura enviada, e a primeira fatura é + * finalizada na hora para já ter a página hospedada onde o pagador gera o voucher. + */ + public function testCreateSubscriptionWithBoletoSendsTheInvoiceAndFinalizesTheFirstOne(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active_send_invoice_boleto'), + self::fixture('invoices/open_boleto_send_invoice'), + self::fixture('invoices/open_boleto_send_invoice'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_1UC8LwPjx0CusuMrr3Vq7Hpk'; + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + + $result = (new StripeGateway())->createSubscription($subscription); + + $this->assertSame([ + 'post /v1/subscriptions', + 'post /v1/invoices/in_1UC8LxPjx0CusuMr8L1JgWdN/finalize', + 'get /v1/invoices/in_1UC8LxPjx0CusuMr8L1JgWdN', + ], self::calledPaths($httpClient)); + + $params = $httpClient->calls[0][2]; + $this->assertSame('send_invoice', $params['collection_method']); + $this->assertSame(3, $params['days_until_due']); + $this->assertSame(['payment_method_types' => ['boleto']], $params['payment_settings']); + $this->assertArrayNotHasKey('payment_behavior', $params); + + $this->assertSame(SubscriptionStatus::ACTIVE, $result->status); + $this->assertSame(PaymentMethod::BANK_SLIP, $result->paymentMethod); + $this->assertSame(InvoiceStatus::PENDING, $result->latestInvoice->status); + $this->assertStringContainsString('invoice.stripe.com', $result->latestInvoice->url); + $this->assertSame(1788830709, $result->latestInvoice->dueDate->getTimestamp()); + } + + /** + * A chave de idempotência da criação deriva a da finalização da primeira fatura. + */ + public function testCreateSubscriptionWithBoletoDerivesTheFinalizeIdempotencyKey(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active_send_invoice_boleto'), + self::fixture('invoices/open_boleto_send_invoice'), + self::fixture('invoices/open_boleto_send_invoice'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_1UC8LwPjx0CusuMrr3Vq7Hpk'; + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + + (new StripeGateway())->createSubscription($subscription, 'chave-sub-1'); + + $this->assertSame('chave-sub-1', $httpClient->header(0, 'Idempotency-Key')); + $this->assertSame('chave-sub-1:finalize', $httpClient->header(1, 'Idempotency-Key')); + } + + /** + * Assinatura de boleto sem primeira fatura (trial) não tem o que finalizar. + */ + public function testCreateSubscriptionWithBoletoAndNoInvoiceSkipsTheFinalization(): void + { + $created = self::fixture('subscriptions/active_send_invoice_boleto'); + $created['latest_invoice'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$created]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_1UC8LwPjx0CusuMrr3Vq7Hpk'; + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + + $result = (new StripeGateway())->createSubscription($subscription); + + $this->assertSame(['post /v1/subscriptions'], self::calledPaths($httpClient)); + $this->assertNull($result->latestInvoice); + } + + public function testUpdateSubscriptionSwitchingToBoletoChangesTheCollectionMethod(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active_send_invoice_boleto')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UC8LxPjx0CusuMrKfvHqdWA'; + $subscription->paymentMethod = PaymentMethod::BANK_SLIP; + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame(['payment_method_types' => ['boleto']], $params['payment_settings']); + $this->assertSame('send_invoice', $params['collection_method']); + $this->assertSame(3, $params['days_until_due']); + } + + /** + * A volta à cobrança automática não depende do estado lido do gateway: num model fresco, + * só com o id, a troca de boleto para outro método também escreve `collection_method`. + */ + public function testUpdateSubscriptionSwitchingFromBoletoRestoresAutomaticCollection(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UC8LxPjx0CusuMrKfvHqdWA'; + $subscription->paymentMethod = PaymentMethod::PIX; + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame(['payment_method_types' => ['pix']], $params['payment_settings']); + $this->assertSame('charge_automatically', $params['collection_method']); + $this->assertArrayNotHasKey('days_until_due', $params); + } + + /** + * O método lido do gateway não reescreve o modo de cobrança: um update de outra coisa numa + * assinatura de boleto não mexe em `collection_method`. + */ + public function testUpdateSubscriptionKeepsTheCollectionMethodWhenTheBoletoCameFromTheGateway(): void + { + $original = json_decode(json_encode(self::fixture('subscriptions/active_send_invoice_boleto'))); + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active_send_invoice_boleto')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UC8LxPjx0CusuMrKfvHqdWA'; + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $subscription->metadata = ['origem' => 'teste']; + $subscription->original = $original; + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertArrayNotHasKey('payment_settings', $params); + $this->assertArrayNotHasKey('collection_method', $params); + } + public function testCreateSubscriptionWithExtraItemsCreatesPricesOnDemand(): void { $httpClient = RecordingStripeHttpClient::withResponses([ @@ -729,14 +864,14 @@ public function testUpdateSubscriptionRefusesMethodsTheDriverDoesNotCoverBeforeT $this->assertSame(Capability::MULTIPLE_PAYMENT_METHODS, $e->capability); } - $boleto = new Subscription(); - $boleto->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; - $boleto->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $automaticPix = new Subscription(); + $automaticPix->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $automaticPix->paymentMethod = PaymentMethod::AUTOMATIC_PIX; try { - $gateway->updateSubscription($boleto); - $this->fail('Esperava UnsupportedOperationException'); - } catch (UnsupportedOperationException $e) { - $this->assertSame(Capability::BANK_SLIP, $e->capability); + $gateway->updateSubscription($automaticPix); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('paymentMethod must be one of', $e->getMessage()); } $this->assertSame([], $httpClient->calls); diff --git a/tests/fixtures/stripe/README.md b/tests/fixtures/stripe/README.md index 5bda15f..02b503b 100644 --- a/tests/fixtures/stripe/README.md +++ b/tests/fixtures/stripe/README.md @@ -1,9 +1,9 @@ # Fixtures da Stripe -Respostas da sandbox da Stripe, API `2026-07-29.dahlia`, gravadas em 2026-09-02 com o -`expand` que o driver usa (`payments.data.payment.payment_intent` no Invoice; -`latest_charge.balance_transaction` e `latest_charge.refunds` no PaymentIntent). Só o -`client_secret` dos PaymentIntents foi substituído por um placeholder. +Respostas da sandbox da Stripe, API `2026-07-29.dahlia`, gravadas em 2026-09-02 (as de +boleto em 2026-09-04) com o `expand` que o driver usa (`payments.data.payment.payment_intent` +no Invoice; `latest_charge.balance_transaction` e `latest_charge.refunds` no PaymentIntent). +Só o `client_secret` dos PaymentIntents foi substituído por um placeholder. ## `invoices/` @@ -32,11 +32,19 @@ Montadas sobre `open_requires_payment_method.json`, porque a sandbox não produz | `open_partially_paid.json` | `amount_paid` 5000 e `amount_remaining` 7345 | | `open_without_payment_intent.json` | `payments.data` vazio | +Gravada na sandbox em 2026-09-04: + +| Arquivo | Como foi produzida | +|---|---| +| `open_boleto_send_invoice.json` | primeira fatura de uma assinatura `send_invoice` com `payment_settings.payment_method_types: ['boleto']`, finalizada na hora (GET com o expand do driver) | + ## `payment_intents/` `after_declined_attempt.json`, `paid.json`, `partially_refunded.json`, `refunded.json` e `disputed.json` são o GET do PaymentIntent dos Invoices acima. `requires_action.json` é `after_declined_attempt.json` com o status trocado e um `next_action` de 3DS. +`boleto_requires_action.json` (2026-09-04) é a resposta da criação de um PaymentIntent de +boleto confirmado server-side, com o voucher em `next_action.boleto_display_details`. ## `disputes/` @@ -72,6 +80,7 @@ e-mail foram renomeados para os valores estáveis das fixtures (`sub_1UBJmk...`, | `active_cancel_at_period_end.json` | a mesma depois de `cancelSubscription(atPeriodEnd: true)` | | `canceled.json` | a mesma depois do cancelamento imediato (já no plano anual, pela troca) | | `trialing.json` | assinatura criada com `trialDays` 7 no cartão salvo | +| `active_send_invoice_boleto.json` | assinatura criada com `collection_method` `send_invoice`, `days_until_due` 3 e `payment_settings.payment_method_types: ['boleto']` (GET com o expand do driver; ids reais da sessão de 2026-09-04) | Montadas sobre `active.json` (ou `trialing.json`), porque a sandbox não produz o estado: diff --git a/tests/fixtures/stripe/invoices/open_boleto_send_invoice.json b/tests/fixtures/stripe/invoices/open_boleto_send_invoice.json new file mode 100644 index 0000000..a2afb88 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_boleto_send_invoice.json @@ -0,0 +1,239 @@ +{ + "id": "in_1UC8LxPjx0CusuMr8L1JgWdN", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": true, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "subscription_create", + "collection_method": "send_invoice", + "created": 1788571509, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VCXQBStDgeEid0", + "customer_account": null, + "customer_address": null, + "customer_email": "boleto-lote3@exemplo.com.br", + "customer_name": "Boleto Teste Lote3", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": 1788830709, + "effective_at": 1788571524, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1hRMGZKUlBrdFUzazI2S01mYUR3b0Y3UU9qb2RULDE3OTExMjQyOQ02006Ati0Ydk?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1hRMGZKUlBrdFUzazI2S01mYUR3b0Y3UU9qb2RULDE3OTExMjQyOQ02006Ati0Ydk/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UC8LxPjx0CusuMrB5oVCtqi", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "1 × Plano boleto lote3 (a R$ 123.45 / month)", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UC8LxPjx0CusuMr8L1JgWdN", + "livemode": false, + "metadata": {}, + "parent": { + "invoice_item_details": null, + "subscription_item_details": { + "invoice_item": null, + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": "sub_1UC8LxPjx0CusuMrKfvHqdWA", + "subscription_item": "si_VCXQ1RlvYb1XiI" + }, + "type": "subscription_item_details" + }, + "period": { + "end": 1791163509, + "start": 1788571509 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UC8LwPjx0CusuMrr3Vq7Hpk", + "product": "prod_VCXQ5X2E8yDpUz" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UC8LxPjx0CusuMr8L1JgWdN/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "6B7ZYNWI-0001", + "on_behalf_of": null, + "parent": { + "quote_details": null, + "subscription_details": { + "metadata": {}, + "subscription": "sub_1UC8LxPjx0CusuMrKfvHqdWA" + }, + "type": "subscription_details" + }, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": [ + "boleto" + ] + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UC8MDPjx0CusuMrFJLLrEUj", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788571525, + "currency": "brl", + "invoice": "in_1UC8LxPjx0CusuMr8L1JgWdN", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UC8MDPjx0CusuMr05S6MVu1", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_fake_secret_placeholder", + "confirmation_method": "automatic", + "created": 1788571525, + "currency": "brl", + "customer": "cus_VCXQBStDgeEid0", + "customer_account": null, + "description": "Subscription creation", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UC8LxPjx0CusuMr8L1JgWdN" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "boleto": { + "expires_after_days": 3 + } + }, + "payment_method_types": [ + "boleto" + ], + "payment_record": null, + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788571509, + "period_start": 1788571509, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788571524, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788571510 +} diff --git a/tests/fixtures/stripe/payment_intents/boleto_requires_action.json b/tests/fixtures/stripe/payment_intents/boleto_requires_action.json new file mode 100644 index 0000000..c345ca5 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/boleto_requires_action.json @@ -0,0 +1,69 @@ +{ + "id": "pi_3UC8LGPjx0CusuMr1jDGqScY", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic_async", + "client_secret": "pi_fake_secret_placeholder", + "confirmation_method": "automatic", + "created": 1788571466, + "currency": "brl", + "customer": null, + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": { + "item_0_description": "Assinatura mensal", + "item_0_price": "12345", + "item_0_quantity": "1" + }, + "next_action": { + "boleto_display_details": { + "expires_at": 1788836340, + "hosted_voucher_url": "https://payments.stripe.com/boleto/voucher/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1hRQ0F2NDBxZGZ0WFhjT3Q4M2Q2d1BvSVhkbTk50100QLU2LKPD", + "number": "01010101010101010101010101010101010101010101010", + "pdf": "https://payments.stripe.com/boleto/voucher/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1hRQ0F2NDBxZGZ0WFhjT3Q4M2Q2d1BvSVhkbTk50100QLU2LKPD/pdf" + }, + "type": "boleto_display_details" + }, + "on_behalf_of": null, + "payment_method": "pm_1UC8LGPjx0CusuMrOMQIs9ua", + "payment_method_configuration_details": null, + "payment_method_options": { + "boleto": { + "expires_after_days": 3 + } + }, + "payment_method_types": [ + "boleto" + ], + "payment_record": null, + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_action", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/subscriptions/active_send_invoice_boleto.json b/tests/fixtures/stripe/subscriptions/active_send_invoice_boleto.json new file mode 100644 index 0000000..6f2f2f9 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/active_send_invoice_boleto.json @@ -0,0 +1,198 @@ +{ + "id": "sub_1UC8LxPjx0CusuMrKfvHqdWA", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788571509, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788571509 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "send_invoice", + "created": 1788571509, + "currency": "brl", + "customer": "cus_VCXQBStDgeEid0", + "customer_account": null, + "days_until_due": 3, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_VCXQ1RlvYb1XiI", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788571510, + "current_period_end": 1791163509, + "current_period_start": 1788571509, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UC8LwPjx0CusuMrr3Vq7Hpk", + "object": "plan", + "active": true, + "amount": 12345, + "amount_decimal": "12345", + "billing_scheme": "per_unit", + "created": 1788571508, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_VCXQ5X2E8yDpUz", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UC8LwPjx0CusuMrr3Vq7Hpk", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788571508, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": { + "id": "prod_VCXQ5X2E8yDpUz", + "object": "product", + "active": true, + "attributes": [], + "created": 1788571508, + "default_price": "price_1UC8LwPjx0CusuMrr3Vq7Hpk", + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": {}, + "name": "Plano boleto lote3", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788571508, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 12345, + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "subscription": "sub_1UC8LxPjx0CusuMrKfvHqdWA", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UC8LxPjx0CusuMrKfvHqdWA" + }, + "latest_invoice": "in_1UC8LxPjx0CusuMr8L1JgWdN", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "boleto" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UC8LwPjx0CusuMrr3Vq7Hpk", + "object": "plan", + "active": true, + "amount": 12345, + "amount_decimal": "12345", + "billing_scheme": "per_unit", + "created": 1788571508, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_VCXQ5X2E8yDpUz", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788571509, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} From 33796e416c803ca156c0b6e34e3b3cdb8fdb1621 Mon Sep 17 00:00:00 2001 From: Ramon Sena Date: Sat, 5 Sep 2026 07:23:13 -0300 Subject: [PATCH 32/32] =?UTF-8?q?feat(stripe):=20implementa=20Pix=20Autom?= =?UTF-8?q?=C3=A1tico=20via=20mandate=20com=20integra=C3=A7=C3=A3o=20condi?= =?UTF-8?q?cionada=20=C3=A0=20libera=C3=A7=C3=A3o=20da=20conta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 97 +++- phpunit.xml.dist | 2 + src/Builders/SubscriptionBuilder.php | 17 + src/Contracts/AutomaticPixContract.php | 19 +- src/Enums/Capability.php | 2 +- src/Enums/PaymentMethod.php | 7 +- .../UnsupportedOperationException.php | 27 +- src/Gateways/IuguGateway.php | 18 +- src/Gateways/StripeGateway.php | 387 ++++++++++++-- src/Models/AutomaticPix.php | 42 +- src/Models/AutomaticPixCancellation.php | 3 + src/Models/Invoice.php | 38 +- src/Models/Subscription.php | 70 ++- src/config/multi-payment.php | 2 + tests/Integration/StripeAutomaticPixTest.php | 114 +++++ tests/TestCase.php | 13 + tests/Unit/CapabilityGuardsTest.php | 45 +- .../Unit/Gateways/GatewayCapabilitiesTest.php | 4 +- .../Gateways/IuguGatewaySubscriptionTest.php | 26 + .../StripeGatewayAutomaticPixTest.php | 483 ++++++++++++++++++ .../Gateways/StripeGatewayCustomerTest.php | 13 +- .../StripeGatewaySubscriptionTest.php | 9 +- tests/Unit/SubscriptionTest.php | 38 +- tests/fixtures/stripe/README.md | 14 + tests/fixtures/stripe/mandates/active.json | 29 ++ tests/fixtures/stripe/mandates/inactive.json | 29 ++ .../subscriptions/active_automatic_pix.json | 238 +++++++++ .../incomplete_automatic_pix.json | 212 ++++++++ 28 files changed, 1903 insertions(+), 95 deletions(-) create mode 100644 tests/Integration/StripeAutomaticPixTest.php create mode 100644 tests/Unit/Gateways/StripeGatewayAutomaticPixTest.php create mode 100644 tests/fixtures/stripe/mandates/active.json create mode 100644 tests/fixtures/stripe/mandates/inactive.json create mode 100644 tests/fixtures/stripe/subscriptions/active_automatic_pix.json create mode 100644 tests/fixtures/stripe/subscriptions/incomplete_automatic_pix.json diff --git a/README.md b/README.md index c9ea736..528d069 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ IUGU_MAX_INSTALLMENTS=12 # opcional; máximo de parcelas habilitado na conta ( #stripe STRIPE_APIKEY= +STRIPE_PIX_MANDATE_REFERENCE= # opcional; nome exibido no aplicativo do banco no mandato de Pix Automático (ver Pix Automático) #idempotência (opcional; ver a seção Idempotência) MULTIPAYMENT_IDEMPOTENCY_TTL=86400 @@ -158,7 +159,9 @@ Toda operação fora das capabilities do gateway lança `UnsupportedOperationExc qualquer requisição**, inclusive antes de criar o cliente que acompanha a fatura ou a assinatura. A exceção traz `capability`, `gateway` e `reason` (`not_implemented` quando o gateway oferece e a lib ainda não implementou; `gateway_limitation` quando o gateway não -oferece). Ver [Tratamento de erros](#tratamento-de-erros). +oferece; `managed_by_gateway` quando o próprio gateway conduz a operação e a chamada pela lib +não se aplica, como o agendamento de Pix Automático no Stripe). Ver +[Tratamento de erros](#tratamento-de-erros). A matriz abaixo é gerada a partir das declarações dos drivers com `composer capabilities:table`; o teste `GatewayCapabilitiesTest` falha quando o README fica defasado em relação ao código. A @@ -169,7 +172,7 @@ coluna "Restrições" é o que `restriction()` devolve para cada gateway. | `CREDIT_CARD` | Fatura paga com cartão de crédito. | sim | sim | Stripe: Na conta brasileira só cartão de crédito Visa e Mastercard; outra bandeira é recusada na cobrança com DeclineCode::BRAND_NOT_SUPPORTED. | | `PIX` | Fatura paga com Pix avulso, com QR Code de pagamento único. | sim | sim | | | `BANK_SLIP` | Fatura paga com boleto bancário. | sim | sim | Stripe: A Stripe aceita boleto de R$ 5,00 a R$ 49.999,99, com vencimento de hoje a 60 dias; fora dessas janelas a criação é recusada antes da requisição. | -| `AUTOMATIC_PIX` | Recorrência de Pix Automático criada junto com a fatura, com reagendamento e cancelamento pela lib. | sim | não implementado | | +| `AUTOMATIC_PIX` | Recorrência de Pix Automático autorizada pelo pagador; quem agenda cada cobrança depende de `MANAGES_RECURRENCE`. | sim | sim | Iugu: A recorrência nasce na fatura (Invoice com automaticPix e método pix) e a aplicação é o motor de recorrência; a assinatura não aceita paymentMethod automatic_pix.
Stripe: A recorrência é o mandato de uma assinatura (paymentMethod automatic_pix na criação) e o gateway agenda as cobranças; fatura avulsa com automaticPix não é aceita, e as operações de agendamento e de cancelamento de cobrança da lib respondem managed_by_gateway. | | `MULTIPLE_PAYMENT_METHODS` | Fatura aberta a mais de um método de pagamento, escolhido pelo pagador na hora de pagar. | sim | não implementado | | | `RAW_CARD_DATA` | Cartão informado com número e CVV pela API; sem ela, o cartão é tokenizado no navegador e só o token chega à lib. | sim | limitação do gateway | | | `CARD_SETUP_AUTHENTICATION` | Autenticação do portador com o emissor (3DS) ao salvar o cartão: cartão que exige ação do pagador volta com `CreditCard::$requiresAction` verdadeiro e `id` nulo, e `confirmCreditCardSetup()` conclui o salvamento depois da autenticação. | limitação do gateway | sim | | @@ -400,8 +403,10 @@ ou `SubscriptionStatus::UNKNOWN` com aviso no log; em `paymentMethod`, `availablePaymentMethods` e `interval`, lança `ModelAttributeValidationException` na escrita, com a lista de valores aceitos. Em `availablePaymentMethods` só entram `CREDIT_CARD`, `BANK_SLIP` e `PIX`; `AUTOMATIC_PIX` é -recusado na validação, porque a fatura com Pix Automático é criada com `PIX` e o objeto -`automaticPix` preenchido. Nenhum driver emite `AUTOMATIC_PIX` em `paymentMethod` hoje. +recusado na lista e em `Invoice::$paymentMethod`, porque a fatura com Pix Automático é criada +com `PIX` e o objeto `automaticPix` preenchido. Em `Subscription::$paymentMethod` ele é +aceito: no Stripe é o método da assinatura com mandato (ver +[Pix Automático](#pix-automático)). > **Mudança de comportamento (versão 5.0.0).** `$invoice->status === Invoice::STATUS_PAID` e > comparações equivalentes com `paymentMethod` e `interval` passam a ser **falsas**, porque a @@ -708,7 +713,7 @@ deduplica por conta própria com a `IdempotencyStore` (abaixo): | Suspender, retomar, cancelar, atualizar assinatura, trocar de plano | store da lib | gateway | | Criar plano | store da lib | gateway | | Desativar plano (`deactivatePlan`) | (limitação do gateway) | gateway | -| Reagendar e cancelar Pix Automático | store da lib | (não implementado) | +| Reagendar e cancelar Pix Automático | store da lib | (não se aplica: o gateway agenda, `managed_by_gateway`) | Quando uma operação faz mais de uma requisição de escrita (salvar o cartão antes de cobrar, criar o tax id ao atualizar o cliente, remover subitens antes de atualizar a assinatura), a @@ -805,7 +810,7 @@ MultiPaymentException | `IdempotencyConflictException` | Chave de idempotência reutilizada (409 na Iugu em cliente e assinatura; `idempotency_error` na Stripe quando o payload mudou), a primeira requisição com a chave ainda em andamento, ou lock ocupado na `IdempotencyStore` da lib. `resourceId` traz o id do recurso original quando o gateway o informa | Consultar o resultado da primeira requisição (`resourceId` ou o registro da aplicação) ou usar chave nova; nunca repetir com a mesma chave e outro conteúdo | | `AuthenticationException` | Chave de API inválida, revogada, sem permissão (401 ou 403) ou não configurada | Registrar e alertar. Repetir a chamada ou trocar de gateway não resolve | | `GatewayNotAvailableException` | Erro 5xx, falha de conexão ou timeout | Repetir mais tarde ou tentar outro gateway | -| `UnsupportedOperationException` | Operação fora das capabilities do gateway, ou fora da restrição de uma capability suportada (`restriction()`), antes de qualquer requisição; `capability`, `gateway` e `reason` (`not_implemented` ou `gateway_limitation`) dizem qual e por quê | Rotear para um gateway que declare a capability; melhor ainda, consultar `supports()` e `restriction()` antes (ver [Capabilities](#capabilities)) | +| `UnsupportedOperationException` | Operação fora das capabilities do gateway, ou fora da restrição de uma capability suportada (`restriction()`), antes de qualquer requisição; `capability`, `gateway` e `reason` (`not_implemented`, `gateway_limitation` ou `managed_by_gateway`) dizem qual e por quê | Rotear para um gateway que declare a capability; melhor ainda, consultar `supports()` e `restriction()` antes (ver [Capabilities](#capabilities)). Em `managed_by_gateway`, seguir a orientação da mensagem: a operação é conduzida pelo próprio gateway | | `RefundNotSupportedException` | Estorno recusado pela lib antes de chamar o gateway: limitação do gateway (boleto, Pix parcial; `isCapabilityLimitation()` verdadeiro e `capability` preenchida) ou estado da fatura (já estornada, valor acima do restante, prazo vencido). Herda direto de `MultiPaymentException`: `catch (UnsupportedOperationException)` não a captura | Ver [Estorno](#estorno) | | `ModelAttributeValidationException` | Atributo obrigatório ausente ou inválido, antes de qualquer requisição, inclusive regra de valor que só um gateway impõe (`PlanInterval::DAY` e teto de 599 meses na Iugu, `nextBillingAt` diferente de `trialEndsAt`, `page` e `limit` fora da faixa, plano com `id` em `save()`, valor de estorno zero ou negativo) | Corrigir a chamada | | `ConfigurationException` | Gateway não configurado ou classe inválida; driver que declara uma capability sem implementar o contract ou sem o método do despacho por convenção; `IdempotencyStore` sem registro no container ou sobre um cache sem lock | Corrigir a configuração ou o driver | @@ -895,8 +900,8 @@ o deixava nulo, traz o valor de `declineCode`. Compare com `declineCode`. > `GatewayNotAvailableException` deixa de repetir credencial errada; quem capturava > `GatewayException` para chave recusada na Iugu precisa capturar `AuthenticationException`. > -> Na mesma versão, operação não suportada ou ainda não implementada (Pix Automático no Stripe; -> desconto percentual e desativação +> Na mesma versão, operação não suportada ou ainda não implementada (fatura avulsa com Pix +> Automático no Stripe; desconto percentual e desativação > de plano na Iugu; cartão com dados crus e duplicação fora de Pix pendente no Stripe) deixou de > chegar como `GatewayException` (ou `GatewayException::methodNotFound`) e passou a lançar > `UnsupportedOperationException`, que herda de `MultiPaymentException`. Um @@ -1022,11 +1027,61 @@ Stripe. `toArray()` passa a emitir `due_date` e `pix_expires_at`. #### Pix Automático -O Pix Automático está disponível no gateway Iugu. No Stripe ele ainda **não está implementado -nesta lib** (planejado para uma versão futura; a conta Stripe da empresa também aguarda a -liberação do recurso). Até lá, todas as operações de Pix Automático no Stripe, inclusive criar -fatura com `automatic_pix`, lançam `UnsupportedOperationException` com `capability` -`AUTOMATIC_PIX` e `reason` `not_implemented`, antes de qualquer requisição. +O Pix Automático está disponível nos dois gateways, com desenhos opostos (a responsabilidade +pela agenda está em +[Pix Automático: quem agenda a cobrança](#pix-automático-quem-agenda-a-cobrança)): + +| | Iugu | Stripe | +|---|---|---| +| Onde a recorrência nasce | Na fatura: `Invoice` com método `pix` e `automaticPix` preenchido | Na assinatura: `Subscription` com `paymentMethod` `automatic_pix` (mandato) | +| Quem agenda cada cobrança | A aplicação, pelas operações de `AutomaticPixContract` | O gateway (`MANAGES_RECURRENCE`), com notificação de pré-débito três dias antes | +| Reagendar e cancelar cobrança | `rescheduleAutomaticPixPayment()`, `cancelAutomaticPixScheduledPayment()` | `UnsupportedOperationException` com `reason` `managed_by_gateway` | +| Encerrar a recorrência | `cancelAutomaticPixRecurrence()` | Cancelar a assinatura (`cancelSubscription()`); a Stripe encerra o mandato | +| Consultar cancelamentos | `getAutomaticPixCancellation()`, `listAutomaticPixCancellations()` | As mesmas operações, lendo o Mandate (`mandate_...`): mandato `inactive` devolve um cancelamento `completed` | +| Estado no model | `Invoice::$automaticPix` | `Subscription::$automaticPix`, com `nextDebitAt` e `preDebitNotificationAt` | + +No Stripe, a assinatura com `paymentMethod` `automatic_pix` registra o mandato na criação +(`payment_method_options.pix.mandate_options`) e nasce com a primeira fatura em aberto: o +pagador autoriza o mandato ao pagar essa fatura (a página hospedada vem em +`latestInvoice->url`) e a Stripe cobra os ciclos seguintes sozinha. A lib deriva o mandato do +plano: o valor é a soma do plano com os itens recorrentes (com desconto na assinatura ele vira +um teto, `amount_type` `maximum`), a agenda vem do intervalo do plano (semanal, mensal, +trimestral, semestral ou anual; outro intervalo é recusado antes da requisição) e o primeiro +débito (`start_date`) do fim do trial ou de `nextBillingAt`, com o mínimo de três dias a +partir de hoje. O plano precisa de valor fixo (Price com `unit_amount`) e a assinatura com +mandato não troca de método depois de criada (cancele e crie outra). +`Subscription::$automaticPix` refina o mandato na escrita (`startsAt`, `endsAt`, `frequency`; +no builder, `setAutomaticPix()`) e volta preenchido na leitura, com as datas derivadas +`nextDebitAt` (o débito acontece três dias depois do início do ciclo) e +`preDebitNotificationAt`. O nome +exibido no aplicativo do banco vem da configuração +`multi-payment.gateways.stripe.pix_mandate_reference` (`STRIPE_PIX_MANDATE_REFERENCE`). + +```php +$subscription = (new \Potelo\MultiPayment\MultiPayment('stripe')) + ->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomer($customer) + ->setPaymentMethod(\Potelo\MultiPayment\Enums\PaymentMethod::AUTOMATIC_PIX) + ->create(); + +$subscription->latestInvoice->url; // página onde o pagador autoriza o mandato +$subscription->automaticPix->startsAt; // primeiro débito (mínimo hoje mais 3 dias) +$subscription->automaticPix->nextDebitAt; // próximo débito (ciclo mais 3 dias) +$subscription->automaticPix->preDebitNotificationAt; // quando o pagador é notificado +``` + +O id e o status do mandato (`AutomaticPix::$mandateId`, `$mandateStatus`) não vêm na leitura +da assinatura: chegam pelo webhook `mandate.updated` da Stripe ou preenchidos pela consulta de +cancelamentos (`listAutomaticPixCancellations($mandateId)`). A fatura avulsa com +`automaticPix` no Stripe é recusada antes de qualquer requisição +(`UnsupportedOperationException`, `AUTOMATIC_PIX`, `not_implemented`): nesse gateway a +recorrência vive na assinatura. A conta Stripe da empresa ainda aguarda a liberação do +recurso; até lá, a criação real responde com a recusa do próprio gateway. + +Um `start_date` derivado do relógio muda entre tentativas com a mesma chave de idempotência +(a Stripe compara o payload); num retry com chave, informe `startsAt` em `automaticPix`, como +já vale para as outras datas derivadas do instante da chamada. Na Iugu, ele é configurado como parte da fatura: @@ -1078,7 +1133,14 @@ aplicação agenda), verdadeiro no Stripe (o gateway agenda). A regra é esta: - **No Stripe, o gateway agenda.** O mandato vive na Subscription e a Stripe controla o calendário: envia ao pagador a notificação de pré-débito obrigatória três dias antes de cada débito, cobra e faz as retentativas automáticas. A aplicação não agenda nada; a data de - início do mandato precisa respeitar esse prazo de três dias. + início do mandato precisa respeitar esse prazo de três dias, e as operações de agendamento + da lib respondem `UnsupportedOperationException` com `reason` `managed_by_gateway`. + +Por isso a assinatura com `paymentMethod` `automatic_pix` exige `MANAGES_RECURRENCE` além de +`AUTOMATIC_PIX`: na Iugu ela é recusada antes de qualquer requisição (a recorrência nasce na +fatura), e no Stripe a fatura avulsa com `automaticPix` é recusada (a recorrência nasce na +assinatura). As duas restrições são consultáveis em +`restriction(Capability::AUTOMATIC_PIX, $gateway)`. **Migrar uma recorrência de um gateway para o outro exige desligar o motor da aplicação para aquela recorrência** quando o destino é o Stripe. Se o motor continuar ativo, a aplicação e o @@ -1442,13 +1504,18 @@ Particularidades do Stripe: quitar (`url` é a página hospedada). O método `pix` em assinatura depende de habilitação na conta Stripe; sem ela, a criação é recusada pelo gateway com `ValidationException` ("The payment method type `pix` is invalid"). +- **Com Pix Automático (`setPaymentMethod(PaymentMethod::AUTOMATIC_PIX)`), a assinatura + também nasce com a primeira fatura em aberto** e registra o mandato na criação; a Stripe + agenda as cobranças seguintes. Ver [Pix Automático](#pix-automático). - **Com boleto, a assinatura nasce ativa em modo de fatura enviada** (`collection_method` `send_invoice`, com `days_until_due` de 3 dias, sobrescritível por `gateway_options['days_until_due']`): a primeira fatura é finalizada na criação e volta em `latestInvoice` aberta, com vencimento e com a página hospedada em `url`, onde o pagador gera o voucher; as faturas dos ciclos seguintes são emitidas pela Stripe com o mesmo prazo. A troca de método de uma assinatura existente para boleto muda o modo de cobrança para - `send_invoice`, e a troca de boleto para cartão ou Pix devolve a cobrança automática. + `send_invoice`, e a troca de boleto para cartão ou Pix devolve a cobrança automática. Uma + assinatura com mandato de Pix Automático não troca de método (nem uma existente passa a + tê-lo): as duas direções são recusadas antes da requisição. - **Itens extras criam Prices no Stripe.** Cada `SubscriptionItem` vira um subscription item com um Product e um Price próprios, criados na hora (o item precisa de `description` e `amount`); item com `recurring` falso vai como item avulso da primeira fatura. No update diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 157fb49..9acc80c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -36,5 +36,7 @@ + +
diff --git a/src/Builders/SubscriptionBuilder.php b/src/Builders/SubscriptionBuilder.php index 2174ae3..15971ac 100644 --- a/src/Builders/SubscriptionBuilder.php +++ b/src/Builders/SubscriptionBuilder.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\CreditCard; +use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Models\SubscriptionItem; @@ -145,6 +146,22 @@ public function setPaymentMethod(PaymentMethod|string $paymentMethod): Subscript return $this; } + /** + * Define o estado do mandato de Pix Automático (`startsAt`, `endsAt`, `frequency`) e o + * método de pagamento como Pix Automático (ver `Subscription::$automaticPix`). + * + * @param AutomaticPix $automaticPix + * + * @return $this + */ + public function setAutomaticPix(AutomaticPix $automaticPix): SubscriptionBuilder + { + $this->model->automaticPix = $automaticPix; + $this->model->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + return $this; + } + /** * Define o cartão que a assinatura cobra, pelo model ou pelo id de um cartão já salvo no * cliente, e o método de pagamento como cartão (ver `Subscription::$creditCard`). diff --git a/src/Contracts/AutomaticPixContract.php b/src/Contracts/AutomaticPixContract.php index ef48e5f..4b34861 100644 --- a/src/Contracts/AutomaticPixContract.php +++ b/src/Contracts/AutomaticPixContract.php @@ -12,13 +12,15 @@ /** * Operações de gestão de uma recorrência de Pix Automático. * - * Quem agenda cada cobrança depende do gateway. Na Iugu a API não gerencia a recorrência: a - * aplicação é o motor de recorrência e precisa chamar estas operações na periodicidade certa - * para que as cobranças aconteçam, sejam reagendadas ou canceladas. No Stripe o mandato vive - * na Subscription e o próprio gateway agenda, notifica o pagador com três dias de - * antecedência e faz as retentativas. Ao migrar uma recorrência de um gateway que não agenda - * para um que agenda, desligue o motor da aplicação para aquela recorrência, sob risco de - * cobrança dupla. + * Quem agenda cada cobrança depende do gateway (`Capability::MANAGES_RECURRENCE`). Na Iugu a + * API não gerencia a recorrência: a aplicação é o motor de recorrência e precisa chamar as + * operações de agendamento na periodicidade certa para que as cobranças aconteçam, sejam + * reagendadas ou canceladas. No Stripe o mandato vive na Subscription e o próprio gateway + * agenda, notifica o pagador com três dias de antecedência e faz as retentativas: as + * operações de agendamento e de cancelamento de cobrança lançam + * `UnsupportedOperationException` com `reason` `managed_by_gateway`, e as de consulta leem o + * Mandate. Ao migrar uma recorrência de um gateway que não agenda para um que agenda, + * desligue o motor da aplicação para aquela recorrência, sob risco de cobrança dupla. */ interface AutomaticPixContract { @@ -29,6 +31,7 @@ interface AutomaticPixContract * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * @return Invoice * @throws GatewayException|GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException gateway que agenda por conta própria (`managed_by_gateway`) */ public function rescheduleAutomaticPixPayment(Invoice $invoice, ?string $idempotencyKey = null): Invoice; @@ -39,6 +42,7 @@ public function rescheduleAutomaticPixPayment(Invoice $invoice, ?string $idempot * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * @return AutomaticPixCancellation * @throws GatewayException|GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException gateway que agenda por conta própria (`managed_by_gateway`) */ public function cancelAutomaticPixScheduledPayment( AutomaticPixCharge $charge, @@ -52,6 +56,7 @@ public function cancelAutomaticPixScheduledPayment( * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * @return AutomaticPixCancellation * @throws GatewayException|GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException gateway que agenda por conta própria (`managed_by_gateway`) */ public function cancelAutomaticPixRecurrence( AutomaticPix $automaticPix, diff --git a/src/Enums/Capability.php b/src/Enums/Capability.php index b6a8891..a553629 100644 --- a/src/Enums/Capability.php +++ b/src/Enums/Capability.php @@ -22,7 +22,7 @@ enum Capability: string /** Fatura paga com boleto bancário. */ case BANK_SLIP = 'bank_slip'; - /** Recorrência de Pix Automático criada junto com a fatura, com reagendamento e cancelamento pela lib. */ + /** Recorrência de Pix Automático autorizada pelo pagador; quem agenda cada cobrança depende de `MANAGES_RECURRENCE`. */ case AUTOMATIC_PIX = 'automatic_pix'; /** Fatura aberta a mais de um método de pagamento, escolhido pelo pagador na hora de pagar. */ diff --git a/src/Enums/PaymentMethod.php b/src/Enums/PaymentMethod.php index 260c847..faade1a 100644 --- a/src/Enums/PaymentMethod.php +++ b/src/Enums/PaymentMethod.php @@ -20,9 +20,10 @@ enum PaymentMethod: string case PIX = 'pix'; /** - * Pix Automático, recorrência autorizada pelo pagador. Nenhum driver o emite ainda em - * `Invoice::$paymentMethod`: a fatura com Pix Automático é criada com `PIX` em - * `availablePaymentMethods` e `automaticPix` preenchido. + * Pix Automático, recorrência autorizada pelo pagador. No Stripe é o método de uma + * assinatura com mandato (`Subscription::$paymentMethod`); na Iugu a recorrência nasce na + * fatura, criada com `PIX` em `availablePaymentMethods` e `automaticPix` preenchido, e + * nenhum driver o emite em `Invoice::$paymentMethod`. */ case AUTOMATIC_PIX = 'automatic_pix'; diff --git a/src/Exceptions/UnsupportedOperationException.php b/src/Exceptions/UnsupportedOperationException.php index 20bdbe5..f129c63 100644 --- a/src/Exceptions/UnsupportedOperationException.php +++ b/src/Exceptions/UnsupportedOperationException.php @@ -7,8 +7,9 @@ /** * Operação recusada pela lib antes de qualquer requisição, porque o gateway não oferece a - * capability (`gateway_limitation`) ou porque o gateway oferece e a lib ainda não a implementou - * para ele (`not_implemented`). + * capability (`gateway_limitation`), porque o gateway oferece e a lib ainda não a implementou + * para ele (`not_implemented`) ou porque a operação é conduzida pelo próprio gateway e a + * chamada pela lib não se aplica (`managed_by_gateway`). */ class UnsupportedOperationException extends MultiPaymentException { @@ -18,6 +19,9 @@ class UnsupportedOperationException extends MultiPaymentException /** O gateway oferece o recurso e a lib ainda não o implementou para ele. */ public const REASON_NOT_IMPLEMENTED = 'not_implemented'; + /** O próprio gateway conduz a operação; a chamada pela lib não se aplica. */ + public const REASON_MANAGED_BY_GATEWAY = 'managed_by_gateway'; + /** * Capability recusada, ou nulo quando a recusa vem de uma regra que nenhuma capability * descreve. @@ -34,7 +38,7 @@ class UnsupportedOperationException extends MultiPaymentException public string $gateway; /** - * Motivo da recusa: `gateway_limitation` ou `not_implemented`. + * Motivo da recusa: `gateway_limitation`, `not_implemented` ou `managed_by_gateway`. * * @var string */ @@ -110,6 +114,23 @@ public static function restricted(string $gateway, Capability $capability, strin return new static($message, $gateway, $capability, self::REASON_GATEWAY_LIMITATION); } + /** + * O próprio gateway conduz a operação, então a chamada pela lib não se aplica; a mensagem + * orienta o caminho que vale nesse gateway. + * + * @param string $gateway + * @param Capability $capability + * @param string $detail orientação acrescentada ao fim da mensagem + * @return static + */ + public static function managedByGateway(string $gateway, Capability $capability, string $detail = ''): static + { + $message = "No gateway {$gateway} a operação de [{$capability->value}] é conduzida pelo próprio gateway" + . ' e não se aplica pela lib.'; + + return new static(self::appendDetail($message, $detail), $gateway, $capability, self::REASON_MANAGED_BY_GATEWAY); + } + /** * Decide o motivo pela declaração do gateway: capability em `notYetImplemented()` é * `not_implemented`; fora das duas listas é `gateway_limitation`. diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index 5f170e2..9276948 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -189,7 +189,8 @@ public function emulated(): array * * `INSTALLMENTS`: o número de parcelas vai em `gatewayOptions['months']`, até o máximo da * conta (`multi-payment.gateways.iugu.max_installments`, 12 por padrão), e a lib não lê as - * parcelas da fatura paga. + * parcelas da fatura paga. `AUTOMATIC_PIX`: a recorrência nasce na fatura e a aplicação é + * o motor de recorrência; a assinatura não aceita o método. */ public function restrictions(): array { @@ -204,6 +205,11 @@ public function restrictions(): array . ' a lib não lê as parcelas da fatura paga.', maxInstallments: $maxInstallments, ), + Capability::AUTOMATIC_PIX->value => new CapabilityRestriction( + description: 'A recorrência nasce na fatura (Invoice com automaticPix e método pix) e' + . ' a aplicação é o motor de recorrência; a assinatura não aceita paymentMethod' + . ' automatic_pix.', + ), ]; } @@ -2737,6 +2743,16 @@ private function subscriptionToIuguData(Subscription $subscription, bool $creati // lista vazia: a Iugu usa os métodos habilitados na conta $payableWith = $subscription->resolvedPaymentMethods(); + // a mesma recusa do guard de capabilities do model, para a chamada direta ao driver: + // a assinatura com o método exige que o gateway agende as cobranças + if (in_array(PaymentMethod::AUTOMATIC_PIX, $payableWith, true)) { + throw UnsupportedOperationException::forGateway( + $this, + Capability::MANAGES_RECURRENCE, + 'Na Iugu a recorrência de Pix Automático nasce na fatura (Invoice com automaticPix' + . ' e método pix); a assinatura não aceita paymentMethod automatic_pix.' + ); + } if ( !empty($payableWith) && ($creating || !$this->isOriginalPayableWith($subscription, $payableWith)) diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index d90121f..926fb6c 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -141,6 +141,30 @@ class StripeGateway implements GatewayContract, SubscriptionContract, PlanContra */ private const BOLETO_DAYS_UNTIL_DUE = 3; + /** + * Prazo, em dias, entre o início do ciclo de cobrança e o débito de um mandato de Pix + * Automático: a Stripe notifica o pagador no início do ciclo e debita três dias depois. O + * mesmo prazo é o mínimo entre hoje e o `start_date` do mandato. + */ + private const PIX_MANDATE_DEBIT_OFFSET_DAYS = 3; + + /** + * Agenda do mandato de Pix Automático (`payment_schedule`) por intervalo de plano, no + * formato `interval:interval_count`. Intervalo sem agenda correspondente é recusado antes + * da requisição. Os valores seguem as periodicidades do Pix Automático. + */ + private const PIX_MANDATE_SCHEDULES = [ + 'week:1' => AutomaticPix::FREQUENCY_WEEKLY, + 'month:1' => AutomaticPix::FREQUENCY_MONTHLY, + 'month:3' => AutomaticPix::FREQUENCY_QUARTERLY, + 'month:6' => AutomaticPix::FREQUENCY_SEMIANNUAL, + 'month:12' => AutomaticPix::FREQUENCY_ANNUAL, + 'year:1' => AutomaticPix::FREQUENCY_ANNUAL, + ]; + + /** Status do Mandate da Stripe que encerra a recorrência. */ + private const MANDATE_STATUS_INACTIVE = 'inactive'; + /** Tipo de InvoicePayment cujo pagamento é um PaymentIntent. */ private const INVOICE_PAYMENT_TYPE_PAYMENT_INTENT = 'payment_intent'; @@ -222,6 +246,7 @@ public function capabilities(): array Capability::PERCENT_DISCOUNT, Capability::PLAN_CHANGE_PRORATION, Capability::MANAGES_RECURRENCE, + Capability::AUTOMATIC_PIX, ]; } @@ -231,7 +256,6 @@ public function capabilities(): array public function notYetImplemented(): array { return [ - Capability::AUTOMATIC_PIX, Capability::MULTIPLE_PAYMENT_METHODS, Capability::DELAYED_CAPTURE, ]; @@ -250,7 +274,9 @@ public function notYetImplemented(): array * plano e na atualização a data da próxima cobrança segue o ciclo. `COUPONS`: o cupom da * Stripe dura meses inteiros (`duration_in_months`), então `cycles` maior que 1 exige plano * com intervalo mensal ou anual, e `validUntil` vira meses inteiros contados da aplicação, - * arredondados para cima. + * arredondados para cima. `AUTOMATIC_PIX`: a recorrência é o mandato da assinatura, + * agendado pelo gateway; fatura avulsa com `automaticPix` e as operações de agendamento da + * lib não se aplicam. */ public function restrictions(): array { @@ -283,6 +309,12 @@ public function restrictions(): array description: 'nextBillingAt vale só na criação da assinatura; na troca de plano e na' . ' atualização a Stripe não aceita uma data arbitrária de próxima cobrança.', ), + Capability::AUTOMATIC_PIX->value => new CapabilityRestriction( + description: 'A recorrência é o mandato de uma assinatura (paymentMethod automatic_pix' + . ' na criação) e o gateway agenda as cobranças; fatura avulsa com automaticPix não' + . ' é aceita, e as operações de agendamento e de cancelamento de cobrança da lib' + . ' respondem managed_by_gateway.', + ), ]; } @@ -827,6 +859,16 @@ private static function retryAfterFromHeaders(?iterable $headers): ?int public function createInvoice(Invoice $invoice, ?string $idempotencyKey = null): Invoice { $this->assertSupportsAll($invoice->requiredCapabilities()); + // sem a recusa, a fatura seria criada em silêncio sem a recorrência pedida + if (!empty($invoice->automaticPix) || !empty($invoice->automaticPixCharge)) { + throw UnsupportedOperationException::notImplemented( + (string) $this, + Capability::AUTOMATIC_PIX, + 'Nesse gateway a recorrência de Pix Automático vive na assinatura: crie uma' + . ' Subscription com paymentMethod automatic_pix. A fatura avulsa com automaticPix' + . ' ainda não é suportada pela lib.' + ); + } $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); $paymentMethod = $this->invoicePaymentMethod($invoice); @@ -3020,7 +3062,12 @@ private function stripeListPage(callable $fetch, array $params, int $page): arra * cobrada na criação e a recusa sobe como `ChargingException` (`payment_behavior` * `error_if_incomplete`); com Pix a assinatura nasce com a primeira fatura em aberto até o * pagamento (`default_incomplete`), lida em `latestInvoice`, com a página hospedada em - * `url` para o pagador quitar. Com boleto a assinatura nasce ativa em modo de fatura + * `url` para o pagador quitar. Com Pix Automático (`paymentMethod` `AUTOMATIC_PIX`) a + * assinatura também nasce com a primeira fatura em aberto e registra o mandato em + * `payment_method_options.pix.mandate_options`, derivado do plano e de + * `Subscription::$automaticPix` (ver `pixMandateOptions()`); o pagador autoriza o mandato + * ao pagar a primeira fatura e a Stripe agenda as cobranças seguintes + * (`MANAGES_RECURRENCE`). Com boleto a assinatura nasce ativa em modo de fatura * enviada (`send_invoice`, com `days_until_due` de 3 dias, sobrescritível por * `gatewayOptions['days_until_due']`): a primeira fatura é finalizada na hora e volta em * `latestInvoice` aberta, com a página hospedada em `url`, onde o pagador gera o voucher; @@ -3065,12 +3112,22 @@ public function createSubscription(Subscription $subscription, ?string $idempote $stripeSubscriptionData['days_until_due'] = self::BOLETO_DAYS_UNTIL_DUE; } else { $stripeSubscriptionData['collection_method'] = 'charge_automatically'; - $stripeSubscriptionData['payment_behavior'] = $paymentMethod === PaymentMethod::PIX - ? 'default_incomplete' - : 'error_if_incomplete'; + // no Pix (avulso ou com mandato) o pagador precisa agir para a primeira fatura + $stripeSubscriptionData['payment_behavior'] = in_array( + $paymentMethod, + [PaymentMethod::PIX, PaymentMethod::AUTOMATIC_PIX], + true + ) ? 'default_incomplete' : 'error_if_incomplete'; } - if (!is_null($paymentMethod)) { + if ($paymentMethod === PaymentMethod::AUTOMATIC_PIX) { + $stripeSubscriptionData['payment_settings'] = [ + 'payment_method_types' => ['pix'], + 'payment_method_options' => [ + 'pix' => ['mandate_options' => $this->pixMandateOptions($subscription, $priceId)], + ], + ]; + } elseif (!is_null($paymentMethod)) { $stripeSubscriptionData['payment_settings'] = [ 'payment_method_types' => [self::paymentMethodToStripeType($paymentMethod)], ]; @@ -3218,7 +3275,30 @@ public function updateSubscription(Subscription $subscription, ?string $idempote $data['default_payment_method'] = $defaultPaymentMethodId; } - if (!is_null($paymentMethod) && !$this->isOriginalStripePaymentMethod($subscription, $paymentMethod)) { + // o mandato de Pix Automático é registrado na criação: numa assinatura que já o tem, + // o método lido do gateway não é uma troca (e sair dele exige encerrar o mandato); + // sem ele, a troca para o método ainda não é suportada + $originalHasPixMandate = is_object( + $subscription->original->payment_settings->payment_method_options->pix->mandate_options ?? null + ); + if ($paymentMethod === PaymentMethod::AUTOMATIC_PIX) { + if (!$originalHasPixMandate) { + throw UnsupportedOperationException::notImplemented( + (string) $this, + Capability::AUTOMATIC_PIX, + 'A lib só registra o mandato de Pix Automático na criação da assinatura.' + ); + } + } elseif (!is_null($paymentMethod) && $originalHasPixMandate) { + // sem a recusa, a comparação com os types originais (['pix']) engoliria a troca + // em silêncio e o mandato continuaria valendo + throw UnsupportedOperationException::notImplemented( + (string) $this, + Capability::AUTOMATIC_PIX, + 'A lib não implementa trocar o método de uma assinatura com mandato de Pix' + . ' Automático; cancele a assinatura e crie outra com o método desejado.' + ); + } elseif (!is_null($paymentMethod) && !$this->isOriginalStripePaymentMethod($subscription, $paymentMethod)) { $data['payment_settings'] = [ 'payment_method_types' => [self::paymentMethodToStripeType($paymentMethod)], ]; @@ -3540,8 +3620,9 @@ public function listSubscriptions(Customer $customer, int $page = 1, int $limit /** * Resolve o único método de pagamento da assinatura, como `invoicePaymentMethod()` faz * para a fatura; nulo quando o model não aponta método (a Stripe cobra o método padrão do - * cliente). Mais de um método é recusado (`MULTIPLE_PAYMENT_METHODS`), e um método fora - * do mapa do driver é recusado pela capability dele. + * cliente). Pix Automático é aceito (o mandato da assinatura); mais de um método é + * recusado (`MULTIPLE_PAYMENT_METHODS`), e um método fora do mapa do driver é recusado + * pela capability dele. * * @param Subscription $subscription * @return PaymentMethod|null @@ -3560,7 +3641,11 @@ private function subscriptionPaymentMethod(Subscription $subscription): ?Payment } $method = empty($methods) ? null : reset($methods); - if (!is_null($method) && !in_array($method, self::PAYMENT_METHOD_TYPES, true)) { + if ( + !is_null($method) + && $method !== PaymentMethod::AUTOMATIC_PIX + && !in_array($method, self::PAYMENT_METHOD_TYPES, true) + ) { throw UnsupportedOperationException::forGateway($this, Capability::forPaymentMethod($method)); } @@ -3892,6 +3977,100 @@ private function stripePriceRecurring(string $priceId): array ]; } + /** + * Monta o `mandate_options` do Pix Automático de uma assinatura, a partir do plano e de + * `Subscription::$automaticPix`. A agenda (`payment_schedule`) vem da frequência informada + * em `automaticPix` ou do intervalo do plano (`PIX_MANDATE_SCHEDULES`); intervalo sem + * agenda é recusado antes da requisição. O valor é a soma do plano com os itens + * recorrentes; com desconto na assinatura o débito varia entre ciclos e o valor vira um + * teto (`amount_type` `maximum`). O `start_date` vem de `automaticPix->startsAt`, senão do + * fim do trial ou de `nextBillingAt`, com o mínimo de três dias a partir de hoje (data + * derivada anterior ao mínimo é elevada a ele; data informada anterior é recusada). O + * `end_date` vem de `automaticPix->endsAt` e o `reference` (nome exibido no aplicativo do + * banco) da configuração `multi-payment.gateways.stripe.pix_mandate_reference`. + * + * @param Subscription $subscription + * @param string $priceId + * @return array + * @throws ModelAttributeValidationException|UnsupportedOperationException + * @throws GatewayException|GatewayNotAvailableException + */ + private function pixMandateOptions(Subscription $subscription, string $priceId): array + { + $automaticPix = $subscription->automaticPix; + $stripePrice = $this->stripeRequest(function () use ($priceId) { + return $this->client->prices->retrieve($priceId); + }); + + $schedule = $automaticPix?->frequency; + if (is_null($schedule)) { + $interval = ($stripePrice->recurring->interval ?? 'month') + . ':' . ($stripePrice->recurring->interval_count ?? 1); + $schedule = self::PIX_MANDATE_SCHEDULES[$interval] ?? null; + if (is_null($schedule)) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::AUTOMATIC_PIX, + 'O Pix Automático aceita agenda semanal, mensal, trimestral, semestral ou anual,' + . " e o intervalo do plano [{$interval}] não corresponde a nenhuma delas;" + . ' informe a frequência em automaticPix.' + ); + } + } + + if (is_null($stripePrice->unit_amount ?? null)) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::AUTOMATIC_PIX, + 'O mandato de Pix Automático precisa do valor por ciclo, e o Price do plano' + . " [{$priceId}] não tem unit_amount fixo (preço por camadas ou por uso);" + . ' use um plano de valor fixo.' + ); + } + $amount = (int) $stripePrice->unit_amount; + foreach ($subscription->items ?? [] as $item) { + if ($item instanceof SubscriptionItem && $item->recurring && !is_null($item->amount)) { + $amount += (int) $item->amount * (int) ($item->quantity ?? 1); + } + } + + $minimumStart = Carbon::now()->addDays(self::PIX_MANDATE_DEBIT_OFFSET_DAYS)->startOfDay(); + // as datas derivadas usam o início do dia: dentro do mesmo dia, o retry com a mesma + // chave de idempotência reproduz o payload + $startsAt = $automaticPix?->startsAt + ?? $subscription->trialEndsAt + ?? (!empty($subscription->trialDays) ? Carbon::now()->addDays($subscription->trialDays)->startOfDay() : null) + ?? $subscription->nextBillingAt; + if (!is_null($automaticPix?->startsAt) && $automaticPix->startsAt->lt($minimumStart)) { + throw ModelAttributeValidationException::invalid( + 'AutomaticPix', + 'startsAt', + 'startsAt must be at least ' . self::PIX_MANDATE_DEBIT_OFFSET_DAYS + . ' days from now for automatic pix on the stripe gateway' + ); + } + if (is_null($startsAt) || $startsAt->lt($minimumStart)) { + $startsAt = $minimumStart; + } + + $mandateOptions = [ + 'amount' => $amount, + 'amount_type' => empty($subscription->discounts) ? 'fixed' : 'maximum', + 'payment_schedule' => $schedule, + 'start_date' => $startsAt->getTimestamp(), + ]; + + $reference = Config::get('multi-payment.gateways.stripe.pix_mandate_reference'); + if (!empty($reference)) { + $mandateOptions['reference'] = $reference; + } + if (!is_null($automaticPix?->endsAt)) { + $mandateOptions['end_date'] = $automaticPix->endsAt->getTimestamp(); + } + + return $mandateOptions; + } + /** * Itens da atualização declarativa: os subscription items atuais fora da lista desejada * são removidos (o item do plano fica), item com `id` tem a quantidade atualizada e item @@ -4174,23 +4353,35 @@ private function parseStripeSubscription( : (array) $metadata; } - $defaultPaymentMethod = $stripeSubscription->default_payment_method ?? null; - $method = is_object($defaultPaymentMethod) - ? (self::PAYMENT_METHOD_TYPES[$defaultPaymentMethod->type ?? ''] ?? null) - : null; - $types = $stripeSubscription->payment_settings->payment_method_types ?? null; - if (is_array($types)) { - $methods = array_values(array_filter(array_map( - static fn ($type) => self::PAYMENT_METHOD_TYPES[$type] ?? null, - $types - ))); - if (!empty($methods)) { - $subscription->availablePaymentMethods = $methods; - $method = $method ?? (count($methods) === 1 ? $methods[0] : null); + $pixMandateOptions = $stripeSubscription->payment_settings->payment_method_options->pix->mandate_options ?? null; + if (is_object($pixMandateOptions)) { + // o mandato faz do Pix Automático o método da assinatura; a lista fica de fora + // para um save() posterior não recusar o método como ausente dela + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + $subscription->automaticPix = $this->parsePixMandateOptions( + $pixMandateOptions, + $subscription->automaticPix, + $planItem->current_period_end ?? null + ); + } else { + $defaultPaymentMethod = $stripeSubscription->default_payment_method ?? null; + $method = is_object($defaultPaymentMethod) + ? (self::PAYMENT_METHOD_TYPES[$defaultPaymentMethod->type ?? ''] ?? null) + : null; + $types = $stripeSubscription->payment_settings->payment_method_types ?? null; + if (is_array($types)) { + $methods = array_values(array_filter(array_map( + static fn ($type) => self::PAYMENT_METHOD_TYPES[$type] ?? null, + $types + ))); + if (!empty($methods)) { + $subscription->availablePaymentMethods = $methods; + $method = $method ?? (count($methods) === 1 ? $methods[0] : null); + } + } + if (!is_null($method)) { + $subscription->paymentMethod = $method; } - } - if (!is_null($method)) { - $subscription->paymentMethod = $method; } if ($withLatestInvoice) { @@ -4208,6 +4399,44 @@ private function parseStripeSubscription( return $subscription; } + /** + * Converte o `mandate_options` do Pix Automático de uma assinatura no model genérico. A + * frequência é a agenda (`payment_schedule`), as datas vêm de `start_date` e `end_date` e + * as duas datas derivadas seguem o ciclo: a notificação de pré-débito sai no início do + * ciclo (`current_period_end` da leitura) e o débito acontece três dias depois. O id e o + * status do mandato não vêm na assinatura; chegam pelo webhook `mandate.updated` ou pela + * consulta de cancelamentos. + * + * @param object $mandateOptions + * @param AutomaticPix|null $automaticPix + * @param int|null $currentPeriodEnd + * @return AutomaticPix + */ + private function parsePixMandateOptions( + object $mandateOptions, + ?AutomaticPix $automaticPix, + ?int $currentPeriodEnd + ): AutomaticPix { + $automaticPix ??= new AutomaticPix(); + + $automaticPix->frequency = $mandateOptions->payment_schedule ?? $automaticPix->frequency; + if (!empty($mandateOptions->start_date)) { + $automaticPix->startsAt = Carbon::createFromTimestamp($mandateOptions->start_date); + } + if (!empty($mandateOptions->end_date)) { + $automaticPix->endsAt = Carbon::createFromTimestamp($mandateOptions->end_date); + } + if (!empty($currentPeriodEnd)) { + $automaticPix->preDebitNotificationAt = Carbon::createFromTimestamp($currentPeriodEnd); + $automaticPix->nextDebitAt = Carbon::createFromTimestamp($currentPeriodEnd) + ->addDays(self::PIX_MANDATE_DEBIT_OFFSET_DAYS); + } + $automaticPix->gateway = 'stripe'; + $automaticPix->original = $mandateOptions; + + return $automaticPix; + } + /** * Converte um subscription item da Stripe (fora o do plano) num item de assinatura. A * descrição vem do Product expandido, senão do apelido do Price. @@ -4232,46 +4461,140 @@ private function parseStripeSubscriptionItem(object $stripeItem): SubscriptionIt /** * @inheritDoc + * + * No Stripe a Stripe agenda e retenta cada débito do mandato; a operação lança + * `UnsupportedOperationException` com `reason` `managed_by_gateway` sem nenhuma + * requisição. */ public function rescheduleAutomaticPixPayment(Invoice $invoice, ?string $idempotencyKey = null): Invoice { - throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); + throw UnsupportedOperationException::managedByGateway( + (string) $this, + Capability::AUTOMATIC_PIX, + 'A Stripe agenda e retenta as cobranças do mandato; não há reagendamento pela lib.' + ); } /** * @inheritDoc + * + * No Stripe cada débito do mandato é conduzido pela Stripe; a operação lança + * `UnsupportedOperationException` com `reason` `managed_by_gateway` sem nenhuma + * requisição. */ public function cancelAutomaticPixScheduledPayment( AutomaticPixCharge $charge, ?string $idempotencyKey = null ): AutomaticPixCancellation { - throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); + throw UnsupportedOperationException::managedByGateway( + (string) $this, + Capability::AUTOMATIC_PIX, + 'A Stripe conduz cada débito do mandato; não há cancelamento de um agendamento pela lib.' + ); } /** * @inheritDoc + * + * No Stripe o mandato vive na assinatura e é encerrado com ela; a operação lança + * `UnsupportedOperationException` com `reason` `managed_by_gateway` orientando o + * cancelamento da assinatura. */ public function cancelAutomaticPixRecurrence( AutomaticPix $automaticPix, ?string $idempotencyKey = null ): AutomaticPixCancellation { - throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); + throw UnsupportedOperationException::managedByGateway( + (string) $this, + Capability::AUTOMATIC_PIX, + 'O mandato vive na assinatura: cancele a assinatura (cancelSubscription) e a Stripe o encerra.' + ); } /** * @inheritDoc + * + * No Stripe não existe um objeto de cancelamento: a consulta lê o Mandate + * (`recurrenceId`, id `mandate_`) e responde pelo status dele. Mandato `inactive` devolve + * o cancelamento como `completed`; mandato ainda ativo lança `NotFoundException`. */ public function getAutomaticPixCancellation(AutomaticPixCancellation $cancellation): AutomaticPixCancellation { - throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); + if (empty($cancellation->recurrenceId)) { + throw ModelAttributeValidationException::required('AutomaticPixCancellation', 'recurrenceId'); + } + + $stripeMandate = $this->retrieveStripeMandate($cancellation->recurrenceId); + if (($stripeMandate->status ?? null) !== self::MANDATE_STATUS_INACTIVE) { + throw new NotFoundException( + "The automatic pix recurrence [{$cancellation->recurrenceId}] has no cancellation on stripe:" + . ' the mandate is still active.' + ); + } + + return $this->parseMandateCancellation($stripeMandate, $cancellation); } /** * @inheritDoc + * + * No Stripe não existe um objeto de cancelamento: a consulta lê o Mandate (`id` do model, + * `mandate_`) e devolve no máximo um item, `completed`, quando o mandato está `inactive` + * (lista vazia com o mandato ativo ou fora da primeira página). A leitura também preenche + * `mandateId` e `mandateStatus` no model informado. */ public function listAutomaticPixCancellations(AutomaticPix $automaticPix, int $page = 1, int $limit = 100): array { - throw UnsupportedOperationException::forGateway($this, Capability::AUTOMATIC_PIX); + if (empty($automaticPix->id)) { + throw ModelAttributeValidationException::required('AutomaticPix', 'id'); + } + + $stripeMandate = $this->retrieveStripeMandate($automaticPix->id); + $automaticPix->mandateId = $stripeMandate->id ?? $automaticPix->id; + $automaticPix->mandateStatus = $stripeMandate->status ?? null; + + if ($page > 1 || ($stripeMandate->status ?? null) !== self::MANDATE_STATUS_INACTIVE) { + return []; + } + + return [$this->parseMandateCancellation($stripeMandate)]; + } + + /** + * Lê um Mandate da Stripe pelo id. + * + * @param string $mandateId + * @return \Stripe\Mandate + * @throws GatewayException|GatewayNotAvailableException + */ + private function retrieveStripeMandate(string $mandateId) + { + return $this->stripeRequest(function () use ($mandateId) { + return $this->client->mandates->retrieve($mandateId); + }); + } + + /** + * Converte um Mandate `inactive` no cancelamento genérico: `completed`, com o id do + * mandato como recorrência. A Stripe não informa a data do encerramento. + * + * @param object $stripeMandate + * @param AutomaticPixCancellation|null $cancellation + * @return AutomaticPixCancellation + */ + private function parseMandateCancellation( + object $stripeMandate, + ?AutomaticPixCancellation $cancellation = null + ): AutomaticPixCancellation { + $cancellation ??= new AutomaticPixCancellation(); + + $cancellation->id ??= $stripeMandate->id ?? null; + $cancellation->recurrenceId ??= $stripeMandate->id ?? null; + $cancellation->status = AutomaticPixCancellation::STATUS_COMPLETED; + $cancellation->gateway = 'stripe'; + $cancellation->original = $stripeMandate; + + return $cancellation; } /** diff --git a/src/Models/AutomaticPix.php b/src/Models/AutomaticPix.php index 2b90866..c854393 100644 --- a/src/Models/AutomaticPix.php +++ b/src/Models/AutomaticPix.php @@ -30,13 +30,53 @@ class AutomaticPix extends Model public ?Carbon $endsAt = null; public string $retryPolicy = self::RETRY_POLICY_NOT_ALLOWED; public ?string $status = null; + + /** + * Id do mandato no gateway, quando a recorrência é registrada como mandato (Stripe). No + * Stripe coincide com `id`; a leitura da assinatura não o traz, então ele chega pelo + * webhook `mandate.updated` ou preenchido pela consulta de cancelamentos. + * + * @var string|null + */ + public ?string $mandateId = null; + + /** + * Status do mandato no gateway (`active`, `inactive`, `pending`), preenchido quando o + * mandato é lido. + * + * @var string|null + */ + public ?string $mandateStatus = null; + + /** + * Data prevista do próximo débito na conta do pagador. No Stripe o débito acontece três + * dias depois do início do ciclo de cobrança. + * + * @var Carbon|null + */ + public ?Carbon $nextDebitAt = null; + + /** + * Data em que o pagador recebe a notificação de pré-débito, três dias antes do débito. + * + * @var Carbon|null + */ + public ?Carbon $preDebitNotificationAt = null; + public ?string $gateway = null; public $original = null; /** @inheritDoc */ public function fill(array $data): void { - foreach (['starts_at' => 'startsAt', 'ends_at' => 'endsAt'] as $key => $attribute) { + foreach ( + [ + 'starts_at' => 'startsAt', + 'ends_at' => 'endsAt', + 'next_debit_at' => 'nextDebitAt', + 'pre_debit_notification_at' => 'preDebitNotificationAt', + ] as $key => $attribute + ) { if (!empty($data[$key])) { $this->{$attribute} = $data[$key] instanceof Carbon ? $data[$key] diff --git a/src/Models/AutomaticPixCancellation.php b/src/Models/AutomaticPixCancellation.php index 621b8bc..80ce86c 100644 --- a/src/Models/AutomaticPixCancellation.php +++ b/src/Models/AutomaticPixCancellation.php @@ -11,6 +11,9 @@ class AutomaticPixCancellation extends Model { public const STATUS_REQUESTED = 'requested'; + /** Cancelamento concluído: a recorrência não gera mais cobranças (no Stripe, mandato `inactive`). */ + public const STATUS_COMPLETED = 'completed'; + public ?string $id = null; public ?string $recurrenceId = null; public ?string $paymentId = null; diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 8f71c5a..6bbeeb8 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -10,6 +10,7 @@ use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Idempotency\IdempotencyKey; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; /** @@ -435,6 +436,7 @@ public function save(GatewayContract|string|null $gateway = null, bool $validate // gravado no model prevalece) $gateway = ConfigurationHelper::resolveGateway($this->gatewayForSave($gateway)); $this->assertGatewaySupports($gateway); + $this->assertAutomaticPixBelongsToTheInvoice($gateway); if (empty($this->customer->id)) { $this->customer->save($gateway, $validate, IdempotencyKey::derive($idempotencyKey, 'customer')); } @@ -525,9 +527,9 @@ public static function assertCreditCardIsPayable(string $model, ?CreditCard $cre /** * Na criação, além do que o `Model` exige, a fatura precisa da capability de cada método * de `resolvedPaymentMethods()`, de `MULTIPLE_PAYMENT_METHODS` quando há mais de um, de - * `AUTOMATIC_PIX` quando `automaticPix` está preenchido e de `RAW_CARD_DATA` quando o - * cartão vem com os dados crus (sem `id` nem `token`). Com `id` preenchido, só o que o - * `Model` exige. + * `AUTOMATIC_PIX` quando `automaticPix` ou `automaticPixCharge` está preenchido e de + * `RAW_CARD_DATA` quando o cartão vem com os dados crus (sem `id` nem `token`). Com `id` + * preenchido, só o que o `Model` exige. * * @return Capability[] * @throws ModelAttributeValidationException método de pagamento fora de `PaymentMethod::selectable()` @@ -547,7 +549,7 @@ public function requiredCapabilities(): array if (count($methods) > 1) { $capabilities[] = Capability::MULTIPLE_PAYMENT_METHODS; } - if (!empty($this->automaticPix)) { + if (!empty($this->automaticPix) || !empty($this->automaticPixCharge)) { $capabilities[] = Capability::AUTOMATIC_PIX; } if (!empty($this->creditCard) && empty($this->creditCard->id) && empty($this->creditCard->token)) { @@ -557,6 +559,34 @@ public function requiredCapabilities(): array return array_values(array_unique($capabilities, SORT_REGULAR)); } + /** + * Recusa `automaticPix` e `automaticPixCharge` num gateway que gerencia a recorrência + * (`MANAGES_RECURRENCE`): nele o mandato vive na assinatura, e a fatura avulsa com + * recorrência ainda não é suportada pela lib. A recusa acontece antes de qualquer + * requisição, inclusive antes de criar o cliente. + * + * @param GatewayContract $gateway + * @return void + * @throws UnsupportedOperationException + */ + private function assertAutomaticPixBelongsToTheInvoice(GatewayContract $gateway): void + { + if ( + (empty($this->automaticPix) && empty($this->automaticPixCharge)) + || !$gateway->supports(Capability::MANAGES_RECURRENCE) + ) { + return; + } + + throw UnsupportedOperationException::notImplemented( + (string) $gateway, + Capability::AUTOMATIC_PIX, + 'Nesse gateway a recorrência de Pix Automático vive na assinatura: crie uma' + . ' Subscription com paymentMethod automatic_pix. A fatura avulsa com automaticPix' + . ' ainda não é suportada pela lib.' + ); + } + /** * Diz se o dinheiro da fatura foi recebido; delega a `InvoiceStatus::isSettled()`. String * fora do enum devolve falso. diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index 91da775..a532adb 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -60,12 +60,14 @@ class Subscription extends Model /** * Além de `SUBSCRIPTIONS`, a assinatura precisa da capability de cada método de * `resolvedPaymentMethods()` (e de `MULTIPLE_PAYMENT_METHODS` quando há mais de um), de - * `RAW_CARD_DATA` quando o cartão vem com os dados crus (sem `id` nem `token`), de - * `PERCENT_DISCOUNT` quando algum desconto é percentual e de `COUPONS` quando algum - * desconto é limitado a mais de um ciclo ou tem data de validade. + * `MANAGES_RECURRENCE` quando o método é Pix Automático (a assinatura com mandato só + * existe onde o gateway agenda as cobranças), de `RAW_CARD_DATA` quando o cartão vem com + * os dados crus (sem `id` nem `token`), de `PERCENT_DISCOUNT` quando algum desconto é + * percentual e de `COUPONS` quando algum desconto é limitado a mais de um ciclo ou tem + * data de validade. * * @return Capability[] - * @throws ModelAttributeValidationException método de pagamento fora de `PaymentMethod::selectable()` + * @throws ModelAttributeValidationException método de pagamento que a assinatura não aceita */ public function requiredCapabilities(): array { @@ -75,6 +77,9 @@ public function requiredCapabilities(): array foreach ($methods as $method) { $capabilities[] = Capability::forPaymentMethod($method); } + if (in_array(PaymentMethod::AUTOMATIC_PIX, $methods, true)) { + $capabilities[] = Capability::MANAGES_RECURRENCE; + } if (count($methods) > 1) { $capabilities[] = Capability::MULTIPLE_PAYMENT_METHODS; } @@ -160,6 +165,16 @@ public function requiredCapabilities(): array */ public ?CreditCard $creditCard = null; + /** + * Estado da recorrência de Pix Automático da assinatura, preenchido na leitura quando ela + * é cobrada por mandato (Stripe). Na escrita é opcional e refina o mandato: `startsAt` + * (primeiro débito, no mínimo três dias à frente), `endsAt` (fim do mandato) e `frequency` + * (agenda, derivada do intervalo do plano quando ausente). + * + * @var AutomaticPix|null + */ + public ?AutomaticPix $automaticPix = null; + /** * Duração do período de teste em dias, contada do momento da requisição. O driver a * converte em `trialEndsAt` e a zera, então o model devolvido traz a data; incompatível @@ -270,6 +285,12 @@ public function fill(array $data): void $data['latest_invoice'] = $invoice; } + if (!empty($data['automatic_pix']) && is_array($data['automatic_pix'])) { + $automaticPix = new AutomaticPix(); + $automaticPix->fill($data['automatic_pix']); + $data['automatic_pix'] = $automaticPix; + } + $data['items'] = $this->fillCollection($data['items'] ?? null, SubscriptionItem::class); $data['discounts'] = $this->fillCollection($data['discounts'] ?? null, SubscriptionDiscount::class); @@ -323,7 +344,7 @@ public function toArray(): array } } - foreach (['customer', 'credit_card', 'latest_invoice'] as $key) { + foreach (['customer', 'credit_card', 'latest_invoice', 'automatic_pix'] as $key) { if (!empty($array[$key])) { $array[$key] = $array[$key]->toArray(); } @@ -354,8 +375,10 @@ public function resolvedPaymentMethod(): ?PaymentMethod * string apensada por `[]=` entra no array sem conversão); senão o método de * `resolvedPaymentMethod()`; senão lista vazia. Lança `ModelAttributeValidationException` * para valor fora de `PaymentMethod::selectable()`, para `paymentMethod` fora da lista - * informada e para `creditCard` sem cartão entre os métodos resultantes (num model lido do - * gateway a lista vem preenchida: para trocar o método, troque a lista ou a zere). + * informada, para `creditCard` sem cartão entre os métodos resultantes (num model lido do + * gateway a lista vem preenchida: para trocar o método, troque a lista ou a zere) e para + * `automaticPix` sem Pix Automático como método (o estado do mandato só existe nesse + * método; sem a recusa, ele seria descartado em silêncio). * * @return PaymentMethod[] * @throws ModelAttributeValidationException @@ -379,6 +402,15 @@ public function resolvedPaymentMethods(): array Invoice::assertCreditCardIsPayable($this->getClassName(), $this->creditCard, $methods); + if (!empty($this->automaticPix) && !in_array(PaymentMethod::AUTOMATIC_PIX, $methods, true)) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'automaticPix', + 'automaticPix was given but automatic_pix is not the payment method;' + . ' set paymentMethod to automatic_pix or remove it' + ); + } + return $methods; } @@ -393,23 +425,34 @@ protected function validateCreditCardAttribute(): void /** * Na escrita, `paymentMethod` precisa ser um método selecionável - * (`PaymentMethod::selectable()`). + * (`PaymentMethod::selectable()`) ou Pix Automático, que na assinatura é um método de + * primeira classe (mandato). * * @return void * @throws ModelAttributeValidationException */ protected function validatePaymentMethodAttribute(): void { - if (!in_array($this->paymentMethod, PaymentMethod::selectable(), true)) { - $accepted = implode(', ', array_column(PaymentMethod::selectable(), 'value')); + $accepted = [...PaymentMethod::selectable(), PaymentMethod::AUTOMATIC_PIX]; + if (!in_array($this->paymentMethod, $accepted, true)) { + $acceptedValues = implode(', ', array_column($accepted, 'value')); throw ModelAttributeValidationException::invalid( $this->getClassName(), 'paymentMethod', - "paymentMethod must be one of: {$accepted}" + "paymentMethod must be one of: {$acceptedValues}" ); } } + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateAutomaticPixAttribute(): void + { + $this->automaticPix->validate(); + } + /** * @return void * @throws ModelAttributeValidationException @@ -499,7 +542,10 @@ protected function attributesExtraValidation(array $attributes): void ); } - if (in_array('paymentMethod', $attributes) && in_array('creditCard', $attributes)) { + if ( + in_array('automaticPix', $attributes) + || (in_array('paymentMethod', $attributes) && in_array('creditCard', $attributes)) + ) { $this->resolvedPaymentMethods(); } diff --git a/src/config/multi-payment.php b/src/config/multi-payment.php index 7e44d5c..23dfb46 100644 --- a/src/config/multi-payment.php +++ b/src/config/multi-payment.php @@ -76,6 +76,8 @@ 'api_key' => env('STRIPE_APIKEY'), 'customer_column' => 'stripe_id', 'class' => \Potelo\MultiPayment\Gateways\StripeGateway::class, + // nome exibido no aplicativo do banco do pagador no mandato de Pix Automático + 'pix_mandate_reference' => env('STRIPE_PIX_MANDATE_REFERENCE'), ], ], ]; \ No newline at end of file diff --git a/tests/Integration/StripeAutomaticPixTest.php b/tests/Integration/StripeAutomaticPixTest.php new file mode 100644 index 0000000..dd5ea05 --- /dev/null +++ b/tests/Integration/StripeAutomaticPixTest.php @@ -0,0 +1,114 @@ + Config::get('multi-payment.gateways.stripe.api_key'), + 'stripe_version' => StripeGateway::STRIPE_API_VERSION, + ]); + + foreach ($this->subscriptionsCriadas as $id) { + try { + $client->subscriptions->cancel($id); + } catch (\Throwable $e) { + // limpeza é best effort: assinatura já cancelada pelo teste + } + } + + foreach ($this->pricesCriados as $id) { + try { + $client->prices->update($id, ['active' => false]); + } catch (\Throwable $e) { + // limpeza é best effort: falha ao arquivar não invalida o teste + } + } + + parent::tearDown(); + } + + /** + * A assinatura com Pix Automático nasce com a primeira fatura em aberto + * (`default_incomplete`) e o mandato registrado: o model volta com o método + * `AUTOMATIC_PIX`, `automaticPix` preenchido e `startsAt` no mínimo três dias à frente. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCreateASubscriptionWithAPixMandate(string $gateway): void + { + $identifier = 'multipayment-pix-automatico-' . uniqid(); + $plan = new Plan(); + $plan->name = 'Plano Pix Automático'; + $plan->identifier = $identifier; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + $plan->save($gateway); + $this->pricesCriados[] = $plan->id; + + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + + $subscription = MultiPayment::setGateway($gateway)->newSubscription() + ->setPlanId($identifier) + ->setCustomer($customer) + ->setPaymentMethod(PaymentMethod::AUTOMATIC_PIX) + ->create(); + $this->subscriptionsCriadas[] = $subscription->id; + + $this->assertSame(SubscriptionStatus::PENDING, $subscription->status); + $this->assertSame(PaymentMethod::AUTOMATIC_PIX, $subscription->paymentMethod); + $this->assertInstanceOf(AutomaticPix::class, $subscription->automaticPix); + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $subscription->automaticPix->frequency); + $this->assertTrue( + $subscription->automaticPix->startsAt->greaterThanOrEqualTo(Carbon::now()->addDays(3)->startOfDay()) + ); + $this->assertNotNull($subscription->latestInvoice); + $this->assertNotEmpty($subscription->latestInvoice->url); + + $read = MultiPayment::setGateway($gateway)->getSubscription($subscription->id); + $this->assertSame(PaymentMethod::AUTOMATIC_PIX, $read->paymentMethod); + $this->assertInstanceOf(AutomaticPix::class, $read->automaticPix); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index f001fe7..60179d2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -17,6 +17,19 @@ protected function setUp(): void return; } + // Pix Automático aguarda liberação na conta Stripe; os testes do grupo só rodam com a + // flag ligada no ambiente (e dispensam o sleep, porque não tocam na Iugu) + if (in_array('pix-automatico-stripe', $this->groups(), true)) { + if (!filter_var(env('STRIPE_PIX_AUTOMATICO_ENABLED'), FILTER_VALIDATE_BOOL)) { + $this->markTestSkipped( + 'A conta Stripe ainda não tem Pix Automático liberado;' + . ' defina STRIPE_PIX_AUTOMATICO_ENABLED=true para rodar.' + ); + } + + return; + } + // pausa para respeitar o rate limit da sandbox da Iugu — a da Stripe não tem esse limite; // o gateway do teste vem do dataProvider (primeiro argumento, posicional ou chave 'gateway') $providedData = $this->providedData(); diff --git a/tests/Unit/CapabilityGuardsTest.php b/tests/Unit/CapabilityGuardsTest.php index ebb85d6..e4f15fe 100644 --- a/tests/Unit/CapabilityGuardsTest.php +++ b/tests/Unit/CapabilityGuardsTest.php @@ -131,6 +131,21 @@ public function testAutomaticPixInvoiceOnStripeFailsBeforeCreatingTheCustomer(): $this->assertNotImplemented(Capability::AUTOMATIC_PIX, fn () => $builder->create()); } + /** + * A cobrança sobre uma recorrência existente (`automaticPixCharge` sem `automaticPix`) + * recebe a mesma recusa da recorrência nova, antes de criar o cliente. + */ + public function testAutomaticPixChargeInvoiceOnStripeFailsBeforeCreatingTheCustomer(): void + { + $builder = (new MultiPayment('stripe'))->newInvoice() + ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->addAvailablePaymentMethod(PaymentMethod::PIX) + ->addAutomaticPixCharge('Mensalidade do plano'); + + $this->assertNotImplemented(Capability::AUTOMATIC_PIX, fn () => $builder->create()); + } + public function testRawCardInvoiceOnStripeFailsBeforeCreatingTheCustomer(): void { $builder = (new MultiPayment('stripe'))->newInvoice() @@ -148,6 +163,31 @@ public function testRawCardInvoiceOnStripeFailsBeforeCreatingTheCustomer(): void $this->assertSame([], $this->stripeHttp->calls); } + /** + * A assinatura com Pix Automático exige `MANAGES_RECURRENCE`, então na Iugu (onde a + * aplicação é o motor de recorrência) ela é recusada antes de criar o cliente. + */ + public function testAutomaticPixSubscriptionOnIuguFailsBeforeCreatingTheCustomer(): void + { + $api = new QueuedIuguApiRequest([]); + $customer = new Customer(); + $customer->name = 'Fulano'; + $customer->email = 'fulano@exemplo.com'; + + $builder = (new MultiPayment(new IuguGateway($api)))->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomer($customer) + ->setPaymentMethod(PaymentMethod::AUTOMATIC_PIX); + + $this->assertUnsupported( + Capability::MANAGES_RECURRENCE, + UnsupportedOperationException::REASON_GATEWAY_LIMITATION, + 'iugu', + fn () => $builder->create() + ); + $this->assertCount(0, $api->calls); + } + public function testPercentDiscountSubscriptionOnIuguFailsBeforeCreatingTheCustomer(): void { $api = new QueuedIuguApiRequest([]); @@ -384,7 +424,8 @@ public function testFacadeExposesTheDeclarations(): void $this->assertSame($iugu, $multiPayment->gateway($iugu)); $this->assertTrue($multiPayment->supports(Capability::PIX)); $this->assertTrue($multiPayment->supports(Capability::BANK_SLIP)); - $this->assertFalse($multiPayment->supports(Capability::AUTOMATIC_PIX)); + $this->assertTrue($multiPayment->supports(Capability::AUTOMATIC_PIX)); + $this->assertFalse($multiPayment->supports(Capability::DELAYED_CAPTURE)); $this->assertTrue($multiPayment->supports(Capability::AUTOMATIC_PIX, 'iugu')); $this->assertTrue($multiPayment->gateway('iugu')->supports(Capability::INSTALLMENTS)); $this->assertSame((new StripeGateway())->capabilities(), $multiPayment->capabilities()); @@ -407,7 +448,7 @@ public function testLaravelFacadeResolvesTheCapabilityMethods(): void Facade::getFacadeApplication()->bind('multiPayment', fn () => new MultiPayment('stripe')); $this->assertTrue(\Potelo\MultiPayment\Facades\MultiPayment::supports(Capability::PIX)); - $this->assertFalse(\Potelo\MultiPayment\Facades\MultiPayment::supports(Capability::AUTOMATIC_PIX)); + $this->assertFalse(\Potelo\MultiPayment\Facades\MultiPayment::supports(Capability::DELAYED_CAPTURE)); $this->assertContains(Capability::SUBSCRIPTIONS, \Potelo\MultiPayment\Facades\MultiPayment::capabilities('iugu')); $this->assertInstanceOf(StripeGateway::class, \Potelo\MultiPayment\Facades\MultiPayment::gateway()); } diff --git a/tests/Unit/Gateways/GatewayCapabilitiesTest.php b/tests/Unit/Gateways/GatewayCapabilitiesTest.php index d83c564..a5e5e80 100644 --- a/tests/Unit/Gateways/GatewayCapabilitiesTest.php +++ b/tests/Unit/Gateways/GatewayCapabilitiesTest.php @@ -73,7 +73,7 @@ public static function matrixProvider(): array Capability::CREDIT_CARD->name => [self::SUPPORTED, self::SUPPORTED], Capability::PIX->name => [self::SUPPORTED, self::SUPPORTED], Capability::BANK_SLIP->name => [self::SUPPORTED, self::SUPPORTED], - Capability::AUTOMATIC_PIX->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], + Capability::AUTOMATIC_PIX->name => [self::SUPPORTED, self::SUPPORTED], Capability::MULTIPLE_PAYMENT_METHODS->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], Capability::RAW_CARD_DATA->name => [self::SUPPORTED, self::LIMITATION], Capability::CARD_SETUP_AUTHENTICATION->name => [self::LIMITATION, self::SUPPORTED], @@ -271,7 +271,7 @@ public function testTheFacadeExposesSupportsAllAndTheRestrictions(): void $payment = new MultiPayment('stripe'); $this->assertTrue($payment->supportsAll(Capability::CREDIT_CARD, Capability::PIX)); - $this->assertFalse($payment->supportsAll(Capability::CREDIT_CARD, Capability::AUTOMATIC_PIX)); + $this->assertFalse($payment->supportsAll(Capability::CREDIT_CARD, Capability::DELAYED_CAPTURE)); $this->assertSame(['visa', 'mastercard'], $payment->restriction(Capability::CREDIT_CARD)->allowedBrands); $this->assertNull($payment->restriction(Capability::PIX)); $this->assertSame(12, $payment->restriction(Capability::INSTALLMENTS, 'iugu')->maxInstallments); diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php index f332445..1f1c88a 100644 --- a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -108,6 +108,32 @@ public function testCreateSubscriptionMapsGenericFieldsToIuguPayload(): void ], $call['data']); } + /** + * Na Iugu a recorrência de Pix Automático nasce na fatura: a assinatura com o método é + * recusada pela falta de `MANAGES_RECURRENCE`, a mesma capability do guard do model, sem + * nenhuma requisição. + */ + public function testCreateSubscriptionRejectsAutomaticPixAsThePaymentMethod(): void + { + $api = new QueuedIuguApiRequest([]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1']]); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + try { + $gateway->createSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::MANAGES_RECURRENCE, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('nasce na fatura', $e->getMessage()); + } + + $this->assertCount(0, $api->calls); + } + public function testGatewayOptionsOverrideTheGeneratedPayload(): void { $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); diff --git a/tests/Unit/Gateways/StripeGatewayAutomaticPixTest.php b/tests/Unit/Gateways/StripeGatewayAutomaticPixTest.php new file mode 100644 index 0000000..22d722a --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayAutomaticPixTest.php @@ -0,0 +1,483 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + 'multi-payment.gateways.stripe.pix_mandate_reference' => 'Empresa Exemplo', + ])); + Facade::setFacadeApplication($app); + + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreateSubscriptionWithAutomaticPixRegistersTheMandate(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceResponse(), + self::fixture('subscriptions/incomplete_automatic_pix'), + self::fixture('invoices/open_requires_payment_method'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + $result = (new StripeGateway())->createSubscription($subscription); + + $this->assertSame([ + 'get /v1/prices/price_fake1', + 'post /v1/subscriptions', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1', + ], self::calledPaths($httpClient)); + + $params = $httpClient->calls[1][2]; + $this->assertSame('charge_automatically', $params['collection_method']); + $this->assertSame('default_incomplete', $params['payment_behavior']); + $this->assertSame(['pix'], $params['payment_settings']['payment_method_types']); + + $this->assertSame([ + 'amount' => 10000, + 'amount_type' => 'fixed', + 'payment_schedule' => 'monthly', + 'start_date' => Carbon::parse('2026-09-08 00:00:00')->getTimestamp(), + 'reference' => 'Empresa Exemplo', + ], $params['payment_settings']['payment_method_options']['pix']['mandate_options']); + + $this->assertSame(SubscriptionStatus::PENDING, $result->status); + $this->assertSame(PaymentMethod::AUTOMATIC_PIX, $result->paymentMethod); + $this->assertNull($result->availablePaymentMethods); + $this->assertInstanceOf(AutomaticPix::class, $result->automaticPix); + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $result->automaticPix->frequency); + $this->assertSame(1788825600, $result->automaticPix->startsAt->getTimestamp()); + $this->assertSame(1791157981, $result->automaticPix->preDebitNotificationAt->getTimestamp()); + $this->assertSame( + Carbon::createFromTimestamp(1791157981)->addDays(3)->getTimestamp(), + $result->automaticPix->nextDebitAt->getTimestamp() + ); + $this->assertSame('stripe', $result->automaticPix->gateway); + } + + /** + * A frequência e as datas informadas em `automaticPix` prevalecem sobre as derivadas do + * plano, e um desconto na assinatura faz o valor do mandato virar um teto (`maximum`). + */ + public function testCreateSubscriptionWithAutomaticPixHonorsTheModelAndTheDiscounts(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceResponse(), + self::fixture('subscriptions/incomplete_automatic_pix'), + self::fixture('invoices/open_requires_payment_method'), + ]); + + $startsAt = Carbon::now()->addDays(10)->startOfDay(); + $endsAt = Carbon::now()->addYear()->startOfDay(); + + $subscription = self::subscriptionModel(); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + $subscription->automaticPix = new AutomaticPix(); + $subscription->automaticPix->frequency = AutomaticPix::FREQUENCY_WEEKLY; + $subscription->automaticPix->startsAt = $startsAt; + $subscription->automaticPix->endsAt = $endsAt; + $discount = new SubscriptionDiscount(); + $discount->id = 'coupon_fake1'; + $subscription->discounts = [$discount]; + + (new StripeGateway())->createSubscription($subscription); + + $mandateOptions = $httpClient->calls[1][2]['payment_settings']['payment_method_options']['pix']['mandate_options']; + $this->assertSame('weekly', $mandateOptions['payment_schedule']); + $this->assertSame('maximum', $mandateOptions['amount_type']); + $this->assertSame($startsAt->getTimestamp(), $mandateOptions['start_date']); + $this->assertSame($endsAt->getTimestamp(), $mandateOptions['end_date']); + } + + /** + * O valor do mandato soma o plano com os itens recorrentes, a data derivada anterior ao + * mínimo de três dias é elevada a ele, e sem `pix_mandate_reference` na configuração o + * `reference` fica de fora. + */ + public function testCreateSubscriptionWithAutomaticPixSumsTheItemsAndFloorsTheDerivedStart(): void + { + $app = new Container(); + $app->instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($app); + + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceResponse(), + ['id' => 'prod_item0', 'object' => 'product', 'name' => 'Consultas', 'active' => true, 'created' => 1786700000, 'metadata' => []], + self::priceResponse(), + self::fixture('subscriptions/incomplete_automatic_pix'), + self::fixture('invoices/open_requires_payment_method'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + $subscription->trialEndsAt = Carbon::now()->addDay(); + $item = new SubscriptionItem(); + $item->description = 'Consultas'; + $item->amount = 2500; + $item->quantity = 2; + $subscription->items = [$item]; + + (new StripeGateway())->createSubscription($subscription); + + $mandateOptions = $httpClient->calls[3][2]['payment_settings']['payment_method_options']['pix']['mandate_options']; + $this->assertSame(15000, $mandateOptions['amount']); + $this->assertSame(Carbon::parse('2026-09-08 00:00:00')->getTimestamp(), $mandateOptions['start_date']); + $this->assertArrayNotHasKey('reference', $mandateOptions); + } + + public function testCreateSubscriptionWithAutomaticPixRejectsAStartDateEarlierThanThreeDays(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceResponse(), + ]); + + $subscription = self::subscriptionModel(); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + $subscription->automaticPix = new AutomaticPix(); + $subscription->automaticPix->startsAt = Carbon::now()->addDay(); + + try { + (new StripeGateway())->createSubscription($subscription); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('startsAt must be at least 3 days', $e->getMessage()); + } + + $this->assertSame(['get /v1/prices/price_fake1'], self::calledPaths($httpClient)); + } + + /** + * O mandato precisa do valor por ciclo: um Price sem `unit_amount` fixo (por camadas ou + * por uso) é recusado antes da criação da assinatura. + */ + public function testCreateSubscriptionWithAutomaticPixRejectsAPlanWithoutAFixedUnitAmount(): void + { + $price = self::priceResponse(); + $price['unit_amount'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$price]); + + $subscription = self::subscriptionModel(); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + try { + (new StripeGateway())->createSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertStringContainsString('unit_amount', $e->getMessage()); + } + + $this->assertSame(['get /v1/prices/price_fake1'], self::calledPaths($httpClient)); + } + + /** + * Trocar o método de uma assinatura que já tem mandato é recusado antes da rede: sem a + * recusa, a troca seria engolida em silêncio e o mandato continuaria valendo. + */ + public function testUpdateSubscriptionRefusesLeavingTheMandateForAnotherMethod(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->paymentMethod = PaymentMethod::PIX; + $subscription->original = json_decode(json_encode(self::fixture('subscriptions/active_automatic_pix'))); + + try { + (new StripeGateway())->updateSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); + } + + $this->assertSame([], $httpClient->calls); + } + + public function testCreateSubscriptionWithAutomaticPixRejectsAPlanIntervalWithoutASchedule(): void + { + $price = self::priceResponse(); + $price['recurring'] = ['interval' => 'week', 'interval_count' => 2, 'usage_type' => 'licensed']; + $httpClient = RecordingStripeHttpClient::withResponses([$price]); + + $subscription = self::subscriptionModel(); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + try { + (new StripeGateway())->createSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('[week:2]', $e->getMessage()); + } + + $this->assertSame(['get /v1/prices/price_fake1'], self::calledPaths($httpClient)); + } + + public function testGetSubscriptionReadsTheMandateIntoAutomaticPix(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active_automatic_pix'), + self::fixture('invoices/open_requires_payment_method'), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::ACTIVE, $result->status); + $this->assertSame(PaymentMethod::AUTOMATIC_PIX, $result->paymentMethod); + $this->assertNull($result->availablePaymentMethods); + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $result->automaticPix->frequency); + $this->assertSame(1791157981, $result->automaticPix->preDebitNotificationAt->getTimestamp()); + $this->assertNull($result->automaticPix->mandateId); + $this->assertNull($result->automaticPix->mandateStatus); + } + + /** + * Sem `current_period_end` na leitura, as datas derivadas do ciclo ficam nulas; o restante + * do mandato é lido normalmente. + */ + public function testGetSubscriptionWithoutAPeriodEndLeavesTheDerivedDatesNull(): void + { + $fixture = self::fixture('subscriptions/active_automatic_pix'); + unset($fixture['items']['data'][0]['current_period_end']); + RecordingStripeHttpClient::withResponses([ + $fixture, + self::fixture('invoices/open_requires_payment_method'), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->getSubscription($subscription); + + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $result->automaticPix->frequency); + $this->assertNull($result->automaticPix->nextDebitAt); + $this->assertNull($result->automaticPix->preDebitNotificationAt); + } + + /** + * As três operações de agendamento respondem `managed_by_gateway` sem nenhuma requisição; + * o encerramento da recorrência orienta o cancelamento da assinatura. + */ + public function testSchedulingOperationsAreManagedByTheGateway(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $gateway = new StripeGateway(); + + $charge = new AutomaticPixCharge(); + $charge->id = 'pay_1'; + $charge->endToEndId = 'E123'; + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'mandate_1UBJmkPjx0CusuMr7PxMnd41'; + + foreach ( + [ + fn () => $gateway->rescheduleAutomaticPixPayment(new \Potelo\MultiPayment\Models\Invoice()), + fn () => $gateway->cancelAutomaticPixScheduledPayment($charge), + fn () => $gateway->cancelAutomaticPixRecurrence($automaticPix), + ] as $operation + ) { + try { + $operation(); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_MANAGED_BY_GATEWAY, $e->reason); + } + } + + $this->assertSame([], $httpClient->calls); + } + + public function testListAutomaticPixCancellationsReadsTheMandate(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('mandates/active'), + ]); + + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'mandate_1UBJmkPjx0CusuMr7PxMnd41'; + + $cancellations = (new StripeGateway())->listAutomaticPixCancellations($automaticPix); + + $this->assertSame( + ['get /v1/mandates/mandate_1UBJmkPjx0CusuMr7PxMnd41'], + self::calledPaths($httpClient) + ); + $this->assertSame([], $cancellations); + $this->assertSame('mandate_1UBJmkPjx0CusuMr7PxMnd41', $automaticPix->mandateId); + $this->assertSame('active', $automaticPix->mandateStatus); + } + + public function testListAutomaticPixCancellationsReturnsTheCompletedOneForAnInactiveMandate(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('mandates/inactive'), + ]); + + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'mandate_1UBJmkPjx0CusuMr7PxMnd41'; + + $cancellations = (new StripeGateway())->listAutomaticPixCancellations($automaticPix); + + $this->assertCount(1, $cancellations); + $this->assertSame(AutomaticPixCancellation::STATUS_COMPLETED, $cancellations[0]->status); + $this->assertSame('mandate_1UBJmkPjx0CusuMr7PxMnd41', $cancellations[0]->recurrenceId); + $this->assertSame('inactive', $automaticPix->mandateStatus); + } + + public function testGetAutomaticPixCancellationReadsTheMandate(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('mandates/inactive'), + ]); + + $cancellation = new AutomaticPixCancellation(); + $cancellation->recurrenceId = 'mandate_1UBJmkPjx0CusuMr7PxMnd41'; + + $result = (new StripeGateway())->getAutomaticPixCancellation($cancellation); + + $this->assertSame(AutomaticPixCancellation::STATUS_COMPLETED, $result->status); + $this->assertSame('mandate_1UBJmkPjx0CusuMr7PxMnd41', $result->id); + $this->assertSame('stripe', $result->gateway); + } + + public function testGetAutomaticPixCancellationOnAnActiveMandateIsNotFound(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('mandates/active'), + ]); + + $cancellation = new AutomaticPixCancellation(); + $cancellation->recurrenceId = 'mandate_1UBJmkPjx0CusuMr7PxMnd41'; + + $this->expectException(NotFoundException::class); + + (new StripeGateway())->getAutomaticPixCancellation($cancellation); + } + + /** + * Num model lido do gateway o mandato já existe e o método não é uma troca: o update passa + * sem reescrever `payment_settings`. + */ + public function testUpdateSubscriptionKeepsAnExistingMandateUntouched(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active_automatic_pix'), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + $subscription->metadata = ['origem' => 'teste']; + $subscription->original = json_decode(json_encode(self::fixture('subscriptions/active_automatic_pix'))); + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertArrayNotHasKey('payment_settings', $params); + $this->assertSame(['origem' => 'teste'], $params['metadata']); + } + + private static function subscriptionModel(): Subscription + { + $subscription = new Subscription(); + $subscription->customer = new Customer(); + $subscription->customer->id = 'cus_fake123'; + $subscription->planId = 'price_fake1'; + + return $subscription; + } + + /** + * @return string[] `método caminho` de cada chamada gravada + */ + private static function calledPaths(RecordingStripeHttpClient $httpClient): array + { + return array_map( + static fn (array $call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), + $httpClient->calls + ); + } + + private static function fixture(string $path): array + { + return json_decode(file_get_contents(__DIR__ . "/../../fixtures/stripe/{$path}.json"), true); + } + + private static function priceResponse(): array + { + return [ + 'id' => 'price_fake1', + 'object' => 'price', + 'active' => true, + 'currency' => 'brl', + 'lookup_key' => 'plano_mensal', + 'nickname' => null, + 'created' => 1786700000, + 'product' => 'prod_fake1', + 'recurring' => ['interval' => 'month', 'interval_count' => 1, 'usage_type' => 'licensed'], + 'type' => 'recurring', + 'unit_amount' => 10000, + 'unit_amount_decimal' => '10000', + ]; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayCustomerTest.php b/tests/Unit/Gateways/StripeGatewayCustomerTest.php index cde539c..2f0b0d3 100644 --- a/tests/Unit/Gateways/StripeGatewayCustomerTest.php +++ b/tests/Unit/Gateways/StripeGatewayCustomerTest.php @@ -277,21 +277,22 @@ public function testParsesNumberOnlyLine1IntoAddressNumber(): void $this->assertSame('123', $result->address->number); } - public function testUnimplementedOperationThrowsUnsupportedOperationExceptionWithoutHittingTheApi(): void + public function testManagedByGatewayOperationThrowsUnsupportedOperationExceptionWithoutHittingTheApi(): void { $httpClient = RecordingStripeHttpClient::withResponses([]); try { (new StripeGateway())->rescheduleAutomaticPixPayment(new Invoice()); - $this->fail('Pix Automático no Stripe deveria lançar UnsupportedOperationException'); + $this->fail('Reagendamento de Pix Automático no Stripe deveria lançar UnsupportedOperationException'); } catch (UnsupportedOperationException $e) { $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); $this->assertSame('stripe', $e->gateway); - $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); - $this->assertTrue($e->isNotImplemented()); + $this->assertSame(UnsupportedOperationException::REASON_MANAGED_BY_GATEWAY, $e->reason); + $this->assertFalse($e->isNotImplemented()); $this->assertSame( - 'A capability [automatic_pix] ainda não está implementada nesta lib para o gateway stripe;' - . ' o gateway oferece o recurso.', + 'No gateway stripe a operação de [automatic_pix] é conduzida pelo próprio gateway' + . ' e não se aplica pela lib.' + . ' A Stripe agenda e retenta as cobranças do mandato; não há reagendamento pela lib.', $e->getMessage() ); } diff --git a/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php b/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php index 344964c..0f1c6e3 100644 --- a/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php +++ b/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php @@ -864,14 +864,17 @@ public function testUpdateSubscriptionRefusesMethodsTheDriverDoesNotCoverBeforeT $this->assertSame(Capability::MULTIPLE_PAYMENT_METHODS, $e->capability); } + // o mandato de Pix Automático nasce com a assinatura: a troca para o método num + // model sem o mandato lido do gateway é recusada antes da rede $automaticPix = new Subscription(); $automaticPix->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; $automaticPix->paymentMethod = PaymentMethod::AUTOMATIC_PIX; try { $gateway->updateSubscription($automaticPix); - $this->fail('Esperava ModelAttributeValidationException'); - } catch (ModelAttributeValidationException $e) { - $this->assertStringContainsString('paymentMethod must be one of', $e->getMessage()); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); } $this->assertSame([], $httpClient->calls); diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php index 90564dd..873876a 100644 --- a/tests/Unit/SubscriptionTest.php +++ b/tests/Unit/SubscriptionTest.php @@ -12,6 +12,7 @@ use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\InvoiceItem; use Potelo\MultiPayment\Models\Subscription; +use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Models\SubscriptionItem; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Contracts\SubscriptionContract; @@ -176,6 +177,33 @@ public function testSubscriptionValidationRejectsNonSelectablePaymentMethod(): v $subscription->validate(); } + /** + * `fill()` com `automatic_pix` cria o model aninhado (com as datas do mandato) e + * `toArray()` o devolve como array. + */ + public function testFillAndToArrayCarryAutomaticPix(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'automatic_pix' => [ + 'frequency' => AutomaticPix::FREQUENCY_MONTHLY, + 'starts_at' => '2026-10-01', + 'next_debit_at' => '2026-10-04', + 'pre_debit_notification_at' => '2026-10-01', + ], + ]); + + $this->assertInstanceOf(AutomaticPix::class, $subscription->automaticPix); + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $subscription->automaticPix->frequency); + $this->assertSame('2026-10-04', $subscription->automaticPix->nextDebitAt->format('Y-m-d')); + $this->assertSame('2026-10-01', $subscription->automaticPix->preDebitNotificationAt->format('Y-m-d')); + + $array = $subscription->toArray(); + $this->assertIsArray($array['automatic_pix']); + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $array['automatic_pix']['frequency']); + } + public function testSubscriptionPropagatesItemValidation(): void { $subscription = new Subscription(); @@ -939,9 +967,13 @@ function (Subscription $s) { }, '/paymentMethod \[credit_card\] must be one of availablePaymentMethods/', ], - 'metodo nao selecionavel' => [ - fn (Subscription $s) => $s->paymentMethod = PaymentMethod::AUTOMATIC_PIX, - '/paymentMethod must be one of: credit_card, bank_slip, pix/', + 'automatic pix na lista de metodos' => [ + fn (Subscription $s) => $s->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX], + '/availablePaymentMethods must be one of: credit_card, bank_slip, pix/', + ], + 'automatic pix sem o metodo' => [ + fn (Subscription $s) => $s->automaticPix = new AutomaticPix(), + '/automaticPix was given but automatic_pix is not the payment method/', ], 'cartao invalido' => [ function (Subscription $s) { diff --git a/tests/fixtures/stripe/README.md b/tests/fixtures/stripe/README.md index 02b503b..9053a04 100644 --- a/tests/fixtures/stripe/README.md +++ b/tests/fixtures/stripe/README.md @@ -92,6 +92,20 @@ Montadas sobre `active.json` (ou `trialing.json`), porque a sandbox não produz | `unpaid.json` | status | | `paused.json` | status, sobre `trialing.json` (trial que terminou sem método de pagamento) | +Montadas a partir da documentação do Pix Automático (a conta ainda não tem o recurso +liberado; regravar na sandbox quando a Stripe o liberar): + +| Arquivo | Diferença | +|---|---| +| `incomplete_automatic_pix.json` | sobre `incomplete.json`: `payment_settings` com `payment_method_types: ['pix']` e `payment_method_options.pix.mandate_options`, sem método padrão | +| `active_automatic_pix.json` | sobre `active.json`: o mesmo `payment_settings` e um PaymentMethod `pix` como método padrão | + +## `mandates/` + +Montadas a partir da documentação do objeto Mandate (a conta ainda não tem Pix Automático +liberado): `active.json` e `inactive.json` diferem só no `status`. Regravar na sandbox quando +o recurso for liberado. + ## `webhooks/` Eventos entregues por `stripe listen` (CLI 1.50.10) numa sessão de sandbox em 2026-09-04, um diff --git a/tests/fixtures/stripe/mandates/active.json b/tests/fixtures/stripe/mandates/active.json new file mode 100644 index 0000000..a9ebcb6 --- /dev/null +++ b/tests/fixtures/stripe/mandates/active.json @@ -0,0 +1,29 @@ +{ + "id": "mandate_1UBJmkPjx0CusuMr7PxMnd41", + "object": "mandate", + "customer_acceptance": { + "accepted_at": 1788565981, + "online": { + "ip_address": "127.0.0.1", + "user_agent": "Mozilla/5.0" + }, + "type": "online" + }, + "livemode": false, + "multi_use": {}, + "payment_method": "pm_1UBJmjPjx0CusuMrPixMand1", + "payment_method_details": { + "pix": { + "amount": 10000, + "amount_includes_iof": "never", + "amount_type": "fixed", + "end_date": null, + "payment_schedule": "monthly", + "reference": "Empresa Exemplo", + "start_date": 1788825600 + }, + "type": "pix" + }, + "status": "active", + "type": "multi_use" +} diff --git a/tests/fixtures/stripe/mandates/inactive.json b/tests/fixtures/stripe/mandates/inactive.json new file mode 100644 index 0000000..14edd57 --- /dev/null +++ b/tests/fixtures/stripe/mandates/inactive.json @@ -0,0 +1,29 @@ +{ + "id": "mandate_1UBJmkPjx0CusuMr7PxMnd41", + "object": "mandate", + "customer_acceptance": { + "accepted_at": 1788565981, + "online": { + "ip_address": "127.0.0.1", + "user_agent": "Mozilla/5.0" + }, + "type": "online" + }, + "livemode": false, + "multi_use": {}, + "payment_method": "pm_1UBJmjPjx0CusuMrPixMand1", + "payment_method_details": { + "pix": { + "amount": 10000, + "amount_includes_iof": "never", + "amount_type": "fixed", + "end_date": null, + "payment_schedule": "monthly", + "reference": "Empresa Exemplo", + "start_date": 1788825600 + }, + "type": "pix" + }, + "status": "inactive", + "type": "multi_use" +} diff --git a/tests/fixtures/stripe/subscriptions/active_automatic_pix.json b/tests/fixtures/stripe/subscriptions/active_automatic_pix.json new file mode 100644 index 0000000..5adb988 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/active_automatic_pix.json @@ -0,0 +1,238 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrPixMand1", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "pix": {}, + "shared_payment_granted_token": null, + "type": "pix" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": { + "pix": { + "mandate_options": { + "amount": 10000, + "amount_includes_iof": "never", + "amount_type": "fixed", + "end_date": null, + "payment_schedule": "monthly", + "reference": "Empresa Exemplo", + "start_date": 1788825600 + } + } + }, + "payment_method_types": [ + "pix" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/incomplete_automatic_pix.json b/tests/fixtures/stripe/subscriptions/incomplete_automatic_pix.json new file mode 100644 index 0000000..9295f23 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/incomplete_automatic_pix.json @@ -0,0 +1,212 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": { + "pix": { + "mandate_options": { + "amount": 10000, + "amount_includes_iof": "never", + "amount_type": "fixed", + "end_date": null, + "payment_schedule": "monthly", + "reference": "Empresa Exemplo", + "start_date": 1788825600 + } + } + }, + "payment_method_types": [ + "pix" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "incomplete", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +}