Pull to refresh

Организация on-line платежей на сайте. Для тех, кто никогда этим не занимался, но боится, что придётся. Часть 2: архитектура

Reading time13 min
Views7K
Вслед за первой частью, призванной в первую очередь показать, что «не так страшен чёрт, как его малюют»

Статья об архитектуре части проекта, которая занимается он-лайн платежами. Намеренно не хотелось бы сейчас подробно описывать API конкретного биллинга или процедуру регистрации в нём. Тонкости конкретных биллингов нужно обсуждать отдельно, иначе тему просто не раскрыть. Цель статьи: обсудить вариант архитектуры, позволяющий нанизывать новые виды биллингов и типы платежей, с наименьшей головной болью.

Итак, для начала, представьте, мы немного подумали и сделали у себя на сайте очень простую продажу товаров через одну из биллинговых систем.
  1. У нас есть информация о товаре: ID товара, цена, <характеристики>.
  2. Пользователи ходят по сайту и нажимают на кнопку «купить». Сохраняем информацию о покупке: ID покупки, ID товара, цена товара в тот момент, <информация о покупателе>;
  3. Пользователь смотрит свои покупки, жмёт «оплатить» для одной из них. Сохраняем информацию о платеже: ID платежа, ID покупки, дата платежа, статус платежа (, сумма платежа), и отправляем пользователя к биллинговой системе;
  4. Скрипт, обрабатывающий ответы биллинга, сохраняет данные об ответе: ID ответа, <всё, что прислал биллинг >, дата ответа, статус ответа. Проверяет валидность ответа, по результату проверки сохраняет статус ответа. Если всё ок, то выставляет нужной покупке статус «оплачено»
  5. информация об оплаченной покупке отображается у модераторов с пометкой «необходимо доставить»

*Информация о покупателе — это может быть номер пользователя, по которому вы сможете найти все необходимые данные, а могут быть непосредственно данные (адрес, телефон, …), если вы не хотите обременять своих пользователей регистрацией.

Всё это мы отладили, и некоторое время даже были довольны своей работой. Но, всё чаще слышим: надо бы ещё биллинг такой-то прикрутить. К тому же мы хотим продавать не только товары, но и разные типы аккаунтов своим пользователям, а ещё пусть отдельно платят, если хотят поднять свой рейтинг на 10 пунктов, и так далее и так далее. Назовём это покупками, но будем иметь ввиду что покупки теперь разного типа.

Как известно, хорошая мысля приходит опосля. Только когда мне пришлось прикрутить пару биллингов и организовать несколько различных типов покупок, в моей системе отделились обработка покупки от обработки биллинга, выделились общие части в работе с разными биллингами, закономерности в обработке покупок разных типов.

Отделение обработки покупок от обработки биллинговых операций, даёт возможность
  • подключать один раз и в одном месте новый биллинг, независимо от того сколько типов покупок существует в системе;
  • подключать один раз и в одном месте новый тип покупки, независимо от того, сколько биллингов должно с ним работать;

При обработке разных типов покупок, можно заметить, что все их можно разбить на составляющие:
  1. В системе доступна информация о конкретном товаре: ID (уникальный для этого типа), цена, <характеристики>. Это может быть описание товара в магазине, или описание типа аккаунта и периода его действия, или описание услуги увеличения рейтинга на N позиций;
  2. Сохранение информации о выборе пользователя (какой пользователь, какой тип товара и какой номер товара выбрал);
  3. Изменение статуса покупки (оплачена, удалена, …);
  4. Реализация покупки, назовём это так. (например: доставка товара, или смена типа аккаунта на указанный период, или увеличение рейтинга на N позиций);

Сейчас видно, что принципиальные различия есть только в пунктах 1 и 4. При соблюдении интерфейса класса, описывающего тип покупки и действия при реализации покупки, схема обработки различных типов покупки становится единой.

Работу с биллинговой системой можно разбить на пункты:
  1. Сохранение информации о платеже: ID платежа, тип биллинга, ID покупки, статус платежа, <прочие характеристики>;
  2. Редирект пользователя на биллинговую систему, с указанием номера платежа и суммы покупки;
  3. Проверка валидности ответа от биллинга;
  4. Смена статуса платежа;
  5. Если всё ок, вызов обработки покупки (смена статуса покупки, реализация покупки, …).
Пункты 2 и 3 для различных биллингов будут свои. Т.О. при соблюдении интерфейса класса, описывающего тип биллинга, реализующего функции 2 и 3, схема работы с различными биллингами тоже унифицируется.

Диаграмма классов, для визуального отображения описанной структуры:

image

Это общие принципы, которые я стараюсь соблюдать в своей работе. Думаю эту схему можно и нужно совершенствовать. Надеюсь на конструктивное обсуждение.



Помню, по первой части статьи, что от меня ждут не только общие слова, но и конкретные строки кода. Приблизительно, код этой конструкции приведён ниже. Реальный пример, выдернут из контекста и урезан по максимуму, чтоб выделить основную суть. К сожалению даже при этом получилось многовато кода :)

