九、构建支付模块

支付模块为我们网店的进一步销售功能提供了基础。它将使我们能够在即将到来的销售模块的结账过程中实际选择付款方式。付款方式通常可以是各种类型。有些可以是静态的,如支票货币和送货时现金,而另一些可以是普通信用卡,如 Visa 卡、万事达卡、美国运通卡、Discover 卡和 Switch/Solo 卡。在本章中,我们将讨论这两种类型。

在本章中,我们将探讨以下主题:

  • 要求
  • 依赖关系
  • 实施
  • 单元测试
  • 功能测试

要求

我们在第 4 章模块化网店 App需求规范中定义的应用需求并未真正说明我们需要实现的支付方式类型。因此,在本章中,我们将开发两种付款方式:卡付款和支票付款。至于信用卡支付,我们将不会连接到真正的支付处理器,但其他一切都将像使用信用卡一样进行。

理想情况下,我们希望通过一个接口完成此操作,类似于以下内容:

namespace Foggyline\SalesBundle\Interface;

interface Payment
{
  function authorize();
  function capture();
  function cancel();
}

然后,这个将强制要求具有SalesBundle模块,而我们还没有开发该模块。因此,我们将使用一个简单的 Symfonycontroller类继续使用我们的支付方法,该类提供了自己的方式来解决以下功能:

  • 功能authorize();
  • 功能capture();
  • 功能cancel();

authorize方法用于我们只想授权交易而不实际执行交易的情况。结果是一个事务 ID,我们未来的SalesBundle模块可以存储并重用该 ID,以用于进一步的capturecancel操作。capture方法使我们更进一步,首先执行授权操作,然后捕获资金。cancel方法基于先前存储的授权令牌执行取消。

我们将通过标记的 Symfony 服务公开我们的支付方式。服务的标记是一个很好的功能,它使我们能够查看容器和使用相同标记标记的所有服务,这是我们可以用来获取所有paymentmethod服务的东西。标记命名必须遵循某种模式,这是我们作为应用程序创建者强加给自己的。考虑到这一点,我们将在每个支付服务上标记一个namepayment_method

稍后,SalesBundle模块将获取并使用所有标记为payment_method的服务,然后在内部使用这些服务生成可用支付方式列表,供您使用。

依赖关系

该模块对任何其他模块没有明确的依赖关系。然而,首先构建SalesBundle模块,然后公开payment模块可能使用的几个接口可能会更方便。

实施

我们从创建一个名为Foggyline\PaymentBundle的新模块开始。我们在控制台的帮助下通过运行以下命令来执行此操作:

php bin/console generate:bundle --namespace=Foggyline/PaymentBundle

该命令触发了一个交互过程,该过程会向我们提出几个问题,如下所示:

Implementation

完成后,将自动修改文件app/AppKernel.phpapp/config/routing.ymlAppKernel类的registerBundles方法已添加到$bundles数组下的以下行:

new Foggyline\PaymentBundle\FoggylinePaymentBundle(),

routing.yml已更新为以下条目:

foggyline_payment:
  resource: "@FoggylinePaymentBundle/Resources/config/routing.xml"
  prefix:   /

为了避免与核心应用程序代码冲突,我们需要将prefix: /更改为prefix: /payment/

创建卡片实体

尽管本章中我们不会在数据库中存储任何信用卡,但我们希望重用 Symfony 自动生成 CRUD 功能,以便为我们提供信用卡模型和表单。让我们继续创建一个Card实体。我们将使用控制台执行此操作,如下所示:

php bin/console generate:doctrine:entity

该命令触发交互式生成器,为其提供FoggylinePaymentBundle:Card实体快捷方式,我们还需要提供实体属性。我们想用以下字段为我们的Card实体建模:

  • card_type:字符串
  • card_number:字符串
  • expiry_date:日期
  • security_code:字符串

一旦完成,生成器将在src/Foggyline/PaymentBundle/目录中创建Entity/Card.phpRepository/CardRepository.php。我们现在可以更新数据库,以便它拉入Card实体,如下所示:

