Add a PayPal payment option to your page

Overview

This example shows how to process payments via PayPal. It contains a simplistic ShoppingCart which serves to allow a user to select items to buy. This happens on the home page of the example. When the user is satisfied with their selections, the user clicks on “Pay” which transitions them to another “Summary” page on which an overview of the order is shown and where a PayPalButtonsPanel is displayed.

Clicking on the PayPal button here ferries the user to PayPal and finally returns to the Summary page to show the status of the order after payment.

@startuml

!include ../../../base.iuml

State "View: / " as View1 : Choose what to buy
State "View: /order_summary " as View2 : order summary
State "PayPal" as PayPal : Complete the payment on PayPal


View1 -right-> View2 : click pay
View2 --> PayPal : click PayPal button
PayPal -down-> View2 



'skinparam roundcorner 25

'cloud "Complete payment on PayPal" as cloud1


'state View1 {
'    state one
'} 
'state "View: /order_summary"  as View2


'View1 -> View2
'View2 -down-> cloud1 : "click PayPal button"
'cloud1 -> View2 


@enduml

Create a ShoppingCart

Create a session_scoped() ShoppingCart object to collect all the items you want to buy. It has a pay method which creates a PayPalOrder when called.

@session_scoped
class ShoppingCart(Base):
    __tablename__ = 'paymentpaypal_shoppingcart'

    id = Column(Integer, primary_key=True)
    item_name = Column(UnicodeText)
    quantity = Column(Integer)
    price = Column(Numeric(precision=4, scale=2))
    currency_code = Column(String(3))
    paypal_order_id = Column(Integer, ForeignKey(PayPalOrder.id), nullable=True)
    paypal_order = relationship(PayPalOrder)

    fields = ExposedNames()
    fields.item_name = lambda i: Field(label='Item name')
    fields.quantity = lambda i: IntegerField(min_value=1, max_value=10, label='Quantity')
    fields.price = lambda i: NumericField(min_value=1, label='Price')
    fields.currency_code = lambda i: ChoiceField([Choice('USD', Field(label='USD')),
                                                  Choice('EUR', Field(label='EUR'))],
                                                 label='Currency')

    events = ExposedNames()
    events.pay_event = lambda i: Event(label='Pay', action=Action(i.pay))
    events.clear_event = lambda i: Event(label='Continue shopping', action=Action(i.clear))

    def pay(self):
        json_dict = self.as_json()
        print(json_dict)
        order = PayPalOrder(json_string=json.dumps(json_dict))
        Session.add(order)
        self.paypal_order = order

    def clear(self):
        self.paypal_order = None

Create the order JSON

Also add an as_json method which represents your order as a dictionary that conforms to PayPal’s JSON specification for creating orders and with “intent” set to “CAPTURE”.

    def as_json(self):
        tax_rate = decimal.Decimal('0.15')
        invoice_id = id(self)

        item_price = self.price
        item_tax = round(self.price * tax_rate, 2)
        item_quantity = self.quantity
        item_name = self.item_name

        order_pre_tax_total = item_quantity*item_price
        order_total_tax = item_quantity*item_tax
        order_total_including_tax = item_quantity*(item_price+item_tax)

        brand_name = 'My Example company'
        return \
        {
            "intent": "CAPTURE",

            "purchase_units": [
                {
                    "description": item_name,
                    "invoice_id": invoice_id,
                    "soft_descriptor": brand_name,
                    "amount": {
                        "currency_code": self.currency_code,
                        "value": str(order_total_including_tax),
                        "breakdown": {
                            "item_total": {
                                "currency_code": self.currency_code,
                                "value": str(order_pre_tax_total)
                            },
                            "tax_total": {
                                "currency_code": self.currency_code,
                                "value": str(order_total_tax)
                            },
                        }
                    },
                    "items": [
                        {
                            "name": item_name,
                            "unit_amount": {
                                "currency_code": self.currency_code,
                                "value": str(item_price)
                            },
                            "tax": {
                                "currency_code": self.currency_code,
                                "value": str(item_tax)
                            },
                            "quantity": str(item_quantity),
                            "category": "DIGITAL_GOODS"
                        },

                    ]

                }
            ]
        }

Create the PurchaseForm

Create a PurchaseForm class which is the user interface of your simple shopping cart. Wire its only button to the ShoppingCart’s pay event so that a PayPalOrder is created when clicked.

class PurchaseForm(Form):
    def __init__(self, view, shopping_cart):
        super().__init__(view, 'purchase')
        self.use_layout(FormLayout())

        if self.exception:
            self.layout.add_alert_for_domain_exception(self.exception)

        self.layout.add_input(TextInput(self, shopping_cart.fields.item_name))
        self.layout.add_input(TextInput(self, shopping_cart.fields.quantity))
        self.layout.add_input(TextInput(self, shopping_cart.fields.price))
        self.layout.add_input(SelectInput(self, shopping_cart.fields.currency_code))

        self.add_child(Button(self, shopping_cart.events.pay_event, style='primary'))

Create the PurchaseSummary

Create a Form to display the final order details. This Widget should contain a PayPalButtonsPanel, but only if the order is ready to be paid. The same Widget is again displayed upon return from PayPal, and in that case only display the order success message.

