diff --git a/.gitignore b/.gitignore index bdfb0a1..f03e63a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ examples/rede/credentials.php *.p12 *.pfx *.pem + +# arquivos gerados pelos exemplos (carnês, boletos) +examples/*/*.pdf diff --git a/CLAUDE.md b/CLAUDE.md index 26b939b..b454088 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ AsaasGateway / EfiGateway ──implements──▶ Interface extends │ cada método (customer/charge/pix/webhook/subscription) devolve um Resource novo ▼ Resources (Customer, Charge, Pix, Webhook, Subscription) - │ trait HasAsaasClient / HasEfiClient → PHPay\Http\HasHttpClient (get/post/put/patch/delete) + │ trait HasAsaasClient / HasEfiClient → PHPay\Http\HasHttpClient (get/post/put/patch/delete/download) ▼ Requests (validação estática dos payloads antes de qualquer chamada HTTP) ``` @@ -176,6 +176,11 @@ quebra a integração, cobra o valor errado. ## Particularidades por gateway - **Asaas** — `$sandbox` troca a base URL. Chaves Pix próprias, porque é PSP (como Woovi e Efí). + Assinatura cobre os 14 endpoints da API. **O carnê responde PDF**, por isso usa + `HasHttpClient::download()`, que devolve o corpo cru — `request()` decodifica + JSON e devolveria `[]`. `destroy()` leva as cobranças pendentes e vencidas; + a pausa é `deactivate()`, e **reativar exige um novo `nextDueDate`**. `cycle` é + obrigatório na criação. Atualização de assinatura é `PUT`, como o resto do Asaas. - **Efí** — **duas APIs com as mesmas credenciais**: Cobranças (`cobrancas.api...`, trait `HasEfiClient`, boleto em `charge()`) e Pix (`pix.api...`, trait `HasEfiPixClient`, **só por mTLS**). Cada API tem o seu token (`getToken()` e @@ -232,8 +237,6 @@ quebra a integração, cobra o valor errado. ## O que ainda está em aberto -- `Subscription` só implementa `create()`. Listar, buscar, atualizar, cancelar, - carnê e NFe seguem pendentes na API do Asaas. - Da API Pix da Efí ficaram de fora: Pix Automático pela jornada 1 (`solicrec`, notificação no app do pagador), webhooks de recorrência e de cobrança recorrente (`webhookrec`, `webhookcobr`), envio de Pix e split. diff --git a/README.md b/README.md index 272ad4b..38d46a2 100644 --- a/README.md +++ b/README.md @@ -371,8 +371,8 @@ todos está em [Conceitos](#conceitos). ### Asaas -O único com as cinco capacidades — é PSP, então emite chave Pix própria e -gerencia webhooks por API. +Um dos dois com as cinco capacidades, ao lado do Woovi. É PSP, então emite +chave Pix própria e gerencia webhooks por API. ```php use PHPay\Asaas\AsaasGateway; @@ -427,18 +427,83 @@ $phpay->customer()->restore($cliente['id']); #### Assinaturas +A assinatura gera uma cobrança por ciclo, e cada uma é uma cobrança comum: +aparece em `getPayments()` e é tratada pelo recurso de cobrança. + +```php +use PHPay\Asaas\Enums\SubscriptionCycleEnum; + +$assinaturas = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX))->subscription(); + +$assinatura = $assinaturas + ->setCustomer($cliente) // ou setCustomerId('cus_...') + ->setAmount(Money::reais('49,90')) + ->setCycle(SubscriptionCycleEnum::MONTHLY) + ->setSubscription([ + 'billingType' => 'BOLETO', + 'nextDueDate' => '2026-10-10', + 'description' => 'Plano mensal', + ]) + ->create(); +``` + +> O `create([...])` com o payload inteiro continua funcionando, e o array +> sobrescreve o que os setters montaram. O `cycle` é obrigatório: sem ele o +> Asaas recusa, e o PHPay barra antes. + +Consulta e ciclo de vida: + +```php +$assinaturas->setQueryParams(['customer' => 'cus_...', 'status' => 'ACTIVE'])->getAll(); +$assinaturas->find($id); + +/* muda as próximas cobranças; com updatePendingPayments, as pendentes também */ +$assinaturas->update($id, ['description' => 'Plano anual', 'updatePendingPayments' => true]); + +/* pausa: para de gerar cobranças e mantém as que existem */ +$assinaturas->deactivate($id); +$assinaturas->reactivate($id, '2026-11-10'); // o Asaas exige um novo vencimento + +/* remove: as cobranças pendentes e vencidas vão junto; as pagas ficam */ +$assinaturas->destroy($id); +``` + +Cobranças, carnê e cartão: + ```php -$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX))->subscription(); +$assinaturas->getPayments($id, ['status' => 'PENDING']); + +/* o carnê vem como os bytes do PDF */ +file_put_contents('carne.pdf', $assinaturas->paymentBook($id, month: 12, year: 2026)); + +/* troca o cartão sem cobrar — as cobranças pendentes passam para o novo */ +$assinaturas->updateCreditCard($id, [ + 'creditCardToken' => $token, // ou creditCard + creditCardHolderInfo + 'remoteIp' => $ipDoComprador, +]); +``` + +Nota fiscal emitida automaticamente para cada cobrança: -$phpay->setCustomer($cliente)->create([ - 'billingType' => 'BOLETO', - 'value' => 100, - 'nextDueDate' => '2026-04-09', - 'cycle' => 'MONTHLY', +```php +$assinaturas->createInvoiceSettings($id, [ + 'municipalServiceName' => 'Desenvolvimento de software', + 'effectiveDatePeriod' => 'ON_PAYMENT_CONFIRMATION', + 'taxes' => [ // os sete são obrigatórios; 0 quando não houver + 'retainIss' => false, + 'iss' => 2, + 'pis' => 0.65, + 'cofins' => 3, + 'csll' => 0, + 'inss' => 0, + 'ir' => 0, + ], ]); -/* ou com um cliente existente */ -$phpay->setCustomerId('cus_000006337812')->create([...]); +$assinaturas->getInvoiceSettings($id); +$assinaturas->updateInvoiceSettings($id, [...]); +$assinaturas->destroyInvoiceSettings($id); +$assinaturas->getInvoices($id); // as notas já emitidas ``` #### Webhooks e chaves Pix @@ -1056,7 +1121,7 @@ Dois pontos merecem auditoria de quem vem da v1: | Gateway | Cobranças | Clientes | Assinaturas | Webhooks | Pix | | --- | :---: | :---: | :---: | :---: | :---: | -| **Asaas** | ✅ | ✅ | ✍️ | ✅ | ✅ | +| **Asaas** | ✅ | ✅ | ✅ | ✅ | ✅ | | **Woovi/OpenPix** | ✅ | ✅ | ✅ | ✅ | ✅ | | **Mercado Pago** | ✅ | ✅ | ✅ | — | ✅ | | **PagBank** | ✅ | ✅ | ✅ | — | ✅ | @@ -1068,8 +1133,6 @@ Dois pontos merecem auditoria de quem vem da v1: **✅** pronto · **✍️** parcial · **🕥** planejado · **—** não existe na API do gateway -> Assinaturas do Asaas: criação pronta; listar, atualizar e cancelar pendentes. - --- ## Contribuindo diff --git a/examples/asaas/subscriptions.php b/examples/asaas/subscriptions.php index 3c6b8ee..0bdff18 100644 --- a/examples/asaas/subscriptions.php +++ b/examples/asaas/subscriptions.php @@ -1,30 +1,30 @@ NAME, - 'cpfCnpj' => CPF_CNPJ, -]; +$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX)); /** - * @var Subscription $phpay + * @var Subscription $subscriptions */ -$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX))->subscription(); +$subscriptions = $phpay->subscription(); try { - $subscriptionCreated = $phpay - ->setCustomer($customer) - ->create([ + $subscription = $subscriptions + ->setCustomer(Customer::make(NAME, CPF_CNPJ)) + ->setAmount(Money::reais('100,00')) + ->setCycle(SubscriptionCycleEnum::MONTHLY) + ->setSubscription([ 'billingType' => 'BOLETO', - 'value' => 100, 'nextDueDate' => date('Y-m-d', strtotime('+7 days')), 'discount' => [ 'value' => 10, @@ -38,23 +38,49 @@ 'value' => 1, 'type' => 'FIXED', /* PERCENTAGE */ ], - 'cycle' => 'MONTHLY', 'description' => 'Teste de assinatura', 'maxPayments' => 12, 'externalReference' => '123456', - ]); + ]) + ->create(); + + $id = (string) $subscription['id']; + + /* consulta */ + $subscriptions->find($id); + $subscriptions->setQueryParams(['customer' => $subscription['customer']])->getAll(); - print_r($subscriptionCreated); + /* as cobranças que a assinatura já gerou */ + $subscriptions->getPayments($id); - /* para uma segunda assinatura do mesmo cliente, reaproveite o id */ - $phpay - ->setCustomerId($subscriptionCreated['customer']) + /* o carnê, em PDF */ + file_put_contents(__DIR__ . '/carne.pdf', $subscriptions->paymentBook($id)); + + /* muda a descrição das próximas cobranças e das pendentes */ + $subscriptions->update($id, [ + 'description' => 'Assinatura atualizada', + 'updatePendingPayments' => true, + ]); + + /* pausa e reativa — reativar exige um novo vencimento */ + $subscriptions->deactivate($id); + $subscriptions->reactivate($id, date('Y-m-d', strtotime('+30 days'))); + + /* + | para uma segunda assinatura do mesmo cliente, reaproveite o id — num + | recurso novo, porque o anterior guarda o que os setters montaram + */ + $phpay->subscription() + ->setCustomerId((string) $subscription['customer']) ->create([ 'billingType' => 'PIX', 'value' => 50, 'nextDueDate' => date('Y-m-d', strtotime('+7 days')), 'cycle' => 'MONTHLY', ]); + + /* remove — as cobranças pendentes vão junto */ + $subscriptions->destroy($id); } catch (PHPayException $exception) { echo $exception->getMessage() . PHP_EOL; } diff --git a/src/Gateways/Asaas/Enums/SubscriptionCycleEnum.php b/src/Gateways/Asaas/Enums/SubscriptionCycleEnum.php new file mode 100644 index 0000000..c633197 --- /dev/null +++ b/src/Gateways/Asaas/Enums/SubscriptionCycleEnum.php @@ -0,0 +1,17 @@ + $subscription + * @return SubscriptionInterface + */ + public function setSubscription(array $subscription): SubscriptionInterface; + + /** + * set the amount of each charge + * + * @param Money|int|float $amount + * @return SubscriptionInterface + */ + public function setAmount(Money|int|float $amount): SubscriptionInterface; + + /** + * set how often a charge is generated + * + * @param SubscriptionCycleEnum $cycle + * @return SubscriptionInterface + */ + public function setCycle(SubscriptionCycleEnum $cycle): SubscriptionInterface; + + /** + * set list filters + * + * @param array $queryParams + * @return SubscriptionInterface + */ + public function setQueryParams(array $queryParams): SubscriptionInterface; + /** * create subscription * * @param array $subscription * @return array */ - public function create(array $subscription): array; + public function create(array $subscription = []): array; + + /** + * list subscriptions + * + * @return array + */ + public function getAll(): array; + + /** + * find a subscription + * + * @param string $id + * @return array + */ + public function find(string $id): array; + + /** + * update a subscription + * + * @param string $id + * @param array $data + * @return array + */ + public function update(string $id, array $data): array; + + /** + * stop generating charges, keeping the existing ones + * + * @param string $id + * @return array + */ + public function deactivate(string $id): array; + + /** + * generate charges again + * + * @param string $id + * @param string $nextDueDate + * @return array + */ + public function reactivate(string $id, string $nextDueDate): array; + + /** + * remove a subscription + * + * @param string $id + * @return bool + */ + public function destroy(string $id): bool; + + /** + * replace the card of a subscription without charging it + * + * @param string $id + * @param array $card + * @return array + */ + public function updateCreditCard(string $id, array $card): array; + + /** + * charges already generated by a subscription + * + * @param string $id + * @param array $filters + * @return array + */ + public function getPayments(string $id, array $filters = []): array; + + /** + * payment book (carnê) of a subscription, as a PDF + * + * @param string $id + * @param int|null $month + * @param int|null $year + * @return string + */ + public function paymentBook(string $id, ?int $month = null, ?int $year = null): string; + + /** + * configure the invoice issued for each charge + * + * @param string $id + * @param array $settings + * @return array + */ + public function createInvoiceSettings(string $id, array $settings): array; + + /** + * find the invoice settings + * + * @param string $id + * @return array + */ + public function getInvoiceSettings(string $id): array; + + /** + * update the invoice settings + * + * @param string $id + * @param array $settings + * @return array + */ + public function updateInvoiceSettings(string $id, array $settings): array; + + /** + * remove the invoice settings + * + * @param string $id + * @return bool + */ + public function destroyInvoiceSettings(string $id): bool; + + /** + * invoices issued for the charges of a subscription + * + * @param string $id + * @param array $filters + * @return array + */ + public function getInvoices(string $id, array $filters = []): array; } diff --git a/src/Gateways/Asaas/Resources/Subscription/Requests/StoreSubscriptionAsaasRequest.php b/src/Gateways/Asaas/Resources/Subscription/Requests/StoreSubscriptionAsaasRequest.php index 01d5d89..13211e7 100644 --- a/src/Gateways/Asaas/Resources/Subscription/Requests/StoreSubscriptionAsaasRequest.php +++ b/src/Gateways/Asaas/Resources/Subscription/Requests/StoreSubscriptionAsaasRequest.php @@ -2,7 +2,7 @@ namespace PHPay\Asaas\Resources\Subscription\Requests; -use PHPay\Asaas\Enums\BillingTypeEnum; +use PHPay\Asaas\Enums\{BillingTypeEnum, SubscriptionCycleEnum}; use PHPay\Exceptions\ValidationException; class StoreSubscriptionAsaasRequest @@ -42,12 +42,19 @@ public static function validate(array $subscription): void if (!isset($subscription['nextDueDate']) || !is_string($subscription['nextDueDate'])) { throw ValidationException::make('Asaas', $messages->nextDueDate); } + + if (!isset($subscription['cycle']) + || !is_string($subscription['cycle']) + || !SubscriptionCycleEnum::tryFrom($subscription['cycle']) instanceof SubscriptionCycleEnum + ) { + throw ValidationException::make('Asaas', $messages->cycle); + } } /** * messages for validation * - * @return object{customer: string, billingType: string, value: string, nextDueDate: string} + * @return object{customer: string, billingType: string, value: string, nextDueDate: string, cycle: string} */ public static function messages(): object { @@ -56,6 +63,7 @@ public static function messages(): object 'billingType' => 'O campo billingType é obrigatório, e tem como disponível as seguintes opções: UNDEFINED, BOLETO, CREDIT_CARD, PIX.', 'value' => 'O campo value é obrigatório, deve ser numérico e maior que zero.', 'nextDueDate' => 'O campo nextDueDate é obrigatório e deve ser do tipo string.', + 'cycle' => 'O campo cycle é obrigatório: use setCycle() com SubscriptionCycleEnum (WEEKLY, BIWEEKLY, MONTHLY, BIMONTHLY, QUARTERLY, SEMIANNUALLY ou YEARLY).', ]; } } diff --git a/src/Gateways/Asaas/Resources/Subscription/Requests/SubscriptionCreditCardAsaasRequest.php b/src/Gateways/Asaas/Resources/Subscription/Requests/SubscriptionCreditCardAsaasRequest.php new file mode 100644 index 0000000..a8a87fc --- /dev/null +++ b/src/Gateways/Asaas/Resources/Subscription/Requests/SubscriptionCreditCardAsaasRequest.php @@ -0,0 +1,91 @@ + $card + * @return void + * @throws ValidationException + */ + public static function validate(array $card): void + { + $messages = self::messages(); + + if (!isset($card['remoteIp']) + || !is_string($card['remoteIp']) + || filter_var($card['remoteIp'], FILTER_VALIDATE_IP) === false + ) { + throw ValidationException::make('Asaas', $messages->remoteIp); + } + + if (isset($card['creditCardToken']) && is_string($card['creditCardToken']) && $card['creditCardToken'] !== '') { + return; + } + + if (!self::hasAll($card['creditCard'] ?? null, self::CARD)) { + throw ValidationException::make('Asaas', $messages->creditCard); + } + + if (!self::hasAll($card['creditCardHolderInfo'] ?? null, self::HOLDER)) { + throw ValidationException::make('Asaas', $messages->holder); + } + } + + /** + * messages for validation + * + * @return object{remoteIp: string, creditCard: string, holder: string} + */ + public static function messages(): object + { + return (object) [ + 'remoteIp' => 'O campo remoteIp é obrigatório e deve ser o IP de quem está pagando.', + 'creditCard' => 'Informe creditCardToken, ou creditCard com holderName, number, expiryMonth, expiryYear e ccv.', + 'holder' => 'O creditCardHolderInfo precisa de name, email, cpfCnpj, postalCode, addressNumber e phone.', + ]; + } + + /** + * whether the value is an array with every field filled. + * + * @param mixed $value + * @param array $fields + * @return bool + */ + private static function hasAll(mixed $value, array $fields): bool + { + if (!is_array($value)) { + return false; + } + + foreach ($fields as $field) { + if (!isset($value[$field]) || $value[$field] === '') { + return false; + } + } + + return true; + } +} diff --git a/src/Gateways/Asaas/Resources/Subscription/Requests/SubscriptionInvoiceSettingsAsaasRequest.php b/src/Gateways/Asaas/Resources/Subscription/Requests/SubscriptionInvoiceSettingsAsaasRequest.php new file mode 100644 index 0000000..6594f68 --- /dev/null +++ b/src/Gateways/Asaas/Resources/Subscription/Requests/SubscriptionInvoiceSettingsAsaasRequest.php @@ -0,0 +1,83 @@ + $settings + * @return void + * @throws ValidationException + */ + public static function validate(array $settings): void + { + $messages = self::messages(); + $taxes = $settings['taxes'] ?? null; + + if (!is_array($taxes)) { + throw ValidationException::make('Asaas', $messages->taxes); + } + + foreach (self::TAXES as $tax) { + if (!array_key_exists($tax, $taxes)) { + throw ValidationException::make('Asaas', sprintf($messages->tax, $tax)); + } + } + + if (!is_bool($taxes['retainIss'])) { + throw ValidationException::make('Asaas', $messages->retainIss); + } + + foreach (array_diff(self::TAXES, ['retainIss']) as $tax) { + if (!is_int($taxes[$tax]) && !is_float($taxes[$tax])) { + throw ValidationException::make('Asaas', sprintf($messages->rate, $tax)); + } + } + + if (isset($settings['effectiveDatePeriod']) + && !in_array($settings['effectiveDatePeriod'], self::PERIODS, true) + ) { + throw ValidationException::make('Asaas', $messages->period); + } + } + + /** + * messages for validation + * + * @return object{taxes: string, tax: string, retainIss: string, rate: string, period: string} + */ + public static function messages(): object + { + return (object) [ + 'taxes' => 'O campo taxes é obrigatório, com retainIss, iss, pis, cofins, csll, inss e ir.', + 'tax' => 'Falta o imposto %s em taxes — informe 0 quando não houver.', + 'retainIss' => 'O campo taxes.retainIss deve ser booleano.', + 'rate' => 'O imposto %s deve ser a alíquota em percentual, como número.', + 'period' => 'O effectiveDatePeriod deve ser ON_PAYMENT_CONFIRMATION, ON_PAYMENT_DUE_DATE, BEFORE_PAYMENT_DUE_DATE, ON_DUE_DATE_MONTH ou ON_NEXT_MONTH.', + ]; + } +} diff --git a/src/Gateways/Asaas/Resources/Subscription/Subscription.php b/src/Gateways/Asaas/Resources/Subscription/Subscription.php index b37741b..22451c7 100644 --- a/src/Gateways/Asaas/Resources/Subscription/Subscription.php +++ b/src/Gateways/Asaas/Resources/Subscription/Subscription.php @@ -3,14 +3,27 @@ namespace PHPay\Asaas\Resources\Subscription; use GuzzleHttp\Client; +use PHPay\Asaas\Enums\SubscriptionCycleEnum; use PHPay\Asaas\Requests\AsaasCustomerRequest; use PHPay\Asaas\Resources\Customer\Customer; use PHPay\Asaas\Resources\Subscription\Interface\SubscriptionInterface; -use PHPay\Asaas\Resources\Subscription\Requests\StoreSubscriptionAsaasRequest; +use PHPay\Asaas\Resources\Subscription\Requests\{ + StoreSubscriptionAsaasRequest, + SubscriptionCreditCardAsaasRequest, + SubscriptionInvoiceSettingsAsaasRequest +}; use PHPay\Asaas\Traits\HasAsaasClient; use PHPay\Exceptions\{ApiException, ValidationException}; -use PHPay\Support\Customer as CustomerData; +use PHPay\Support\{Customer as CustomerData, Money}; +/** + * subscriptions of the Asaas API. + * + * the subscription generates one charge per cycle; each of them is a regular + * charge, listed by getPayments() and handled by the charge resource. an + * invoice (nota fiscal) can be issued for every charge automatically, once + * createInvoiceSettings() is called. + */ class Subscription implements SubscriptionInterface { /** @@ -28,6 +41,16 @@ class Subscription implements SubscriptionInterface */ private ?string $customerId = null; + /** + * @var array + */ + private array $subscription = []; + + /** + * @var array + */ + private array $queryParams = []; + /** * construct * @@ -100,20 +123,290 @@ public function setCustomer(CustomerData|array $customer): SubscriptionInterface return $this->setCustomerId($created['id']); } + /** + * set the payload of the subscription. + * + * @param array $subscription + * @return SubscriptionInterface + * @see fields available in https://docs.asaas.com/reference/criar-nova-assinatura + */ + public function setSubscription(array $subscription): SubscriptionInterface + { + $this->subscription = array_replace($this->subscription, $subscription); + + return $this; + } + + /** + * set the amount of each charge. + * + * Asaas takes reais as a decimal — pass a Money and the unit is handled + * for you, or a raw number, which is read as reais. + * + * @param Money|int|float $amount + * @return SubscriptionInterface + */ + public function setAmount(Money|int|float $amount): SubscriptionInterface + { + $this->subscription['value'] = Money::asReais($amount); + + return $this; + } + + /** + * set how often a charge is generated + * + * @param SubscriptionCycleEnum $cycle + * @return SubscriptionInterface + */ + public function setCycle(SubscriptionCycleEnum $cycle): SubscriptionInterface + { + $this->subscription['cycle'] = $cycle->value; + + return $this; + } + + /** + * set list filters + * + * @param array $queryParams + * @return SubscriptionInterface + * @see params in https://docs.asaas.com/reference/listar-assinaturas + */ + public function setQueryParams(array $queryParams): SubscriptionInterface + { + $this->queryParams = $queryParams; + + return $this; + } + /** * create subscription * + * the payload is what the setters built, overridden by the array given + * here — so create([...]) alone keeps working as it always did. + * * @param array $subscription * @return array * @throws ValidationException|ApiException * @see fields available in https://docs.asaas.com/reference/criar-nova-assinatura */ - public function create(array $subscription): array + public function create(array $subscription = []): array { + $subscription = array_replace($this->subscription, $subscription); + $subscription['customer'] = $subscription['customer'] ?? $this->customerId; StoreSubscriptionAsaasRequest::validate($subscription); return $this->post('subscriptions', $subscription); } + + /** + * list subscriptions + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('subscriptions', $this->queryParams); + } + + /** + * find a subscription + * + * @param string $id + * @return array + * @throws ApiException + */ + public function find(string $id): array + { + return $this->get("subscriptions/{$id}"); + } + + /** + * update a subscription. + * + * changes apply to the charges still to be generated; send + * `updatePendingPayments: true` to apply them to the pending ones too. + * + * @param string $id + * @param array $data + * @return array + * @throws ApiException + * @see fields available in https://docs.asaas.com/reference/atualizar-assinatura-existente + */ + public function update(string $id, array $data): array + { + return $this->put("subscriptions/{$id}", $data); + } + + /** + * stop generating charges, keeping the existing ones. + * + * the pause Asaas recommends instead of destroy(), which also removes the + * pending and overdue charges. + * + * @param string $id + * @return array + * @throws ApiException + */ + public function deactivate(string $id): array + { + return $this->update($id, ['status' => 'INACTIVE']); + } + + /** + * generate charges again. + * + * Asaas requires a new due date to reactivate — the old one is likely + * already in the past. + * + * @param string $id + * @param string $nextDueDate Y-m-d of the next charge + * @return array + * @throws ApiException + */ + public function reactivate(string $id, string $nextDueDate): array + { + return $this->update($id, [ + 'status' => 'ACTIVE', + 'nextDueDate' => $nextDueDate, + ]); + } + + /** + * remove a subscription. + * + * the pending and overdue charges go with it; paid ones stay. to only + * pause, use deactivate(). + * + * @param string $id + * @return bool + * @throws ApiException + */ + public function destroy(string $id): bool + { + return $this->delete("subscriptions/{$id}"); + } + + /** + * replace the card of a subscription without charging it. + * + * the pending charges move to the new card as well. + * + * @param string $id + * @param array $card `creditCardToken`, or `creditCard` and `creditCardHolderInfo`, + * plus the `remoteIp` of the buyer + * @return array + * @throws ValidationException|ApiException + * @see fields available in https://docs.asaas.com/reference/atualizar-cartao-de-credito-assinatura + */ + public function updateCreditCard(string $id, array $card): array + { + SubscriptionCreditCardAsaasRequest::validate($card); + + return $this->put("subscriptions/{$id}/creditCard", $card); + } + + /** + * charges already generated by a subscription — the future ones do not + * exist yet. + * + * @param string $id + * @param array $filters e.g. ['status' => 'PENDING'] + * @return array + * @throws ApiException + */ + public function getPayments(string $id, array $filters = []): array + { + return $this->get("subscriptions/{$id}/payments", $filters); + } + + /** + * payment book (carnê) of a subscription: the raw PDF. + * + * @param string $id + * @param int|null $month last month the book covers + * @param int|null $year last year the book covers + * @return string the PDF bytes, ready to save or stream + * @throws ApiException + */ + public function paymentBook(string $id, ?int $month = null, ?int $year = null): string + { + return $this->download("subscriptions/{$id}/paymentBook", array_filter([ + 'month' => $month, + 'year' => $year, + ], static fn (?int $value): bool => $value !== null)); + } + + /** + * configure the invoice (nota fiscal) issued for each charge. + * + * @param string $id + * @param array $settings `taxes` is required + * @return array + * @throws ValidationException|ApiException + * @see fields available in https://docs.asaas.com/reference/criar-configuracao-para-emissao-de-notas-fiscais + */ + public function createInvoiceSettings(string $id, array $settings): array + { + SubscriptionInvoiceSettingsAsaasRequest::validate($settings); + + return $this->post("subscriptions/{$id}/invoiceSettings", $settings); + } + + /** + * find the invoice settings + * + * @param string $id + * @return array + * @throws ApiException + */ + public function getInvoiceSettings(string $id): array + { + return $this->get("subscriptions/{$id}/invoiceSettings"); + } + + /** + * update the invoice settings + * + * @param string $id + * @param array $settings `taxes` is required here too + * @return array + * @throws ValidationException|ApiException + */ + public function updateInvoiceSettings(string $id, array $settings): array + { + SubscriptionInvoiceSettingsAsaasRequest::validate($settings); + + return $this->put("subscriptions/{$id}/invoiceSettings", $settings); + } + + /** + * remove the invoice settings — no more invoices are issued + * + * @param string $id + * @return bool + * @throws ApiException + */ + public function destroyInvoiceSettings(string $id): bool + { + return $this->delete("subscriptions/{$id}/invoiceSettings"); + } + + /** + * invoices issued for the charges of a subscription + * + * @param string $id + * @param array $filters + * @return array + * @throws ApiException + * @see params in https://docs.asaas.com/reference/listar-notas-fiscais-das-cobrancas-de-uma-assinatura + */ + public function getInvoices(string $id, array $filters = []): array + { + return $this->get("subscriptions/{$id}/invoices", $filters); + } } diff --git a/src/Http/HasHttpClient.php b/src/Http/HasHttpClient.php index 6355c8f..c8947b2 100644 --- a/src/Http/HasHttpClient.php +++ b/src/Http/HasHttpClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\Client; use PHPay\Exceptions\ApiException; +use Psr\Http\Message\ResponseInterface; use Throwable; /** @@ -102,6 +103,20 @@ protected function delete(string $endpoint): bool return true; } + /** + * get a file: the raw body, for endpoints that answer with a PDF or an + * image instead of JSON. + * + * @param string $endpoint + * @param array $filters + * @return string + * @throws ApiException + */ + protected function download(string $endpoint, array $filters = []): string + { + return $this->send('GET', $endpoint, ['query' => $filters])->getBody()->getContents(); + } + /** * perform the request and decode the response body. * @@ -119,8 +134,35 @@ protected function request( array $options = [], ?Client $client = null ): array { + $content = $this->send($method, $endpoint, $options, $client)->getBody()->getContents(); + + if ($content === '') { + return []; + } + + $decoded = json_decode($content, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * perform the request, turning every failure into an ApiException. + * + * @param string $method + * @param string $endpoint + * @param array $options + * @param Client|null $client + * @return ResponseInterface + * @throws ApiException + */ + private function send( + string $method, + string $endpoint, + array $options = [], + ?Client $client = null + ): ResponseInterface { try { - $response = ($client ?? $this->client)->request($method, $endpoint, $options); + return ($client ?? $this->client)->request($method, $endpoint, $options); } catch (Throwable $exception) { throw ApiException::fromThrowable( $exception, @@ -129,15 +171,5 @@ protected function request( $endpoint ); } - - $content = $response->getBody()->getContents(); - - if ($content === '') { - return []; - } - - $decoded = json_decode($content, true); - - return is_array($decoded) ? $decoded : []; } } diff --git a/tests/Unit/Asaas/SubscriptionTest.php b/tests/Unit/Asaas/SubscriptionTest.php index e995004..e2ad844 100644 --- a/tests/Unit/Asaas/SubscriptionTest.php +++ b/tests/Unit/Asaas/SubscriptionTest.php @@ -1,7 +1,10 @@ setCustomer(['id' => 'cus_abc']) - ->create(['billingType' => 'PIX', 'value' => 10, 'nextDueDate' => '2026-01-10']); + ->create(['billingType' => 'PIX', 'value' => 10, 'nextDueDate' => '2026-01-10', 'cycle' => 'MONTHLY']); expect($history)->toHaveCount(1) ->and(recordedBody($history)['customer'])->toBe('cus_abc'); @@ -39,8 +42,250 @@ $client = mockClient([jsonResponse([])], $history); expect(fn () => (new Subscription('token', true, $client)) - ->create(['billingType' => 'BOLETO', 'value' => 100, 'nextDueDate' => '2026-01-10'])) + ->create(['billingType' => 'BOLETO', 'value' => 100, 'nextDueDate' => '2026-01-10', 'cycle' => 'MONTHLY'])) ->toThrow(ValidationException::class, 'O campo customer é obrigatório'); expect($history)->toBeEmpty(); })->group('asaas'); + +/** + * Asaas subscription resource on a mocked client. + * + * @param array $responses + * @param array $history + * @return Subscription + */ +function asaasSubscription(array $responses, array &$history = []): Subscription +{ + return new Subscription('token', true, mockClient($responses, $history)); +} + +/** + * invoice settings with every tax the Asaas API requires. + * + * @return array + */ +function invoiceSettings(): array +{ + return [ + 'municipalServiceName' => 'Desenvolvimento de software', + 'effectiveDatePeriod' => 'ON_PAYMENT_CONFIRMATION', + 'taxes' => [ + 'retainIss' => false, + 'iss' => 2, + 'pis' => 0.65, + 'cofins' => 3, + 'csll' => 0, + 'inss' => 0, + 'ir' => 0, + ], + ]; +} + +it('monta a assinatura pelos setters, com o valor em Money', function () { + $history = []; + + asaasSubscription([jsonResponse(['id' => 'sub_001'])], $history) + ->setCustomerId('cus_abc') + ->setAmount(Money::reais('49,90')) + ->setCycle(SubscriptionCycleEnum::QUARTERLY) + ->setSubscription(['billingType' => 'PIX', 'nextDueDate' => '2026-10-10']) + ->create(); + + expect(recordedBody($history))->toBe([ + 'value' => 49.9, + 'cycle' => 'QUARTERLY', + 'billingType' => 'PIX', + 'nextDueDate' => '2026-10-10', + 'customer' => 'cus_abc', + ]); +})->group('asaas'); + +it('deixa o array do create() sobrescrever o que os setters montaram', function () { + $history = []; + + asaasSubscription([jsonResponse(['id' => 'sub_001'])], $history) + ->setCustomerId('cus_abc') + ->setAmount(Money::reais(10)) + ->setCycle(SubscriptionCycleEnum::MONTHLY) + ->create(['billingType' => 'BOLETO', 'nextDueDate' => '2026-10-10', 'value' => 20]); + + expect(recordedBody($history)['value'])->toBe(20); +})->group('asaas'); + +it('exige o ciclo, que o Asaas recusa ausente', function () { + $history = []; + + expect(fn () => asaasSubscription([], $history) + ->setCustomerId('cus_abc') + ->create(['billingType' => 'PIX', 'value' => 10, 'nextDueDate' => '2026-10-10'])) + ->toThrow(ValidationException::class, 'setCycle()'); + + expect($history)->toBeEmpty(); +})->group('asaas'); + +it('lista com filtros e busca por id', function () { + $history = []; + $subscription = asaasSubscription([jsonResponse(['data' => []]), jsonResponse(['id' => 'sub_001'])], $history); + + $subscription->setQueryParams(['customer' => 'cus_abc', 'status' => 'ACTIVE'])->getAll(); + $subscription->find('sub_001'); + + expect((string) $history[0]['request']->getUri())->toEndWith('/subscriptions?customer=cus_abc&status=ACTIVE') + ->and((string) $history[1]['request']->getUri())->toEndWith('/subscriptions/sub_001'); +})->group('asaas'); + +it('atualiza com PUT, repassando updatePendingPayments', function () { + $history = []; + + asaasSubscription([jsonResponse(['id' => 'sub_001'])], $history) + ->update('sub_001', ['description' => 'Plano anual', 'updatePendingPayments' => true]); + + expect($history[0]['request']->getMethod())->toBe('PUT') + ->and((string) $history[0]['request']->getUri())->toEndWith('/subscriptions/sub_001') + ->and(recordedBody($history))->toBe(['description' => 'Plano anual', 'updatePendingPayments' => true]); +})->group('asaas'); + +it('pausa sem remover cobranças e reativa com novo vencimento', function () { + $history = []; + $subscription = asaasSubscription([jsonResponse([]), jsonResponse([])], $history); + + $subscription->deactivate('sub_001'); + $subscription->reactivate('sub_001', '2026-11-10'); + + expect(recordedBody($history, 0))->toBe(['status' => 'INACTIVE']) + ->and(recordedBody($history, 1))->toBe(['status' => 'ACTIVE', 'nextDueDate' => '2026-11-10']); +})->group('asaas'); + +it('remove a assinatura', function () { + $history = []; + + expect(asaasSubscription([jsonResponse(['deleted' => true, 'id' => 'sub_001'])], $history)->destroy('sub_001')) + ->toBeTrue() + ->and($history[0]['request']->getMethod())->toBe('DELETE') + ->and((string) $history[0]['request']->getUri())->toEndWith('/subscriptions/sub_001'); +})->group('asaas'); + +it('troca o cartão sem cobrar, por token ou pelos dados do cartão', function () { + $history = []; + $subscription = asaasSubscription([jsonResponse([]), jsonResponse([])], $history); + + $subscription->updateCreditCard('sub_001', ['creditCardToken' => 'tok_card', 'remoteIp' => '200.100.50.25']); + $subscription->updateCreditCard('sub_001', [ + 'creditCard' => [ + 'holderName' => 'MARIO LUCAS', + 'number' => '5162306219378829', + 'expiryMonth' => '05', + 'expiryYear' => '2030', + 'ccv' => '318', + ], + 'creditCardHolderInfo' => [ + 'name' => 'Mário Lucas', + 'email' => 'fale@phpay.io', + 'cpfCnpj' => '12345678909', + 'postalCode' => '01310100', + 'addressNumber' => '100', + 'phone' => '11940028922', + ], + 'remoteIp' => '200.100.50.25', + ]); + + expect($history[0]['request']->getMethod())->toBe('PUT') + ->and((string) $history[0]['request']->getUri())->toEndWith('/subscriptions/sub_001/creditCard') + ->and(recordedBody($history, 1)['creditCard']['holderName'])->toBe('MARIO LUCAS'); +})->group('asaas'); + +it('recusa troca de cartão incompleta sem chamar a API', function (array $card, string $message) { + $history = []; + + expect(fn () => asaasSubscription([], $history)->updateCreditCard('sub_001', $card)) + ->toThrow(ValidationException::class, $message); + + expect($history)->toBeEmpty(); +})->with([ + 'sem ip' => [['creditCardToken' => 'tok_card'], 'remoteIp'], + 'ip inválido' => [['creditCardToken' => 'tok_card', 'remoteIp' => 'localhost'], 'remoteIp'], + 'sem cartão' => [['remoteIp' => '200.100.50.25'], 'creditCardToken'], + 'sem titular' => [[ + 'creditCard' => ['holderName' => 'X', 'number' => '1', 'expiryMonth' => '1', 'expiryYear' => '2030', 'ccv' => '1'], + 'remoteIp' => '200.100.50.25', + ], 'creditCardHolderInfo'], +])->group('asaas'); + +it('lista as cobranças geradas pela assinatura', function () { + $history = []; + + asaasSubscription([jsonResponse(['data' => [['id' => 'pay_001']]])], $history) + ->getPayments('sub_001', ['status' => 'PENDING']); + + expect((string) $history[0]['request']->getUri())->toEndWith('/subscriptions/sub_001/payments?status=PENDING'); +})->group('asaas'); + +it('devolve o carnê como os bytes do PDF', function () { + $history = []; + $pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"; + + $book = asaasSubscription([new Response(200, ['content-type' => 'application/pdf'], $pdf)], $history) + ->paymentBook('sub_001', 12, 2026); + + expect($book)->toBe($pdf) + ->and((string) $history[0]['request']->getUri())->toEndWith('/subscriptions/sub_001/paymentBook?month=12&year=2026'); +})->group('asaas'); + +it('pede o carnê sem mês e ano quando não informados', function () { + $history = []; + + asaasSubscription([new Response(200, ['content-type' => 'application/pdf'], '%PDF')], $history) + ->paymentBook('sub_001'); + + expect($history[0]['request']->getUri()->getQuery())->toBe(''); +})->group('asaas'); + +it('falha com ApiException quando o carnê não existe', function () { + expect(fn () => asaasSubscription([jsonResponse(['errors' => [['description' => 'Assinatura não encontrada.']]], 404)]) + ->paymentBook('sub_404')) + ->toThrow(ApiException::class, 'Assinatura não encontrada.'); +})->group('asaas'); + +it('configura, consulta, atualiza e remove a emissão de nota fiscal', function () { + $history = []; + $subscription = asaasSubscription([ + jsonResponse(['municipalServiceName' => 'Desenvolvimento de software']), + jsonResponse(['municipalServiceName' => 'Desenvolvimento de software']), + jsonResponse([]), + jsonResponse(['deleted' => true]), + ], $history); + + $subscription->createInvoiceSettings('sub_001', invoiceSettings()); + $subscription->getInvoiceSettings('sub_001'); + $subscription->updateInvoiceSettings('sub_001', ['observations' => 'Nota mensal'] + invoiceSettings()); + + expect($subscription->destroyInvoiceSettings('sub_001'))->toBeTrue() + ->and(array_map(fn (array $t) => $t['request']->getMethod(), $history))->toBe(['POST', 'GET', 'PUT', 'DELETE']) + ->and((string) $history[0]['request']->getUri())->toEndWith('/subscriptions/sub_001/invoiceSettings') + ->and(recordedBody($history, 0)['taxes']['pis'])->toBe(0.65); +})->group('asaas'); + +it('recusa configuração de nota fiscal sem os impostos que o Asaas exige', function (array $settings, string $message) { + $history = []; + + expect(fn () => asaasSubscription([], $history)->createInvoiceSettings('sub_001', $settings)) + ->toThrow(ValidationException::class, $message); + + expect($history)->toBeEmpty(); +})->with([ + 'sem taxes' => [['observations' => 'x'], 'O campo taxes é obrigatório'], + 'sem ir' => [['taxes' => ['retainIss' => false, 'iss' => 2, 'pis' => 0, 'cofins' => 0, 'csll' => 0, 'inss' => 0]], 'Falta o imposto ir'], + 'retainIss texto' => [['taxes' => ['retainIss' => 'não', 'iss' => 2, 'pis' => 0, 'cofins' => 0, 'csll' => 0, 'inss' => 0, 'ir' => 0]], 'booleano'], + 'alíquota texto' => [['taxes' => ['retainIss' => false, 'iss' => '2%', 'pis' => 0, 'cofins' => 0, 'csll' => 0, 'inss' => 0, 'ir' => 0]], 'O imposto iss'], + 'período errado' => [['effectiveDatePeriod' => 'TODO_DIA'] + invoiceSettings(), 'effectiveDatePeriod'], +])->group('asaas'); + +it('lista as notas fiscais das cobranças da assinatura', function () { + $history = []; + + asaasSubscription([jsonResponse(['data' => []])], $history) + ->getInvoices('sub_001', ['status' => 'AUTHORIZED']); + + expect((string) $history[0]['request']->getUri())->toEndWith('/subscriptions/sub_001/invoices?status=AUTHORIZED'); +})->group('asaas'); diff --git a/tests/Unit/Requests/AsaasChargeRequestTest.php b/tests/Unit/Requests/AsaasChargeRequestTest.php index c7afeb3..d3d7b1f 100644 --- a/tests/Unit/Requests/AsaasChargeRequestTest.php +++ b/tests/Unit/Requests/AsaasChargeRequestTest.php @@ -75,6 +75,7 @@ 'billingType' => 'BOLETO', 'value' => 100, 'nextDueDate' => '2026-01-10', + 'cycle' => 'MONTHLY', ]))->not->toThrow(ValidationException::class); expect(fn () => StoreSubscriptionAsaasRequest::validate([ @@ -82,7 +83,16 @@ 'billingType' => 'BOLETO', 'value' => 100, 'nextDueDate' => 20260110, + 'cycle' => 'MONTHLY', ]))->toThrow(ValidationException::class); + + /* sem cycle o Asaas recusa — a validação barra antes */ + expect(fn () => StoreSubscriptionAsaasRequest::validate([ + 'customer' => 'cus_001', + 'billingType' => 'BOLETO', + 'value' => 100, + 'nextDueDate' => '2026-01-10', + ]))->toThrow(ValidationException::class, 'O campo cycle é obrigatório'); })->group('asaas'); it('prefixa toda mensagem de validação com o gateway', function () {