php bin/console doctrine:schema:update --force

实体就位后,我们就可以生成它的 CRUD 了。我们将使用以下命令执行此操作:

php bin/console generate:doctrine:crud

这将导致创建一个src/Foggyline/PaymentBundle/Controller/CardController.php文件。它还为我们的app/config/routing.yml file添加了一个条目,如下所示:

foggyline_payment_card:
  resource: "@FoggylinePaymentBundle/Controller/CardController.php"
  type:    annotation

同样,视图文件是在app/Resources/views/card/目录下创建的。因为我们实际上不会对卡片本身执行任何与 CRUD 相关的操作,所以我们可以继续删除所有生成的视图文件,以及CardController类的整个主体。此时,我们应该有Card实体、CardType表单和空CardController类。

创建卡支付服务

卡支付服务将为我们未来的销售模块提供结账过程所需的相关信息。其作用是提供订单的支付方式标签、代码和处理 URL,如authorizecapturecancel

我们首先在src/Foggyline/PaymentBundle/Resources/config/services.xml文件的 services 元素下定义以下服务:

<service id="foggyline_payment.card_payment"class="Foggyline\PaymentBundle\Service\CardPayment">
  <argument type="service" id="form.factory"/>
  <argument type="service" id="router"/>
  <tag name="payment_method"/>
</service>

此服务接受两个参数:一个是form.factory,另一个是routerform.factory将在服务中用于为CardType表单创建表单视图。标签在这里是一个关键元素,因为我们的SalesBundle模块将根据分配给服务的payment_method标签寻找付款方式。

我们现在需要在src/Foggyline/PaymentBundle/Service/CardPayment.php文件中创建实际的服务类,如下所示:

namespace Foggyline\PaymentBundle\Service;

use Foggyline\PaymentBundle\Entity\Card;

class CardPayment
{
  private $formFactory;
  private $router;

  public function __construct(
    $formFactory,
    \Symfony\Bundle\FrameworkBundle\Routing\Router $router
  )
  {
    $this->formFactory = $formFactory;
    $this->router = $router;
  }

  public function getInfo()
  {
    $card = new Card();
    $form = $this->formFactory->create('Foggyline\PaymentBundle\Form\CardType', $card);

    return array(
      'payment' => array(
      'title' =>'Foggyline Card Payment',
      'code' =>'card_payment',
      'url_authorize' => $this->router->generate('foggyline_payment_card_authorize'),
      'url_capture' => $this->router->generate('foggyline_payment_card_capture'),
      'url_cancel' => $this->router->generate('foggyline_payment_card_cancel'),
      'form' => $form->createView()
      )
    );
  }
}

getInfo方法将为我们未来的SalesBundle模块提供必要的信息,以便其构建结账流程的付款步骤。我们在这里传递三种不同类型的 URL:authorizecapturecancel。这些路线目前还不存在,因为我们将很快创建它们。我们的想法是,我们将把支付行动和流程转移到实际的payment方法。我们未来的SalesBundle模块将只对这些支付 URL 执行AJAX POST,并期望 JSON 响应成功或错误。成功响应应产生某种事务 ID,错误响应应产生一条标签消息,以显示给用户。

创建卡支付控制器和路由

我们将编辑src/Foggyline/PaymentBundle/Resources/config/routing.xml文件,向其添加以下路由定义:

<route id="foggyline_payment_card_authorize" path="/card/authorize">
  <default key="_controller">FoggylinePaymentBundle:Card:authorize</default>
</route>

<route id="foggyline_payment_card_capture" path="/card/capture">
  <default key="_controller">FoggylinePaymentBundle:Card:capture</default>
</route>

<route id="foggyline_payment_card_cancel" path="/card/cancel">
  <default key="_controller">FoggylinePaymentBundle:Card:cancel</default>
</route>

然后我们将编辑CardController类的主体,向其添加以下内容:

