Web Payments



This web payments demo simulates payments in Android Pay and credit cards. The credit cards are not sent to the server. For Android Pay, we run a test transaction of 50 cents. This amount is refunded within a week. The server is written in Node.js.

Client side code

pr.js

/**
 * Show the msg to the user.
 *
 * @private
 * @param {string} msg The message to print in the 'live output' section of the
 * page.
 */
function print(msg) {
  let element = document.getElementById('msg');
  element.innerHTML = element.innerHTML + '<br>' + msg;
}

/**
 * Notify Chrome that the instrument authorization has completed.
 *
 * @private
 * @param {PaymentResponse} instrument The payment instrument that was authed.
 * @param {string} result Whether the auth was successful. Should be either
 * 'success' or 'fail'.
 * @param {string} msg The message to print in the 'live output' section of the
 * page after the browser hides its UI.
 */
function complete(instrument, result, msg) {
  instrument.complete(result).then(function() {
    print(msg);
  }).catch(function(error) {
    print(error);
  });
}

/**
 * Lets the user know that shipping is not possible.
 *
 * @private
 * @param {string} message The message to print in the 'live output' section of
 * the page.
 * @param {PaymentDetails} details The details for payment.
 * @param {function} callback The callback to invoke.
 */
function cannotShip(message, details, callback) {
  print(message);
  delete details.shippingOptions;
  callback(details);
}

/**
 * Lets the user know that shipping is possible.
 *
 * @private
 * @param {PaymentDetails} details The details for payment.
 * @param {Array} shippingOptions The shipping options.
 * @param {function} callback The callback to invoke.
 */
function canShip(details, shippingOptions, callback) {
  let selectedShippingOption;
  for (let i in shippingOptions) {
    if (shippingOptions[i].selected) {
      selectedShippingOption = shippingOptions[i];
    }
  }

  let subtotal = 0.50;
  let total = subtotal;
  if (selectedShippingOption) {
    let shippingPrice = Number(selectedShippingOption.amount.value);
    total = subtotal + shippingPrice;
  }

  details.shippingOptions = shippingOptions;
  details.total = {
    label: 'Total',
    amount: {currency: 'USD', value: total.toFixed(2)},
  };
  details.displayItems = [
    {
      label: 'Sub-total',
      amount: {currency: 'USD', value: subtotal.toFixed(2)},
    },
  ];
  if (selectedShippingOption) {
    details.displayItems.splice(0, 0, selectedShippingOption);
  }

  callback(details);
}

/**
 * Converts the payment instrument into a dictionary.
 *
 * @private
 * @param {PaymentResponse} instrument The instrument to convert.
 * @return {object} The dictionary representation of the instrument.
 */
function instrumentToDictionary(instrument) {
  let details = instrument.details;
  if ('cardNumber' in details) {
    details.cardNumber = 'XXXX-XXXX-XXXX-' + details.cardNumber.substr(12);
  }

  if ('cardSecurityCode' in details) {
    details.cardSecurityCode = '***';
  }

  return {
    methodName: instrument.methodName,
    details: details,
    shippingAddress: addressToDictionary(instrument.shippingAddress),
    shippingOption: instrument.shippingOption,
    payerName: instrument.payerName,
    payerPhone: instrument.payerPhone,
    payerEmail: instrument.payerEmail,
  };
}

/**
 * Converts the payment instrument into a JSON string.
 *
 * @private
 * @param {PaymentResponse} instrument The instrument to convert.
 * @return {string} The string representation of the instrument.
 */
function instrumentToJsonString(instrument) {
  /* PaymentResponse is an interface, but JSON.stringify works only on
   * dictionaries. */
  return JSON.stringify(instrumentToDictionary(instrument), undefined, 2);
}

/**
 * Simulates credit card processing without talking to the server.
 *
 * @private
 * @param {PaymentResponse} instrument The credit card information to simulate
 * processing.
 */
function simulateCreditCardProcessing(instrument) {
  let simulationTimeout = window.setTimeout(function() {
    window.clearTimeout(simulationTimeout);
    instrument.complete('success').then(function() {
      print(instrumentToJsonString(instrument));
      print('Simulated credit card authorization');
    }).catch(function(error) {
      print(error);
    });
  }, 5 * 1000);  /* +5 seconds to simulate server latency. */
}

/**
 * Converts the shipping address into a dictionary.
 *
 * @private
 * @param {PaymentAddress} address The address to convert.
 * @return {object} The dictionary with address data.
 */
function addressToDictionary(address) {
  if (address.toJSON) {
    return address.toJSON();
  }
  return {
    recipient: address.recipient,
    organization: address.organization,
    addressLine: address.addressLine,
    dependentLocality: address.dependentLocality,
    city: address.city,
    region: address.region,
    postalCode: address.postalCode,
    sortingCode: address.sortingCode,
    country: address.country,
    phone: address.phone,
  };
}

