# ====================================================================
# Gossamer Threads Module Library - http://gossamer-threads.com/
#
#   GT::Payment::Remote::PayPal
#   Author: Jason Rhinelander
#   CVS Info : 087,071,086,086,085      
#   $Id: PayPal.pm,v 1.8 2006/04/08 03:42:05 brewt Exp $
#
# Copyright (c) 2004 Gossamer Threads Inc.  All Rights Reserved.
# ====================================================================
#
# Description:
#   PayPal IPN payment processing.
#   IPN information: (PayPal login required)
#   https://www.paypal.com/cgi-bin/webscr?cmd=p/acc/ipn-info
#
#   Net::SSLeay is required.  Windows (ActivePerl) Net::SSLeay packages are
#   available through Gossamer Threads.
#

package GT::Payment::Remote::PayPal;
use strict;
use Carp;
use GT::WWW;
use GT::WWW::https;

# Usage:
#   process(
#       param => $GT_CGI_OBJ,
#       on_valid => \&CODEREF, # Called when everything checks out
#       on_pending => \&CODEREF, # Optional - another IPN request will come in when no longer pending
#       on_failed => \&CODEREF, # "The payment has failed. This will only happen if the payment was made from your customer's bank account"
#       on_denied => \&CODEREF, # "You, the merchant, denied the payment. This will only happen if the payment was previously pending due to one of the "pending reasons" below"
#       on_invalid => \&CODEREF, # This request did NOT come from PayPal
#       on_recurring => \&CODEREF, # A recurring payment
#       on_recurring_signup => \&CODEREF, # A recurring payment signup
#       on_recurring_cancel => \&CODEREF, # A recurring payment cancellation
#       on_recurring_failed => \&CODEREF, # A subscription payment failure
#       on_recurring_eot => \&CODEREF, # A  subscription "end of term" notification
#       on_recurring_modify => \&CODEREF, # A subscription modification notification
#       duplicate => \&CODEREF, # Check to make sure this isn't a duplicate (1 = okay, 0/undef = duplicate)
#       email => \&CODEREF, # Called with the specified e-mail - check it against the primary e-mail account, return 1 for valid, 0/undef for error
#       on_error => \&CODEREF # Optional
#   )
# Only on_error is optional.  on_valid will be called if the request is valid,
# on_invalid is invalid, and on_error if an error occurs (such as an HTTP error,
# connection problem, etc.)
sub process {
    shift if $_[0] and UNIVERSAL::isa($_[0], __PACKAGE__);

    my %opts = @_;
    $opts{param} and UNIVERSAL::isa($opts{param}, 'GT::CGI') or croak 'Usage: ->process(param => $gtcgi, ...)';
    my $in = $opts{param};
    for (qw/on_valid on_failed on_denied duplicate email/) {
        ref $opts{$_} eq 'CODE' or croak "Usage: ->process($_ => \&CODEREF, ...)";
    }

    for (qw/on_error on_pending on_invalid on_recurring on_recurring_signup on_recurring_cancel
        on_recurring_failed on_recurring_eot on_recurring_modify/) {
        !$opts{$_} or ref $opts{$_} eq 'CODE' or croak "Usage: ->process($_ => \\&CODEREF, ...) (optional)";
    }

    my $sandbox = $opts{sandbox} ? 'sandbox.' : '';
    my $wwws = GT::WWW->new("https://www.${sandbox}paypal.com/cgi-bin/webscr");
    my @param;

    for my $p ($in->param) {
        for my $v ($in->param($p)) {
            push @param, $p, $v;
        }
    }

    # PayPal says:
    #   You will also need to append a variable named "cmd" with the value
    #   "_notify-validate" (e.g. cmd=_notify-validate) to the POST string.
    $wwws->parameters(@param, cmd => '_notify-validate');

    my $result = $wwws->post;
    my $status;

    # PayPal says:
    #   PayPal will respond to the post with a single word, "VERIFIED" or
    #   "INVALID", in the body of the response. When you receive a VERIFIED
    #   response, you need to:
    #
    #     * Check that the "payment_status" is "completed"
    #     * If the "payment_status" is "completed", check the "txn_id" against
    #       the previous PayPal transaction you have processed to ensure it is
    #       not a duplicate.
    #     * After you have checked the "payment_status" and "txn_id", make sure
    #       the "receiver_email" is an email address registered in your PayPal
    #       account
    #     * Once you have completed the above checks, you may update your
    #       database based on the information provided. 
    if ($result) {
        my $status = "$result";
        unless ($status eq 'VERIFIED') {
            $opts{on_invalid}->($status) if $opts{on_invalid};
            return;
        }

        # For certain txn_types payment_status and txn_id aren't available
        my $txn_type = $in->param('txn_type');
        if ($txn_type =~ /^subscr_(?:signup|cancel|failed|eot|modify)$/) {
            if ($txn_type eq 'subscr_signup') {
                $opts{on_recurring_signup}->() if $opts{on_recurring_signup};
            }
            elsif ($txn_type eq 'subscr_cancel') {
                $opts{on_recurring_cancel}->() if $opts{on_recurring_cancel};
            }
            elsif ($txn_type eq 'subscr_failed') {
                $opts{on_recurring_failed}->() if $opts{on_recurring_failed};
            }
            elsif ($txn_type eq 'substr_eot') {
                $opts{on_recurring_eot}->() if $opts{on_recurring_eot};
            }
            elsif ($txn_type eq 'substr_modify') {
                $opts{on_recurring_modify}->() if $opts{on_recurring_modify};
            }
            return;
        }

        # * Check that the "payment_status" is "completed" [sic; should be "Completed"]
        unless ((my $status = $in->param('payment_status')) eq 'Completed') {
            if ($status eq 'Pending') {
                $opts{on_pending}->() if $opts{on_pending};
            }
            elsif ($status eq 'Failed') {
                $opts{on_failed}->();
            }
            elsif ($status eq 'Denied') {
                $opts{on_denied}->();
            }
            elsif ($status eq 'Refunded') {
                $opts{on_refund}->() if $opts{on_refund};
            }
            elsif ($opts{on_error}) {
                $opts{on_error}->("PayPal sent invalid/unknown payment_status value: '$status'");
            }
            return;
        }

        my $txn_id = $in->param('txn_id');
        return unless $txn_id;

        # * If the "payment_status" is "completed", check the "txn_id" against
        #   the previous PayPal transaction you have processed to ensure it is
        #   not a duplicate.
        $opts{duplicate}->($txn_id) or return;

        # * After you have checked the "payment_status" and "txn_id", make sure
        #   the "receiver_email" is an email address registered in your PayPal
        #   account
        $opts{email}->($in->param('receiver_email')) or return; # Ignore if the e-mail addresses don't match

        if ($txn_type eq 'subscr_payment') {
            $opts{on_recurring}->() if $opts{on_recurring};
        }
        else {
            $opts{on_valid}->();
        }
    }
    elsif ($opts{on_error}) {
        if (defined $result) {
            my $http_status = $result->status;
            $opts{on_error}->("Server returned a non-okay status: " . int($http_status) . " $http_status");
        }
        else {
            $opts{on_error}->("Connection error: " . $wwws->error);
        }
    }
    return;
}