public function authorizeAction(Request $request)
{
  $transaction = md5(time() . uniqid()); // Just a dummy string, simulating some transaction id, if any

  if ($transaction) {
    return new JsonResponse(array(
      'success' => $transaction
    ));
  }

  return new JsonResponse(array(
    'error' =>'Error occurred while processing Card payment.'
  ));
}

public function captureAction(Request $request)
{
  $transaction = md5(time() . uniqid()); // Just a dummy string, simulating some transaction id, if any

  if ($transaction) {
    return new JsonResponse(array(
      'success' => $transaction
    ));
  }

  return new JsonResponse(array(
    'error' =>'Error occurred while processing Card payment.'
  ));
}

public function cancelAction(Request $request)
{
  $transaction = md5(time() . uniqid()); // Just a dummy string, simulating some transaction id, if any

  if ($transaction) {
    return new JsonResponse(array(
      'success' => $transaction
    ));
  }

  return new JsonResponse(array(
    'error' =>'Error occurred while processing Card payment.'
  ));
}

我们应该现在能够访问类似/app_dev.php/payment/card/authorize的 URL,并看到authorizeAction的输出。这里给出的实现是虚拟的。在本章中,我们不打算连接到真正的支付处理 API。我们需要知道的是,sales模块在其签出过程中将呈现通过payment_method标记服务的getInfo方法的['payment']['form']键推送的任何可能的表单视图。也就是说,结账过程应该在“卡付款”下显示信用卡表单。签出行为将被编码,这样,如果选择使用表单付款并单击下订单按钮,则该付款表单将阻止签出过程继续进行,直到提交付款表单以授权或捕获付款本身中定义的 URL。当我们进入SalesBundle模块时,我们将进一步讨论这个问题。

创建支票付款服务

除了信用卡支付方式之外,让我们继续定义另一种静态支付方式,称为支票货币

我们首先在src/Foggyline/PaymentBundle/Resources/config/services.xml文件的 services 元素下定义以下服务:

<service id="foggyline_payment.check_money"class="Foggyline\PaymentBundle\Service\CheckMoneyPayment">
  <argument type="service" id="router"/>
  <tag name="payment_method"/>
</service>

这里定义的service只接受一个router参数。tag name与刷卡服务相同。

然后我们将创建src/Foggyline/PaymentBundle/Service/CheckMoneyPayment.php文件,内容如下:

namespace Foggyline\PaymentBundle\Service;

class CheckMoneyPayment
{
  private $router;

  public function __construct(
    \Symfony\Bundle\FrameworkBundle\Routing\Router $router
  )
  {
    $this->router = $router;
  }

  public function getInfo()
  {
    return array(
      'payment' => array(
        'title' =>'Foggyline Check Money Payment',
        'code' =>'check_money',
        'url_authorize' => $this->router->generate('foggyline_payment_check_money_authorize'),
        'url_capture' => $this->router->generate('foggyline_payment_check_money_capture'),
        'url_cancel' => $this->router->generate('foggyline_payment_check_money_cancel'),
        //'form' =>''
      )
    );
  }
}

与卡片支付不同,支票货币支付没有getInfo方法下定义的表单密钥。这是因为没有信用卡条目可供其定义。这将是一种静态支付方式。然而,我们仍然需要定义authorizecapturecancelURL,即使它们的实现可能只是一个简单的 JSON 响应,带有成功键或错误键。

创建支票付款控制器及路由

一旦支票付款服务到位,我们就可以继续为其创建必要的路线。我们首先将以下路由定义添加到src/Foggyline/PaymentBundle/Resources/config/routing.xml文件中:

<route id="foggyline_payment_check_money_authorize"path="/check_money/authorize">
  <default key="_controller">FoggylinePaymentBundle:CheckMoney:authorize</default>
</route>

<route id="foggyline_payment_check_money_capture"path="/check_money/capture">
  <default key="_controller">FoggylinePaymentBundle:CheckMoney:capture</default>
