Back-End

16 mai, 2013

Entidades substituíveis – Checkout e método de pagamento

Publicidade

“Se todos Fleeps são Sloops e todos Sloops são Loopies, então todos Fleeps são definitivamente Loopies”

No artigo sobre O.C.P., foi dito que as entidades de software devem ser abertas para extensão, mas fechadas para edição. Foram mostrados alguns problemas causados pelo acoplamento, como a dificuldade em se manter o código, e como refatorar o código inicial para que pudéssemos evitar edições durante seu ciclo de vida. Naquele artigo, você viu um código todo coberto por testes e resolveu seguir aquelas dicas na construção de um sistema de pagamentos. Seguindo a linha T.D.D., você começa a escrever seus testes e fica feliz com o resultado. Seu código ficou elegante, tem uma boa cobertura por testes, o acoplamento é baixo e a manutenção não terá tantos problemas, pelo menos não aparentemente.

Então, um novo requisito surge e, aquilo que parecia adequado, se mostra problemático. Após alguma depuração, você percebe que, apesar de todos os testes estarem passando, a aplicação falha. Isso ocorre porque os testes unitários trabalham com unidades isoladas, encapsuladas ou “mockadas”. Com isso, os testes unitários passam quando a aplicação, que é um conjunto de unidades que trabalham juntas, está falhando. O comportamento esperado de uma unidade pode parecer correto quando ela está sendo testada isoladamente, mas quando confrontada com seu tipo base, o comportamento continua como esperado? Nossos Fleeps estão se comportando como os Loopies que são?

Por volta 1994, Barbara Liskov e Jeannette Wing formularam um princípio de design que ficou conhecido como L.S.P., ou Princípio da Substituição de Liskov, e afirma o seguinte:

“Let q(x) be a property provable about objects x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.”

Ou seja:

  1. Se a variável x é do tipo T, então q(x) será verdadeiro.
  2. Se a variável y é do tipo S e todo S é T, então q(y) deve ser verdadeiro.

Um sistema de pagamentos online

Vamos imaginar que tenhamos um e-commerce e que, após escolher alguns itens na loja, o cliente vá fazer o checkout. Além disso, nossa loja vai aceitar, nesse instante, pagamentos via Cartão de Crédito, comunicando diretamente com o serviço da Cielo. Como se trata de um checkout, começamos a implementação por ele:

O teste

<?php
namespace Neto\Commerce;

use Neto\Commerce\Shipping\ShippingMethod;
use Neto\Commerce\Payment\PaymentMethod;

class CheckoutTest extends \PHPUnit_Framework_TestCase
{
    private function createConfiguredCheckout(ShoppingCart $shoppingCart,
                                              ShippingMethod $shippingMethod,
                                              PaymentMethod $paymentMethod)
    {
        $shippingFrom = '14400000';
        $shippingTo = '01000000';

        $checkout = new Checkout();
        $checkout->configure($shoppingCart,
                             $paymentMethod,
                             $shippingMethod,
                             $shippingFrom,
                             $shippingTo);

        return $checkout;
    }

    private function createShippingMethod($shippingAmount = 0)
    {
        $shippingMethod = $this->getMock('\Neto\Commerce\Shipping\ShippingMethod',
                                         array('getShippingAmount'));

        if ($shippingAmount > 0) {
            $shippingMethod->expects($this->once())
                           ->method('getShippingAmount')
                           ->will($this->returnValue($shippingAmount));    
        }

        return $shippingMethod;
    }

    private function createShoppingCart()
    {
        $shoppingCart = new ShoppingCart();
        $shoppingCart->addItem(new Product(123, 'item 1', 100, 1, 2, 15, 30));
        $shoppingCart->addItem(new Product(456, 'item 2', 100, 1, 2, 15, 30));

        return $shoppingCart;
    }

    /**
     * @testdox Checkout::configure() will throw an Exception if the cart is empty.
     * @expectedException \LogicException
     * @expectedExceptionMessage Cart is empty
     */
    public function testConfigureWillThrowAnExceptionIfCartIsEmpty()
    {
        $shippingMethod = $this->createShippingMethod();
        $paymentMethod = $this->getMock('\Neto\Commerce\Payment\PaymentMethod');
        $shoppingCart = new ShoppingCart();

        $this->createConfiguredCheckout($shoppingCart,
                                        $shippingMethod,
                                        $paymentMethod);
    }