1;

__END__

=head1 NAME

GT::Payment::Remote::PayPal - PayPal payment handling

=head1 SYNOPSIS

    use GT::Payment::Remote::PayPal;
    use GT::CGI;

    my $in = new GT::CGI;

    GT::Payment::Remote::PayPal->process(
        param => $in,
        on_valid => \&valid,
        on_pending => \&pending,
        on_failed => \&failed,
        on_denied => \&denied,
        on_invalid => \&invalid,
        on_recurring => \&recurring,
        on_recurring_signup => \&r_signup,
        on_recurring_cancel => \&r_cancel,
        on_recurring_failed => \&r_failed,
        on_recurring_eot => \&r_eot,
        on_recurring_modify => \&r_modify,
        duplicate => \&duplicate,
        email => \&email,
        on_error => \&error
    );

    sub valid {
        # Update database - the payment has been made successfully.
    }
    sub pending {
        # Optional; store a "payment pending" status if you wish.  This is optional
        # because another postback will be made with a completed, failed, or denied
        # status.
    }
    failed {
        # According to PayPal IPN documentation: "The payment has failed.  This
        # will only happen if the payment was made from your customer's bank
        # account."
        # Store a "payment failed" status for the order
    }
    sub denied {
        # According to PayPal IPN documentation: "You, the merchant, denied the
        # payment.  This will only happen if the payment was previously pending due
        # to one of the "pending reasons" [in pending_reason]"
    }
    sub invalid {
        # This means the request did NOT come from PayPal.  You should log the
        # request for follow up.
    }
    sub recurring {
        # This means a recurring payment has been made successfully.  Update
        # database.
    }
    sub r_signup {
        # This means a recurring signup has been made (NOT a payment, just a
        # signup).
    }
    sub r_cancel {
        # The user has cancelled their recurring payment
    }
    sub r_failed {
        # A recurring payment has failed (probably declined).
    }
    sub r_eot {
        # A recurring payment has come to its natural conclusion.  This only
        # applies to payments with a set number of payments.
    }
    sub r_modify {
        # Something has been modified regarding the recurring payment
    }
    sub duplicate {
        # Check to see if the payment has already been made.  If it _has_ been
        # made, you should return undef, otherwise return 1 to indicate that this
        # is not a duplicate postback.  The "txn_id" value is passed in, but is
        # also available through $in->param('txn_id').
    }
    sub email {
        # This will be called with an e-mail address.  You should check to make
        # sure that the e-mail address entered is the same as the one on the PayPal
        # account.  Return true (1) if everything checks out, undef otherwise.
    }
    sub error {
        # An error message is passed in here.  This is called when a error such as
        # a connection problem or HTTP problem occurs.
    }

