Custom Tenders

Overview

Custom tenders allow developers to build out additional payment experiences that are able to be launched on a customer-facing Clover device or as a merchant-facing experience.  These new user flows allow for additional payment methods, such as enabling QR scanning, to exist for a given merchant.

This tutorial walks through the process of creating an app that can create a custom tender type and also implements a payment flow that can be initiated from the Register, Sale, and Secure Payment apps.

Use Cases

  1. 1 Clover Mini or Mobile
    • Initiated via Register or Sale apps for a merchant-facing experience
    • Initiated via Secure Payments app for a customer-facing experience
  2. 1 Clover Station or Mini with a tethered Mini
    • Initiated via Secure Payments app on tethered Mini for a customer-facing experience
    • Initiated via Register or Sale apps for a merchant-facing experience
  3. Semi-integrated approach of a 3rd party POS with a tethered Mini
    • Initiated via Secure Payments app on tethered Mini for a customer-facing experience

Design Requirements

  1. A Cancel button must exist so the customer or merchant can exit your payment flow.
  2. Tender button graphic must be a logo of resolution specified below.
    • mdpi: 230 x 104px
    • hdpi: 345 x 156px
  3. A logo asset corresponding to each experience (customer and/or merchant).
    • White (#FFFFFF) logo with transparent background for Secure Payment app. (Customer-Facing Experience)
      logo-customer-tender
    • Gray (#666666) logo with transparent background for Sale app. (Merchant-Facing Experience)
      logo-merchant-tender
  4. If no logo is provided, the button will default to the custom tender’s name.

Note

We have written an example app as part of our clover examples repo named extensibletenderexample.  Feel free to check it out!

Create a Custom Tender app

This tutorial assumes that you are already familiar with creating an Android app for Clover devices.  For reference on how to do so, please go here: Create Your First Android App

Note

Setup used for this tutorial:

  • JVM 1.8.0
  • Gradle 2.3
  • Android Studio 1.3.2

This app is split into two sections:

  1. Initializing a custom tender for a merchant
  2. Linking into the Register, Sale, and Secure Payment apps

Part One

For this part of the tutorial, we will be using the TenderConnector to initialize our custom tender for our merchant.  The base code that is necessary to do so is below, however, production-ready code must also be able to handle error messages and properly inform the user.

Note

The app must be run once before being able to use a custom tender, as the initialization code must be executed in order to register the tender with the merchant.  It is recommended to run the custom tender app right after it has finished installation on your Clover device.  Please note the initialization in the example code:

tenderConnector.checkAndCreateTender(...)

Important

Don’t forget to add the SDK as a dependency in your build.gradle file.
compile 'com.clover.sdk:clover-android-sdk:latest.release'
Also, make sure to add the necessary Android permission to your AndroidManifest.xml.
uses-permission android:name="android.permission.GET_ACCOUNTS"

private void createTenderType(final Context context) {
    new AsyncTask<Void, Void, Exception>() {

        private TenderConnector tenderConnector;

        @Override
        protected void onPreExecute() {
            super.onPreExecute();
            tenderConnector = new TenderConnector(context, CloverAccount.getAccount(context), null);
            tenderConnector.connect();
        }

        @Override
        protected Exception doInBackground(Void... params) {
            try {
                tenderConnector.checkAndCreateTender(getString(R.string.tender_name), getPackageName(), true, false);
            } catch (Exception exception) {
                Log.e(TAG, exception.getMessage(), exception.getCause());
                return exception;
            }
            return null;
        }

        @Override
        protected void onPostExecute(Exception exception) {
            tenderConnector.disconnect();
            tenderConnector = null;
        }
    }.execute();
}

Custom Tender Initialized screen of Extensible Tender Example
Custom Tender Initialized

Part Two

There are two different payment experiences that you can choose to implement depending on your specific use case.

If you want a purely merchant-facing payment, such only allowing the merchant to scan a QR code or enter a customer’s phone number for something like a digital wallet redemption, then proceed with Merchant-Facing Setup below.

If you want to develop a customer-facing experience that would launch from within the Secure Payment app on either a Mobile or Mini (1 device use case), or on a tethered Mini (2 devices use case). In this case, proceed to Customer-Facing Setup.

Note

In some cases, it may make sense to allow the merchant to be able to use either method, such as the phone number example above.  Please develop your merchant/customer experience and UI accordingly.

Important

Make sure to add setResult(RESULT_CANCELED) in onCreate to create a default exit result.

Merchant-Facing Setup

  1. Below is the basic setup of an activity for a merchant-facing custom tender.
    public class MerchantFacingTenderActivity extends Activity {
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
    
            setContentView(R.layout.activity_tender);
    
            setResult(RESULT_CANCELED);
    
            /**
             * @see Intents.ACTION_MERCHANT_TENDER
             */
            final long amount = getIntent().getLongExtra(Intents.EXTRA_AMOUNT, 0);
            final Currency currency = (Currency) getIntent().getSerializableExtra(Intents.EXTRA_CURRENCY);
            final long taxAmount = getIntent().getLongExtra(Intents.EXTRA_TAX_AMOUNT, 0);
            final ArrayList<Parcelable> taxableAmounts = getIntent().getParcelableArrayListExtra(Intents.EXTRA_TAXABLE_AMOUNTS);
            final ServiceChargeAmount serviceCharge = getIntent().getParcelableExtra(Intents.EXTRA_SERVICE_CHARGE_AMOUNT);
    
            final String orderId = getIntent().getStringExtra(Intents.EXTRA_ORDER_ID);
            final String merchantId = getIntent().getStringExtra(Intents.EXTRA_MERCHANT_ID);
    
            final Tender tender = getIntent().getParcelableExtra(Intents.EXTRA_TENDER);
    
            // Merchant Facing specific fields
            final Order order = getIntent().getParcelableExtra(Intents.EXTRA_ORDER);
            final String note = getIntent().getStringExtra(Intents.EXTRA_NOTE);
    
            setupViews(amount, currency, orderId, merchantId);
        }
    
        public void setupViews(final long amount, Currency currency, String orderId, String merchantId) {
            TextView amountText = (TextView) findViewById(R.id.text_amount);
            amountText.setText(Utils.longToAmountString(currency, amount));
    
            TextView orderIdText = (TextView) findViewById(R.id.text_orderid);
            orderIdText.setText(orderId);
            TextView merchantIdText = (TextView) findViewById(R.id.text_merchantid);
            merchantIdText.setText(merchantId);
    
            Button approveButton = (Button) findViewById(R.id.acceptButton);
            approveButton.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    Intent data = new Intent();
                    data.putExtra(Intents.EXTRA_AMOUNT, amount);
                    data.putExtra(Intents.EXTRA_CLIENT_ID, Utils.nextRandomId());
                    data.putExtra(Intents.EXTRA_NOTE, "Transaction Id: " + Utils.nextRandomId());
    
                    setResult(RESULT_OK, data);
                    finish();
                }
            });
    
            Button declineButton = (Button) findViewById(R.id.declineButton);
            declineButton.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    Intent data = new Intent();
                    data.putExtra(Intents.EXTRA_DECLINE_REASON, "You pressed the decline button");
    
                    setResult(RESULT_CANCELED, data);
                    finish();
                }
            });
        }
    }
  2. You will need to declare an MERCHANT_TENDER intent-filter in the AndroidManifest.xml in order for Android to resolve any intents to your activity. Set exported to true.
    <activity
        android:name=".MerchantFacingTenderActivity"
        android:exported="true"
        android:label="@string/title_activity_test_tender">
    
        <meta-data
            android:name="clover.intent.meta.MERCHANT_TENDER_IMAGE"
            android:resource="@mipmap/thirdparty_gray_example" />
    
        <intent-filter>
            <action android:name="clover.intent.action.MERCHANT_TENDER" />
            <category android:name="android.intent.category.DEFAULT" />
        </intent-filter>
        </activity>

    Note

    You may also use a logo in the Sale app, instead of your custom tender’s name, by declaring a MERCHANT_TENDER_IMAGE meta-data for the same activity the intent-filter was declared for and referencing your logo asset. It must be solid gray according to the design guidelines.

