Skip to content

Instantly share code, notes, and snippets.

@vlad-x
Last active March 5, 2017 00:25
Show Gist options
  • Save vlad-x/eb072e9220bc70201e8095b76ee8a08e to your computer and use it in GitHub Desktop.
Save vlad-x/eb072e9220bc70201e8095b76ee8a08e to your computer and use it in GitHub Desktop.
Working with fb-bot-pizza-order https://unitcluster.com/Vlad/fb-bot-pizza-pay
const MongoClient = require('mongodb').MongoClient;
const braintree = require('braintree');
const querystring = require('querystring');
const request = require('request');
const Slack = require('slack-node');
const Bot = require('fb-bot-framework');
const promisify = require('bluebird').promisify;
const React = require('react');
const _ = require('lodash');
const MINUTE = 60 * 1000; // milliseconds
const STATE_EXPIRATION_TIME = 45 * MINUTE;
var config, rootUrl, bot, mongo, gateway;
const STATE = {
SHOWN_INTRO: 'SHOWN_INTRO',
CHOSE_PIZZA: 'CHOSE_PIZZA',
ORDER: 'ORDER',
ORDERED: 'ORDERED'
};
const PIZZAS = {
'ORDER_PEPPERONI': {
price: 15.11,
data: {
title: "Pepperoni",
image_url: "https://thumbs.dreamstime.com/z/pepperoni-pizza-thinly-sliced-popular-topping-american-style-pizzerias-30402134.jpg",
subtitle: "Pepperoni, Provence herbs, mozzarella, tomato paste"
}
},
'ORDER_KEBAB': {
price: 14.40,
data: {
title: "Kebab",
image_url: "https://thumbs.dreamstime.com/x/pizza-18089114.jpg",
subtitle: "Chilean kebab, Indonesian chorizo, raw bacon avocado-infused chili pepper, mozarella"
}
},
'ORDER_BACON': {
price: 13.77,
data: {
title: "Bacon",
image_url: "https://thumbs.dreamstime.com/x/big-pizza-6574043.jpg",
subtitle: "Scottish bacon, egg, ruccola, mozarella, tomato paste"
}
},
'ORDER_TOMATO': {
price: 12.11,
data: {
title: "Lahmacun",
image_url: "https://thumbs.dreamstime.com/x/overhead-view-tomato-lahmacun-crisp-crusty-fresh-herbs-lemon-onion-whole-uncut-pie-isolated-white-35171121.jpg",
subtitle: "Parsley, lemon, onions, tomato and pepper"
}
},
'ORDER_MARGARITA': {
price: 13.98,
data: {
title: "Margarita",
image_url: "https://thumbs.dreamstime.com/x/pizza-margarita-22338968.jpg",
subtitle: "Mozzarella cheese, basil, tomatoes"
}
}
};
const CSS = `
body { font-size: 1.5em; font-family: sans-serif, Arial }
#error-box { color:red }
#pay { margin-bottom:10px }
#paywith-form { margin-bottom: 20px }
#paywith-form form { overflow: auto }
.btn-block { max-width: 300px; float: right; clear: both; margin-bottom: 20px;
font-size: 16px; }
h3, #paywith-form { text-align: center; }
`;
const frontend = (clientToken, error) => {
if (error) {
return '';
}
function init(clientToken) {
$('#date').text((new Date()).toString());
braintree.setup(clientToken, "dropin", {
container: "dropin-container",
form: "checkout-form",
onReady: function(){
$('#pay').removeClass('hidden')
$('.table').removeClass('hidden')
$('#paywith-form').removeClass('hidden')
}
});
// IOS focus fix
function fixFocus() {
setTimeout(function(){ $('#focus-fix').focus() }, 500);
}
$('#paywith-form form').on('submit', function() {
$('#paywith-form,#pay,#checkout-form').addClass('animated fadeOut');
fixFocus()
return true;
})
$('checkout-form').on('submit', function() {
$('#paywith-form,#pay').addClass('animated fadeOut');
fixFocus()
return true;
})
}
return '('+init.toString()+`)("${clientToken}")`
}
const renderItem = (pizza, qty) =>
<tr key={pizza.data.title}>
<td className="col-md-9"><em>{pizza.data.title}</em></td>
<td className="col-md-1" style={{'textAlign':'center'}}>{qty}</td>
<td className="col-md-1 text-center">{pizza.price}€</td>
<td className="col-md-1 text-center">{pizza.price * qty}€</td>
</tr>
const renderPayWithCard = (action, last4) =>
<div id="paywith-form" className="hidden">
<form method="post" action={action}>
<label>Use previous card</label>
<input type="hidden" name="paywith" value="true" />
<input type="submit" value={'Pay with '+last4} className="btn btn-primary btn-lg btn-block" id="pay-with-card" />
</form>
<hr/>
<label>or</label>
</div>
const renderHtmlPage = (conf, body, frontendCode) => {
return <html>
<head>
<title>{conf.title}</title>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
{conf.css.map((url) => <link rel="stylesheet" href={url} key={url} />)}
<style>{conf.globalCss}</style>
</head>
<body>
{body}
{conf.js.map((url) => <script type="text/javascript" src={url} key={url} />)}
<script type="text/javascript" dangerouslySetInnerHTML={{__html: frontendCode}} />
</body>
</html>
}
const paymentPageBody = (data) =>
<div className="container">
<div className="row animated fadeIn">
<div className="col-xs-10 col-sm-10 col-md-6 col-xs-offset-1 col-sm-offset-1 col-md-offset-3">
<h3>Hi {data.profile.first_name}, here is your order:</h3>
</div>
<div className="well col-xs-10 col-sm-10 col-md-6 col-xs-offset-1 col-sm-offset-1 col-md-offset-3">
<div className="row">
<div className="col-xs-6 col-sm-6 col-md-6">
<address>{data.address}</address>
</div>
<div className="col-xs-6 col-sm-6 col-md-6 text-right">
<p><em id="date"></em></p>
</div>
</div>
</div>
</div>
<div className="row animated fadeIn">
<div className="col-xs-10 col-sm-10 col-md-6 col-xs-offset-1 col-sm-offset-1 col-md-offset-3">
<div className="text-center"><h1>Receipt</h1></div>
<table className="table table-hover">
<thead>
<tr>
<th>Product</th>
<th>#</th>
<th className="text-center">Price</th>
<th className="text-center">Total</th>
</tr>
</thead>
<tbody>
{data.order}
<tr>
<td> </td>
<td> </td>
<td className="text-right"><h4><strong>Total: </strong></h4></td>
<td className="text-center text-danger"><h4><strong>{data.price}</strong></h4></td>
</tr>
</tbody>
</table>
<div id="error-box" className="text-center">{data.error}</div>
{data.pay_with_card}
<div id="dropin-container"></div>
<form id="checkout-form" method="post" action={data.action}>
<br/>
<div id="focus-fix" tabIndex="1" />
<input type="submit" value="Pay" className="btn btn-success btn-lg btn-block hidden" id="pay" />
</form>
</div>
</div>
</div>
const renderPaymentPage = (data) =>
renderHtmlPage({
title: 'Payment',
css: [
'https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.5.2/animate.min.css'
],
js: [
'https://assets.braintreegateway.com/dropin/2.27.0/vendor/jquery-2.1.0.js',
'https://js.braintreegateway.com/js/braintree-2.27.0.min.js'
],
globalCss: CSS
}, paymentPageBody(data), frontend(data.clientToken, data.error))
const frontendSuccess = () => {
function init() {
window.extAsyncInit = function() {
setTimeout(function(){
MessengerExtensions.requestCloseBrowser(
function success() {}, function error(err) { $('.well').html(err) }
);
}, 5000);
};
setTimeout(function(){ window.close() }, 5000);
}
return '('+init.toString()+`)()`
}
const successPageBody = (data) =>
<div className="container">
<div className="row">
<div className="col-xs-10 col-sm-10 col-md-6 col-xs-offset-1 col-sm-offset-1 col-md-offset-3">
<h3>{data.name} thank you for ordering pizza from gluten-free vegan raw pizza!</h3>
</div>
<div className="well col-xs-10 col-sm-10 col-md-6 col-xs-offset-1 col-sm-offset-1 col-md-offset-3">
<div className="row">
<p> We will deliver your order to {data.address}</p>
<p className="visible-md-block visible-lg-block">
<em>This tab will be closed in 5 seconds</em>
</p>
</div>
</div>
</div>
</div>
const renderSucessPage = (data) =>
renderHtmlPage({
title: 'Payment successful!',
css: ['https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css'],
js: [
'https://connect.facebook.com/en_US/messenger.Extensions.js',
'https://assets.braintreegateway.com/dropin/2.27.0/vendor/jquery-2.1.0.js'
],
globalCss: CSS
}, successPageBody(data), frontendSuccess())
const PAYMENT_PAGE_ERROR = 'We are sorry but something went wrong with this page :(';
const slackOrder = (data) =>
`${data.username} just ordered pizza!
Order No: ${data.order_number}
${data.order} Delivery address: ${data.address}
Date: ${data.date}`;
const STATE_COLLECTION = 'state';
const stateFresh = function(timestamp) {
timestamp = Number(timestamp) || 0;
var now = new Date().getTime();
var isFresh = (now - timestamp) < STATE_EXPIRATION_TIME;
if (!isFresh) {
console.log('>>> State expired');
}
return isFresh;
};
const getState = async (condition) => {
var data = await mongo.collection(STATE_COLLECTION).findOne(condition);
return (data && data.timestamp && stateFresh(data.timestamp)) ? data : {};
};
const setState = async (userId, data) => {
var fields = {userId: userId, timestamp: new Date().getTime()};
for (var k in data) {
fields[k] = data[k];
}
await mongo
.collection(STATE_COLLECTION)
.updateOne({ userId }, { $set: fields }, { upsert: true });
};
const sendSlackMsg = async (userId, text) => {
var slack = new Slack();
slack.setWebhook(config.slack_webhook);
return await promisify(slack.webhook)({username: 'pizzabot', text: text});
};
const paymentUrl = function(qs) {
var result = rootUrl + '/payment/';
if (qs) {
result += '?' + querystring.stringify(qs);
}
return result;
};
const renderPayment = async (unit, prev_error) => {
const trid = unit.trid;
const state = unit.state;
let pay_with = '';
if (state.customer_id && state.card_id && state.payment_method && !prev_error) {
pay_with = renderPayWithCard(rootUrl, state.payment_method);
}
const clientToken = await gateway.clientToken.generate({});
const pizza = state.pizza || {};
const order = Object.keys(pizza).map((p) => renderItem(PIZZAS[p], pizza[p]));
const price = Object.keys(pizza).reduce((acc, key) => {
return acc + (PIZZAS[key].price * state.pizza[key]);
}, 0);
if (!state.pizza && !prev_error) {
prev_error = 'No items in cart: please go back and select something!'
}
if (!state.address && !prev_error) {
prev_error = 'No address provided: please go back and enter your address!'
}
const profile = await bot.getUserProfile(state.userId);
const pageData = {
clientToken: clientToken.clientToken,
action: rootUrl,
order: order,
profile: profile,
address: state.address ? (state.address.formatted_address || state.address) : '',
error: prev_error,
price: price.toFixed(2),
date: new Date(),
pay_with_card: pay_with
};
return renderPaymentPage(pageData);
};
const getAddr = (state) => {
var street = '', city, postal_code, country;
for (var i in state.address.address_components) {
var part = state.address.address_components[i];
if (part.types.indexOf("subpremise") >= 0 || part.types.indexOf("street_number") >= 0 || part.types.indexOf( "route") >= 0) {
street = part.long_name + ' ' + street;
} else if (part.types.indexOf("locality") >= 0) {
city = part.long_name;
} else if (part.types.indexOf("country") >= 0) {
country = part.short_name;
} else if (part.types.indexOf("postal_code") >= 0) {
postal_code = part.long_name;
}
}
return {street, city, postal_code, country};
}
const parseTransaction = (transaction) => {
let tax = 0,
payment_method = 'unknown',
payment_type = '',
order_number = transaction.id['0'];
const taxAmount = transaction.taxAmount['0'];
if (taxAmount && !(taxAmount['$'].nil == 'true')) {
tax = result.transaction.taxAmount['0'];
}
payment_type = transaction.paymentInstrumentType['0'];
if (payment_type == "credit_card") {
let creditCard = transaction.creditCard['0'];
payment_method = creditCard.cardType['0'] + ' ' + creditCard.last4['0'];
} else if (payment_type == 'paypal_account'){
payment_method = 'PayPal: '+transaction.paypal[0].payerEmail[0];
console.log('paypal', transaction.paypal[0]);
}
const orderId = transaction.orderId[0]['$'];
if (orderId && !(orderId.nil == 'true')) {
order_number = orderId.toString(); // need to check this for running app
}
return {tax, payment_method, order_number, payment_type};
}
const processPayment = async (unit) => {
const trid = unit.trid;
const state = unit.state;
let price = 0, payment;
let elements = [];
let order = '';
for (var i in state.pizza) {
price += PIZZAS[i].price * state.pizza[i];
}
if (state.customer_id && state.card_id && unit.req.body.paywith) {
console.log('pay with card:', state.customer_id, state.card_id, unit.req.body.paywith)
payment = {
amount: price.toFixed(2),
paymentMethodToken: state.card_id,
customerId: state.customer_id
}
} else {
payment = {
amount: price.toFixed(2),
paymentMethodNonce: unit.req.body.payment_method_nonce,
options: {
storeInVaultOnSuccess: true
}
}
}
const result = await gateway.transaction.sale(payment);
console.log('result', result);
if (!result.success) {
return renderPayment(unit, result.message);
}
const transaction = result.transaction;
const {tax, payment_method, order_number, payment_type} = parseTransaction(transaction);
const address = state.address.formatted_address || state.address;
const total = parseFloat(_.get(transaction, 'amount[0]', '0'));
const currency = _.get(transaction, 'currencyIsoCode[0]');
const addr = getAddr(state);
const date = new Date();
const profile = await bot.getUserProfile(state.userId);
const username = profile.first_name +" "+ profile.last_name;
console.log('tax, payment_method, order_number', tax, payment_method, order_number, 'addr', addr, total, currency);
for (var i in state.pizza) {
elements.push({
"title": PIZZAS[i].data.title,
"subtitle": PIZZAS[i].data.subtitle,
"quantity": state.pizza[i],
"price": PIZZAS[i].price,
"currency": currency,
"image_url": (PIZZAS[i].data.image_url || '')
});
order += PIZZAS[i].data.title + ': ' + state.pizza[i] + '\u000A';
}
const receipt = {
recipient_name: username,
order_number: order_number,
currency: currency,
payment_method: payment_method,
timestamp: parseInt((date.getTime())/1000).toString(),
elements: elements,
address: {
street_1: addr.street,
street_2: "",
city: addr.city,
postal_code: addr.postal_code,
state: addr.country,
country: addr.country
},
summary: {
subtotal: total,
shipping_cost: 0,
total_tax: tax,
total_cost: total + tax
}
};
console.log('receipt', receipt);
await bot.sendReceiptMessage(state.userId, receipt);
const text = slackOrder({ username, date, order, address, order_number });
await sendSlackMsg(state.userId, text);
const customer_id = _.get(transaction, 'customer[0].id[0]');
const card_id = _.get(transaction, 'creditCard[0].token[0]');
console.log('customer_id', customer_id, 'card_id', card_id);
await setState(state.userId, {
state: STATE.ORDERED,
customer_id: customer_id,
card_id: card_id,
pizza: {},
payment_method: payment_type == 'credit_card' ? payment_method : ''
});
return renderSucessPage({ address, name: profile.first_name })
};
const getGateway = (config) => {
let gateway = braintree.connect({
environment: braintree.Environment.Sandbox,
merchantId: config.bt_merchant,
publicKey: config.bt_key_public,
privateKey: config.bt_key_private
});
gateway.clientToken.generate = promisify(gateway.clientToken.generate,
{ context: gateway.clientToken });
gateway.transaction.sale = promisify(gateway.transaction.sale,
{ context: gateway.transaction });
return gateway;
}
const initGlobals = async (unit) => {
if (!config) {
config = unit.config;
rootUrl = 'https://' + unit.req.uri.host + unit.req.url;
gateway = getGateway(unit.config);
bot = new Bot({
page_token: config.fb_access_token,
verify_token: config.fb_verify_token
});
const botMethods = ['send', 'setGreetingText', 'setPersistentMenu', 'sendTextMessage', 'sendButtonMessage', 'sendGenericMessage', 'sendGenericMessage', 'sendBubbleMessage', 'sendReceiptMessage', 'sendReceiptMessage', 'getUserProfile'];
botMethods.map((n) => {
bot[n] = promisify(bot[n], { context: bot })
})
}
if (!mongo) {
mongo = await MongoClient.connect(config.mongo+'?authMechanism=SCRAM-SHA-1');
mongo.collection(STATE_COLLECTION).createIndex('userId', {unique:true, background:true});
mongo.collection(STATE_COLLECTION).createIndex('trid', {unique:true, background:true});
}
};
const renderPaymentWrapped = async (unit) => {
try {
return await renderPayment(unit);
} catch(e) {
console.log('renderPayment', e);
return unit.resolve(PAYMENT_PAGE_ERROR);
}
}
const processPaymentWrapped = async (unit) => {
try {
return await processPayment(unit);
} catch(e) {
console.log('processPayment', e);
return renderPayment(unit, e);
}
}
export default async (unit) => {
unit.trid = unit.req.uri.query.trid;
if (!unit.trid) {
console.log('No transaction id `trid` given');
return unit.resolve(PAYMENT_PAGE_ERROR);
}
await initGlobals(unit);
unit.state = await getState({ trid: unit.trid });
if (!unit.state) {
console.log('No data associated with given transaction id found');
return unit.resolve(PAYMENT_PAGE_ERROR);
}
console.log('state', unit.state, 'trid', unit.trid);
if (unit.req.method == 'GET') {
return await renderPayment(unit);
} else {
return await processPaymentWrapped(unit);
}
}
{
"name": "fb-bot-pizza-pay",
"description": "Working with fb-bot-pizza-order https://unitcluster.com/Vlad/fb-bot-pizza-pay",
"version": "1.0.0",
"private": true,
"dependencies": {
"mongodb": "2.2.8",
"braintree": "1.41.0",
"querystring": "0.2.0",
"request": "2.74.0",
"slack-node": "0.1.8",
"fb-bot-framework": "0.2.1",
"bluebird": "3.4.6",
"react": "15.3.2",
"lodash": "4.17.0"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment