# ====================================================================
# Gossamer Threads Module Library - http://gossamer-threads.com/
#
#   GT::Payment::Direct::Moneris
#   Author: Jason Rhinelander
#   CVS Info : 087,071,086,086,085      
#   $Id: Moneris.pm,v 1.12 2008/09/23 23:55:26 brewt Exp $
#
# Copyright (c) 2004 Gossamer Threads Inc.  All Rights Reserved.
# ====================================================================
#
# Description:
#   Handle payment processing via Moneris eSelect Plus.
#

package GT::Payment::Direct::Moneris;
use strict;
use vars qw/@ISA $ERRORS $VERSION %REQUIRED %RESPONSE $AUTOLOAD %BRANDS %NAME_MAP/;

use GT::Base;
use GT::WWW;
use GT::WWW::https;

$VERSION = sprintf "%d.%03d", q$Revision: 1.12 $ =~ /(\d+)\.(\d+)/;

use constants
    LIVE_SERVER => 'https://www3.moneris.com:43924/gateway2/servlet/MpgRequest',
    TEST_SERVER => 'https://esqa.moneris.com:43924/gateway2/servlet/MpgRequest',
    TIMEOUT => 60;

@ISA = 'GT::Base';

%REQUIRED = (
    AUTHORIZE => [qw(
        account_token
        account_token2
        credit_card_number
        credit_card_expiry_month
        credit_card_expiry_year
        charge_total
        billing_fname
        billing_lname
        billing_address
        billing_city
        billing_state
        billing_postal_code
        billing_country
        order_id
    )],
    CAPTURE => [qw(
        account_token
        charge_total
        capture_reference_id
        order_id
    )]
);

# The following credit card brands are supported by Moneris
%BRANDS = (
    VISA => 1,
    MASTERCARD => 1, # Can also be passed as 'MC'
    AMERICAN_EXPRESS => 1, # Can also be passed as 'AMEX'
    DISCOVER => 1, # Can also be passed as 'DISC'
    NOVA => 1,
    DINERS => 1,
    EUROCARD => 1
);

%RESPONSE = (
    0   => 'Approved, account balances included',
    1   => 'Approved, account balances not included',
    2   => 'Approved, country club',
    3   => 'Approved, maybe more ID',
    4   => 'Approved, pending ID (sign paper draft)',
    5   => 'Approved, blind',
    6   => 'Approved, VIP',
    7   => 'Approved, administrative transaction',
    8   => 'Approved, national NEG file hit OK',
    9   => 'Approved, commercial',
    23  => 'Amex - credit approval',
    24  => 'Amex 77 - credit approval',
    25  => 'Amex - credit approval ',
    26  => 'Amex - credit approval ',
    27  => 'Credit card approval',
    28  => 'VIP Credit Approved',
    29  => 'Credit Response Acknowledgement',
    50  => 'Decline',
    51  => 'Expired Card',
    52  => 'PIN retries exceeded',
    53  => 'No sharing',
    54  => 'No security module',
    55  => 'Invalid transaction',
    56  => 'No Support',
    57  => 'Lost or stolen card',
    58  => 'Invalid status',
    59  => 'Restricted Card',
    60  => 'No Chequing account',
    60  => 'No Savings account',
    61  => 'No PBF',
    62  => 'PBF update error',
    63  => 'Invalid authorization type',
    64  => 'Bad Track 2',
    65  => 'Adjustment not allowed',
    66  => 'Invalid credit card advance increment',
    67  => 'Invalid transaction date',
    68  => 'PTLF error',
    69  => 'Bad message error',
    70  => 'No IDF',
    71  => 'Invalid route authorization',
    72  => 'Card on National NEG file ',
    73  => 'Invalid route service (destination)',
    74  => 'Unable to authorize',
    75  => 'Invalid PAN length',
    76  => 'Low funds',
    77  => 'Pre-auth full',
    78  => 'Duplicate transaction',
    79  => 'Maximum online refund reached',
    80  => 'Maximum offline refund reached',
    81  => 'Maximum credit per refund reached',
    82  => 'Number of times used exceeded',
    83  => 'Maximum refund credit reached',
    84  => 'Duplicate transaction - authorization number has already been corrected by host.',
    85  => 'Inquiry not allowed',
    86  => 'Over floor limit ',
    87  => 'Maximum number of refund credit by retailer',
    88  => 'Place call ',
    89  => 'CAF status inactive or closed',
    90  => 'Referral file full',
    91  => 'NEG file problem',
    92  => 'Advance less than minimum',
    93  => 'Delinquent',
    94  => 'Over table limit',
    95  => 'Amount over maximum',
    96  => 'PIN required',
    97  => 'Mod 10 check failure',
    98  => 'Force Post',
    99  => 'Bad PBF',
    100 => 'Unable to process transaction',
    101 => 'Place call',
    102 => '',
    103 => 'NEG file problem',
    104 => 'CAF problem',
    105 => 'Card not supported',
    106 => 'Amount over maximum',
    107 => 'Over daily limit',
    108 => 'CAF Problem',
    109 => 'Advance less than minimum',
    110 => 'Number of times used exceeded',
    111 => 'Delinquent',
    112 => 'Over table limit',
    113 => 'Timeout',
    115 => 'PTLF error',
    121 => 'Administration file problem',
    122 => 'Unable to validate PIN: security module down',
    150 => 'Merchant not on file',
    200 => 'Invalid account',
    201 => 'Incorrect PIN',
    202 => 'Advance less than minimum',
    203 => 'Administrative card needed',
    204 => 'Amount over maximum ',
    205 => 'Invalid Advance amount',
    206 => 'CAF not found',
    207 => 'Invalid transaction date',
    208 => 'Invalid expiration date',
    209 => 'Invalid transaction code',
    210 => 'PIN key sync error',
    212 => 'Destination not available',
    251 => 'Error on cash amount',
    252 => 'Debit not supported',
    426 => 'AMEX - Denial 12',
    427 => 'AMEX - Invalid merchant',
    429 => 'AMEX - Account error',
    430 => 'AMEX - Expired card',
    431 => 'AMEX - Call Amex',
    434 => 'AMEX - Call 03',
    435 => 'AMEX - System down',
    436 => 'AMEX - Call 05',
    437 => 'AMEX - Declined',
    438 => 'AMEX - Declined',
    439 => 'AMEX - Service error',
    440 => 'AMEX - Call Amex',
    441 => 'AMEX - Amount error',
    475 => 'CREDIT CARD - Invalid expiration date',
    476 => 'CREDIT CARD - Invalid transaction, rejected',
    477 => 'CREDIT CARD - Refer Call',
    478 => 'CREDIT CARD - Decline, Pick up card, Call',
    479 => 'CREDIT CARD - Decline, Pick up card',
    480 => 'CREDIT CARD - Decline, Pick up card',
    481 => 'CREDIT CARD - Decline',
    482 => 'CREDIT CARD - Expired Card',
    483 => 'CREDIT CARD - Refer',
    484 => 'CREDIT CARD - Expired card - refer',
    485 => 'CREDIT CARD - Not authorized',
    486 => 'CREDIT CARD - CVV Cryptographic error',
    487 => 'CREDIT CARD - Invalid CVV',
    489 => 'CREDIT CARD - Invalid CVV',
    490 => 'CREDIT CARD - Invalid CVV',
    800 => 'Bad format',
    801 => 'Bad data',
    802 => 'Invalid Clerk ID',
    809 => 'Bad close ',
    810 => 'System timeout',
    811 => 'System error',
    821 => 'Bad response length',
    877 => 'Invalid PIN block',
    878 => 'PIN length error',
    880 => 'Final packet of a multi-packet transaction',
    881 => 'Intermediate packet of a multi-packet transaction',
    889 => 'MAC key sync error',
    898 => 'Bad MAC value',
    899 => 'Bad sequence number - resend transaction',
    900 => 'Capture - PIN Tries Exceeded',
    901 => 'Capture - Expired Card',
    902 => 'Capture - NEG Capture',
    903 => 'Capture - CAF Status 3',
    904 => 'Capture - Advance < Minimum',
    905 => 'Capture - Num Times Used',
    906 => 'Capture - Delinquent',
    907 => 'Capture - Over Limit Table',
    908 => 'Capture - Amount Over Maximum',
    909 => 'Capture - Capture',
    960 => 'Initialization failure - merchant number mismatch',
    961 => 'Initialization failure -pinpad  mismatch',
    963 => 'No match on Poll code',
    964 => 'No match on Concentrator ID',
    965 => 'Invalid software version number',
    966 => 'Duplicate terminal name'
);