class PurchaseSummary(Form):
    def __init__(self, view, shopping_cart):
        super().__init__(view, 'summary')
        self.use_layout(FormLayout())
        paypal_order = shopping_cart.paypal_order

        if self.exception:
            self.layout.add_alert_for_domain_exception(self.exception)

        self.add_child(P(view, text='%s: %s' % (shopping_cart.fields.item_name.label, shopping_cart.item_name)))
        self.add_child(P(view, text='%s: %s' % (shopping_cart.fields.quantity.label, shopping_cart.quantity)))
        self.add_child(P(view, text='%s: %s' % (shopping_cart.fields.price.label, shopping_cart.price)))
        self.add_child(P(view, text='%s: %s' % (shopping_cart.fields.currency_code.label, shopping_cart.currency_code)))

        self.add_child(Alert(view, 'Your order(%s) status: %s (%s)' % (paypal_order.id, paypal_order.status, paypal_order.paypal_id), 'secondary'))

        if paypal_order.is_due_for_payment:
            credentails = self.get_credentials_from_configuration()
            #credentails = self.get_credentials_hardcoded()
            self.add_child(PayPalButtonsPanel(view, 'paypal_buttons', shopping_cart.paypal_order, credentails, shopping_cart.currency_code))
        elif paypal_order.is_paid:
            self.add_child(Alert(view, 'Your order has been paid successfully', 'primary'))
            self.add_child(Button(self, shopping_cart.events.clear_event, style='primary'))
        else:
            self.add_child(Alert(view, 'We could not complete your payment at PayPal (Order status: %s)' % paypal_order.status, 'warning'))

    def get_credentials_hardcoded(self):
        return PayPalClientCredentials('test', 'invalidpassword', True)

Note

Notice that in this example is a call to get_credentials_from_configuration, and a commented-out call to get_credentials_hardcoded.

For now, comment out the call to get_credentials_from_configuration and use the one to get_credentials_hardcoded.

Combine it all in a UserInterface

Add Purchaseform to your UserInterface on the ‘/’ UrlBoundView and PurchaseSummary on the ‘/order_summary’ UrlBoundView. Then define Transitions between the two.

There is no need to add a Transition to PayPal.

class ShoppingUI(UserInterface):
    def assemble(self):
        shopping_cart = ShoppingCart.for_current_session()

        home = self.define_view('/', title='Paypal Example')
        home.set_slot('main', PurchaseForm.factory(shopping_cart))

        order_summary_page = self.define_view('/order_summary', title='Order Summary')
        order_summary_page.set_slot('main', PurchaseSummary.factory(shopping_cart))

        self.define_transition(shopping_cart.events.pay_event, home, order_summary_page)
        self.define_transition(shopping_cart.events.clear_event, order_summary_page, home)

        self.define_page(MainPage)

Obtain sandbox credentials at PayPal

At this point (provided you commented out the use of get_credentials_from_configuration) you can run the example up to a point.

To be able to interact with PayPal, register as a developer at PayPal

Create Configuration for your PayPal merchant account

Create a Configuration for your project in which you can supply the credentials for interacting with PayPal while avoiding hardcoding these in the source code of your app.

class PaymentPayPalConfig(Configuration):
    filename = 'paymentpaypal.config.py'
    config_key = 'paymentpaypal'

    client_id = ConfigSetting(description='The PayPal client ID that needs to receive funds from payments')
    client_secret = ConfigSetting(description='The PayPal client secret')
    sandboxed = ConfigSetting(default=True, description='If False, use the PayPal live environment instead of sandboxed', dangerous=True)

Remember to also include this Configuration in your pyproject.toml:

[build-system]
requires = [
  "setuptools >= 68",
  "wheel",
  "setuptools-git >= 1.1",
  "pytest-runner",
  "toml",
  "reahl-component-metadata >= 7.0.0"
  ]
build-backend = "setuptools.build_meta"

[project]
name = "paymentpaypal"
version = "0.1"
requires-python = ">=3.8"
dependencies = [
  "reahl-web>=7.0,<7.1",
  "reahl-component>=7.0,<7.1",
  "reahl-sqlalchemysupport>=7.0,<7.1",
  "reahl-sqlitesupport>=7.0,<7.1",
  "reahl-web-declarative>=7.0,<7.1",
  "reahl-domain>=7.0,<7.1",
  "reahl-paypalsupport>=7.0,<7.1",
  "SQLAlchemy>=1.4,<2.1.999"
  ]
authors = [ 
  {name = "Iwan Vosloo", email = "[email protected]"},
  {name = "Craig Sparks", email = "[email protected]"}
]

[tool.setuptools]
py-modules = ["paymentpaypal"]

[tool.setuptools.packages.find]
exclude = ["etc", "build", "dist"]

[tool.reahl-component]
configuration = "reahl.doc.examples.howtos.paymentpaypal.paymentpaypal:PaymentPayPalConfig"
persisted = [
  "reahl.doc.examples.howtos.paymentpaypal.paymentpaypal:ShoppingCart"
  ]

Create a paymentpaypal.config.py file in your configuration directory, with the necessary settings:


#Read here to create a paypal sandbox account:
# https://developer.paypal.com/docs/api/overview/#create-sandbox-accounts
import os
paymentpaypal.client_id = os.environ['PAYPAL_CLIENT_ID']
paymentpaypal.client_secret = os.environ['PAYPAL_CLIENT_SECRET']
paymentpaypal.sandboxed = True

Note

Here, the config file is coded to read the settings from environment variables to make it easy for us to test, but you can hard-code the settings for yourself in the config file.

Update PurchaseSummary to read this configuration

In PurchaseSummary, write get_credentials_from_configuration to read the config and return a PayPalClientCredentials object with the result. (Remember to also update the commented-out code to call this method.)

    def get_credentials_from_configuration(self):
        config = ExecutionContext.get_context().config
        paypal_client_id = config.paymentpaypal.client_id
        paypal_client_secret = config.paymentpaypal.client_secret
        sandboxed = config.paymentpaypal.sandboxed
        return PayPalClientCredentials(paypal_client_id, paypal_client_secret, sandboxed)

Congratulations!

You should now be able to make payments to the PayPal sandbox environment using the example.