</route>

<route id="foggyline_payment_check_money_cancel"path="/check_money/cancel">
  <default key="_controller">FoggylinePaymentBundle:CheckMoney:cancel</default>
</route>

然后我们将创建src/Foggyline/PaymentBundle/Controller/CheckMoneyController.php文件,内容如下:

namespace Foggyline\PaymentBundle\Controller;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class CheckMoneyController extends Controller
{
  public function authorizeAction(Request $request)
  {
    $transaction = md5(time() . uniqid());
    return new JsonResponse(array(
      'success' => $transaction
    ));
  }

  public function captureAction(Request $request)
  {
    $transaction = md5(time() . uniqid());
    return new JsonResponse(array(
      'success' => $transaction
    ));
  }

  public function cancelAction(Request $request)
  {
    $transaction = md5(time() . uniqid());
    return new JsonResponse(array(
      'success' => $transaction
    ));
  }
}

与卡支付类似,这里我们添加了一个简单的虚拟实现authorizecapturecancel方法。方法响应稍后将反馈到SalesBundle模块。我们可以在这些方法中轻松实现更健壮的功能,但这超出了本章的范围。

单元测试

我们的FoggylinePaymentBundle模块非常简单。它只提供两种付款方式:信用卡和支票。它通过两个简单的service类实现。由于我们不打算进行完整的代码覆盖率测试,因此我们将只讨论作为单元测试一部分的CardPaymentCheckMoneyPayment服务类。

首先,我们将在phpunit.xml.dist文件的testsuites元素下添加以下行:

<directory>src/Foggyline/PaymentBundle/Tests</directory>

有了它,从我们商店的根目录运行phpunit命令应该可以获取我们在src/Foggyline/PaymentBundle/Tests/目录下定义的任何测试。

现在,让我们继续为我们的CardPayment服务创建一个测试。我们将创建一个src/Foggyline/PaymentBundle/Tests/Service/CardPaymentTest.php文件,内容如下:

namespace Foggyline\PaymentBundle\Tests\Service;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class CardPaymentTest extends KernelTestCase
{
  private $container;
  private $formFactory;
  private $router;

  public function setUp()
  {
    static::bootKernel();
    $this->container = static::$kernel->getContainer();
    $this->formFactory = $this->container->get('form.factory');
    $this->router = $this->container->get('router');
  }

  public function testGetInfoViaService()
  {
    $payment = $this->container->get('foggyline_payment.card_payment');
    $info = $payment->getInfo();
    $this->assertNotEmpty($info);
    $this->assertNotEmpty($info['payment']['form']);
  }

  public function testGetInfoViaClass()
  {
    $payment = new \Foggyline\PaymentBundle\Service\CardPayment(
       $this->formFactory,
       $this->router
    );

    $info = $payment->getInfo();
    $this->assertNotEmpty($info);
    $this->assertNotEmpty($info['payment']['form']);
  }
}

在这里,我们正在运行两个简单的测试,看看是否可以通过容器或直接实例化服务,并简单地调用其getInfo方法。该方法将返回一个包含['payment']['form']键的响应。

现在,让我们继续为我们的CheckMoneyPayment服务创建一个测试。我们将创建一个src/Foggyline/PaymentBundle/Tests/Service/CheckMoneyPaymentTest.php文件,内容如下:

namespace Foggyline\PaymentBundle\Tests\Service;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class CheckMoneyPaymentTest extends KernelTestCase
{
  private $container;
  private $router;

  public function setUp()
  {
    static::bootKernel();
    $this->container = static::$kernel->getContainer();
    $this->router = $this->container->get('router');
  }

  public function testGetInfoViaService()
  {
    $payment = $this->container->get('foggyline_payment.check_money');
    $info = $payment->getInfo();
    $this->assertNotEmpty($info);
  }

  public function testGetInfoViaClass()
  {
    $payment = new \Foggyline\PaymentBundle\Service\CheckMoneyPayment(
        $this->router
      );

    $info = $payment->getInfo();
    $this->assertNotEmpty($info);
  }
}