# This contains a list of generic methods that take any value, and are handled
# via AUTOLOAD.
%NAME_MAP = (
    billing_fname => 1,
    billing_lname => 1,
    billing_company => 1,
    billing_address_1 => 1,
    billing_address_2 => 1,
    billing_city => 1,
    billing_state => 1,
    billing_postal_code => 1,
    billing_country => 1,
    billing_email => 1,
    billing_phone => 1,
    billing_fax => 1,
    billing_note => 1,
    order_id => 1,
    account_token => 1,
    account_token2 => 1
);

$ERRORS = {
    CARD_NUMBER_NONE     => "No credit card number entered",
    CARD_NUMBER_NUMERIC  => "Credit card number is not numeric",
    CARD_NUMBER_LENGTH   => "Invalid credit card number: Invalid length",
    CARD_NUMBER_INVALID  => "The credit card number entered is not valid: %s",
    BRAND_NONE           => "No credit card brand entered",
    BRAND_INVALID        => "Credit card brand '%s' is invalid or not supported%s",
    EXPIRY_INVALID       => "Invalid expiry date entered: %s",
    EXPIRY_MONTH_NONE    => "Empty expiry month entered",
    EXPIRY_MONTH_NUMERIC => "Expiry month must be numeric: %s",
    EXPIRY_MONTH_INVALID => "Invalid expiry month entered: %s",
    EXPIRY_YEAR_NONE     => "Empty expiry year entered",
    EXPIRY_YEAR_NUMERIC  => "Expiry year must be numeric: %s",
    EXPIRY_YEAR_4_DIGIT  => "Expiry year must be 4 digits: %s",
    EXPIRY_YEAR_INVALID  => "Invalid expiry year entered: %s",
    TOTAL_NONE           => "No total amount entered",
    TOTAL_NUMERIC        => "Total amount entered is not numeric: %s",
    EMAIL_NONE           => "No e-mail address entered",
    EMAIL_INVALID        => "Invalid e-mail address '%s' entered: %s",
    GENERIC_NONE         => "No value entered for %s",
    GENERIC_INVALID      => "Invalid value '%s' for %s: %s",
    MISSING_FIELDS       => "The following must be set before calling %s: %s",

    TYPE_INVALID         => "Invalid/unsupported transaction type: %s",

    AUTHORIZE_FIRST      => "You must authorize before capturing",
    CAPTURE_REF_NONE     => "No capture reference ID entered",
    CAPTURE_REF_INVALID  => "Invalid capture reference ID '%s': %s",

    FIELD_MISSING        => "The transaction server reported missing fields: %s",
    FIELD_INVALID        => "The transaction server reported invalid data: %s",
    TRANSACTION_INVALID  => "Setup problem: Invalid store information: %s",
    TRANSACTION_PROBLEM  => "A transaction server error has occurred: %s",
    TRANSACTION_BAD      => "You attempted to capture without authorizing first: %s",
    VERSION_TOO_OLD      => "The current version of the software is outdated: %s",
    DECLINED             => "Credit card declined: %s",
    ERROR                => "Credit card processing error: %s",
    UNKNOWN              => "The transaction server returned an unrecognized response: %s"
};

sub new {
    my $class = shift;
    $class = ref $class if ref $class;
    my $self = {};
    bless $self, $class;

    $self->debug("New $class object created") if $self->{_debug} and $self->{_debug} >= 2;

    while (@_) {
        my ($method, $value) = splice @_, 0, 2;
        $self->debug("Found '$method' => '$value' in new() arguments - calling \$self->$method($value)") if $self->{_debug} and $self->{_debug} >= 2;
        $self->$method($value);
    }

    return $self;
}

