public function validate($server) { $this->clearErrors(); $metaData = Transaction::get('array'); // Check the currency is a valid one if (!Metadata\Iso4217::checkCurrency($server->getField('Currency'))) { $this->addError('Currency', $this->CURRENCY_INVALID); } $this->validateAmount($server->getField('Amount')); // Perform some general validations and return ourself return parent::validate($server); }
public function validate($item) { $metaData = Transaction::get('array'); foreach ($this->fieldsToCheck as $field) { if (is_a($this, 'Academe\\SagePay\\Validator\\Model\\Address')) { // I'm assuming/hoping that Billing and Delivery validation rules are identical $data = $metaData['Billing' . $field]; } else { $data = $metaData[$field]; } $value = $item->getField($field); if ($this->hasError($field)) { // We only store one error per field, so if we have already got an error for this // field then don't waste time checking others. This also means that we can have // more specific fields in the child objects which over-ride these completly. continue; } // State is only used when the country is US, the validation rules stores assume country is US. // so here we tweak them. if ($field == 'State' && $item->getField('Country') != 'US') { $data['required'] = false; $data['min'] = 0; $data['max'] = 0; } // If the item is required, check it's not empty if ($data['required'] && !v::notEmpty()->validate($value)) { if ($field == 'PostCode') { // Add an exception for Postcodes when the country is one which does not have postcodes if (!in_array($item->getField('Country'), $this->countriesWhichDontHavePostcodes)) { $this->addError($field, sprintf($this->CANNOT_BE_EMPTY, $field)); } } else { if ($field == 'Amount') { // 0 equates to empty, so do a special check if ($item->getField('Amount') != '0' && !v::string()->notEmpty()->validate($item->getField('Amount'))) { $this->addError($field, sprintf($this->CANNOT_BE_EMPTY, $field)); } } else { $this->addError($field, sprintf($this->CANNOT_BE_EMPTY, $field)); } } } // If there is a minimum or maximum check the length. // TODO: Check whether this code works well when only one or the other is set $min = isset($data['min']) ? $data['min'] : null; $max = isset($data['max']) ? $data['max'] : null; if ($min != null && $max != null) { // Check the length of the field if ($field == 'State') { print_r("\n\nMin: {$field} : {$min}, \n\n"); die; } if (!v::length($min, $max, true)->validate($value)) { if ($min == $max) { $this->addError($field, sprintf($this->BAD_LENGTH, $field, $min)); } else { $this->addError($field, sprintf($this->BAD_RANGE, $field, $min, $max)); } } } // Check the contents of the field if (isset($data['chars'])) { // We build two regexes, one for testing whether it matches and the other for // filtering out the bad characters to show the user which are not valid. $regex = $this->buildRegex($data['chars']); try { if (!v::regex($regex)->validate($value)) { $cleanupRegex = $this->buildRegex($data['chars'], false); $badChars = preg_replace($cleanupRegex, '', $value); $this->addError($field, sprintf($this->BAD_CHARACTERS, $field, $badChars, $regex)); } } catch (\Exception $e) { throw new \Exception("preg_match has a problem with this regex '{$regex}'"); } } } return $this; }
/** * Collect the query data together for the transaction registration. * Returns key/value pairs * Different types of transaction will need different sets of fields to * be sent to SagePay. This is indicated by the $message_type. */ public function queryData($format_as_querystring = true, $message_type = 'server-registration') { $this->checkTxModel(); // Make sure all the models are expanded into the main transaction data model. $this->expandModels(); $query = array(); // Get the list of fields we want to send to SagePay from the transaction metadata. // Filter the list by the message type. This is the superset of fields. $all_fields = Metadata\Transaction::get('array', array('source' => $message_type)); $fields_to_send = array(); $optional_fields = array(); foreach ($all_fields as $field_name => $field_meta) { $fields_to_send[] = $field_name; if (empty($field_meta['required'])) { $optional_fields[] = $field_name; } } // Some fields will need renaming before sending to SagePay. Do some prelimiary checks first. $TxType = $this->getField('TxType'); $rename_vendor_tx_code = $TxType == 'RELEASE' || $TxType == 'ABORT' || $TxType == 'VOID' || $TxType == 'CANCEL'; // Loop through the fields, both optional and mandatory. foreach ($fields_to_send as $field) { $value = $this->getField($field); if (!in_array($field, $optional_fields) || isset($value)) { // If the input characterset is UTF-8, then convert the string to ISO-8859-1 // for transfer to SagePay. // FIXME: catch invalid UTF-8 stream errors. iconv() can easily fail if you // pass it duff data. // FIXME: don't make any assumptions about what the input chatset could be. It // my not be UTF-8 but maystill need conversion to ISO-8859-1 if ($this->input_charset == 'UTF-8') { $value = iconv('UTF-8', 'ISO-8859-1//TRANSLIT', $value); } // Some fields are sent to SagePay as a different name to their local storage. // An important one is VendorTxCode, which is used as the primary key of each transaction, // but also as a foreign key reference to linked transactions, but has the same name when // sent to SagePay in both cases. // But only for some transactino service types. Other services use RelatedVendorTxCode as // that same field name. if ($field == 'RelatedVendorTxCode' && $rename_vendor_tx_code) { $field = 'VendorTxCode'; } $query[$field] = $value; } } if ($format_as_querystring) { // Return the query string return http_build_query($query, '', '&'); } else { // Just return the data as an array. // This may be more useful for some transport packages that like to construct // their own query string.. return $query; } }
/** * Generate the database column creation statements from the Transaction metadata. * Extend this method to add further custom columns if required. */ public function createColumnsDdl() { // NOTE: the column lengths listed in the metadata are to support unicode (UTF-8) characters, // and not ASCII bytes. // If the database is not set up with a unicode charset, then we need to add a percentage // to the lengths of all columns. $field_meta = Metadata\Transaction::get('object', array('store' => true)); $columns = array(); foreach ($field_meta as $name => $field) { $column = '`' . $name . '` '; if (!isset($field->max)) { continue; } $max = $field->max; // If currency, then take the max value as a string and add 4 (for the dp and 3 dp digits) // Some currencies have three decimal digits. if ($field->type == 'currency') { $max = strlen((string) $max) + 4; } // Now here is a nasty hack. // Columns that can accept UTF-8 data need to have their length multiplied by // four to (nearly) guarantee a full UTF-8 string can fit in. There are probably ways around // this, but the documentation is (and always has been) very confused, mixing up the // storage of charactersets, the searching of charactersets and the automatic // conversion between the two. This is nasty, nasty, but should work most places // in practice. if (isset($field->chars) && in_array('^', $field->chars) || $field->type == 'rfc532n' || $field->type == 'html' || $field->type == 'xml') { $max = $max * 4; } // VARCHAR for most columns, apart from the really long ones. $datatype = 'VARCHAR'; if ($max > 2048) { $datatype = 'TEXT'; $max = null; } $column .= $datatype; if (isset($max)) { $column .= '(' . $max . ')'; } if (!empty($field->required)) { $column .= ' NOT NULL'; } $columns[] = $column; } return implode(",\n", $columns); }
/** * Return the list of transaction fields to maintain for the transaction. * All these fields will be saved to persistent storage. */ public function transactionFields() { // We only want the fields that will be stored. // Actually, we want all fields as we don't know what we will be using. (CHECKME: contradictory) $metadata = \Academe\SagePay\Metadata\Transaction::get('array', array('store' => true)); $result = array(); foreach ($metadata as $name => $attr) { // Get its default value. if (isset($attr['default'])) { // A default value has been supplied in the metadata. $default = $attr['default']; } else { // Default required fields to an empty string; they will have to be saved. $default = !empty($attr['required']) ? '' : null; } $result[$name] = $default; } return $result; }
/** * The notification callback for SagePay Server PAYMENT, DEFERRED, AUTHENTICATE. * This handles the callback from SagePay in response to a [successful] transaction registration. * * The redirect URL should not carray any information that allows an end user to be able to * highjack it and effect a payment. For this reason, SagePay will be sent to the same page * regardless of the status. That page can then inspect the transaction to decide what action * to take. A successful payment will be a status of OK, AUTHENTICATED or REGISTERED. A failed * payment will be ABORT, NOTAUTHED, REJECTED or ERROR. In the middle is PENDIND, where the * transaction is not yet complete (neither paid nor failed) and will take some time to process. * * Before redirecting to SagePay on registering the transaction, the VendorTxId needs to have been * saved to the session. That way the transaction result can be inspected on return to the store, * and appropriate action can be taken. * * @param $post array POST data sent to the page request. * @param $redirect_url string The URL SagePay should redirect to, regardless of status. */ public function notification($post, $redirect_url) { // End of line characters. // This is what SagePay expects as a line terminator. $eol = "\r\n"; // Get the main details that identify the transaction. $Status = isset($post['Status']) ? (string) $post['Status'] : ''; $StatusDetail = isset($post['StatusDetail']) ? (string) $post['StatusDetail'] : ''; $VendorTxCode = isset($post['VendorTxCode']) ? (string) $post['VendorTxCode'] : ''; $VPSTxId = isset($post['VPSTxId']) ? (string) $post['VPSTxId'] : ''; // Deal with the quirk noted on page 29 of the token registration docs, i.e. that braces are // not returned in the TOKEN notification post. Version 3.00 should have been a good // opportunity for SagePay to fix quirks like this, but instead we need to deal with it here. if (isset($post['TxType']) && $post['TxType'] == "TOKEN") { $VPSTxId = '{' . $VPSTxId . '}'; } $VPSSignature = isset($post['VPSSignature']) ? (string) $post['VPSSignature'] : ''; // Assume this process will be successful. $retStatus = 'OK'; $retStatusDetail = ''; // If we have no VendorTxCode then we can go no further. if (empty($VendorTxCode)) { // Return an appropriate error to the caller. $retStatus = 'ERROR'; $retStatusDetail = 'No VendorTxCode sent'; } if ($retStatus == 'OK') { // Get the transaction record. // A transaction object should already have been injected to look this up. if ($retStatus == 'OK' && $this->getTransactionModel() === null) { // Internal error. $retStatus = 'INVALID'; $retStatusDetail = 'Internal error (missing transaction object)'; } else { // Fetch the transaction record from storage. $this->findTransaction($VendorTxCode); } } if ($this->getField('VendorTxCode') !== null) { if ($this->getField('Status') != 'PENDING') { // Already processed status. $retStatus = 'INVALID'; $retStatusDetail = 'Transaction has already been processed'; } elseif ($VPSTxId != $this->getField('VPSTxId')) { // Mis-matching VPSTxId values. $retStatus = 'INVALID'; $retStatusDetail = 'VPSTxId mismatch'; } } else { // Return failure to find transaction. $retStatus = 'INVALID'; $retStatusDetail = 'No transaction found'; } // With some of the major checks done, let's dig a little deeper into // the transaction to see if it has been tampered with. The anit-tamper // checks allows us to used a non-secure connection for the . if ($retStatus == 'OK') { // Gather some additional parameters, making sure they are all set (defaulting to ''). // Derive this list from the transaction metadata, with flag "tamper" set. $field_meta = Metadata\Transaction::get(); foreach ($field_meta as $field_name => $field) { if (!empty($field->tamper)) { // Make sure a string has been passed in, defaulting to an empty string if necessary. $post[$field_name] = isset($post[$field_name]) ? (string) $post[$field_name] : ''; } } /* From protocol V3 documentation: VPSTxId + VendorTxCode + Status + TxAuthNo + VendorName (aka Vendor) + AVSCV2 + SecurityKey (saved with the transaction registration) + AddressResult + PostCodeResult + CV2Result + GiftAid + 3DSecureStatus + CAVV + AddressStatus + PayerStatus + CardType + Last4Digits + DeclineCode + ExpiryDate + FraudResponse + BankAuthCode */ // Construct a concatenated POST string hash. // These could be constructed from the transaction metadata. if (isset($post['TxType']) && $post['TxType'] == "TOKEN") { $strMessage = $post['VPSTxId'] . $post['VendorTxCode'] . $post['Status'] . $this->getField('Vendor') . $post['Token'] . $this->getField('SecurityKey'); } else { $strMessage = $post['VPSTxId'] . $post['VendorTxCode'] . $post['Status'] . $post['TxAuthNo'] . $this->getField('Vendor') . $post['AVSCV2'] . $this->getField('SecurityKey') . $post['AddressResult'] . $post['PostCodeResult'] . $post['CV2Result'] . $post['GiftAid'] . $post['3DSecureStatus'] . $post['CAVV'] . $post['AddressStatus'] . $post['PayerStatus'] . $post['CardType'] . $post['Last4Digits'] . $post['DeclineCode'] . $post['ExpiryDate'] . $post['FraudResponse'] . $post['BankAuthCode']; } $MySignature = strtoupper(md5($strMessage)); if ($MySignature !== $VPSSignature) { // Message that record has been tampered with. $retStatus = 'ERROR'; $retStatusDetail = 'Notification has been tampered with'; } } // If still a success, then all tests have passed. if ($retStatus == 'OK') { // We found a PENDING transaction, so update it. // We don't want to be updating the local transaction in any other circumstance. // However, we might want to log the errors somewhere else. // First SagePay V2 fields. $this->setField('Status', $Status); $this->setField('StatusDetail', $StatusDetail); $this->setField('TxAuthNo', $post['TxAuthNo']); $this->setField('AVSCV2', $post['AVSCV2']); $this->setField('AddressResult', $post['AddressResult']); $this->setField('PostCodeResult', $post['PostCodeResult']); $this->setField('CV2Result', $post['CV2Result']); $this->setField('GiftAid', $post['GiftAid']); $this->setField('3DSecureStatus', $post['3DSecureStatus']); $this->setField('CAVV', $post['CAVV']); $this->setField('AddressStatus', $post['AddressStatus']); $this->setField('PayerStatus', $post['PayerStatus']); $this->setField('CardType', $post['CardType']); $this->setField('Last4Digits', $post['Last4Digits']); // SagePay V3.00 fields. // No need to store, or attempt to store, the VPSSignature - it is a throw-away // hash of the notification data, with local salt. $this->setField('FraudResponse', $post['FraudResponse']); $this->setField('Surcharge', $this->arrayElement($post, 'Surcharge')); $this->setField('BankAuthCode', $post['BankAuthCode']); $this->setField('DeclineCode', $post['DeclineCode']); $this->setField('ExpiryDate', $post['ExpiryDate']); $this->setField('Token', $this->arrayElement($post, 'Token')); // Save the transaction record to local storage. // We don't want to throw exceptions here; SagePay must get its response. try { $this->save(); } catch (Exception\RuntimeException $e) { $retStatus = 'ERROR'; $retStatusDetail = 'Cannot save result to database: ' . $e->getMessage(); } catch (RuntimeException $e) { $retStatus = 'ERROR'; $retStatusDetail = 'Cannot save result to database: ' . $e->getMessage(); } } // Finally return the result to SagePay, including the relevant redirect URL. // If the status sent to us is ERROR, then return INVALID to SagePay. // It is not clear why, but the sample code provided by SagePay does this. if ($Status == 'ERROR') { $retStatus = 'INVALID'; } // Replace any tokens in the URL with values from the transaction, if required. // The tokens will be {fieldName} for inserting in a path part of the URL or // {{fieldName}} for inserting into a query parameter of the URL. // e.g. http://example.com/notification_{Status}.php?id={{VendorTxCode}} // although you probably don't want to expose the VendorTxCode. $fields = $this->toArray(); foreach ($fields as $field => $value) { $token_path = '{' . $field . '}'; $token_query = '{' . $token_path . '}'; // Query parameters and path parts use different escaping. if (strpos($redirect_url, $token_query) !== false) { $redirect_url = str_replace($token_query, urlencode($value), $redirect_url); } if (strpos($redirect_url, $token_path) !== false) { $redirect_url = str_replace($token_path, rawurlencode($value), $redirect_url); } } // The return string should be fed out to the caller as the only result. // The status we send back is one of OK, INVALID or ERROR. return 'Status=' . $retStatus . $eol . 'StatusDetail=' . $retStatusDetail . $eol . 'RedirectURL=' . $redirect_url . $eol; }
public function setUp() { $this->transactionData = Transaction::get('array'); }