    /**
     * @testdox Checkout::configure() will configure the PaymentMethod with item total and shipping amount.
     */
    public function testConfigureWillConfigureThePaymentTotalsWithCartTotals()
    {
        $shippingAmount = 10;
        $shippingMethod = $this->createShippingMethod($shippingAmount);
        $shoppingCart = $this->createShoppingCart();
        $itemTotal = $shoppingCart->getItemTotal();

        $paymentMethod = $this->getMock('\Neto\Commerce\Payment\PaymentMethod',
                                        array('configure'));

        $paymentMethod->expects($this->at(0))
                      ->method('configure')
                      ->with($itemTotal + $shippingAmount,
                             $itemTotal,
                             $shippingAmount);

        $this->createConfiguredCheckout($shoppingCart,
                                        $shippingMethod,
                                        $paymentMethod);
    }

    /**
     * @testdox Checkout::pay() will throw an Exception if called before Checkout::configure()
     * @expectedException \BadMethodCallException
     * @expectedExceptionMessage Checkout must be configured first
     */
    public function testPayWillThrowAnExceptionIfNotConfiguredFirst()
    {
        $checkout = new Checkout();
        $checkout->pay();
    }

    /**
     * @testdox Checkout::pay() will calls PaymentMethod::pay()
     */
    public function testPayWillCallsPaymentMethodPay()
    {
        $shippingMethod = $this->createShippingMethod();
        $shoppingCart = $this->createShoppingCart();

        $paymentMethod = $this->getMock('\Neto\Commerce\Payment\PaymentMethod',
                                        array('pay', 'configure'));

        $paymentMethod->expects($this->once())
                      ->method('pay');

        $checkout = $this->createConfiguredCheckout($shoppingCart,
                                                    $shippingMethod,
                                                    $paymentMethod);

        $checkout->pay();
    }

    public function testCheckoutPayWillReturnTheValueReturnedByPaymentMethodPay()
    {
        $shippingMethod = $this->createShippingMethod();
        $shoppingCart = $this->createShoppingCart();

        $paymentMethod = $this->getMock('\Neto\Commerce\Payment\PaymentMethod',
                                        array('pay', 'configure'));

        $paymentMethod->expects($this->any())
                      ->method('pay')
                      ->will($this->onConsecutiveCalls(true, false));

        $checkout = $this->createConfiguredCheckout($shoppingCart,
                                                    $shippingMethod,
                                                    $paymentMethod);

        $this->assertTrue($checkout->pay());
        $this->assertFalse($checkout->pay());
    }
}

O código

<?php
namespace Neto\Commerce;

use Neto\Commerce\Shipping\ShippingMethod;
use Neto\Commerce\Payment\PaymentMethod;

class Checkout
{
    /**
     * @var \Neto\Commerce\Payment\PaymentMethod
     */
    private $paymentMethod;

    public function configure(ShoppingCart $shoppingCart,
                              PaymentMethod $paymentMethod,
                              ShippingMethod $shippingMethod,
                              $shippingFrom,
                              $shippingTo)
    {
        if (count($shoppingCart) == 0) {
            throw new \LogicException('Cart is empty');
        }

        $itemTotal = $shoppingCart->getItemTotal();
        $shippingAmount = $shoppingCart->getShippingAmount($shippingMethod,
                                                           $shippingFrom,
                                                           $shippingTo);

        $paymentMethod->configure($itemTotal + $shippingAmount,
                                  $itemTotal,
                                  $shippingAmount);

        $this->paymentMethod = $paymentMethod;
    }

    public function pay()
    {
        if ($this->paymentMethod === null) {
            throw new \BadMethodCallException('Checkout must be configured first');
        }

        return $this->paymentMethod->pay();
    }
}

O problema

Como estamos recebendo os dados do cartão do cliente diretamente na loja e utilizando o serviço da Cielo para autorizar o pagamento, esperamos que o método ‘PaymentMethod::pay()’ retorne ‘TRUE’ se a transação tiver autorizada, ou ‘FALSE’, caso a transação não tenha sido autorizada. De fato, a solução é simples e funciona perfeitamente para esse caso de uso.

Passado algum tempo, devido a uma questão de mercado, a loja resolve utilizar PayPal como solução de pagamento. Com o novo requisito, pegamos a interface `PaymentMethod` e fazemos a implementação do PayPal Express Checkout. Essa implementação é feita utilizando o método `PaymentMethod::configure()` para executar a operação `SetExpressCheckout` que configurará a transação, e o método `PaymentMethod::pay()` para executar executar a operação `DoExpressCheckoutPayment`, que finalizará a transação. Executando os testes unitários, veremos que a abordagem com PayPal funciona, todos os testes unitários passam e ficamos felizes, afinal, temos a possibilidade de pagamentos com Cielo e PayPal.

