Статья об архитектуре части проекта, которая занимается он-лайн платежами. Намеренно не хотелось бы сейчас подробно описывать API конкретного биллинга или процедуру регистрации в нём. Тонкости конкретных биллингов нужно обсуждать отдельно, иначе тему просто не раскрыть. Цель статьи: обсудить вариант архитектуры, позволяющий нанизывать новые виды биллингов и типы платежей, с наименьшей головной болью.
Итак, для начала, представьте, мы немного подумали и сделали у себя на сайте очень простую продажу товаров через одну из биллинговых систем.
- У нас есть информация о товаре: ID товара, цена, <характеристики>.
- Пользователи ходят по сайту и нажимают на кнопку «купить». Сохраняем информацию о покупке: ID покупки, ID товара, цена товара в тот момент, <информация о покупателе>;
- Пользователь смотрит свои покупки, жмёт «оплатить» для одной из них. Сохраняем информацию о платеже: ID платежа, ID покупки, дата платежа, статус платежа (, сумма платежа), и отправляем пользователя к биллинговой системе;
- Скрипт, обрабатывающий ответы биллинга, сохраняет данные об ответе: ID ответа, <всё, что прислал биллинг >, дата ответа, статус ответа. Проверяет валидность ответа, по результату проверки сохраняет статус ответа. Если всё ок, то выставляет нужной покупке статус «оплачено»
- информация об оплаченной покупке отображается у модераторов с пометкой «необходимо доставить»
*Информация о покупателе — это может быть номер пользователя, по которому вы сможете найти все необходимые данные, а могут быть непосредственно данные (адрес, телефон, …), если вы не хотите обременять своих пользователей регистрацией.
Всё это мы отладили, и некоторое время даже были довольны своей работой. Но, всё чаще слышим: надо бы ещё биллинг такой-то прикрутить. К тому же мы хотим продавать не только товары, но и разные типы аккаунтов своим пользователям, а ещё пусть отдельно платят, если хотят поднять свой рейтинг на 10 пунктов, и так далее и так далее. Назовём это покупками, но будем иметь ввиду что покупки теперь разного типа.
Как известно, хорошая мысля приходит опосля. Только когда мне пришлось прикрутить пару биллингов и организовать несколько различных типов покупок, в моей системе отделились обработка покупки от обработки биллинга, выделились общие части в работе с разными биллингами, закономерности в обработке покупок разных типов.
Отделение обработки покупок от обработки биллинговых операций, даёт возможность
- подключать один раз и в одном месте новый биллинг, независимо от того сколько типов покупок существует в системе;
- подключать один раз и в одном месте новый тип покупки, независимо от того, сколько биллингов должно с ним работать;
При обработке разных типов покупок, можно заметить, что все их можно разбить на составляющие:
- В системе доступна информация о конкретном товаре: ID (уникальный для этого типа), цена, <характеристики>. Это может быть описание товара в магазине, или описание типа аккаунта и периода его действия, или описание услуги увеличения рейтинга на N позиций;
- Сохранение информации о выборе пользователя (какой пользователь, какой тип товара и какой номер товара выбрал);
- Изменение статуса покупки (оплачена, удалена, …);
- Реализация покупки, назовём это так. (например: доставка товара, или смена типа аккаунта на указанный период, или увеличение рейтинга на N позиций);
Сейчас видно, что принципиальные различия есть только в пунктах 1 и 4. При соблюдении интерфейса класса, описывающего тип покупки и действия при реализации покупки, схема обработки различных типов покупки становится единой.
Работу с биллинговой системой можно разбить на пункты:
- Сохранение информации о платеже: ID платежа, тип биллинга, ID покупки, статус платежа, <прочие характеристики>;
- Редирект пользователя на биллинговую систему, с указанием номера платежа и суммы покупки;
- Проверка валидности ответа от биллинга;
- Смена статуса платежа;
- Если всё ок, вызов обработки покупки (смена статуса покупки, реализация покупки, …).
Диаграмма классов, для визуального отображения описанной структуры:
Это общие принципы, которые я стараюсь соблюдать в своей работе. Думаю эту схему можно и нужно совершенствовать. Надеюсь на конструктивное обсуждение.
Помню, по первой части статьи, что от меня ждут не только общие слова, но и конкретные строки кода. Приблизительно, код этой конструкции приведён ниже. Реальный пример, выдернут из контекста и урезан по максимуму, чтоб выделить основную суть. К сожалению даже при этом получилось многовато кода :)
Сразу оговорюсь, в другом языке, можно было обойтись абстрактным классом и его наследниками, но поскольку в 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();
case PURCHASE_ACCOUNT: $purchase = new CPurchaseAccount();
case PURCHASE_RAIT: $purchase = new CPurchaseRait();
// ...
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: return new CBillingRobox();
case BILLING_WM: return new CBillingWM();
// ...
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.