Сразу оговорюсь, в другом языке, можно было обойтись абстрактным классом и его наследниками, но поскольку в PHP нельзя переопределить статическую функцию, предков разделили на интерфейс + базовый класс.

Интерфейс покупок и пример для реализации платного мембершипа:
interface InterfacePurchase {
  public function getId();

  public function getItemId();
  public function setItemId ($val);
  
  public function getItemType();
  public function setItemType ($val);
  
  public function getPrice();
  public function setPrice ($val);

  public function getUserId();
  public function setUserId($val);

  public function getStatus();
  public function setStatus($val);

  public function save ();

  /**
   * действия после оплаты покупки
   */
  public function callbackPayment ();
  
  /**
   * возвращает объект-товар. для каждого типа покупки, свой тип товара
   */
  public function getItem ();
}

class CPurchase {
  protected $_mPurchase = null;
  
  /**
   * @return InterfacePurchase
   **/
  public static function createPurchaseByType ($type) {
    $purchase = null;
    switch($type){
      case PURCHASE_SHOP:     $purchase = new CPurchaseShop(); break;
      case PURCHASE_ACCOUNT:    $purchase = new CPurchaseAccount(); break;
      case PURCHASE_RAIT:      $purchase = new CPurchaseRait(); break;
      // ...
      default: throw new ExceptionUnknownPurchaseType (__CLASS__);
    }
    $purchase->_mPurchase = new CPurchaseItem ();
    return $purchase;
  }
  
  /**
   * @return InterfacePurchase
   **/
  public static function loadPurchaseById($id){
    $purchase_item = CPurchaseItem::getById($id);
    $purchase = self::createPurchaseByType($purchase_item->getType());
    $purchase->_mPurchase = $purchase_item;
  }

  public function getId() { return $this->_mPurchase->getId(); }

  public function getItemId() { return $this->_mPurchase->getItemId();}
  public function setItemId ($val) { return $this->_mPurchase->setItemId( $val ); }
  
  public function getItemType() { return $this->_mPurchase->getItemType(); }
  public function setItemType ($val) { return $this->_mPurchase->setItemType( $val ); }
  
  public function getPrice() { return $this->_mPurchase->getPrice (); }
  public function setPrice ($val) { return $this->_mPurchase->setPrice ( $val ); }

  public function getUserId() { return $this->_mPurchase->getUserId(); }
  public function setUserId($val) { return $this->_mPurchase->setUserId($val); }

  public function getStatus() { return $this->_mPurchase->getStatus(); }
  public function setStatus($val) { return $this->_mPurchase->setStatus($val); }

  public function save () { $this->_mPurchase->save(); }

}

Class CPurchaseAccount extends CPurchase implements InterfacePurchase {
  
  public function getItem (){
    $item = null;
    If ($item_id = $this->getItemId()) {
      $item = CMembership::getById($item_id);
    }
    return $item;
  }
  public function callbackPayment () {
    $this->setStatus(PURCHASE_STATUS_OK);
    ServiceAccount::setMembership($this->getUserId(), $this->getItemId());
  }
}

* This source code was highlighted with Source Code Highlighter.


Интерфейс биллинга и пример для реализации работы с Robox:
interface InterfaceBilling {
  public function getId();
  
  public function getPurchaseId();
  public function setPurchaseId ($val);
  
  public function getBillingType();
  public function setBillingType ($val);
  
  public function getStatus();
  public function setStatus($val);

  public function save ();

  /**
   * по правилам конкретного биллинга перенаправляем юзера
   */
  public function redirectToBilling ();
  
  /**
   * по набору параметров, определяем, от какого биллинга пришёл ответ
   */
  public static function checkResponseFormat ($data);
  
  /**
   * проверяем валидность ответа от биллинга
   */
  public function checkResult ($data);
  
  /**
   * даём ответ биллингу по результатам проверок. В фрмате, который требует конкретный биллинг.
   */
  public function addResultInView ($view, $results);
}

class CBilling {
  protected $_mBilling = null;

  /**
   * @return InterfaceBilling
   **/
  public static function createBillingByType( $type ) {
    switch($type){
      case BILLING_ROBOX: $billing = new CBillingRobox(); break;
      case BILLING_WM: $billing = new CBillingWM(); break;
      // ...
      default: throw new ExceptionUnknownBillingType (__CLASS__);
    }
    $billing->_mBilling = new CBillingItem();
    $this->setBillingType($type);
  }
  
  public static function getBillingTypeByRequest($response_data) {
    $billing_type = null;
    if(CBillingRobox::checkResponseFormat($response_data)) {
      $billing_type = self::BILLING_ROBOX;
    }
    if(CBillingWM::checkResponseFormat($response_data)) {
      $billing_type = self::BILLING_WM;
    }
    
    return $billing_type;
  }

  public function getId() { return $this->_mBilling->getId(); }
  
  public function getPurchaseId() { return $this->_mBilling->getPurchaseId(); }
  public function setPurchaseId ($val) { return $this->_mBilling->setPurchaseId($val); }
  
  public function getBillingType() { return $this->_mBilling->getBillingType(); }
  public function setBillingType ($val) { return $this->_mBilling->setBillingType($val); }
  
