diff --git a/CLAUDE.md b/CLAUDE.md index 3995369..94252ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,6 +135,23 @@ e rode `php examples/asaas/charges.php` (ou `make asaas resource=charges`). `PHPay\Efi\`, `PHPay\MercadoPago\` → `src/Gateways//`. Gateway novo precisa de um root novo no `composer.json` — não introduza `PHPay\Gateways\...`. +## Valores monetários + +Use **`PHPay\Support\Money`**. Os gateways discordam da unidade — Asaas e Mercado +Pago querem reais decimais, os outros sete querem centavos inteiros — e errar não +quebra a integração, cobra o valor errado. + +- Fábricas: `Money::reais()` (aceita float, int e string em notação brasileira) e + `Money::centavos()`. Acessores na instância: `toReais()` e `toCentavos()`. +- **Dentro dos gateways**, normalize com `Money::asReais()` ou `Money::asCentavos()`, + que aceitam `Money` ou número cru. Nunca leia o valor direto do parâmetro. +- Todo método que recebe valor aceita `Money|int` (ou `Money|int|float` nos de reais). + Número cru é lido na unidade que aquele gateway sempre esperou — compatibilidade. +- Onde o valor vive dentro do array de payload (Asaas, Mercado Pago, Efí), existe + `setAmount()`. Gateway novo com valor em array deve ter o mesmo. +- `Money::reais()` **recusa mais de duas casas decimais** de propósito. Não "conserte" + isso com arredondamento: é o que impede erro de um centavo na conciliação. + ## Particularidades por gateway - **Asaas** — `$sandbox` troca a base URL. Único com chaves Pix, porque é PSP. diff --git a/README.md b/README.md index ca63b8c..12517f4 100644 --- a/README.md +++ b/README.md @@ -263,30 +263,56 @@ Nos dois últimos, `isSandbox()` diz em qual ambiente você está: ### Unidade monetária -**Este é o erro mais caro de cometer**, porque a cobrança sai com valor errado -em vez de falhar: +Os gateways discordam sobre a unidade, e **errar não quebra a integração — ela +cobra o valor errado**. Mandar `100.50` num gateway de centavos cobra R$ 1,00, +e você só descobre na conciliação. -| Gateway | Unidade | R$ 100,50 é | -| --- | --- | --- | -| **Asaas** | Reais (decimal) | `100.50` | -| **Mercado Pago** | Reais (decimal) | `100.50` | -| **PagBank** | Centavos (inteiro) | `10050` | -| **Pagar.me** | Centavos (inteiro) | `10050` | -| **Cielo** | Centavos (inteiro) | `10050` | -| **Rede** | Centavos (inteiro) | `10050` | -| **AbacatePay** | Centavos (inteiro, mín. 100) | `10050` | -| **Woovi** | Centavos (inteiro) | `10050` | -| **Efí** | Centavos (inteiro) | `10050` | +Use `Money` e o problema deixa de existir: você diz a unidade que tem, o +gateway pede a unidade que precisa, e nenhum dos dois pode errar. + +```php +use PHPay\Support\Money; -Nos gateways que usam centavos, o PHPay **recusa valor decimal na validação**, -antes de qualquer chamada: +$valor = Money::reais(100.50); // ou Money::centavos(10050) + +$asaas->charge()->setAmount($valor); // vira 100.50 +$pagbank->charge()->addItem('Item', $valor); // vira 10050 +``` + +Também aceita string, que é o que um campo de formulário costuma entregar: ```php -$phpay->charge()->addItem('Item', 100.50); -// ValidationException: ... deve ser um inteiro em CENTAVOS maior que zero. -// R$ 10,50 é 1050. +Money::reais('100,50'); // notação brasileira +Money::reais('1.234,56'); // com separador de milhar +Money::reais('100.50'); // notação com ponto ``` +E tem o que um total precisa: + +```php +$unitario = Money::reais(59.90); + +$unitario->multiply(2); // R$ 119,80 +$unitario->plus(Money::reais(10)); // R$ 69,90 +$unitario->format(); // 'R$ 59,90' +``` + +> `Money::reais(10 / 3)` **lança exceção** em vez de arredondar. Arredondamento +> silencioso é como nascem erros de um centavo na conciliação — arredonde você +> mesmo, ou use `Money::centavos()` para ser exato. + +#### Passando número cru + +Continua funcionando, e cada gateway lê na unidade que sempre esperou: + +| Gateway | Unidade do número cru | R$ 100,50 | +| --- | --- | --- | +| **Asaas**, **Mercado Pago** | Reais (decimal) | `100.50` | +| **PagBank**, **Pagar.me**, **Cielo**, **Rede**, **AbacatePay**, **Woovi**, **Efí** | Centavos (inteiro) | `10050` | + +Nos que usam centavos, o PHPay recusa decimal na validação. Mas é justamente +essa tabela que o `Money` torna desnecessária — **prefira o value object**. + --- ## Gateways diff --git a/examples/abacatepay/charges.php b/examples/abacatepay/charges.php index f98f66d..9ef969c 100644 --- a/examples/abacatepay/charges.php +++ b/examples/abacatepay/charges.php @@ -4,6 +4,7 @@ use PHPay\AbacatePay\Resources\Charge\Charge; use PHPay\Exceptions\PHPayException; use PHPay\PHPay; +use PHPay\Support\Money; require_once __DIR__ . '/../../vendor/autoload.php'; @@ -33,8 +34,8 @@ */ $cobranca = $phpay ->setCustomer($cliente) - ->addProduct('prod-1234', 'Assinatura PHPay', 2000) /* R$ 20,00 */ - ->addProduct('prod-5678', 'Camiseta', 5990, 2, 'Tamanho M') /* R$ 59,90 cada */ + ->addProduct('prod-1234', 'Assinatura PHPay', Money::reais(20.00)) /* R$ 20,00 */ + ->addProduct('prod-5678', 'Camiseta', Money::reais(59.90), 2, 'Tamanho M') /* R$ 59,90 cada */ ->setUrls( completionUrl: 'https://exemplo.test/obrigado', returnUrl: 'https://exemplo.test/loja' @@ -58,7 +59,7 @@ PHPay::gateway($gateway)->charge() ->setCustomerId((string) ($criado['data']['id'] ?? '')) - ->addProduct('prod-1234', 'Assinatura PHPay', 2000) + ->addProduct('prod-1234', 'Assinatura PHPay', Money::reais(20.00)) ->setUrls('https://exemplo.test/obrigado', 'https://exemplo.test/loja') ->create(); diff --git a/examples/asaas/charges.php b/examples/asaas/charges.php index 3aea91b..2bc4ab2 100644 --- a/examples/asaas/charges.php +++ b/examples/asaas/charges.php @@ -4,6 +4,7 @@ use PHPay\Asaas\Resources\Charge\Charge; use PHPay\Exceptions\PHPayException; use PHPay\PHPay; +use PHPay\Support\Money; require_once __DIR__ . '/../../vendor/autoload.php'; @@ -16,7 +17,6 @@ $charge = [ 'billingType' => 'BOLETO', - 'value' => 100.00, 'dueDate' => date('Y-m-d', strtotime('+3 days')), 'description' => 'Cobrança de teste do PHPay', ]; @@ -34,6 +34,8 @@ /* cria a cobrança criando também o cliente */ $chargeCreated = $phpay ->setCharge($charge) + /* o Money cuida da unidade: aqui vira reais, em outros gateways vira centavos */ + ->setAmount(Money::reais(100.00)) ->setCustomer($customer) ->create(); diff --git a/examples/cielo/charges.php b/examples/cielo/charges.php index 6c872c2..5c745e1 100644 --- a/examples/cielo/charges.php +++ b/examples/cielo/charges.php @@ -5,6 +5,7 @@ use PHPay\Cielo\Resources\Charge\Charge; use PHPay\Exceptions\PHPayException; use PHPay\PHPay; +use PHPay\Support\Money; require_once __DIR__ . '/../../vendor/autoload.php'; @@ -25,7 +26,7 @@ $venda = $phpay ->setOrderId('pedido-' . time()) ->setCustomer(['Name' => NAME]) - ->setPix(15700) + ->setPix(Money::reais(157.00)) /* use uma chave estável do seu domínio para tornar o retry seguro */ ->setRequestId('pedido-123456') ->create(); @@ -52,7 +53,7 @@ $comCartao = PHPay::gateway(new CieloGateway(CIELO_MERCHANT_ID, CIELO_MERCHANT_KEY)) ->charge() ->setCustomer(['Name' => NAME]) - ->setCreditCard(15700, [ + ->setCreditCard(Money::reais(157.00), [ 'CardNumber' => '0000000000000001', 'Holder' => 'Mario Lucas', 'ExpirationDate' => '12/2030', diff --git a/examples/pagarme/charges.php b/examples/pagarme/charges.php index 4f79ce5..29ec385 100644 --- a/examples/pagarme/charges.php +++ b/examples/pagarme/charges.php @@ -4,6 +4,7 @@ use PHPay\PagarMe\PagarMeGateway; use PHPay\PagarMe\Resources\Charge\Charge; use PHPay\PHPay; +use PHPay\Support\Money; require_once __DIR__ . '/../../vendor/autoload.php'; @@ -33,7 +34,7 @@ */ $pedido = $phpay ->setCustomer($customer) - ->addItem('Assinatura PHPay', 10050) + ->addItem('Assinatura PHPay', Money::reais(100.50)) ->setPix(1800) ->create(); @@ -56,7 +57,7 @@ /* boleto, reaproveitando um cliente que já existe */ PHPay::gateway($gateway)->charge() ->setCustomerId((string) $pedido['customer']['id']) - ->addItem('Camiseta', 5990, 2) + ->addItem('Camiseta', Money::reais(59.90), 2) ->setBoleto(date('Y-m-d', strtotime('+5 days')), ['Não receber após o vencimento']) ->create(); diff --git a/examples/pagbank/charges.php b/examples/pagbank/charges.php index c32891d..3f6f336 100644 --- a/examples/pagbank/charges.php +++ b/examples/pagbank/charges.php @@ -5,6 +5,7 @@ use PHPay\PagBank\PagBankGateway; use PHPay\PagBank\Resources\Charge\Charge; use PHPay\PHPay; +use PHPay\Support\Money; require_once __DIR__ . '/../../vendor/autoload.php'; @@ -31,8 +32,8 @@ */ $pedido = $phpay ->setCustomer($customer) - ->addItem('Assinatura PHPay', 10050) - ->setQrCode(10050) + ->addItem('Assinatura PHPay', Money::reais(100.50)) + ->setQrCode(Money::reais(100.50)) /* sem CRUD de webhook na API: a notificação é por pedido */ ->setNotificationUrls(['https://exemplo.test/webhook/pagbank']) ->create(); @@ -52,7 +53,7 @@ $comCartao = PHPay::gateway(new PagBankGateway(TOKEN_PAGBANK_SANDBOX)) ->charge() ->setCustomer($customer) - ->addItem('Camiseta', 5990, 2) + ->addItem('Camiseta', Money::reais(59.90), 2) ->setCharges([[ 'reference_id' => 'cobranca-1', 'description' => 'Camiseta', diff --git a/examples/rede/charges.php b/examples/rede/charges.php index dc7cd0c..c1c218b 100644 --- a/examples/rede/charges.php +++ b/examples/rede/charges.php @@ -5,6 +5,7 @@ use PHPay\Rede\Enums\{TransactionKindEnum, TransactionStatusEnum}; use PHPay\Rede\RedeGateway; use PHPay\Rede\Resources\Charge\Charge; +use PHPay\Support\Money; require_once __DIR__ . '/../../vendor/autoload.php'; @@ -31,7 +32,7 @@ $transacao = $phpay ->setReference('pedido-' . time()) ->setCard('5448280000000007', 'MARIO LUCAS', '12', '2030', '123') - ->setPayment(2099, TransactionKindEnum::CREDIT, installments: 1, capture: true) + ->setPayment(Money::reais(20.99), TransactionKindEnum::CREDIT, installments: 1, capture: true) ->setSoftDescriptor('PHPAY') ->create(); @@ -59,7 +60,7 @@ $emDuasEtapas = PHPay::gateway($gateway)->charge() ->setReference('pedido-2-etapas-' . time()) ->setCard('5448280000000007', 'MARIO LUCAS', '12', '2030', '123') - ->setPayment(5000, TransactionKindEnum::CREDIT, capture: false) + ->setPayment(Money::reais(50.00), TransactionKindEnum::CREDIT, capture: false) ->create(); $phpay->capture((string) $emDuasEtapas['tid']); diff --git a/examples/woovi/charges.php b/examples/woovi/charges.php index aca9f1d..f5ec159 100644 --- a/examples/woovi/charges.php +++ b/examples/woovi/charges.php @@ -2,6 +2,7 @@ use PHPay\Exceptions\PHPayException; use PHPay\PHPay; +use PHPay\Support\Money; use PHPay\Woovi\Enums\PixKeyTypeEnum; use PHPay\Woovi\WooviGateway; @@ -27,7 +28,7 @@ $cobranca = $phpay->charge() ->setCorrelationId('pedido-' . time()) ->setCustomer(['name' => NAME, 'email' => EMAIL]) - ->create(10050); /* R$ 100,50 */ + ->create(Money::reais(100.50)); /* R$ 100,50 */ echo $phpay->charge()->getPixCode($cobranca) . PHP_EOL; @@ -47,7 +48,7 @@ /* QR Code estático: sem valor, o pagador escolhe quanto pagar */ $phpay->pix()->staticQrCode('Caixa 1'); - $phpay->pix()->staticQrCode('Mensalidade', 4990, 'mensalidade-2026'); + $phpay->pix()->staticQrCode('Mensalidade', Money::reais(49.90), 'mensalidade-2026'); /* Webhooks com CRUD por API — também só aqui e no Asaas */ $phpay->webhook([ @@ -61,7 +62,7 @@ $phpay->subscription() ->setCustomer(['name' => NAME, 'email' => EMAIL]) ->setDayGenerateCharge(10) - ->create(4990); + ->create(Money::reais(49.90)); /* Cliente avulso */ $phpay->customer(['name' => NAME, 'email' => EMAIL])->create(); diff --git a/src/Gateways/AbacatePay/Resources/Charge/Charge.php b/src/Gateways/AbacatePay/Resources/Charge/Charge.php index 320b0fd..bc8cc7e 100644 --- a/src/Gateways/AbacatePay/Resources/Charge/Charge.php +++ b/src/Gateways/AbacatePay/Resources/Charge/Charge.php @@ -8,6 +8,7 @@ use PHPay\AbacatePay\Resources\Charge\Interface\ChargeInterface; use PHPay\AbacatePay\Traits\HasAbacatePayClient; use PHPay\Exceptions\{ApiException, ValidationException}; +use PHPay\Support\Money; /** * billings of the AbacatePay API. @@ -127,7 +128,7 @@ public function setProducts(array $products): ChargeInterface public function addProduct( string $externalId, string $name, - int $price, + Money|int $price, int $quantity = 1, ?string $description = null ): ChargeInterface { @@ -141,7 +142,7 @@ public function addProduct( 'externalId' => $externalId, 'name' => $name, 'quantity' => $quantity, - 'price' => $price, + 'price' => Money::asCentavos($price), ]; if ($description !== null) { diff --git a/src/Gateways/AbacatePay/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/AbacatePay/Resources/Charge/Interface/ChargeInterface.php index 32b64e2..c41303f 100644 --- a/src/Gateways/AbacatePay/Resources/Charge/Interface/ChargeInterface.php +++ b/src/Gateways/AbacatePay/Resources/Charge/Interface/ChargeInterface.php @@ -2,6 +2,8 @@ namespace PHPay\AbacatePay\Resources\Charge\Interface; +use PHPay\Support\Money; + interface ChargeInterface { /** @@ -49,7 +51,7 @@ public function setProducts(array $products): ChargeInterface; public function addProduct( string $externalId, string $name, - int $price, + Money|int $price, int $quantity = 1, ?string $description = null ): ChargeInterface; diff --git a/src/Gateways/Asaas/Resources/Charge/Charge.php b/src/Gateways/Asaas/Resources/Charge/Charge.php index 38efb6e..0e42c8f 100644 --- a/src/Gateways/Asaas/Resources/Charge/Charge.php +++ b/src/Gateways/Asaas/Resources/Charge/Charge.php @@ -8,6 +8,7 @@ use PHPay\Asaas\Resources\Customer\Customer; use PHPay\Asaas\Traits\HasAsaasClient; use PHPay\Exceptions\{ApiException, ValidationException}; +use PHPay\Support\Money; class Charge implements ChargeInterface { @@ -59,6 +60,22 @@ public function setCharge(array $charge): ChargeInterface return $this; } + /** + * set the amount of the 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 ChargeInterface + */ + public function setAmount(Money|int|float $amount): ChargeInterface + { + $this->charge['value'] = Money::asReais($amount); + + return $this; + } + /** * set query params * diff --git a/src/Gateways/Asaas/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/Asaas/Resources/Charge/Interface/ChargeInterface.php index 6f2d687..d6ebfc5 100644 --- a/src/Gateways/Asaas/Resources/Charge/Interface/ChargeInterface.php +++ b/src/Gateways/Asaas/Resources/Charge/Interface/ChargeInterface.php @@ -2,6 +2,8 @@ namespace PHPay\Asaas\Resources\Charge\Interface; +use PHPay\Support\Money; + interface ChargeInterface { /** @@ -67,6 +69,14 @@ public function setCustomerId(string $customerId): ChargeInterface; */ public function setCustomer(array $customer): ChargeInterface; + /** + * set the amount of the charge, in reais + * + * @param Money|int|float $amount + * @return ChargeInterface + */ + public function setAmount(Money|int|float $amount): ChargeInterface; + /** * set charge * diff --git a/src/Gateways/Cielo/Resources/Charge/Charge.php b/src/Gateways/Cielo/Resources/Charge/Charge.php index e3ca83b..dce5a39 100644 --- a/src/Gateways/Cielo/Resources/Charge/Charge.php +++ b/src/Gateways/Cielo/Resources/Charge/Charge.php @@ -8,6 +8,7 @@ use PHPay\Cielo\Resources\Charge\Interface\ChargeInterface; use PHPay\Cielo\Traits\HasCieloClient; use PHPay\Exceptions\{ApiException, ValidationException}; +use PHPay\Support\Money; /** * sales of the Cielo E-commerce API 3.0. @@ -107,11 +108,11 @@ public function setCustomer(array $customer): ChargeInterface * @param int $amount amount in cents * @return ChargeInterface */ - public function setPix(int $amount): ChargeInterface + public function setPix(Money|int $amount): ChargeInterface { $this->sale['Payment'] = [ 'Type' => PaymentTypeEnum::PIX->value, - 'Amount' => $amount, + 'Amount' => Money::asCentavos($amount), ]; return $this; @@ -124,11 +125,11 @@ public function setPix(int $amount): ChargeInterface * @param array $options extra Payment fields, such as Demonstrative * @return ChargeInterface */ - public function setBoleto(int $amount, array $options = []): ChargeInterface + public function setBoleto(Money|int $amount, array $options = []): ChargeInterface { $this->sale['Payment'] = array_merge([ 'Type' => PaymentTypeEnum::BOLETO->value, - 'Amount' => $amount, + 'Amount' => Money::asCentavos($amount), ], $options); return $this; @@ -144,14 +145,14 @@ public function setBoleto(int $amount, array $options = []): ChargeInterface * @return ChargeInterface */ public function setCreditCard( - int $amount, + Money|int $amount, array $card, int $installments = 1, bool $capture = false ): ChargeInterface { $this->sale['Payment'] = [ 'Type' => PaymentTypeEnum::CREDIT_CARD->value, - 'Amount' => $amount, + 'Amount' => Money::asCentavos($amount), 'Installments' => $installments, 'Capture' => $capture, 'CreditCard' => $card, @@ -268,9 +269,9 @@ public function getPixCode(string $paymentId): ?string * @return array * @throws ApiException */ - public function capture(string $paymentId, ?int $amount = null): array + public function capture(string $paymentId, Money|int|null $amount = null): array { - $query = $amount === null ? '' : '?amount=' . $amount; + $query = $amount === null ? '' : '?amount=' . Money::asCentavos($amount); return $this->put("1/sales/{$paymentId}/capture{$query}"); } @@ -286,9 +287,9 @@ public function capture(string $paymentId, ?int $amount = null): array * @return array * @throws ApiException */ - public function cancel(string $paymentId, ?int $amount = null): array + public function cancel(string $paymentId, Money|int|null $amount = null): array { - $query = $amount === null ? '' : '?amount=' . $amount; + $query = $amount === null ? '' : '?amount=' . Money::asCentavos($amount); return $this->put("1/sales/{$paymentId}/void{$query}"); } diff --git a/src/Gateways/Cielo/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/Cielo/Resources/Charge/Interface/ChargeInterface.php index cc01533..e0904ad 100644 --- a/src/Gateways/Cielo/Resources/Charge/Interface/ChargeInterface.php +++ b/src/Gateways/Cielo/Resources/Charge/Interface/ChargeInterface.php @@ -2,6 +2,8 @@ namespace PHPay\Cielo\Resources\Charge\Interface; +use PHPay\Support\Money; + interface ChargeInterface { /** @@ -34,7 +36,7 @@ public function setCustomer(array $customer): ChargeInterface; * @param int $amount amount in cents * @return ChargeInterface */ - public function setPix(int $amount): ChargeInterface; + public function setPix(Money|int $amount): ChargeInterface; /** * pay with boleto @@ -43,7 +45,7 @@ public function setPix(int $amount): ChargeInterface; * @param array $options * @return ChargeInterface */ - public function setBoleto(int $amount, array $options = []): ChargeInterface; + public function setBoleto(Money|int $amount, array $options = []): ChargeInterface; /** * pay with a credit card @@ -55,7 +57,7 @@ public function setBoleto(int $amount, array $options = []): ChargeInterface; * @return ChargeInterface */ public function setCreditCard( - int $amount, + Money|int $amount, array $card, int $installments = 1, bool $capture = false @@ -115,7 +117,7 @@ public function getPixCode(string $paymentId): ?string; * @param int|null $amount amount in cents * @return array */ - public function capture(string $paymentId, ?int $amount = null): array; + public function capture(string $paymentId, Money|int|null $amount = null): array; /** * cancel or refund a sale @@ -124,5 +126,5 @@ public function capture(string $paymentId, ?int $amount = null): array; * @param int|null $amount amount in cents * @return array */ - public function cancel(string $paymentId, ?int $amount = null): array; + public function cancel(string $paymentId, Money|int|null $amount = null): array; } diff --git a/src/Gateways/Cielo/Resources/Subscription/Interface/SubscriptionInterface.php b/src/Gateways/Cielo/Resources/Subscription/Interface/SubscriptionInterface.php index 513ef53..e13f281 100644 --- a/src/Gateways/Cielo/Resources/Subscription/Interface/SubscriptionInterface.php +++ b/src/Gateways/Cielo/Resources/Subscription/Interface/SubscriptionInterface.php @@ -3,6 +3,7 @@ namespace PHPay\Cielo\Resources\Subscription\Interface; use PHPay\Cielo\Enums\RecurrentIntervalEnum; +use PHPay\Support\Money; interface SubscriptionInterface { @@ -52,7 +53,7 @@ public function setEndDate(string $endDate): SubscriptionInterface; * @param int $amount amount in cents * @return array */ - public function create(int $amount): array; + public function create(Money|int $amount): array; /** * find a recurrence by id @@ -85,7 +86,7 @@ public function reactivate(string $recurrentPaymentId): array; * @param int $amount amount in cents * @return array */ - public function updateAmount(string $recurrentPaymentId, int $amount): array; + public function updateAmount(string $recurrentPaymentId, Money|int $amount): array; /** * change how often the recurrence charges diff --git a/src/Gateways/Cielo/Resources/Subscription/Subscription.php b/src/Gateways/Cielo/Resources/Subscription/Subscription.php index f5438ed..a163c6d 100644 --- a/src/Gateways/Cielo/Resources/Subscription/Subscription.php +++ b/src/Gateways/Cielo/Resources/Subscription/Subscription.php @@ -8,6 +8,7 @@ use PHPay\Cielo\Resources\Subscription\Interface\SubscriptionInterface; use PHPay\Cielo\Traits\HasCieloClient; use PHPay\Exceptions\{ApiException, ValidationException}; +use PHPay\Support\Money; /** * recurrences of the Cielo E-commerce API 3.0. @@ -135,7 +136,7 @@ public function setEndDate(string $endDate): SubscriptionInterface * @return array * @throws ValidationException|ApiException */ - public function create(int $amount): array + public function create(Money|int $amount): array { $this->sale['MerchantOrderId'] = $this->sale['MerchantOrderId'] ?? uniqid('phpay_'); @@ -146,7 +147,7 @@ public function create(int $amount): array $this->sale['Payment'] = [ 'Type' => PaymentTypeEnum::CREDIT_CARD->value, - 'Amount' => $amount, + 'Amount' => Money::asCentavos($amount), 'Installments' => 1, 'CreditCard' => $this->card, 'RecurrentPayment' => $recurrent, @@ -201,11 +202,13 @@ public function reactivate(string $recurrentPaymentId): array * @return array * @throws ValidationException|ApiException */ - public function updateAmount(string $recurrentPaymentId, int $amount): array + public function updateAmount(string $recurrentPaymentId, Money|int $amount): array { - CieloRecurrentRequest::validateAmount($amount); + $centavos = Money::asCentavos($amount); - return $this->putValue("1/RecurrentPayment/{$recurrentPaymentId}/Amount", $amount); + CieloRecurrentRequest::validateAmount($centavos); + + return $this->putValue("1/RecurrentPayment/{$recurrentPaymentId}/Amount", $centavos); } /** diff --git a/src/Gateways/Efi/Resources/Charge/Charge.php b/src/Gateways/Efi/Resources/Charge/Charge.php index 093f7aa..64b1cfd 100644 --- a/src/Gateways/Efi/Resources/Charge/Charge.php +++ b/src/Gateways/Efi/Resources/Charge/Charge.php @@ -7,6 +7,7 @@ use PHPay\Efi\Resources\Charge\Interface\ChargeInterface; use PHPay\Efi\Traits\HasEfiClient; use PHPay\Exceptions\{ApiException, ValidationException}; +use PHPay\Support\Money; class Charge implements ChargeInterface { @@ -80,6 +81,22 @@ public function setCustomer(array $customer): Charge return $this; } + /** + * set the amount of the charge. + * + * Efí takes cents as an integer — pass a Money and the unit is handled + * for you, or a raw integer, which is read as cents. + * + * @param Money|int $amount + * @return ChargeInterface + */ + public function setAmount(Money|int $amount): ChargeInterface + { + $this->charge['value'] = Money::asCentavos($amount); + + return $this; + } + /** * set filters * diff --git a/src/Gateways/Efi/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/Efi/Resources/Charge/Interface/ChargeInterface.php index 9a2f49d..2d4ef23 100644 --- a/src/Gateways/Efi/Resources/Charge/Interface/ChargeInterface.php +++ b/src/Gateways/Efi/Resources/Charge/Interface/ChargeInterface.php @@ -3,9 +3,18 @@ namespace PHPay\Efi\Resources\Charge\Interface; use PHPay\Efi\Resources\Charge\Charge; +use PHPay\Support\Money; interface ChargeInterface { + /** + * set the amount of the charge, in cents + * + * @param Money|int $amount + * @return ChargeInterface + */ + public function setAmount(Money|int $amount): ChargeInterface; + /** * get all charges * diff --git a/src/Gateways/MercadoPago/Resources/Charge/Charge.php b/src/Gateways/MercadoPago/Resources/Charge/Charge.php index a423ffb..c316a05 100644 --- a/src/Gateways/MercadoPago/Resources/Charge/Charge.php +++ b/src/Gateways/MercadoPago/Resources/Charge/Charge.php @@ -7,6 +7,7 @@ use PHPay\MercadoPago\Requests\MercadoPagoChargeRequest; use PHPay\MercadoPago\Resources\Charge\Interface\ChargeInterface; use PHPay\MercadoPago\Traits\HasMercadoPagoClient; +use PHPay\Support\Money; class Charge implements ChargeInterface { @@ -61,6 +62,22 @@ public function setCharge(array $charge): ChargeInterface return $this; } + /** + * set the amount of the charge. + * + * Mercado Pago 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 ChargeInterface + */ + public function setAmount(Money|int|float $amount): ChargeInterface + { + $this->charge['transaction_amount'] = Money::asReais($amount); + + return $this; + } + /** * set the payer of the charge. * diff --git a/src/Gateways/MercadoPago/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/MercadoPago/Resources/Charge/Interface/ChargeInterface.php index ef9fdf1..b74915c 100644 --- a/src/Gateways/MercadoPago/Resources/Charge/Interface/ChargeInterface.php +++ b/src/Gateways/MercadoPago/Resources/Charge/Interface/ChargeInterface.php @@ -2,6 +2,8 @@ namespace PHPay\MercadoPago\Resources\Charge\Interface; +use PHPay\Support\Money; + interface ChargeInterface { /** @@ -12,6 +14,14 @@ interface ChargeInterface */ public function setCharge(array $charge): ChargeInterface; + /** + * set the amount of the charge, in reais + * + * @param Money|int|float $amount + * @return ChargeInterface + */ + public function setAmount(Money|int|float $amount): ChargeInterface; + /** * set the payer of the charge * diff --git a/src/Gateways/PagBank/Resources/Charge/Charge.php b/src/Gateways/PagBank/Resources/Charge/Charge.php index 14fd37b..ac67039 100644 --- a/src/Gateways/PagBank/Resources/Charge/Charge.php +++ b/src/Gateways/PagBank/Resources/Charge/Charge.php @@ -7,6 +7,7 @@ use PHPay\PagBank\Requests\PagBankOrderRequest; use PHPay\PagBank\Resources\Charge\Interface\ChargeInterface; use PHPay\PagBank\Traits\HasPagBankClient; +use PHPay\Support\Money; /** * orders and charges of the PagBank Orders API. @@ -96,7 +97,7 @@ public function setItems(array $items): ChargeInterface * @param int $quantity * @return ChargeInterface */ - public function addItem(string $name, int $unitAmount, int $quantity = 1): ChargeInterface + public function addItem(string $name, Money|int $unitAmount, int $quantity = 1): ChargeInterface { $items = $this->order['items'] ?? []; @@ -108,7 +109,7 @@ public function addItem(string $name, int $unitAmount, int $quantity = 1): Charg 'reference_id' => uniqid('item_'), 'name' => $name, 'quantity' => $quantity, - 'unit_amount' => $unitAmount, + 'unit_amount' => Money::asCentavos($unitAmount), ]; $this->order['items'] = $items; @@ -141,9 +142,9 @@ public function setCharges(array $charges): ChargeInterface * @param string|null $expiresAt defaults to 23:59:59 of the next day * @return ChargeInterface */ - public function setQrCode(int $amount, ?string $expiresAt = null): ChargeInterface + public function setQrCode(Money|int $amount, ?string $expiresAt = null): ChargeInterface { - $qrCode = ['amount' => ['value' => $amount]]; + $qrCode = ['amount' => ['value' => Money::asCentavos($amount)]]; if ($expiresAt !== null) { $qrCode['expiration_date'] = $expiresAt; @@ -265,11 +266,11 @@ public function getPixCode(string $id): ?string * @throws ApiException * @see https://developer.pagbank.com.br/reference/cancelar-pagamento */ - public function refund(string $id, ?int $amount = null): array + public function refund(string $id, Money|int|null $amount = null): array { return $this->post( "charges/{$id}/cancel", - $amount === null ? [] : ['amount' => ['value' => $amount]] + $amount === null ? [] : ['amount' => ['value' => Money::asCentavos($amount)]] ); } } diff --git a/src/Gateways/PagBank/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/PagBank/Resources/Charge/Interface/ChargeInterface.php index 580c5cb..4ade719 100644 --- a/src/Gateways/PagBank/Resources/Charge/Interface/ChargeInterface.php +++ b/src/Gateways/PagBank/Resources/Charge/Interface/ChargeInterface.php @@ -2,6 +2,8 @@ namespace PHPay\PagBank\Resources\Charge\Interface; +use PHPay\Support\Money; + interface ChargeInterface { /** @@ -36,7 +38,7 @@ public function setItems(array $items): ChargeInterface; * @param int $quantity * @return ChargeInterface */ - public function addItem(string $name, int $unitAmount, int $quantity = 1): ChargeInterface; + public function addItem(string $name, Money|int $unitAmount, int $quantity = 1): ChargeInterface; /** * set the charges of the order (card or boleto) @@ -53,7 +55,7 @@ public function setCharges(array $charges): ChargeInterface; * @param string|null $expiresAt * @return ChargeInterface */ - public function setQrCode(int $amount, ?string $expiresAt = null): ChargeInterface; + public function setQrCode(Money|int $amount, ?string $expiresAt = null): ChargeInterface; /** * set the urls notified about order events @@ -109,5 +111,5 @@ public function getPixCode(string $id): ?string; * @param int|null $amount amount in cents * @return array */ - public function refund(string $id, ?int $amount = null): array; + public function refund(string $id, Money|int|null $amount = null): array; } diff --git a/src/Gateways/PagarMe/Resources/Charge/Charge.php b/src/Gateways/PagarMe/Resources/Charge/Charge.php index a5d7ea2..2682cb9 100644 --- a/src/Gateways/PagarMe/Resources/Charge/Charge.php +++ b/src/Gateways/PagarMe/Resources/Charge/Charge.php @@ -8,6 +8,7 @@ use PHPay\PagarMe\Requests\PagarMeOrderRequest; use PHPay\PagarMe\Resources\Charge\Interface\ChargeInterface; use PHPay\PagarMe\Traits\HasPagarMeClient; +use PHPay\Support\Money; /** * orders and charges of the Pagar.me Core API v5. @@ -120,7 +121,7 @@ public function setItems(array $items): ChargeInterface * @param int $quantity * @return ChargeInterface */ - public function addItem(string $description, int $amount, int $quantity = 1): ChargeInterface + public function addItem(string $description, Money|int $amount, int $quantity = 1): ChargeInterface { $items = $this->order['items'] ?? []; @@ -131,7 +132,7 @@ public function addItem(string $description, int $amount, int $quantity = 1): Ch $items[] = [ 'code' => uniqid('item_'), 'description' => $description, - 'amount' => $amount, + 'amount' => Money::asCentavos($amount), 'quantity' => $quantity, ]; @@ -318,11 +319,11 @@ public function getPixCode(string $id): ?string * @return array * @throws ApiException */ - public function capture(string $id, ?int $amount = null): array + public function capture(string $id, Money|int|null $amount = null): array { return $this->post( "charges/{$id}/capture", - $amount === null ? [] : ['amount' => $amount] + $amount === null ? [] : ['amount' => Money::asCentavos($amount)] ); } @@ -337,12 +338,12 @@ public function capture(string $id, ?int $amount = null): array * @return array * @throws ApiException */ - public function cancel(string $id, ?int $amount = null): array + public function cancel(string $id, Money|int|null $amount = null): array { return $this->request( 'DELETE', "charges/{$id}", - ['json' => $amount === null ? [] : ['amount' => $amount]] + ['json' => $amount === null ? [] : ['amount' => Money::asCentavos($amount)]] ); } } diff --git a/src/Gateways/PagarMe/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/PagarMe/Resources/Charge/Interface/ChargeInterface.php index 35ac7d9..45b3898 100644 --- a/src/Gateways/PagarMe/Resources/Charge/Interface/ChargeInterface.php +++ b/src/Gateways/PagarMe/Resources/Charge/Interface/ChargeInterface.php @@ -2,6 +2,8 @@ namespace PHPay\PagarMe\Resources\Charge\Interface; +use PHPay\Support\Money; + interface ChargeInterface { /** @@ -44,7 +46,7 @@ public function setItems(array $items): ChargeInterface; * @param int $quantity * @return ChargeInterface */ - public function addItem(string $description, int $amount, int $quantity = 1): ChargeInterface; + public function addItem(string $description, Money|int $amount, int $quantity = 1): ChargeInterface; /** * set the payments of the order @@ -132,7 +134,7 @@ public function getPixCode(string $id): ?string; * @param int|null $amount amount in cents * @return array */ - public function capture(string $id, ?int $amount = null): array; + public function capture(string $id, Money|int|null $amount = null): array; /** * cancel a charge, refunding fully or partially @@ -141,5 +143,5 @@ public function capture(string $id, ?int $amount = null): array; * @param int|null $amount amount in cents * @return array */ - public function cancel(string $id, ?int $amount = null): array; + public function cancel(string $id, Money|int|null $amount = null): array; } diff --git a/src/Gateways/Rede/Resources/Charge/Charge.php b/src/Gateways/Rede/Resources/Charge/Charge.php index b31b7d6..a130293 100644 --- a/src/Gateways/Rede/Resources/Charge/Charge.php +++ b/src/Gateways/Rede/Resources/Charge/Charge.php @@ -9,6 +9,7 @@ use PHPay\Rede\Resources\Authorization\Authorization; use PHPay\Rede\Resources\Charge\Interface\ChargeInterface; use PHPay\Rede\Traits\HasRedeClient; +use PHPay\Support\Money; /** * transactions of the e.Rede v2 API. @@ -113,12 +114,12 @@ public function setCard( * @return ChargeInterface */ public function setPayment( - int $amount, + Money|int $amount, TransactionKindEnum $kind = TransactionKindEnum::CREDIT, int $installments = 1, bool $capture = true ): ChargeInterface { - $this->transaction['amount'] = $amount; + $this->transaction['amount'] = Money::asCentavos($amount); $this->transaction['kind'] = $kind->value; $this->transaction['installments'] = $installments; $this->transaction['capture'] = $capture; @@ -208,10 +209,10 @@ public function getStatus(string $tid): ?string * @return array * @throws ApiException */ - public function capture(string $tid, ?int $amount = null): array + public function capture(string $tid, Money|int|null $amount = null): array { return $this->request('PUT', "transactions/{$tid}", $this->authorized([ - 'json' => $amount === null ? [] : ['amount' => $amount], + 'json' => $amount === null ? [] : ['amount' => Money::asCentavos($amount)], ])); } @@ -223,10 +224,10 @@ public function capture(string $tid, ?int $amount = null): array * @return array * @throws ApiException */ - public function refund(string $tid, ?int $amount = null): array + public function refund(string $tid, Money|int|null $amount = null): array { return $this->request('POST', "transactions/{$tid}/refunds", $this->authorized([ - 'json' => $amount === null ? [] : ['amount' => $amount], + 'json' => $amount === null ? [] : ['amount' => Money::asCentavos($amount)], ])); } diff --git a/src/Gateways/Rede/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/Rede/Resources/Charge/Interface/ChargeInterface.php index a9b529c..fc1927d 100644 --- a/src/Gateways/Rede/Resources/Charge/Interface/ChargeInterface.php +++ b/src/Gateways/Rede/Resources/Charge/Interface/ChargeInterface.php @@ -3,6 +3,7 @@ namespace PHPay\Rede\Resources\Charge\Interface; use PHPay\Rede\Enums\TransactionKindEnum; +use PHPay\Support\Money; interface ChargeInterface { @@ -50,7 +51,7 @@ public function setCard( * @return ChargeInterface */ public function setPayment( - int $amount, + Money|int $amount, TransactionKindEnum $kind = TransactionKindEnum::CREDIT, int $installments = 1, bool $capture = true @@ -102,7 +103,7 @@ public function getStatus(string $tid): ?string; * @param int|null $amount amount in cents * @return array */ - public function capture(string $tid, ?int $amount = null): array; + public function capture(string $tid, Money|int|null $amount = null): array; /** * refund a transaction, fully or partially @@ -111,5 +112,5 @@ public function capture(string $tid, ?int $amount = null): array; * @param int|null $amount amount in cents * @return array */ - public function refund(string $tid, ?int $amount = null): array; + public function refund(string $tid, Money|int|null $amount = null): array; } diff --git a/src/Gateways/Woovi/Resources/Charge/Charge.php b/src/Gateways/Woovi/Resources/Charge/Charge.php index e040604..5644851 100644 --- a/src/Gateways/Woovi/Resources/Charge/Charge.php +++ b/src/Gateways/Woovi/Resources/Charge/Charge.php @@ -4,6 +4,7 @@ use GuzzleHttp\Client; use PHPay\Exceptions\{ApiException, ValidationException}; +use PHPay\Support\Money; use PHPay\Woovi\Requests\WooviChargeRequest; use PHPay\Woovi\Resources\Charge\Interface\ChargeInterface; use PHPay\Woovi\Traits\HasWooviClient; @@ -114,9 +115,9 @@ public function setQueryParams(array $queryParams): ChargeInterface * @return array * @throws ValidationException|ApiException */ - public function create(int $value): array + public function create(Money|int $value): array { - $this->charge['value'] = $value; + $this->charge['value'] = Money::asCentavos($value); $this->charge['correlationID'] = $this->charge['correlationID'] ?? uniqid('phpay_'); WooviChargeRequest::validate($this->charge); diff --git a/src/Gateways/Woovi/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/Woovi/Resources/Charge/Interface/ChargeInterface.php index 4374d88..7dd67e0 100644 --- a/src/Gateways/Woovi/Resources/Charge/Interface/ChargeInterface.php +++ b/src/Gateways/Woovi/Resources/Charge/Interface/ChargeInterface.php @@ -2,6 +2,8 @@ namespace PHPay\Woovi\Resources\Charge\Interface; +use PHPay\Support\Money; + interface ChargeInterface { /** @@ -42,7 +44,7 @@ public function setQueryParams(array $queryParams): ChargeInterface; * @param int $value amount in cents * @return array */ - public function create(int $value): array; + public function create(Money|int $value): array; /** * find a charge by correlationID or by the gateway id diff --git a/src/Gateways/Woovi/Resources/Pix/Interface/PixInterface.php b/src/Gateways/Woovi/Resources/Pix/Interface/PixInterface.php index 173c4d6..723389c 100644 --- a/src/Gateways/Woovi/Resources/Pix/Interface/PixInterface.php +++ b/src/Gateways/Woovi/Resources/Pix/Interface/PixInterface.php @@ -2,6 +2,7 @@ namespace PHPay\Woovi\Resources\Pix\Interface; +use PHPay\Support\Money; use PHPay\Woovi\Enums\PixKeyTypeEnum; interface PixInterface @@ -38,7 +39,7 @@ public function verifyKey(string $key): array; * @param string|null $correlationId * @return array */ - public function staticQrCode(string $name, ?int $value = null, ?string $correlationId = null): array; + public function staticQrCode(string $name, Money|int|null $value = null, ?string $correlationId = null): array; /** * list static QR Codes diff --git a/src/Gateways/Woovi/Resources/Pix/Pix.php b/src/Gateways/Woovi/Resources/Pix/Pix.php index 37bc3cc..fa90cca 100644 --- a/src/Gateways/Woovi/Resources/Pix/Pix.php +++ b/src/Gateways/Woovi/Resources/Pix/Pix.php @@ -4,6 +4,7 @@ use GuzzleHttp\Client; use PHPay\Exceptions\{ApiException, ValidationException}; +use PHPay\Support\Money; use PHPay\Woovi\Enums\PixKeyTypeEnum; use PHPay\Woovi\Requests\WooviPixKeyRequest; use PHPay\Woovi\Resources\Pix\Interface\PixInterface; @@ -104,12 +105,12 @@ public function verifyKey(string $key): array * @return array * @throws ValidationException|ApiException */ - public function staticQrCode(string $name, ?int $value = null, ?string $correlationId = null): array + public function staticQrCode(string $name, Money|int|null $value = null, ?string $correlationId = null): array { $payload = ['name' => $name]; if ($value !== null) { - $payload['value'] = $value; + $payload['value'] = Money::asCentavos($value); } if ($correlationId !== null) { diff --git a/src/Gateways/Woovi/Resources/Subscription/Interface/SubscriptionInterface.php b/src/Gateways/Woovi/Resources/Subscription/Interface/SubscriptionInterface.php index 19e1b62..754ba70 100644 --- a/src/Gateways/Woovi/Resources/Subscription/Interface/SubscriptionInterface.php +++ b/src/Gateways/Woovi/Resources/Subscription/Interface/SubscriptionInterface.php @@ -2,6 +2,8 @@ namespace PHPay\Woovi\Resources\Subscription\Interface; +use PHPay\Support\Money; + interface SubscriptionInterface { /** @@ -26,7 +28,7 @@ public function setDayGenerateCharge(int $day): SubscriptionInterface; * @param int $value amount in cents * @return array */ - public function create(int $value): array; + public function create(Money|int $value): array; /** * find a subscription by id diff --git a/src/Gateways/Woovi/Resources/Subscription/Subscription.php b/src/Gateways/Woovi/Resources/Subscription/Subscription.php index 28ee532..89cb47d 100644 --- a/src/Gateways/Woovi/Resources/Subscription/Subscription.php +++ b/src/Gateways/Woovi/Resources/Subscription/Subscription.php @@ -4,6 +4,7 @@ use GuzzleHttp\Client; use PHPay\Exceptions\{ApiException, ValidationException}; +use PHPay\Support\Money; use PHPay\Woovi\Requests\WooviSubscriptionRequest; use PHPay\Woovi\Resources\Subscription\Interface\SubscriptionInterface; use PHPay\Woovi\Traits\HasWooviClient; @@ -77,9 +78,9 @@ public function setDayGenerateCharge(int $day): SubscriptionInterface * @return array * @throws ValidationException|ApiException */ - public function create(int $value): array + public function create(Money|int $value): array { - $this->subscription['value'] = $value; + $this->subscription['value'] = Money::asCentavos($value); WooviSubscriptionRequest::validate($this->subscription); diff --git a/src/Support/Money.php b/src/Support/Money.php new file mode 100644 index 0000000..901d88a --- /dev/null +++ b/src/Support/Money.php @@ -0,0 +1,230 @@ +negative); + } + + return new self($cents); + } + + /** + * build from an amount in reais. + * + * accepts a float, an int, or a string in either notation — `'100.50'` + * and `'100,50'` both work, which is what a form field usually hands you. + * + * a value with more than two decimal places is refused rather than + * rounded: silent rounding is how one-cent reconciliation bugs are born. + * Round it yourself, or use centavos(). + * + * @param int|float|string $amount + * @return self + * @throws ValidationException + */ + public static function reais(int|float|string $amount): self + { + $normalized = self::normalize($amount); + + if ($normalized < 0) { + throw ValidationException::make('PHPay', self::messages()->negative); + } + + $cents = $normalized * 100; + + if (abs($cents - round($cents)) > 0.000001) { + throw ValidationException::make('PHPay', self::messages()->precision); + } + + return new self((int) round($cents)); + } + + /** + * the amount in cents, for the gateways that take an integer. + * + * @return int + */ + public function toCentavos(): int + { + return $this->cents; + } + + /** + * the amount in reais, for the gateways that take a decimal. + * + * @return float + */ + public function toReais(): float + { + return round($this->cents / 100, 2); + } + + /** + * multiply by a whole number of units — a line of N identical products. + * + * @param int $times + * @return self + * @throws ValidationException + */ + public function multiply(int $times): self + { + if ($times < 0) { + throw ValidationException::make('PHPay', self::messages()->negativeMultiplier); + } + + return new self($this->cents * $times); + } + + /** + * add another amount. + * + * @param self $other + * @return self + */ + public function plus(self $other): self + { + return new self($this->cents + $other->cents); + } + + /** + * whether two amounts are the same. + * + * @param self $other + * @return bool + */ + public function equals(self $other): bool + { + return $this->cents === $other->cents; + } + + /** + * whether the amount is zero. + * + * @return bool + */ + public function isZero(): bool + { + return $this->cents === 0; + } + + /** + * the amount written the way a Brazilian reads it. + * + * @return string + */ + public function format(): string + { + return 'R$ ' . number_format($this->toReais(), 2, ',', '.'); + } + + /** + * take whatever a caller passed — a Money, an int, a float — and give + * back the cents a gateway needs. + * + * a bare int is read as cents, which is what the gateways that use this + * helper already expected before Money existed. + * + * @param self|int $amount + * @return int + * @throws ValidationException + */ + public static function asCentavos(self|int $amount): int + { + return $amount instanceof self ? $amount->toCentavos() : self::centavos($amount)->toCentavos(); + } + + /** + * take whatever a caller passed and give back the reais a gateway needs. + * + * a bare int or float is read as reais, which is what the gateways that + * use this helper already expected before Money existed. + * + * @param self|int|float $amount + * @return float + * @throws ValidationException + */ + public static function asReais(self|int|float $amount): float + { + return $amount instanceof self ? $amount->toReais() : self::reais($amount)->toReais(); + } + + /** + * messages for validation + * + * @return object{negative: string, precision: string, notNumeric: string, negativeMultiplier: string} + */ + public static function messages(): object + { + return (object) [ + 'negative' => 'Um valor monetário não pode ser negativo.', + 'precision' => 'Um valor em reais não pode ter mais de duas casas decimais. Arredonde antes, ou use Money::centavos() para ser exato.', + 'notNumeric' => 'O valor precisa ser numérico. Aceita float, int ou string em qualquer notação: "100.50" ou "100,50".', + 'negativeMultiplier' => 'A quantidade usada em multiply() não pode ser negativa.', + ]; + } + + /** + * turn whatever came in into a float in reais. + * + * @param int|float|string $amount + * @return float + * @throws ValidationException + */ + private static function normalize(int|float|string $amount): float + { + if (is_string($amount)) { + $limpo = trim($amount); + + /* "1.234,56" vira "1234.56"; "100,50" vira "100.50" */ + if (str_contains($limpo, ',')) { + $limpo = str_replace(['.', ','], ['', '.'], $limpo); + } + + if (!is_numeric($limpo)) { + throw ValidationException::make('PHPay', self::messages()->notNumeric); + } + + return (float) $limpo; + } + + return (float) $amount; + } +} diff --git a/tests/Unit/Support/MoneyAcrossGatewaysTest.php b/tests/Unit/Support/MoneyAcrossGatewaysTest.php new file mode 100644 index 0000000..7d9f199 --- /dev/null +++ b/tests/Unit/Support/MoneyAcrossGatewaysTest.php @@ -0,0 +1,135 @@ +setCharge(['billingType' => 'PIX', 'dueDate' => '2026-01-10']) + ->setCustomerId('cus_1') + ->setAmount($valor) + ->create(); + + $mp = []; + (new MpCharge('TEST-token', mockClient([jsonResponse([])], $mp))) + ->setCharge(['payment_method_id' => 'pix']) + ->setPayer(['email' => 'fale@phpay.io']) + ->setAmount($valor) + ->create(); + + expect(recordedBody($asaas)['value'])->toBe(100.50) + ->and(recordedBody($mp)['transaction_amount'])->toBe(100.50); +})->group('support'); + +it('manda centavos nos gateways que esperam inteiro', function () { + $valor = Money::reais(100.50); + + $pagbank = []; + (new PagBankCharge('token', true, mockClient([jsonResponse([])], $pagbank))) + ->setCustomer(['name' => 'Mário', 'email' => 'a@b.com', 'tax_id' => '12345678901']) + ->addItem('Item', $valor) + ->setQrCode($valor) + ->create(); + + $pagarme = []; + (new PagarMeCharge('sk_test_x', mockClient([jsonResponse([])], $pagarme))) + ->setCustomerId('cus_1') + ->addItem('Item', $valor) + ->setPix() + ->create(); + + $abacate = []; + (new AbacateCharge('token', mockClient([jsonResponse([])], $abacate))) + ->setCustomerId('cus_1') + ->addProduct('p1', 'Item', $valor) + ->setUrls('https://a.test/ok', 'https://a.test/volta') + ->create(); + + $woovi = []; + (new WooviCharge('app-id', true, mockClient([jsonResponse([])], $woovi))) + ->setCorrelationId('pedido-1') + ->create($valor); + + $cielo = []; + (new CieloCharge('id', 'key', true, mockClient([jsonResponse([])], $cielo))) + ->setCustomer(['Name' => 'Mário']) + ->setPix($valor) + ->create(); + + expect(recordedBody($pagbank)['items'][0]['unit_amount'])->toBe(10050) + ->and(recordedBody($pagbank)['qr_codes'][0]['amount']['value'])->toBe(10050) + ->and(recordedBody($pagarme)['items'][0]['amount'])->toBe(10050) + ->and(recordedBody($abacate)['products'][0]['price'])->toBe(10050) + ->and(recordedBody($woovi)['value'])->toBe(10050) + ->and(recordedBody($cielo)['Payment']['Amount'])->toBe(10050); +})->group('support'); + +it('manda centavos também na rede e no efí', function () { + $valor = Money::reais(100.50); + + $rede = []; + $oauth = []; + $gateway = new RedeGateway( + 'pv', + 'segredo', + true, + mockClient([jsonResponse([])], $rede), + mockClient([jsonResponse(['access_token' => 'tok', 'expires_in' => 3600])], $oauth) + ); + + $gateway->charge() + ->setReference('pedido-1') + ->setCard('5448280000000007', 'M L', '12', '2030', '123') + ->setPayment($valor, TransactionKindEnum::CREDIT) + ->create(); + + $efi = []; + (new EfiCharge(['access_token' => 'tok', 'token_type' => 'Bearer'], [ + 'description' => 'Item', + 'expire_at' => date('Y-m-d', strtotime('+1 day')), + ], true, mockClient([jsonResponse([])], $efi))) + ->setAmount($valor) + ->setCustomer(['name' => 'Mário', 'cpf_cnpj' => '12345678901']) + ->create(); + + expect(recordedBody($rede)['amount'])->toBe(10050) + ->and(recordedBody($efi)['items'][0]['value'])->toBe(10050); +})->group('support'); + +it('número cru continua sendo lido na unidade que o gateway já esperava', function () { + /* compatibilidade: quem passava int antes do Money continua funcionando */ + $pagbank = []; + (new PagBankCharge('token', true, mockClient([jsonResponse([])], $pagbank))) + ->setCustomer(['name' => 'Mário', 'email' => 'a@b.com', 'tax_id' => '12345678901']) + ->addItem('Item', 10050) + ->setQrCode(10050) + ->create(); + + $asaas = []; + (new AsaasCharge('token', true, mockClient([jsonResponse([])], $asaas))) + ->setCharge(['billingType' => 'PIX', 'dueDate' => '2026-01-10']) + ->setCustomerId('cus_1') + ->setAmount(100.50) + ->create(); + + expect(recordedBody($pagbank)['items'][0]['unit_amount'])->toBe(10050) + ->and(recordedBody($asaas)['value'])->toBe(100.50); +})->group('support'); diff --git a/tests/Unit/Support/MoneyTest.php b/tests/Unit/Support/MoneyTest.php new file mode 100644 index 0000000..790d355 --- /dev/null +++ b/tests/Unit/Support/MoneyTest.php @@ -0,0 +1,100 @@ +toCentavos())->toBe($centavos) + ->and(Money::centavos($centavos)->toReais())->toBe(round((float) str_replace(',', '.', (string) $reais), 2)); +})->with([ + [100.50, 10050], + [1, 100], + [0.07, 7], + [0.01, 1], + [0, 0], + ['100.50', 10050], + ['100,50', 10050], +])->group('support'); + +it('entende o formato brasileiro com separador de milhar', function () { + expect(Money::reais('1.234,56')->toCentavos())->toBe(123456) + ->and(Money::reais('1234.56')->toCentavos())->toBe(123456); +})->group('support'); + +it('sobrevive à imprecisão de float', function () { + /* 0.07 * 100 dá 7.000000000000001 em ponto flutuante */ + expect(Money::reais(0.07)->toCentavos())->toBe(7) + ->and(Money::reais(0.29)->toCentavos())->toBe(29) + ->and(Money::reais(1.15)->toCentavos())->toBe(115) + ->and(Money::reais(19.99)->toCentavos())->toBe(1999); +})->group('support'); + +it('recusa mais de duas casas decimais em vez de arredondar calado', function () { + expect(fn () => Money::reais(10 / 3)) + ->toThrow(ValidationException::class, 'duas casas decimais'); + + expect(fn () => Money::reais(1.005)) + ->toThrow(ValidationException::class, 'duas casas decimais'); +})->group('support'); + +it('recusa valor negativo', function () { + expect(fn () => Money::reais(-1))->toThrow(ValidationException::class, 'não pode ser negativo'); + expect(fn () => Money::centavos(-1))->toThrow(ValidationException::class, 'não pode ser negativo'); +})->group('support'); + +it('recusa string que não é número', function () { + expect(fn () => Money::reais('cem reais')) + ->toThrow(ValidationException::class, 'precisa ser numérico'); +})->group('support'); + +it('multiplica e soma', function () { + $unitario = Money::reais(59.90); + + expect($unitario->multiply(2)->toCentavos())->toBe(11980) + ->and($unitario->plus(Money::reais(10))->toCentavos())->toBe(6990) + ->and($unitario->multiply(0)->isZero())->toBeTrue(); + + expect(fn () => $unitario->multiply(-1)) + ->toThrow(ValidationException::class, 'não pode ser negativa'); +})->group('support'); + +it('é imutável: operações devolvem outro objeto', function () { + $original = Money::reais(100); + $dobro = $original->multiply(2); + + expect($original->toCentavos())->toBe(10000) + ->and($dobro->toCentavos())->toBe(20000) + ->and($original)->not->toBe($dobro); +})->group('support'); + +it('compara por valor', function () { + expect(Money::reais(100.50)->equals(Money::centavos(10050)))->toBeTrue() + ->and(Money::reais(100.50)->equals(Money::reais(100.51)))->toBeFalse(); +})->group('support'); + +it('formata do jeito que se lê no brasil', function () { + expect(Money::reais(100.50)->format())->toBe('R$ 100,50') + ->and(Money::centavos(1)->format())->toBe('R$ 0,01') + ->and(Money::reais(1234.56)->format())->toBe('R$ 1.234,56'); +})->group('support'); + +it('normaliza o que o gateway recebe, seja Money ou número cru', function () { + /* int cru continua sendo lido como centavos, como antes do Money existir */ + expect(Money::asCentavos(10050))->toBe(10050) + ->and(Money::asCentavos(Money::reais(100.50)))->toBe(10050); + + /* e como reais nos gateways que usam decimal */ + expect(Money::asReais(100.50))->toBe(100.50) + ->and(Money::asReais(Money::centavos(10050)))->toBe(100.50); +})->group('support'); + +it('impede o erro que motivou o value object', function () { + /* + | R$ 100,50 num gateway de centavos: quem manda 100.50 cru cobra R$ 1,00. + | Com Money, o mesmo objeto dá o número certo para cada unidade. + */ + $valor = Money::reais(100.50); + + expect($valor->toCentavos())->toBe(10050) + ->and($valor->toReais())->toBe(100.50); +})->group('support');