=head1 DESCRIPTION

This module is designed to handle PayPal payment processing using PayPal's IPN
system.  It does very little other than generating and sending a proper
response to the PayPal server, and calling the provided code reference(s).

It is strongly recommended that you familiarize yourself with the PayPal
"Single Item Purchases Manual" and "IPN Manual" listed in the L</"SEE ALSO">
section of this document.

=head1 REQUIREMENTS

GT::WWW with the https protocol, which in turn requires Net::SSLeay.  PPM's are
available from Gossamer Threads for the latest Windows releases of ActiveState
Perl 5.6.1 and 5.8.0.

=head1 process

process() is the only function/method provided by this module.  It can be
called as either a function or class method, and takes a hash (not hash
reference) of arguments as described below.  This module requires GT::WWW's
https interface, which in turn requires Net::SSLeay.

process() should be called for PayPal initiated requests.  This can be set up
in your main CGI by looking for PayPal-specific CGI parameters ('txn_type' is a
good one to look for) or by making a seperate .cgi file exclusively for
handling IPN postbacks.

Additionally, it is strongly advised that database connection, authenticate,
etc.  be performed before calling process() to ensure that the payment is
recorded successfully.  If your CGI script has an error, PayPal will retry the
postback again 

Except where indicated, all arguments are required.

=head2 param

param takes a GT::CGI object from which PayPal IPN variables are read.

=head2 on_valid

on_valid takes a code reference as value.  The code reference will be called
when a successful payment has been made.  Inside this code reference you are
responsible for setting a "paid" status for the order in question.

See the PayPal IPN documentation listed below for information on how to
identify an order.

=head2 on_pending

on_pending is called when PayPal sends information on a "Pending" payment.
This parameter is optional, due to the fact that a "Pending" status means that
another notification (either "Completed", "Failed", or "Denied") will be made.

It is, however, recommended that when a Pending payment is encountered, a note
be stored in your application that manual intervention is probably required.

According to PayPal documentation, there are a few cases where this will
happen, which can be obtained from the "pending_reason" CGI input variable.
The possible values and what each means follows (this comes straight from the
PayPal documentation).

=over 4

=item "echeck"

The payment is pending because it was made by an eCheck, which has not yet
cleared.

=item "multi_currency"

You do not have a balance in the currency sent, and you do not have your
Payment Receiving Preferences set to automatically convert and accept this
payment. You must manually accept or deny this payment.

=item "intl"

The payment is pending because you, the merchant, hold an international account
and do not have a withdrawal mechanism. You must manually accept or deny this
payment from your Account Overview.

=item "verify"

The payment is pending because you, the merchant, are not yet verified. You
must verify your account before you can accept this payment.

=item "address"

The payment is pending because your customer did not include a confirmed
shipping address and you, the merchant, have your Payment Receiving Preferences
set such that you want to manually accept or deny each of these payments. To
change your preference, go to the "Preferences" section of your "Profile."

=item "upgrade"

The payment is pending because it was made via credit card and you, the
merchant, must upgrade your account to Business or Premier status in order to
receive the funds.

=item "unilateral"

The payment is pending because it was made to an email address that is not yet
registered or confirmed.

=item "other"

The payment is pending for an "other" reason. For more information, contact
customer service.

=back

=head2 on_failed

Takes a code reference to call in the event of a failed payment notification.
A failed payment "will only happen if the payment was made from your customer's
bank account."

You should record a failed payment in your application.

=head2 on_denied

This code reference is called when a "Denied" payment notification is received.
"This will only happen if the payment was previously pending due to one of the
'pending reasons'" above.

You should record a failed or denied payment in your application.

=head2 on_invalid

This code reference will be called when an invalid request is made.  This
usually means that the request B<did not> come from PayPal.  According to
PayPal, "if you receive an 'INVALID' notification, it should be treated as
suspicious and investigated."  Thus it is strongly recommended that a record of
the invalid request be made.

=head2 duplicate