AUTOLOAD {
    my ($method) = $AUTOLOAD =~ /([^:]+)$/;
    if (exists $NAME_MAP{$method}) {
        no strict 'refs';
        *$method = sub {
            my $self = shift;
            if (@_) {
                my $value = shift;
                $self->debug("Setting '$method' to '$value'") if $self->{_debug};
                defined $value or $self->warn(GENERIC_NONE => $method), return undef;
                $self->{$method} = $value;
                return 1;
            }
            $self->debug("Retrieving '$method': '$self->{$method}'") if $self->{_debug} and $self->{_debug} >= 2;
            return $self->{$method};
        };
    }
    else {
        $method = "$ISA[0]::$method"; # Let GT::Base deal with it for now
    }
    goto &$method;
}

sub credit_card_number {
    my $self = shift;
    if (@_) {
        my $ccnum = shift;
        $self->debug("Setting 'credit_card_number' to '$ccnum'") if $self->{_debug};
        unless (defined $ccnum and $ccnum =~ /\S/) {
            $self->warn('CARD_NUMBER_NONE');
            return undef;
        }
        $ccnum =~ y/ //d;
        if ($ccnum =~ /\D/) {
            $self->warn(CARD_NUMBER_NUMERIC => $ccnum);
            return undef;
        }
        if (length($ccnum) < 13 or length($ccnum) > 20) {
            $self->warn('CARD_NUMBER_LENGTH');
        }
        $self->{credit_card_number} = $ccnum;
        return 1;
    }
    my $return = $self->{credit_card_number};
    $self->debug("Retrieving 'credit_card_number': '$return'") if $self->{_debug} and $self->{_debug} >= 2;
    return $return;
}

# Takes \d\d-\d\d\d\d or \d\d/\d\d\d\d,
# passes them to credit_card_expiry_month and ..._year
# Return 1 if they were set properly, undef otherwise.
# Without arguments, returns: \d\d/\d\d\d\d if month and year are set, undef
# otherwise.
sub credit_card_expiry {
    my $self = shift;
    if (@_ >= 2) {
        my $exp = shift;
        $exp =~ y/ //d;
        if (my ($m, $y) = $exp =~ m|^(\d?\d)[/-](\d\d\d\d)$|) {
            $self->credit_card_expiry_month($m) or return undef;
            $self->credit_card_expiry_year($y) or return undef;
            return 1;
        }
        else {
            $self->warn(EXPIRY_INVALID => $exp);
            return undef;
        }
    }
    my ($m, $y) = ($self->credit_card_expiry_month, $self->credit_card_expiry_year);
    return undef unless defined $m and defined $y;
    return "$m/$y";
}

sub _cc_exp {
# -----------------------------------------------------------------------------
# Returns the credit card expiry in YYMM format, as this is how Moneris takes
# it.
#
    my $self = shift;
    my ($m, $y) = ($self->credit_card_expiry_month, $self->credit_card_expiry_year);
    return substr($y, -2) . $m;
}

sub credit_card_expiry_month {
    my $self = shift;
    if (@_) {
        my $expm = shift;
        $expm =~ y/ //d;
        defined $expm or $self->warn('EXPIRY_MONTH_NONE'), return undef;
        $expm =~ /\D/ and $self->warn(EXPIRY_MONTH_NUMERIC => $expm), return undef;
        $expm < 1 || $expm > 12 and $self->warn(EXPIRY_MONTH_INVALID => "Month '$expm' outside of 1-12 range"), return undef;
        $expm = sprintf "%02d", $expm;
        $self->debug("Setting 'credit_card_expiry_month' to '$expm'") if $self->{_debug};
        $self->{credit_card_expiry_month} = $expm;
        return 1;
    }
    my $return = $self->{credit_card_expiry_month};
    $self->debug("Retrieving 'credit_card_expiry_month': '$return'") if $self->{_debug} and $self->{_debug} >= 2;
    return $return;
}