Mas realmente funciona?

O problema vai aparecer, quando fizermos um teste comportamental. Apesar dos testes unitários passarem, um pagamento via PayPal não se comporta como um pagamento via Cielo. Quando utilizamos a implementação PayPal segundo a interface `PaymentMethod`, percebemos que, para executar o método `PaymentMethod::pay()`, o método `PaymentMethod::configure()` precisará ser chamado antes. Como o método `PaymentMethod::configure()` cria um novo `TOKEN`, então passamos a ter uma nova transação criada na PayPal.

Isso acontece pois, isoladamente, os códigos estão corretos. Mas quando confrontamos a nova implementação com a abordagem inicial, um participante comporta-se de forma diferente do outro. Segundo a abordagem inicial, o novo participante é mais forte, ou seja, faz mais coisas do que deveria fazer. Quando estávamos trabalhando com Cielo, havia uma única chamada ao serviço, que criava a transação e verificava a autorização. Com a nova implementação, duas chamadas são feitas, uma para configurar a transação, **que cria um novo TOKEN** e consequentemente uma nova transação, e uma para finalizar a transação.

A implementação original abstraiu o método de pagamento e, de fato, Checkout não tem conhecimento sobre como a implementação é feita. Qualquer implementação síncrona, teoricamente, deve funcionar, mas como Checkout faz suposições sobre o fluxo de pagamento, a implementação falha quando é assíncrona.

Refatoração

O primeiro passo na refatoração desse código, é remover as suposições que o participante Checkout faz sobre o participante PaymentMethod. Isso é importante, pois somente assim conseguiremos trabalhar na refatoração do participante PaymentMethod. Se analisarmos o processo de checkout, veremos alguns passos comuns, independentemente do método de pagamento, por exemplo:

  1. Cliente informa seus dados cadastrais;
  2. Cliente escolhe o método de pagamento.
  3. Cliente paga.

Apesar de não aparecer na lista acima, existe a criação do pedido. A criação desse pedido, naquele momento, tem um status que indica que ainda não foi pago. Após o cliente efetuar o pagamento, também existe um novo passo, que muda o status de pagamento para “pago”, “não autorizado”, etc.

O novo checkout

<?php
namespace Neto\Commerce;

use \PDO;
use Neto\Commerce\Shipping\ShippingMethod;
use Neto\Commerce\Payment\PaymentSystem;

class Checkout
{
    /**
     * @var integer
     */
    private $orderId;

    /**
     * @var \Neto\Commerce\PaymentSystem
     */
    private $paymentSystem;

    public function configure(ShoppingCart $shoppingCart,
                              PaymentSystem $paymentSystem,
                              ShippingMethod $shippingMethod,
                              $shippingFrom,
                              $shippingTo
    {
        //...

        $this->paymentSystem = $paymentSystem;
        $this->paymentSystem->attach($this);
    }

    //...

    public function update(PaymentSystem $paymentSystem)
    {
        //update order with payment status
    }
}

Se observarmos o novo Checkout, invertemos a responsabilidade pelo fluxo do checkout. O método configure fica responsável por configurar o checkout e dizer para o `PaymentSystem`: “Quando o status de um pagamento mudar, me atualize”. Dessa forma, qualquer mudança de status de pagamento, o método `Checkout::update` será chamado com as informações sobre o pagamento.

A nova interface ‘PaymentSystem’

<?php
namespace Neto\Commerce\Payment;

use Neto\Commerce\Checkout;

interface PaymentSystem
{
    public function attach(Checkout $checkout);
    public function notify();
}

Conclusão

O princípio O.C.P. nos diz que as entidades de software devem ser fechadas para edição, mas abertas para extensão. Quando estivermos estendendo uma abstração, devemos tomar cuidado sobre como isso está sendo feito. Se o novo participante for mais forte, ou mais fraco que o participante anterior, isso pode causar problemas, muitas vezes, muito difíceis de se notar. De fato, muitas vezes, uma violação ao princípio da substituição de Liskov fará a aplicação falhar, mesmo com todos os testes unitários passando.

Por uma questão de espaço, o código foi bastante resumido, mas ele pode ser obtido no Github, em: lsp