This code reference is required to prevent duplicate payments.  It is called
for potentially successful requests to ensure that it is not a duplicate
postback.  It is passed the "txn_id" CGI parameter, which is the
PayPal-generated transaction ID.  You should check this parameter against your
order database.  If you have already recorded this payment as successfully
made, should should return C<undef> from this function, to indicate that the
duplicate check failed.  If the transaction ID is okay (i.e. is not a
duplicate) return 1 to continue.

=head2 recurring

A successful recurring payment has been made.  You should set a "paid" status
for the item in question.

=head2 recurring_signup

=head2 recurring_cancel

=head2 recurring_failed

=head2 recurring_eot

=head2 recurring_modify

These are called when various things have happened to the subscription.  In
particular, signup refers to a new subscription, cancel refers to a cancelled
subscription, failed refers to a failed payment, eot refers to a subscription
that ended naturally (i.e. an end was set when the subscription was initially
made), and modify is called when a payment has been modified.

=head2 email

This code reference, like duplicate, is called to ensure that the payment was
sent to the correct account.  An e-mail address is passed in which must be the
same as the primary account's e-mail address.  If it is the same, return C<1>.
If it is I<not> the same, you should return C<undef> and store a note asking
the user to check that the PayPal e-mail address they have provided is the
correct, primary, PayPal e-mail address.

=head2 on_error

This code reference is optional, but recommended.  It is called when a
non-PayPal generated error occurs - such as a failure to connect to PayPal.  It
is recommended that you provide this code reference and log any errors that
occur.  The error message is passed in.

=head1 INSTRUCTIONS

To implement PayPal payment processing, there are a number of steps required in
addition to this module.  Basically, this module handles only the postback
stage of the PayPal IPN process.

Full PayPal single item, subscription, and IPN documentation is available at
the URL's listed in the L<SEE ALSO|/"SEE ALSO"> section.

=head2 Directing customers to PayPal

This is done by creating a web form containing the following variables.  Your
form, first of all, must post to C<https://www.paypal.com/cgi-bin/webscr>.

Your form should contains various PayPal parameters, as outlined in the PayPal
manuals linked to in the L<SEE ALSO|/"SEE ALSO"> section.

Of particular note is the "notify_url" option, which should be used to specify
a postback URL for PayPal IPN postbacks.
The below is simply a list of the required fields, and only those fields that
are absolutely required are described.  For descriptions of each field, check
the PayPal Single Item Purchases Manual.

=over 4

=item cmd

Must be set to "_xclick".

=item business

Your PayPal ID (e-mail address).  Must be confirmed and linked to your Verified
Business or Premier account.

=item item_name

=item item_number

=item image_url

=item no_shipping


=item return

Although optional, this is highly recommend - takes a URL to bring the buyer
back to after purchasing.  If not specified, they'll remain at PayPal.

=item rm

Return method for the L<return|/return> option.  If "1", a GET request without
the transaction variables will be made, if "2" a POST request WITH the transaction
variables will be made.

=item cancel_return

=item no_note

=item cn

=item cs

=item on0

=item on1

=item os0

=item os1

=item quantity

The quantity of items being purchased.  If omitted, defaults to 1 and will not
be shown in the payment flow.

=item undefined_quantity

"If set to "1", the user will be able to edit the quantity.  This means your
customer will see a field next to quantity which they must complete.  This is
optional; if omitted or set to "0", the quantity will not be editable by the
user.  Instead, it will default to 1"

=item shipping

=back

=head2 IPN

Before PayPal payment notification can occur, you must instruct the user to
enable Instant Payment Notification (IPN) on their PayPal account.  The
postback URL should be provided and handled by you either by detecting a PayPal
request in your main .cgi script (recommended), or through the use of an
additional .cgi script exclusively for PayPal IPN.

If adding to your existing script, it is recommended to look for the 'txn_type'
CGI parameter, which will be set for PayPal IPN postbacks.

Once IPN has been set up, you have to set up your application to direct users
to PayPal in order to initiate a PayPal payment.

=head1 SEE ALSO

L<https://www.paypal.com/html/single_item.pdf> - Single Item Purchases Manual

L<https://www.paypal.com/html/subscriptions.pdf> - Subscriptions and Recurring
Payments Manual

L<https://www.paypal.com/html/ipn.pdf> - IPN Manual

=head1 MAINTAINER

Jason Rhinelander

=head1 COPYRIGHT

Copyright (c) 2004 Gossamer Threads Inc.  All Rights Reserved.
http://www.gossamer-threads.com/

=head1 VERSION

Revision: $Id: PayPal.pm,v 1.8 2006/04/08 03:42:05 brewt Exp $

=cut