sub credit_card_expiry_year {
    my $self = shift;
    if (@_) {
        my $expy = shift;
        $self->debug("Setting 'credit_card_expiry_year' to '$expy'") if $self->{_debug};
        $expy =~ y/ //d;
        defined $expy or $self->warn('EXPIRY_YEAR_NONE'), return undef;
        $expy =~ /\D/ and $self->warn(EXPIRY_YEAR_NUMERIC => $expy), return undef;
        length($expy) == 4 or $self->warn(EXPIRY_YEAR_4_DIGIT => $expy), return undef;
        $self->{credit_card_expiry_year} = $expy;
        return 1;
    }
    my $return = $self->{credit_card_expiry_year};
    $self->debug("Retrieving 'credit_card_expiry_year': $return") if $self->{_debug} and $self->{_debug} >= 2;
    return $return;
}

sub charge_total {
    my $self = shift;
    if (@_) {
        my $total = shift;
        defined $total or $self->warn('TOTAL_NONE'), return undef;
        $total =~ /^(?:\d+\.?\d*|\.\d+)$/ or $self->warn(TOTAL_NUMERIC => $total), return undef;
        $total = sprintf "%.2f", $total;
        $self->debug("Setting 'charge_total' to '$total'") if $self->{_debug};
        $self->{charge_total} = $total;
        return 1;
    }
    my $return = $self->{charge_total};
    $self->debug("Retrieving 'charge_total': '$return'") if $self->{_debug} and $self->{_debug} >= 2;
    return $return;
}

sub billing_email {
    my $self = shift;
    if (@_) {
        my $email = shift;
        $self->debug("Setting 'billing_email' to '$email'") if $self->{_debug};
        if (!defined $email) {
            $self->warn('EMAIL_NONE');
            return undef;
        }
        if ($email !~ /.@.+\../) {
            $self->warn('EMAIL_INVALID' => $email => 'Invalid format');
            return undef;
        }
        $self->{billing_email} = $email;
        return 1;
    }
    my $return = $self->{billing_email};
    $self->debug("Retrieving 'billing_email': '$return'") if $self->{_debug} and $self->{_debug} >= 2;
    return $return;
}

sub billing_address {
    my $self = shift;
    my ($one, $two) = ($self->billing_address_1, $self->billing_address_2);
    return unless defined $one;
    return $two ? $one . "\n" . $two : $one;
}

sub test_mode {
# -----------------------------------------------------------------------------
# Test mode for Moneris involves posting to a different location
#
    my $self = shift;
    if (@_) {
        $self->{test_mode} = !!shift;
        $self->debug(($self->{test_mode} ? "Enabling" : "Disabling") . " test mode") if $self->{_debug};
        return 1;
    }
    $self->debug("Retrieving 'test_mode': '$self->{test_mode}'") if $self->{_debug} and $self->{_debug} >= 2;
    return $self->{test_mode};
}

sub capture_reference_id {
    my $self = shift;
    if (@_) {
        my $value = shift;
        $self->debug("Setting 'capture_reference_id' to '$value'") if $self->{_debug};
        defined $value or $self->warn('CAPTURE_REF_NONE'), return undef;
        $self->{capture_reference_id} = $value;
        return 1;
    }
    my $return;
    if ($self->{preauth_capture_reference_id}) {
        $return = $self->{preauth_capture_reference_id};
        $self->debug("Retrieving 'capture_reference_id': '$return' (from preauth response)") if $self->{_debug} and $self->{_debug} >= 2;
    }
    else {
        $return = $self->{capture_reference_id};
        $self->debug("Retrieving 'capture_reference_id': '$return' (manually set)") if $self->{_debug} and $self->{_debug} >= 2;
    }
    $return;
}

sub _xml {
# -----------------------------------------------------------------------------
# Produces the XML string to post to the Moneris eSelect server
# Takes a single argument of either 'authorize', 'capture', or 'purchase'
#
    my ($self, $type) = @_;

    my $xml = '<?xml version="1.0"?>';
    $xml .= '<request>';
    $xml .= "<store_id>$self->{account_token2}</store_id>";
    $xml .= "<api_token>$self->{account_token}</api_token>";
    $xml .= $self->_xml_billing($type);
    $xml .= '</request>';
    $xml;
}