Custom Tender: Merchant Facing button in Register
Custom Tender: Merchant Facing button in Register Custom Tender: Merchant Facing button in Sale
Custom Tender: Merchant Facing button in Sale

Customer-Facing Setup

  1. Below is the basic setup of an activity for a customer-facing custom tender.
    public class CustomerFacingTenderActivity extends Activity {
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
    
            setContentView(R.layout.activity_tender);
    
            setResult(RESULT_CANCELED);
    
            // Necessary for Customer Facing user experiences
            setSystemUiVisibility();
    
            /**
             * @see Intents.ACTION_CUSTOMER_TENDER
             */
            final long amount = getIntent().getLongExtra(Intents.EXTRA_AMOUNT, 0);
            final Currency currency = (Currency) getIntent().getSerializableExtra(Intents.EXTRA_CURRENCY);
            final long taxAmount = getIntent().getLongExtra(Intents.EXTRA_TAX_AMOUNT, 0);
            final ArrayList taxableAmounts = getIntent().getParcelableArrayListExtra(Intents.EXTRA_TAXABLE_AMOUNTS);
            final ServiceChargeAmount serviceCharge = getIntent().getParcelableExtra(Intents.EXTRA_SERVICE_CHARGE_AMOUNT);
    
            final String orderId = getIntent().getStringExtra(Intents.EXTRA_ORDER_ID);
            final String employeeId = getIntent().getStringExtra(Intents.EXTRA_EMPLOYEE_ID);
            final String merchantId = getIntent().getStringExtra(Intents.EXTRA_MERCHANT_ID);
    
            final Tender tender = getIntent().getParcelableExtra(Intents.EXTRA_TENDER);
    
            // Customer Facing specific fields
            final long tipAmount = getIntent().getLongExtra(Intents.EXTRA_TIP_AMOUNT, 0);
    
            setupViews(amount, currency, orderId, merchantId);
        }
    
        public void setSystemUiVisibility() {
            getWindow().getDecorView().setSystemUiVisibility(
                    View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                            | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                            | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                            | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
                            | View.SYSTEM_UI_FLAG_LOW_PROFILE
                            | View.SYSTEM_UI_FLAG_FULLSCREEN
                            | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
        }
    
        public void setupViews(final long amount, Currency currency, String orderId, String merchantId) {
            TextView amountText = (TextView) findViewById(R.id.text_amount);
            amountText.setText(Utils.longToAmountString(currency, amount));
    
            TextView orderIdText = (TextView) findViewById(R.id.text_orderid);
            orderIdText.setText(orderId);
            TextView merchantIdText = (TextView) findViewById(R.id.text_merchantid);
            merchantIdText.setText(merchantId);
    
            Button approveButton = (Button) findViewById(R.id.acceptButton);
            approveButton.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    Intent data = new Intent();
                    data.putExtra(Intents.EXTRA_AMOUNT, amount);
                    data.putExtra(Intents.EXTRA_CLIENT_ID, Utils.nextRandomId());
                    data.putExtra(Intents.EXTRA_NOTE, "Transaction Id: " + Utils.nextRandomId());
    
                    setResult(RESULT_OK, data);
                    finish();
                }
            });
    
            Button declineButton = (Button) findViewById(R.id.declineButton);
            declineButton.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    Intent data = new Intent();
                    data.putExtra(Intents.EXTRA_DECLINE_REASON, "You pressed the decline button");
    
                    setResult(RESULT_CANCELED, data);
                    finish();
                }
            });
        }
    }
    

    Important

    Customer-facing user experiences must always be fullscreen to prevent the customer from navigating away from your app.

    The necessary flags are:
    View.SYSTEM_UI_FLAG_LAYOUT_STABLE
    View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
    View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
    View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
    View.SYSTEM_UI_FLAG_LOW_PROFILE
    View.SYSTEM_UI_FLAG_FULLSCREEN
    View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY

  2. You will need to declare a CUSTOMER_TENDER intent-filter in the AndroidManifest.xml in order for Android to resolve any intents to your activity. Set exported to true.
    <activity
        android:name=".CustomerFacingTenderActivity"
        android:exported="true"
        android:label="@string/title_activity_test_tender"
        android:theme="@android:style/Theme.Holo.Light.NoActionBar.Fullscreen">
    
        <meta-data
            android:name="clover.intent.meta.CUSTOMER_TENDER_IMAGE"
            android:resource="@mipmap/thirdparty_white_example" />
    
        <intent-filter>
            <action android:name="clover.intent.action.CUSTOMER_TENDER" />
            <category android:name="android.intent.category.DEFAULT" />
        </intent-filter>
    </activity>

     

    Note

    You may also use a logo, instead of your custom tender’s name, by declaring a CUSTOMER_TENDER_IMAGE meta-data for the same activity the intent-filter was declared for and referencing your logo asset. It must be solid white according to the design guidelines.

    Please note that a fullscreen Android theme must be applied (Theme.Holo.Light.NoActionBar.Fullscreen in this example) for a customer-facing experience in order for the UI to appear smoothly before onCreate(Bundle savedInstanceState) is called by the system.

    Custom Tender: Customer Facing button in Secure Payments
    Custom Tender: Customer Facing button in Secure Payments

 

 