/**
 * Converts the shipping address into a JSON string.
 *
 * @private
 * @param {PaymentAddress} address The address to convert.
 * @return {string} The string representation of the address.
 */
function addressToJsonString(address) {
  return JSON.stringify(addressToDictionary(address), undefined, 2);
}

/**
 * Authorizes user's Android Pay for USD $0.50 plus shipping. Simulates credit
 * card processing without talking to the server.
 */
function buy() { // eslint-disable-line no-unused-vars
  document.getElementById('msg').innerHTML = '';

  if (!window.PaymentRequest) {
    print('Web payments are not supported in this browser');
    return;
  }

  let details = {
    total: {label: 'Total', amount: {currency: 'USD', value: '0.50'}},
  };

  let networks = ['visa', 'mastercard', 'amex', 'discover', 'diners', 'jcb',
      'unionpay', 'mir'];
  let payment = new PaymentRequest( // eslint-disable-line no-undef
    [
      {
        supportedMethods: ['https://android.com/pay'],
        data: {
          merchantName: 'Web Payments Demo',
          allowedCardNetworks: ['AMEX', 'MASTERCARD', 'VISA', 'DISCOVER'],
          merchantId: '00184145120947117657',
          paymentMethodTokenizationParameters: {
            tokenizationType: 'GATEWAY_TOKEN',
            parameters: {
              'gateway': 'stripe',
              'stripe:publishableKey': 'pk_live_lNk21zqKM2BENZENh3rzCUgo',
              'stripe:version': '2016-07-06',
            },
          },
        },
      },
      {
        supportedMethods: networks,
      },
      {
        supportedMethods: ['basic-card'],
        data: {
          supportedNetworks: networks,
          supportedTypes: ['debit', 'credit', 'prepaid'],
        },
      },
    ],
    details,
    {
      requestShipping: true,
      requestPayerName: true,
      requestPayerPhone: true,
      requestPayerEmail: true,
      shippingType: 'shipping',
    });

  payment.addEventListener('shippingaddresschange', function(evt) {
    evt.updateWith(new Promise(function(resolve) {
      fetch('/ship', {
        method: 'POST',
        headers: new Headers({'Content-Type': 'application/json'}),
        body: addressToJsonString(payment.shippingAddress),
      })
      .then(function(options) {
        if (options.ok) {
          return options.json();
        }
        cannotShip('Unable to calculate shipping options.', details,
            resolve);
      })
      .then(function(optionsJson) {
        if (optionsJson.status === 'success') {
          canShip(details, optionsJson.shippingOptions, resolve);
        } else {
          cannotShip('Unable to calculate shipping options.', details,
              resolve);
        }
      })
      .catch(function(error) {
        cannotShip('Unable to calculate shipping options. ' + error, details,
            resolve);
      });
    }));
  });

  payment.addEventListener('shippingoptionchange', function(evt) {
    evt.updateWith(new Promise(function(resolve) {
      for (let i in details.shippingOptions) {
        if ({}.hasOwnProperty.call(details.shippingOptions, i)) {
          details.shippingOptions[i].selected =
              (details.shippingOptions[i].id === payment.shippingOption);
        }
      }

      canShip(details, details.shippingOptions, resolve);
    }));
  });

  let paymentTimeout = window.setTimeout(function() {
    window.clearTimeout(paymentTimeout);
    payment.abort().then(function() {
      print('Payment timed out after 20 minutes.');
    }).catch(function() {
      print('Unable to abort, because the user is currently in the process ' +
          'of paying.');
    });
  }, 20 * 60 * 1000);  /* 20 minutes */

  payment.show()
  .then(function(instrument) {
    window.clearTimeout(paymentTimeout);

    if (instrument.methodName !== 'https://android.com/pay') {
      simulateCreditCardProcessing(instrument);
      return;
    }

    let instrumentObject = instrumentToDictionary(instrument);
    instrumentObject.total = details.total;
    let instrumentString = JSON.stringify(instrumentObject, undefined, 2);
    fetch('/buy', {
      method: 'POST',
      headers: new Headers({'Content-Type': 'application/json'}),
      body: instrumentString,
    })
    .then(function(buyResult) {
      if (buyResult.ok) {
        return buyResult.json();
      }
      complete(instrument, 'fail', 'Error sending instrument to server.');
    }).then(function(buyResultJson) {
      print(instrumentString);
      complete(instrument, buyResultJson.status, buyResultJson.message);
    });
  })
  .catch(function(error) {
    print('Could not charge user. ' + error);
  });
}

Server side code

package.json

{
  "name": "web-payments-demo",
  "description": "A web payments demo",
  "version": "1.0.0",
  "author": "Rouslan Solomakhin",
  "engines": {
    "node": ">=0.10.25"
  },
  "license": "Apache Version 2.0",
  "scripts": {
    "start": "node app.js",
    "monitor": "nodemon app.js",
    "deploy": "gcloud app deploy app.yaml"
  },
  "dependencies": {
    "body-parser": "^1.15.1",
    "express": "^4.13.4",
    "shippo": "^1.1.3",
    "stripe": "^4.6.0"
  }
}