my %_Billing = (
    authorize => [
        order_id => 'order_id',
        amount => 'charge_total',
        pan => 'credit_card_number',
        expdate => '_cc_exp',
        crypt_type => \7, # FIXME - 6 is "SSL - SET enabled merchant", 7 is "SSL - nonSET enabled merchant" - what is SET?
    ],
    capture => [
        order_id => 'order_id',
        comp_amount => 'charge_total',
        txn_number => 'capture_reference_id',
        crypt_type => \7, # FIXME - see above
    ],
    txn_type => {
        authorize => 'preauth',
        capture => 'completion',
        sale => 'purchase'
    }
);
$_Billing{sale} = $_Billing{authorize};

sub _xml_billing {
# -----------------------------------------------------------------------------
# Produces the XML content for the charge portion of the transaction.  This is
# credit card information, charge amount, etc. but not billing address
# information.
#
    my ($self, $type) = @_;

    my $xml = "<$_Billing{txn_type}->{$type}>";
    for (my $i = 0; $i < @{$_Billing{$type}}; $i += 2) {
        my ($key, $meth) = @{$_Billing{$type}}[$i, $i+1];
        $xml .= "<$key>" . (ref $meth ? $$meth : $self->$meth()) . "</$key>";
    }

    $xml .= $self->_xml_custinfo($type);
    $xml .= "</$_Billing{txn_type}->{$type}>";
    $xml;
}

my @_Custinfo = (
    first_name   => 'billing_fname',
    last_name    => 'billing_lname',
    company_name => 'billing_company',
    address      => 'billing_address',
    city         => 'billing_city',
    province     => 'billing_state',
    postal_code  => 'billing_postal_code',
    country      => 'billing_country',
    phone_number => 'billing_phone',
    fax          => 'billing_fax'
);


sub _xml_custinfo {
# -----------------------------------------------------------------------------
# Produces the XML custinfo content.  This is usually the billing address
# information.  Although not required by eSelect, this module does require and
# pass this information.
#
    my ($self, $type) = @_;
    my $xml = '<cust_info>';

    if (my $email = $self->billing_email) {
        $xml .= "<email>$email</email>";
    }

    $xml .= '<billing>';
    for (my $i = 0; $i < @_Custinfo; $i += 2) {
        my ($key, $meth) = @_Custinfo[$i, $i+1];
        my $val = $self->$meth();
        if (defined $val) {
            $xml .= "<$key>$val</$key>";
        }
    }
    $xml .= '</billing>';
    $xml .= '</cust_info>';

    $xml;
}

sub _process {
# -----------------------------------------------------------------------------
# Processes a transaction.  Takes a single argument - the type of transaction,
# which must be with 'authorize', 'capture', or 'sale'.
#
    my ($self, $type) = @_;
    $type eq 'authorize' or $type eq 'capture' or $type eq 'sale'
        or return $self->fatal(TYPE_INVALID => $type);

    $self->{response} = undef;

    $self->check($type) or return undef;

    my $www = GT::WWW->new(debug => $self->{_debug});
    if ($self->{test_mode}) {
        $www->url(TEST_SERVER);
    }
    else {
        $www->url(LIVE_SERVER);
    }
    $www->connection_timeout(TIMEOUT);
    $www->post_data($self->_xml('authorize'));
    $www->agent("; GT::Payment::Direct::Moneris/$VERSION");

    $self->debug("Posting data to @{[$self->{test_mode} ? 'test' : 'live']} server") if $self->{_debug};
    my $response = $www->post
        or return $self->warn(TRANSACTION_PROBLEM => $www->error);
    my $status = $response->status;

    $self->debug("Server responded with status " . int($status) . " $status") if $self->{_debug};
    $status or return $self->warn(TRANSACTION_PROBLEM => "Webserver returned error code: " . int($status) . " $status");

    return $self->_parse_response($response->content);
}

