diff --git a/README.md b/README.md index b9a6860..8ce8de2 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Table of Contents * [Payment Page](#payment-page) * [Purchase (redirect)](#purchase-redirect) * [Payment Page Complete Payment](#payment-page-complete-payment) + * [Payment Page Recurring Profiles](#payment-page-recurring-profiles) * [Notification Handler](#notification-handler) # mPAY24 Driver for Omnipay v3 @@ -111,6 +112,12 @@ but could be carried forward through the session instead. The `/pay` endpoint handles the actual payment. +The above form does not redirect the user to a payment page. +Instead, it sends the card details to the gateway, with the token as a key. +So in the next step, the gateway will already have the card details and the +merchant site will just use the pre-generated token to reference them when +completing the payment. + ### Payment Using Token ```php @@ -265,6 +272,54 @@ Alternatively a range of payment methods can be supplied as a JSON string: The transaction is completed in exactly the same way as for the seamless payments. +### Payment Page Recurring Profiles + +The gateway supports two types of profile: a single recurring payment profile for a customer, +and up to 20 interactive profiles for each customer. +The *Payment Page* API will support only ONE of these profile types at a time. +This driver presently support ONLY recurrent payment profiles for *Payment Page*. + +To create or update a customer's recurring payment profile, when making a purchase, +set the `createCard` flag and provide a `customerId`: + + 'createCard' => true, + 'customerId' => 'cust-12345', + +On completing the payment, you can check if the customer recurring profile was created +or updated by checking the profile status: + + $profileWasCreatedOrUpdates = $completeResult->isProfileChanged(); + +If this returns true, then it means the payment details for the current transaction +have been saved against the customer ID. +Use the customer ID as though it were a card reference when making a backend payment. + +A customer ID can be used to make a recurring payment (an offline payment) liek this: + +```php +$gateway = Omnipay::create('Mpay24_Backend'); + +// Set the usual merchant ID and test mode flags. + +$request = $gateway->purchase([ + 'amount' => '9.99', + 'currency' => 'EUR', + 'transactionId' => 'new-transaction-id', + 'description' => 'Recurring Payment Description', + 'card' => [ + 'name' => 'Customer Name', + ], + 'notifyUrl' => 'https://omnipay.acadweb.co.uk/mpay24/notify.php?foo=bar&fee=fah', // mandatory + 'language' => 'de', + // Either + 'customerId' => 'cust-12345', + // or + 'cardReference' => 'cust-12345', +]); +``` + +This will return the details of the successful payment, or error details if not successful. + ## Notification Handler The notification handler will accept notification server requests, diff --git a/src/BackendGateway.php b/src/BackendGateway.php new file mode 100644 index 0000000..4efb8b0 --- /dev/null +++ b/src/BackendGateway.php @@ -0,0 +1,57 @@ +createRequest(PurchaseRequest::class, $parameters); + } + + /** + * @param array $parameters + * @return PurchaseRequest + */ + public function authorize(array $parameters = []) + { + return $this->purchase($parameters); + } + + /** + * @param array $parameters + * @return ListProfilesRequest + */ + public function listProfiles(array $parameters = []) + { + return $this->createRequest(ListProfilesRequest::class, $parameters); + } + + /** + * @param array $parameters + * @return DeleteProfileRequest + */ + public function deleteProfile(array $parameters = []) + { + return $this->createRequest(DeleteProfileRequest::class, $parameters); + } + + /** + * @param array $parameters + * @return DeleteProfileRequest + */ + public function deleteCard(array $parameters = []) + { + return $this->deletePofile($parameters); + } +} diff --git a/src/ConstantsInterface.php b/src/ConstantsInterface.php index e9a3c60..18dcdb0 100644 --- a/src/ConstantsInterface.php +++ b/src/ConstantsInterface.php @@ -165,6 +165,7 @@ interface ConstantsInterface const PTYPE_PAYOLUTION = 'PAYOLUTION'; const PTYPE_SOFORT = 'SOFORT'; const PTYPE_MASTERPASS = 'MASTERPASS'; + const PTYPE_PROFILE = 'PROFILE'; /** * Brands. @@ -221,4 +222,15 @@ interface ConstantsInterface const CSS_NAME_WEB = 'WEB'; const CSS_NAME_MOBILE = 'MOBILE'; const CSS_NAME_MODERN = 'MODERN'; + + /** + * + */ + + const PROFILE_STATUS_IGNORED = 'IGNORED'; + const PROFILE_STATUS_USED = 'USED'; + const PROFILE_STATUS_ERROR = 'ERROR'; + const PROFILE_STATUS_CREATED = 'CREATED'; + const PROFILE_STATUS_UPDATED = 'UPDATED'; + const PROFILE_STATUS_DELETED = 'DELETED'; } diff --git a/src/Messages/AbstractMpay24Request.php b/src/Messages/AbstractMpay24Request.php index e4bc573..f8e7fe0 100644 --- a/src/Messages/AbstractMpay24Request.php +++ b/src/Messages/AbstractMpay24Request.php @@ -7,6 +7,11 @@ use Omnipay\Mpay24\ConstantsInterface; use Mpay24\Mpay24; use Mpay24\Mpay24Config; +use Money\Money; +use Money\Number; +//use Money\Currencies\ISOCurrencies; +use Money\Currency; +use Money\Parser\DecimalMoneyParser; abstract class AbstractMpay24Request extends AbstractRequest implements ConstantsInterface { @@ -135,9 +140,38 @@ protected function getShippingAddressData() } /** - * @return array + * Return the items basket/cart as data with mPAY24 key names. */ - protected function getShoppingCartData() + public function getShoppingCartData(): array { + $data = []; + + if (! empty($this->getItems())) { + $itemNumber = 0; + + foreach ($this->getItems() as $item) { + $itemNumber++; + + $currencyCode = $this->getCurrency(); + $currency = new Currency($currencyCode); + + $moneyParser = new DecimalMoneyParser($this->getCurrencies()); + + $number = Number::fromString($item->getPrice()); + + $money = $moneyParser->parse((string) $number, $currency); + + $data[$itemNumber] = [ + 'number' => $itemNumber, + 'productNr' => $item->getName(), + 'description' => $item->getDescription() ?: $item->getName(), + 'quantity' => $item->getQuantity(), + 'itemPrice' => $item->getPrice(), // Major units + 'amount' => $money->getAmount(), // Minor units + ]; + } + } + + return $data; } } diff --git a/src/Messages/Backend/DeleteProfileRequest.php b/src/Messages/Backend/DeleteProfileRequest.php new file mode 100644 index 0000000..ae2d306 --- /dev/null +++ b/src/Messages/Backend/DeleteProfileRequest.php @@ -0,0 +1,45 @@ + $this->getCustomerId() ?? $this->getCardReference(), + 'profileId' => $this->getProfileId(), + ]; + } + + /** + * @param array $data + * @return ResponseInterface|ListProfilesResponse + */ + public function sendData($data) + { + $mpay24 = $this->getMpay(); + + $result = $mpay24->deleteProfile( + $data['customerId'], + $data['profileId'] + ); + + $resultData = [ + 'operationStatus' => $result->getStatus(), + 'returnCode' => $result->getReturnCode(), + ]; + + return new Response($this, $resultData); + } +} diff --git a/src/Messages/Backend/ListProfilesRequest.php b/src/Messages/Backend/ListProfilesRequest.php new file mode 100644 index 0000000..109e812 --- /dev/null +++ b/src/Messages/Backend/ListProfilesRequest.php @@ -0,0 +1,50 @@ + $this->getCustomerId() ?? $this->getCardReference(), + 'expiredBy' => $this->getExpiredBy(), + 'begin' => $this->getBegin(), + 'size' => $this->getSize(), + ]; + } + + /** + * @param array $data + * @return ResponseInterface|ListProfilesResponse + */ + public function sendData($data) + { + $mpay24 = $this->getMpay(); + + $result = $mpay24->listCustomers( + $data['customerId'], + $data['expiredBy'], + $data['begin'], + $data['size'] + ); + + $resultData = [ + 'operationStatus' => $result->getStatus(), + 'returnCode' => $result->getReturnCode(), + 'profiles' => $result->getProfiles(), + ]; + + return new ListProfilesResponse($this, $resultData); + } +} \ No newline at end of file diff --git a/src/Messages/Backend/ListProfilesResponse.php b/src/Messages/Backend/ListProfilesResponse.php new file mode 100644 index 0000000..4e0a6e0 --- /dev/null +++ b/src/Messages/Backend/ListProfilesResponse.php @@ -0,0 +1,23 @@ +getDataItem('profiles'); + } +} diff --git a/src/Messages/Backend/PurchaseRequest.php b/src/Messages/Backend/PurchaseRequest.php new file mode 100644 index 0000000..ea68c92 --- /dev/null +++ b/src/Messages/Backend/PurchaseRequest.php @@ -0,0 +1,87 @@ +getShoppingCartData(); + + if (! empty($items)) { + $item = array_shift($items); + + $shoppingCartItem = [ + 'number' => $item['number'], + 'productNr' => $item['productNr'], + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'amount' => $item['amount'], + ]; + } else { + $shoppingCartItem = []; + } + + return [ + 'tid' => $this->getTransactionId(), + 'payment' => [ + 'amount' => $this->getAmountInteger(), + 'currency' => $this->getCurrency(), + 'useProfile' => true, + ], + 'additional' => [ + 'customerID' => $this->getCustomerId() ?: $this->getCardReference(), + 'customerName' => $this->getCustomerName(), + 'order' => [ + 'description' => $this->getDescription(), + 'shoppingCart' => [ + 'item' => $shoppingCartItem, + ], + ], + 'confirmationUrl' => $this->getNotifyUrl(), + 'language' => strtoupper($this->getLanguage()), + ], + ]; + } + + /** + * @param array $data + * @return ResponseInterface|PurchaseResponse + */ + public function sendData($data) + { + $mpay24 = $this->getMpay(); + + $result = $mpay24->payment( + static::PTYPE_PROFILE, + $data['tid'], + $data['payment'], + $data['additional'] + ); + + $resultData = [ + 'operationStatus' => $result->getStatus(), + 'returnCode' => $result->getReturnCode(), + 'errNo' => $result->getErrNo(), + 'errText' => $result->getErrText(), + 'transactionReference' => $result->getMpayTid(), + ]; + + return new Response($this, $resultData); + } +} diff --git a/src/Messages/Backend/Response.php b/src/Messages/Backend/Response.php new file mode 100644 index 0000000..d5c471f --- /dev/null +++ b/src/Messages/Backend/Response.php @@ -0,0 +1,15 @@ +getDataItem('PROFILE_ID'); } + public function getCardReference() + { + return $this->getCustomerId(); + } + // Status of the customer profile // TODO: lists as constants public function getProfileStatus() @@ -173,4 +178,24 @@ public function isPending() { return $this->getTransactionState() === static::TRANSACTION_STATE_RESERVED; } + + public function isProfileCreated() + { + return $this->getProfileStatus() === static::PROFILE_STATUS_CREATED; + } + + public function isProfileUpdated() + { + return $this->getProfileStatus() === static::PROFILE_STATUS_UPDATED; + } + + public function isProfileDeleted() + { + return $this->getProfileStatus() === static::PROFILE_STATUS_DELETED; + } + + public function isProfileChanged() + { + return $this->isProfileCreated() || $this->isProfileUpdated() || isProfileDeleted(); + } } diff --git a/src/Messages/PaymentPage/PurchaseRequest.php b/src/Messages/PaymentPage/PurchaseRequest.php index 717e2e7..bbb155e 100644 --- a/src/Messages/PaymentPage/PurchaseRequest.php +++ b/src/Messages/PaymentPage/PurchaseRequest.php @@ -3,6 +3,7 @@ namespace Omnipay\Mpay24\Messages\PaymentPage; use Omnipay\Mpay24\Messages\AbstractMpay24Request; +use Omnipay\Common\Exception\InvalidRequestException; use Mpay24\Mpay24Order; class PurchaseRequest extends AbstractMpay24Request @@ -12,32 +13,6 @@ class PurchaseRequest extends AbstractMpay24Request */ protected $paymentMethodCount = 0; - /** - * Return the items basket/cart as data with mPAY24 key names. - */ - public function getItemsData(): array - { - $data = []; - - if (! empty($this->getItems())) { - $itemNumber = 0; - - foreach ($this->getItems() as $item) { - $itemNumber++; - - $data[$itemNumber] = [ - 'number' => $itemNumber, - 'productNr' => $item->getName(), - 'description' => $item->getDescription() ?: $item->getName(), - 'quantity' => $item->getQuantity(), - 'itemPrice' => $item->getPrice(), - ]; - } - } - - return $data; - } - /** * The data key names are from the mPAY24 spec, but lower camelCase. * @@ -47,6 +22,31 @@ public function getItemsData(): array */ public function getData() { + // Defaults for manual override. + + $useProfile = $this->getUseProfile(); + $customerId = $this->getCardReference() ?? $this->getCustomerId(); + + if ($this->getCreateCard()) { + // The application would like to create or update a card reference, + // also known as customerID, for recurrent payments. + + $useProfile = true; + + if (empty($customerId)) { + throw new InvalidRequestException('The customerId or cardReference parameter is required'); + + // Note: we don't want this as it will fill up the account + // with many random customer IDs, hence throwing the exception.. + + // A specified cardReference has not been supplied (which is + // recommended) so we need to make one up. + // Format is 1 to 32 characters. + + $customerId = bin2hex(random_bytes(16)); + } + } + return [ 'price' => $this->getAmount(), 'currency' => $this->getCurrency(), @@ -60,12 +60,12 @@ public function getData() 'paymentType' => $this->getPaymentType(), 'brand' => $this->getBrand(), 'paymentMethods' => $this->getPaymentMethods(), - 'useProfile' => $this->getUseProfile(), - 'customerId' => $this->getCustomerId(), + 'useProfile' => (bool)$useProfile, + 'customerId' => $customerId, 'customerName' => $this->getCustomerName(), 'billingAddress' => $this->getBillingAddressData(), 'shippingAddress' => $this->getShippingAddressData(), - 'items' => $this->getItemsData(), + 'shoppingCart' => $this->getShoppingCartData(), ]; } @@ -91,8 +91,6 @@ public function sendData($data) { $data = $this->xmlEncodeData($data); - $mpay24 = $this->getMpay(); - $mdxi = new Mpay24Order(); $mdxi->Order->Tid = $data['tid']; @@ -132,8 +130,8 @@ public function sendData($data) // Populate the optional basket. - if (! empty($data['items'])) { - foreach ($data['items'] as $itemNumber => $item) { + if (! empty($data['shoppingCart'])) { + foreach ($data['shoppingCart'] as $itemNumber => $item) { $mdxi->Order->ShoppingCart->Item($itemNumber)->Number = $itemNumber; $mdxi->Order->ShoppingCart->Item($itemNumber)->ProductNr = $item['productNr']; $mdxi->Order->ShoppingCart->Item($itemNumber)->Description = $item['description']; @@ -142,6 +140,8 @@ public function sendData($data) $mdxi->Order->ShoppingCart->Item($itemNumber)->ItemPrice = $item['itemPrice']; //$mdxi->Order->ShoppingCart->Item($itemNumber)->ItemPrice->setTax(1.23); //$mdxi->Order->ShoppingCart->Item($itemNumber)->Price = 10.00; + // Example os styling. The product on a red background. + //$mdxi->Order->ShoppingCart->Item($itemNumber)->Description->setStyle('background-color: red'); } } @@ -155,14 +155,14 @@ public function sendData($data) // https://docs.mpay24.com/docs/working-with-the-mpay24-php-sdk-redirect-integration // Other supported objects (in order): BillingAddr, ShippingAddr - if (isset($data['useProfile'])) { - $mdxi->Order->Customer->setUseProfile($data['useProfile'] ? 'true' : 'false'); - } - if (isset($data['customerId'])) { $mdxi->Order->Customer->setId($data['customerId']); } + if (isset($data['useProfile'])) { + $mdxi->Order->Customer->setUseProfile($data['useProfile'] ? 'true' : 'false'); + } + if (isset($data['customerName'])) { $mdxi->Order->Customer = $data['customerName']; } @@ -200,6 +200,8 @@ public function sendData($data) //echo ''; + $mpay24 = $this->getMpay(); + $paymentPage = $mpay24->paymentPage($mdxi); $data['redirectUrl'] = $paymentPage->getLocation(); @@ -210,6 +212,9 @@ public function sendData($data) return new Response($this, $data); } + /** + * Add a single payment method to the mdxi object. + */ protected function addPaymentType(Mpay24Order $mdxi, string $paymentType, string $brand) { if ($this->paymentMethodCount === 0) { diff --git a/src/ParameterTrait.php b/src/ParameterTrait.php index be796a8..121d1ff 100644 --- a/src/ParameterTrait.php +++ b/src/ParameterTrait.php @@ -456,4 +456,76 @@ public function setPClass($value) { return $this->setParameter('pClass', $value); } + + /** + * @return string + */ + public function getCreateCard() + { + return $this->getParameter('createCard'); + } + + /** + * @param string $value + * @return $this + */ + public function setCreateCard($value) + { + return $this->setParameter('createCard', $value); + } + + /** + * Parameters for selecting profiles to list. + */ + + /** + * @return string + */ + public function getExpiredBy() + { + return $this->getParameter('expiredBy'); + } + + /** + * @param string $value + * @return $this + */ + public function setExpiredBy($value) + { + return $this->setParameter('expiredBy', $value); + } + + /** + * @return string + */ + public function getBegin() + { + return $this->getParameter('begin'); + } + + /** + * @param string $value + * @return $this + */ + public function setBegin($value) + { + return $this->setParameter('begin', $value); + } + + /** + * @return string + */ + public function getSize() + { + return $this->getParameter('size'); + } + + /** + * @param string $value + * @return $this + */ + public function setSize($value) + { + return $this->setParameter('size', $value); + } } diff --git a/src/PaymentPageGateway.php b/src/PaymentPageGateway.php index e69074a..865941d 100644 --- a/src/PaymentPageGateway.php +++ b/src/PaymentPageGateway.php @@ -85,4 +85,22 @@ public function paymentMethods(array $parameters = []) { return $this->createRequest(PaymentMethodsRequest::class, $parameters); } + + /** + * This is the same as purchase, but injects the createCard flag. + */ + public function createCard(array $parameters = []) + { + $parameters['createCard'] = true; + + return $this->purchase($parameters); + } + + /** + * This is the same as createCard, but injects the createCard flag. + */ + public function updateCard(array $parameters = []) + { + return $this->createCard($parameters); + } }