类似地,在这里我们还有两个简单的测试:一个通过容器获取payment方法,另一个直接通过类获取。区别在于我们没有在getInfo方法响应下检查是否存在表单键。

功能测试

我们的模块有两个控制器类,我们想测试它们的响应。我们希望确保CardControllerCheckMoneyController类的authorizecapturecancel方法正常工作。

我们首先创建一个src/Foggyline/PaymentBundle/Tests/Controller/CardControllerTest.php文件,内容如下:

namespace Foggyline\PaymentBundle\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class CardControllerTest extends WebTestCase
{
  private $client;
  private $router;

  public function setUp()
  {
    $this->client = static::createClient();
    $this->router = $this->client->getContainer()->get('router');
  }

  public function testAuthorizeAction()
  {
    $this->client->request('GET', $this->router->generate('foggyline_payment_card_authorize'));
    $this->assertTests();
  }

  public function testCaptureAction()
  {
    $this->client->request('GET', $this->router->generate('foggyline_payment_card_capture'));
    $this->assertTests();
  }

  public function testCancelAction()
  {
    $this->client->request('GET', $this->router->generate('foggyline_payment_card_cancel'));
    $this->assertTests();
  }

  private function assertTests()
  {
    $this->assertSame(200, $this->client->getResponse()->getStatusCode());
    $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type'));
    $this->assertContains('success', $this->client->getResponse()->getContent());
    $this->assertNotEmpty($this->client->getResponse()->getContent());
  }
}

然后我们创建src/Foggyline/PaymentBundle/Tests/Controller/CheckMoneyControllerTest.php,内容如下:

namespace Foggyline\PaymentBundle\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class CheckMoneyControllerTest extends WebTestCase
{
  private $client;
  private $router;

  public function setUp()
  {
    $this->client = static::createClient();
    $this->router = $this->client->getContainer()->get('router');
  }

  public function testAuthorizeAction()
  {
    $this->client->request('GET', $this->router->generate('foggyline_payment_check_money_authorize'));
    $this->assertTests();
  }

  public function testCaptureAction()
  {
    $this->client->request('GET', $this->router->generate('foggyline_payment_check_money_capture'));
    $this->assertTests();
  }

  public function testCancelAction()
  {
    $this->client->request('GET', $this->router->generate('foggyline_payment_check_money_cancel'));
    $this->assertTests();
  }

  private function assertTests()
  {
    $this->assertSame(200, $this->client->getResponse()->getStatusCode());
    $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type'));
    $this->assertContains('success', $this->client->getResponse()->getContent());
    $this->assertNotEmpty($this->client->getResponse()->getContent());
  }
}

两项测试几乎相同。它们包含对authorizecapturecancel方法的测试。因为我们的方法是用一个固定的成功 JSON 响应实现的,所以这里没有什么意外。然而,我们可以通过将我们的支付方式扩展到更健壮的方式来轻松地使用它。

总结

在本章中,我们构建了一个包含两种支付方式的支付模块。信用卡支付方法是这样设计的,它可以模拟使用所涉及的信用卡进行支付。因此,它包含一个表单作为其getInfo方法的一部分。另一方面,支票支付模拟的是一种静态支付方式——不包括任何形式的信用卡。这两种方法都是作为虚拟方法实现的,这意味着它们实际上并没有与任何外部支付处理器通信。

其想法是创建一个最小的结构,展示如何开发一个简单的支付模块进行进一步定制。我们通过一个带标签的服务公开每个支付方法来实现这一点。使用payment_method标签是一个共识,因为我们是构建完整应用程序的人,所以我们可以选择如何在sales模块中实现它。通过为每个支付方式使用相同的标签名称,我们有效地为未来sales创造了条件模块选择所有付款方式,并在其签出过程中呈现它们。

接下来,在下一章中,我们将构建一个装运模块。