Partial Payment

If the merchant is on a plan that supports the Register app / Payments app flow (eg. Register and Register Lite), it allows for paying partially. The Sale app on Payment Plus Plan does not.

You can make partial payment by responding with an amount, which can be less than the total amount. Please see the docs for ACTION_MERCHANT_TENDER in the class Intents in the Clover Android SDK.

In addition, you’ll need to account for are refunds on partial payments made by custom tenders. LineItem-level refunds using Custom tenders are possible (i.e., they are not all-or-nothing). First, ensure your custom tender’s Refund Activity can handle refund Intents:

<intent-filter>
<action android:name="clover.intent.action.REFUND" />
<category android:name="android.intent.category.DEFAULT" />

From your refund activity, capture some Extras from the refund Intent:

final long amount = getIntent().getLongExtra(Intents.EXTRA_AMOUNT, 0);
final String orderId = getIntent().getStringExtra(Intents.EXTRA_ORDER_ID);
final String paymentId = getIntent().getStringExtra(Intents.EXTRA_PAYMENT_ID);
final ArrayList lineItemIds = getIntent().getStringArrayListExtra(Intents.EXTRA_LINE_ITEM_IDS);

If the Refund POSTs successfully on your end, tell Clover everything went as planned:
Intent data = new Intent();
data.putExtra(Intents.EXTRA_ORDER_ID, orderId);
data.putExtra(Intents.EXTRA_PAYMENT_ID, paymentId);
data.putExtra(Intents.EXTRA_LINE_ITEM_IDS, lineItemIds);
data.putExtra(Intents.EXTRA_AMOUNT, amount);
data.putExtra(Intents.EXTRA_CLIENT_ID, "Your_external_payment_ID");
setResult(RESULT_OK, data);
finish();

Else:
setResult(RESULT_CANCELED, data);
finish();

Note

When a lineItem-level, custom tender refund Intent is fired, the EXTRA_AMOUNT is the sum of the lineItems.amounts that were selected. As such, the EXTRA_AMOUNT might be greater than the amount that was paid for using the custom tender. For Orders on which the refund’s EXTRA_AMOUNT <= the custom tender payment, everything will work as intended. However, you’ll still need to add logic to verify this and handle cases where the custom tender was applied as a partial payment, and pass back the appropriate EXTRA_AMOUNT to Clover before finish()-ing.

Reference: https://community.clover.com/questions/2159/custom-tender-full-payment.html