# Attempts to authorize. You'll get back three possible values:
# 1 - Authorization successful, funds guaranteed - capture should now be performed
# 0 - Authorization declined
# undef - An error occurred
sub authorize {
    my $self = shift;
    $self->debug("Performing authorization") if $self->{_debug};

    my $ret = $self->_process('authorize');
    if ($ret) { $self->{preauth_capture_reference_id} = $self->{response}->{TransID} }
    elsif (defined $ret) {
        my $code = $self->{response}->{ResponseCode};
        $self->warn(DECLINED => ($code and $RESPONSE{int $code} or $self->{response}->{Message}));
    }
    else { $self->warn(ERROR => $self->{response}->{Message}) }

    return $ret;
}

sub capture {
    my $self = shift;
    $self->debug("Performing authorization") if $self->{_debug};

    my $ret = $self->_process('capture');
    if (!defined $ret) { $self->warn(ERROR => $self->{response}->{Message}) }
    elsif (!$ret) {
        my $code = $self->{response}->{ResponseCode};
        $self->warn(DECLINED => ($code and $RESPONSE{int $code} or $self->{response}->{Message}));
    }

    return $ret;
}

sub sale {
    my $self = shift;
    $self->debug("Performing sale") if $self->{_debug};

    my $ret = $self->_process('sale');
    if (!defined $ret) { $self->warn(ERROR => $self->{response}->{Message}) }
    elsif (!$ret) {
        my $code = $self->{response}->{ResponseCode};
        $self->warn(DECLINED => ($code and $RESPONSE{int $code} or $self->{response}->{Message}));
    }

    return $ret;
}

sub _parse_response {
    my ($self, $content) = @_;

    my (%r, @stack);
    $self->{response} = \%r;

    while ($content =~ m{<(/)?([^<>]+)>|([^<>]+)}g) {
        my ($slash, $tag, $value) = ($1, $2, $3);
        if ($slash) {
            pop @stack;
        }
        elsif (defined $tag) {
            push @stack, $tag;
        }
        elsif ($value =~ /\S/) {
            $value = undef if $value eq 'null';
            $r{$stack[-1]} = $value;
        }
    }

    my $ret;
    if (not defined $r{ResponseCode}) {
        $ret = undef;
    }
    elsif ($r{ResponseCode} < 50) {
        $ret = 1;
    }
    else {
        $ret = 0;
    }
    $ret;
}

sub check {
# -----------------------------------------------------------------------------
# Checks that all necessary data is provided for an authorize, capture, or
# sale.  Takes one argument - 'authorize', 'capture', or 'sale', though 'sale'
# is really no different from 'authorize'.
#
    my ($self, $type) = @_;

    $type = 'authorize' if $type eq 'sale';
    $type eq 'authorize' or $type eq 'capture'
        or return $self->fatal(TYPE_INVALID => $type);

    my @bad;
    for my $field (@{$REQUIRED{uc $type}}) {
        my $value = $self->$field();
        if ($field eq 'charge_total') {
            push @bad, $field if $value <= 0;
        }
        else {
            push @bad, $field if !$value;
        }
    }
    if (@bad) {
        $self->warn(MISSING_FIELDS => $type => "@bad");
        return undef;
    }
    return 1;
}

sub receipt {
# -----------------------------------------------------------------------------
# After a successful sale, you can call this to get a list of Key => Value
# pairs that make up a rough receipt.  The keys are ordered, so reading them
# into an array probably makes more sense than a hash.
#
    my $self = shift;
    my $r = $self->{response} or return;
    my @receipt;
    my $code = $r->{ResponseCode};
    push @receipt,
        "Order ID" => $self->order_id,
        "Amount" => $r->{TransAmount},
        "Status" => ($code and $RESPONSE{int $code} or $self->{response}->{Message}),
        "Transaction Type" => $r->{TransType},
        "Date" => $r->{TransDate},
        "Auth Code" =>  $r->{AuthCode},
        "Response Code" => $code,
        "Response Message" => $r->{Message},
        "ISO Code" => $r->{ISO},
        "Reference Number" => $r->{ReferenceNum},
        "Cardholder Name" => $self->billing_fname . " " . $self->billing_lname;

    return @receipt;
}

1;