config.js

module.exports = {
  stripeKey: '<PUT_YOUR_STRIPE_SECRET_KEY_HERE>',
  shippoKey: '<PUT_YOUR_SHIPPO_SECRET_KEY_HERE>',
};

app.js

'use strict';

let express = require('express');
let bodyParser = require('body-parser');
let config = require('./config');
let shippo = require('shippo')(config.shippoKey);
let stripe = require('stripe')(config.stripeKey);

let app = express();
app.use(bodyParser.json());
app.use(express.static('public'));

/**
 * Calculates the shipping price.
 */
app.post('/ship', function(req, res) {
  let addressLines = req.body.addressLine;
  if (req.body.dependentLocality.length > 0) {
    addressLines.push(req.body.dependentLocality);
  }

  let postalCode = req.body.postalCode;
  if (req.body.sortingCode.length > 0) {
    if (postalCode.length > 0) {
      addressLines.push(req.body.sortingCode);
    } else {
      postalCode = req.body.sortingCode;
    }
  }

  let street1 = '';
  if (addressLines.length > 0) {
    street1 = addressLines[0];
  }

  let street2 = '';
  if (addressLines.length > 1) {
    street2 = addressLines.slice(1).join(', ');
  }

  let shipment = {
    object_purpose: 'PURCHASE',
    address_from: {
      object_purpose: 'PURCHASE',
      name: 'Rouslan Solomakhin',
      company: 'Google',
      street1: '340 Main St',
      street2: '',
      city: 'Los Angeles',
      state: 'CA',
      zip: '90291',
      country: 'US',
      phone: '310-310-6000',
      email: 'test.source@test.com',
    },
    address_to: {
      object_purpose: 'PURCHASE',
      name: req.body.recipient,
      company: req.body.organization,
      street1: street1,
      street2: street2,
      city: req.body.city,
      state: req.body.region,
      zip: postalCode,
      country: req.body.country,
      phone: req.body.phone,
      email: 'test.destination@test.com',
    },
    parcel: {
      length: '5',
      width: '5',
      height: '5',
      distance_unit: 'in',
      weight: '2',
      mass_unit: 'lb',
    },
    async: false,
  };

  shippo.shipment.create(shipment, function(err, shipment) {
    let result = {};
    if (err) {
      console.log(err);
      result.status = 'fail';
      result.message = 'Error calculating shipping options';
      res.status(200).send(JSON.stringify(result));
      return;
    }

    result.status = 'success';
    result.message = 'Calculated shipping options';
    result.shippingOptions = [];

    let minAmount = -1;
    let minIndex = -1;
    for (let i in shipment.rates_list) {
      if ({}.hasOwnProperty.call(shipment.rates_list, i)) {
        let rate = shipment.rates_list[i];

        let amountNumber = Number(rate.amount);
        if (minAmount === -1 || minAmount > amountNumber) {
          minAmount = amountNumber;
          minIndex = i;
        }

        let option = {
          id: rate.object_id,
          label: rate.provider + ' ' + rate.servicelevel_name,
          amount: {currency: rate.currency, value: rate.amount},
          selected: false,
        };

        result.shippingOptions.push(option);
      }
    }

    if (minIndex !== -1) {
      result.shippingOptions[minIndex].selected = true;
    }

    res.status(200).send(JSON.stringify(result));
  });
});

/**
 * Authorizes and Android Pay token for USD $0.50 via Stripe.
 */
app.post('/buy', function(req, res) {
  const errorString = JSON.stringify({
    status: 'fail',
    message: 'Invalid request',
  });

  if (!req.body
      || !req.body.methodName
      || req.body.methodName !== 'https://android.com/pay'
      || !req.body.details
      || !req.body.details.paymentMethodToken
      || !JSON.parse(req.body.details.paymentMethodToken).id) {
    res.status(200).send(errorString);
    return;
  }

  stripe.charges.create({
    amount: 50,
    currency: 'usd',
    source: JSON.parse(req.body.details.paymentMethodToken).id,
    description: 'Web payments demo',
    capture: false,
  }, function(err, charge) {
    if (err) {
      res.status(200).send(errorString);
    } else {
      res.status(200).send(JSON.stringify({
        status: 'success',
        message: 'Payment authorized',
      }));
    }
  });
});

/**
 * Starts the server.
 */
if (module === require.main) {
  let server = app.listen(process.env.PORT || 8080, function() {
    console.log('App listening on port %s', server.address().port);
  });
}

module.exports = app;

app.yaml

runtime: nodejs
vm: true

handlers:
- url: /.*
  script: IGNORED
  secure: always

skip_files:
- ^node_modules$