  public function getStatus() { return $this->_mBilling->getStatus(); }
  public function setStatus($val) { return $this->_mBilling->setStatus($val); }

  public function save () { $this->_mBilling->save(); }
  
  public function checkSumm($summ) {
    $purchase = CPurchaseItem::getById($this->getPurchaseId());
    return intval($purchase->getPrice()) == intval($summ);
  }

  public function checkStatusNotFinish() {
    $purchase = CPurchaseItem::getById($this->getPurchaseId());
    return PURCHASE_STATUS_OK != $purchase->getStatus();
  }
}

class CBillingRobox extends CBilling implements InterfaceBilling {
  public function redirectToBilling () {
    $redirect_uri = Config::getKey('pay_uri', 'robox');
    $purchase = CPurchaseItem::getById($this->getPurchaseId());
    $hash = array(
      'MrchLogin' => Config::getKey('merchant_login', 'robox'),
      'OutSum' => $purchase->getPrice(),
      'InvId' => $this->getId(),
      'SignatureValue' => $this->_getSignatureValue()
    );
    
    MyApplication::redirect($redirect_uri, $hash);
  }
  
  public static function checkResponseFormat ($data) {
    $is_id = isset($data['InvId']);
    $is_summ = isset($data['OutSum']);
    $is_resp_crc = isset($data['SignatureValue']);
    $result = $is_id && $is_summ && $is_resp_crc;
    return $result;
  }
  
  public function checkResult ($data) {
    $billing_item_id = isset($data['InvId'])? $data['InvId']:0;
    $summ = isset($data['OutSum'])? $data['OutSum']:0;
    $result = FALSE;
    $purchase = null;
    try {
      $this->_mBilling = CBillingItem::sgetById($billing_item_id);
      $purchase = CPurchase::loadPurchaseById($this->getPurchaseId());
    } catch (ExObjectNotFound $e) {}
    
    if($this->_mBilling && $purchase) {
      
        $is_valid_control_summ = $this->_checkControlSumm($data);
        $is_valid_summ = $this->_checkSumm($summ);
        $is_valid_status = $this->_checkStatusNotFinish();
      
        if($is_valid_control_summ && $is_valid_summ && $is_valid_status) {
          $result = TRUE;
          $this->callbackPayment();
          $purchase->callbackPayment();
          
        }
    }
    
    return $result;
  }
  public function addResultInView ($view, $result) {
    if($result && $this->getId()) {
      $view->addText("OK");
      $view->addText($this->getId());
    } else {
      $view->addText("ERROR");
    }
  }
  
  private function _getSignatureValue() {
    $purchase = CPurchaseItem::getById($this->getPurchaseId());
    $hash = array(
      Config::getKey('merchant_login', 'robox') ,
      $purchase->getPrice(),
      $this->getId(),
      Config::getKey('merchant_password1', 'robox')
    );

    return md5(join(':', $hash));
  }
  private function checkControlSumm($data) {
    $resp_crc = isset($data['SignatureValue'])? $data['SignatureValue']:0;
    return strtoupper(self::getControlSumm($data)) == strtoupper($resp_crc);
  }
  static public function getControlSumm($data) {
    $hash = array(
      isset($data['OutSum'])? $data['OutSum']:'',
      isset($data['InvId'])? $data['InvId']:'',
      Config::getKey('merchant_password2', 'robox')
      );
    return md5(join(':', $hash));
  }
}

* This source code was highlighted with Source Code Highlighter.


Пример использования данной архитектуры:
class ModuleBilling {
  private function _createResponse(){
    //сохранить данные, пришедшие от биллинга
  }
// страница, обрабатывающая запросы от биллинга:
  public function actionResultPage () {
    $response = $this->_createResponse();
    $response_data = $_REQUEST;
    $view = new View();
    
    if( $billing_type = CBilling::getBillingTypeByRequest( $response_data ) ) {
      
      $billing = CBilling::createBillingByType($billing_type);
      $result = $billing->checkResult($response_data);
      if($result){
        $response->setStatus(CResponse::STATUS_OK);
      }else{
        $response->setStatus(CResponse::STATUS_ERROR);
      }
      $response->save();
      $billing->addResultInView($view, $result);
    }
    return $view;
  }
  
// редирект пользователя на биллинговую систему:
  public function actionBilling($req = array()){
      $user = ServiceUser::checkAccess();
      $billing_type = Request::getQueryVar('type');
      $purchase_id = Request::getQueryVar('purchase');
      $purchase = CPurchase::loadPurchaseById($purchase_id);
      $purchase->setStatus(PURCHASE_STATUS_WAITMONEY);
      $purchase->save();
      
      $billing = CBilling::createBillingByType($billing_type);
      $billing->setPurchaseId($purchase_id);
      $billing->setStatus(BILLING_STATUS_WAITMONEY);
      $billing->save();
      $billing->redirectToBilling();
  }
}

// где то там в системе:
...
$action = new ModuleBilling ();
$action->actionResultPage();
...

* This source code was highlighted with Source Code Highlighter.
Tags:
Hubs:
+56
Comments33

Articles

Change theme settings