├── .prettierrc.yaml ├── public ├── images │ ├── logo.png │ ├── products │ │ ├── pins.png │ │ ├── shirt.png │ │ └── increment.png │ ├── screenshots │ │ ├── demo-chrome.png │ │ ├── demo-iphone.png │ │ ├── demo-wechat.png │ │ ├── demo-payment-request.png │ │ └── stripe-payments-settings.png │ ├── dropdown.svg │ ├── tip.svg │ ├── github.svg │ ├── error.svg │ ├── logo.svg │ ├── order.svg │ └── flags.svg ├── javascripts │ ├── store.js │ └── payments.js ├── .well-known │ └── apple-developer-merchantid-domain-association ├── index.html └── stylesheets │ └── store.css ├── functions ├── config.js ├── products │ ├── __notfound__.js │ └── __main__.js ├── orders │ ├── __main__.js │ └── __notfound__.js ├── __main__.js ├── public │ └── __notfound__.js └── webhook.js ├── env.example.json ├── helpers └── fileio.js ├── package.json ├── LICENSE ├── .gitignore ├── config.js ├── stripe ├── inventory.js └── setup.js └── README.md /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | trailingComma: es5 3 | bracketSpacing: false 4 | -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe-archive/stripe-stdlib-demo/HEAD/public/images/logo.png -------------------------------------------------------------------------------- /public/images/products/pins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe-archive/stripe-stdlib-demo/HEAD/public/images/products/pins.png -------------------------------------------------------------------------------- /public/images/products/shirt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe-archive/stripe-stdlib-demo/HEAD/public/images/products/shirt.png -------------------------------------------------------------------------------- /public/images/products/increment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe-archive/stripe-stdlib-demo/HEAD/public/images/products/increment.png -------------------------------------------------------------------------------- /public/images/screenshots/demo-chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe-archive/stripe-stdlib-demo/HEAD/public/images/screenshots/demo-chrome.png -------------------------------------------------------------------------------- /public/images/screenshots/demo-iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe-archive/stripe-stdlib-demo/HEAD/public/images/screenshots/demo-iphone.png -------------------------------------------------------------------------------- /public/images/screenshots/demo-wechat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe-archive/stripe-stdlib-demo/HEAD/public/images/screenshots/demo-wechat.png -------------------------------------------------------------------------------- /public/images/screenshots/demo-payment-request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe-archive/stripe-stdlib-demo/HEAD/public/images/screenshots/demo-payment-request.png -------------------------------------------------------------------------------- /public/images/screenshots/stripe-payments-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe-archive/stripe-stdlib-demo/HEAD/public/images/screenshots/stripe-payments-settings.png -------------------------------------------------------------------------------- /public/images/dropdown.svg: -------------------------------------------------------------------------------- 1 | Arrow -------------------------------------------------------------------------------- /functions/config.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | 3 | /** 4 | * Expose the Stripe publishable key and other pieces of config via an endpoint. 5 | * @returns {object} 6 | */ 7 | module.exports = async () => { 8 | return { 9 | stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY, 10 | stripeCountry: config.stripe.country, 11 | country: config.country, 12 | currency: config.currency, 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /functions/products/__notfound__.js: -------------------------------------------------------------------------------- 1 | const {products} = require('../../stripe/inventory'); 2 | 3 | /** 4 | * Retrieve a product by ID. 5 | * @returns {any} 6 | */ 7 | module.exports = async context => { 8 | if (context.path.length > 2) { 9 | return { 10 | headers: { 11 | 'content-type': 'text/plain', 12 | }, 13 | body: Buffer.from('Not found'), 14 | statusCode: 404, 15 | }; 16 | } 17 | 18 | let id = context.path[context.path.length - 1]; 19 | return products.retrieve(id); 20 | }; 21 | -------------------------------------------------------------------------------- /functions/products/__main__.js: -------------------------------------------------------------------------------- 1 | const {products} = require('../../stripe/inventory'); 2 | const setup = require('../../stripe/setup'); 3 | 4 | /** 5 | * Retrieve all products. 6 | * @returns {object} 7 | */ 8 | module.exports = async () => { 9 | const productList = await products.list(); 10 | 11 | // Check if products exist on Stripe Account. 12 | if (products.exist(productList)) { 13 | return productList; 14 | } 15 | 16 | // We need to set up the products. 17 | await setup.run(); 18 | return products.list(); 19 | }; 20 | -------------------------------------------------------------------------------- /functions/orders/__main__.js: -------------------------------------------------------------------------------- 1 | const {orders} = require('../../stripe/inventory'); 2 | 3 | /** 4 | * Create an order. 5 | * @param {string} currency 6 | * @param {array} items 7 | * @param {string} email 8 | * @param {object} shipping 9 | * @returns {object} 10 | */ 11 | module.exports = async (currency, items, email, shipping, context) => { 12 | let order; 13 | 14 | try { 15 | order = await orders.create(currency, items, email, shipping); 16 | } catch (err) { 17 | return {error: err.message}; 18 | } 19 | 20 | return {order}; 21 | }; 22 | -------------------------------------------------------------------------------- /env.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "local": { 3 | "STRIPE_PUBLISHABLE_KEY": "pk_test_•••", 4 | "STRIPE_SECRET_KEY": "sk_test_•••", 5 | "STRIPE_WEBHOOK_SECRET": "whsec_•••", 6 | "STRIPE_ACCOUNT_COUNTRY": "US" 7 | }, 8 | "dev": { 9 | "STRIPE_PUBLISHABLE_KEY": "pk_test_•••", 10 | "STRIPE_SECRET_KEY": "sk_test_•••", 11 | "STRIPE_WEBHOOK_SECRET": "whsec_•••", 12 | "STRIPE_ACCOUNT_COUNTRY": "US" 13 | }, 14 | "release": { 15 | "STRIPE_PUBLISHABLE_KEY": "pk_live_•••", 16 | "STRIPE_SECRET_KEY": "sk_live_•••", 17 | "STRIPE_WEBHOOK_SECRET": "whsec_•••", 18 | "STRIPE_ACCOUNT_COUNTRY": "US" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /public/images/tip.svg: -------------------------------------------------------------------------------- 1 | Group -------------------------------------------------------------------------------- /functions/__main__.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const {promisify} = require('util'); 3 | 4 | const renderFile = promisify(require('ejs').renderFile); 5 | const templatePath = path.join(__dirname, '/../public/index.html'); 6 | 7 | let app; 8 | 9 | /** 10 | * Render the main index of the single-page app. 11 | * @returns {object.http} 12 | */ 13 | module.exports = async context => { 14 | let servicePath = 15 | context.service.environment === 'local' 16 | ? `/${context.service.identifier}` 17 | .replace('.', '/') 18 | .substr(0, context.service.identifier.indexOf('[') + 1) 19 | : ''; 20 | 21 | app = app || (await renderFile(templatePath, {path: servicePath})); 22 | 23 | return { 24 | headers: { 25 | 'content-type': 'text/html', 26 | }, 27 | body: app, 28 | statusCode: 200, 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /helpers/fileio.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | /** 5 | * Read files in a directory, for mapping /public files to the public. 6 | */ 7 | module.exports = { 8 | readFiles: function(base, dir, files) { 9 | dir = dir || ''; 10 | files = files || {}; 11 | let pathname = path.join(base, dir); 12 | let dirList = fs.readdirSync(pathname); 13 | 14 | for (let i = 0; i < dirList.length; i++) { 15 | let dirpath = path.join(dir, dirList[i]); 16 | let dirname = dirpath.split(path.sep).join('/'); 17 | let fullpath = path.join(pathname, dirList[i]); 18 | if (fs.lstatSync(fullpath).isDirectory()) { 19 | this.readFiles(base, dirpath, files); 20 | } else { 21 | files[dirname] = fs.readFileSync(fullpath); 22 | } 23 | } 24 | 25 | return files; 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stripe-stdlib-demo", 3 | "description": 4 | "Sample store accepting universal payments built with Stripe and StdLib", 5 | "version": "0.0.1", 6 | "private": true, 7 | "author": "Romain Huet ", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/stripe/stripe-stdlib-demo.git" 12 | }, 13 | "main": "functions/__main__.js", 14 | "dependencies": { 15 | "ejs": "^2.6.1", 16 | "mime": "^1.3.6", 17 | "stripe": "^4.18.0" 18 | }, 19 | "stdlib": { 20 | "build": "faaslang", 21 | "name": "romainhuet/stripe-stdlib-demo", 22 | "timeout": 10000, 23 | "publish": true, 24 | "personalize": { 25 | "keys": [], 26 | "user": [] 27 | } 28 | }, 29 | "devDependencies": { 30 | "standard": "^10.0.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /public/images/github.svg: -------------------------------------------------------------------------------- 1 | GitHub -------------------------------------------------------------------------------- /public/images/error.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Stripe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # StdLib 2 | env.json 3 | 4 | # macOS 5 | .DS_Store 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (http://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Typescript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | -------------------------------------------------------------------------------- /functions/public/__notfound__.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const mime = require('mime'); 3 | 4 | const fileio = require('../../helpers/fileio'); 5 | 6 | let filepath = './public'; 7 | let staticFiles = fileio.readFiles(filepath); 8 | 9 | /** 10 | * This endpoint handles all routes to `/public` over HTTP, 11 | * and maps them to the `./public` service folder. 12 | * @returns {object.http} 13 | */ 14 | module.exports = async context => { 15 | // Hot reload for local development. 16 | if (context.service && context.service.environment === 'local') { 17 | staticFiles = fileio.readFiles(filepath); 18 | } 19 | 20 | let staticFilepath = path.join(...context.path.slice(1)); 21 | if (!staticFiles[staticFilepath]) { 22 | return { 23 | headers: { 24 | 'Content-Type': 'text/plain', 25 | }, 26 | body: Buffer.from('404 - Not Found'), 27 | statusCode: 404, 28 | }; 29 | } 30 | 31 | let cacheControl = 32 | context.service.environment === 'release' 33 | ? 'max-age=31536000' 34 | : 'max-age=0'; 35 | 36 | return { 37 | headers: { 38 | 'content-type': mime.lookup(staticFilepath), 39 | 'Cache-Control': cacheControl, 40 | }, 41 | body: staticFiles[staticFilepath], 42 | statusCode: 200, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /public/images/logo.svg: -------------------------------------------------------------------------------- 1 | Logo -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * config.js 3 | * Stripe Payments Demo. Created by Romain Huet (@romainhuet). 4 | */ 5 | 6 | module.exports = { 7 | // Default country for the checkout form. 8 | country: 'US', 9 | 10 | // Store currency. 11 | // Note: A few payment methods like iDEAL or SOFORT only work with euros, 12 | // so it's a good common denominator to test both Elements and Sources. 13 | currency: 'eur', 14 | 15 | // Configuration for Stripe. 16 | // API Keys: https://dashboard.stripe.com/account/apikeys 17 | // Webhooks: https://dashboard.stripe.com/account/webhooks 18 | // Storing these keys and secrets as environment variables is a good practice. 19 | // You can fill them in your own `.env` file. 20 | stripe: { 21 | // The two-letter country code of your Stripe account (required for Payment Request). 22 | country: process.env.STRIPE_ACCOUNT_COUNTRY || 'US', 23 | // API version to set for this app (Stripe otherwise uses your default account version). 24 | apiVersion: '2018-02-06', 25 | // Use your test keys for development and live keys for real charges in production. 26 | // For non-card payments like iDEAL, live keys will redirect to real banking sites. 27 | publishableKey: process.env.STRIPE_PUBLISHABLE_KEY, 28 | secretKey: process.env.STRIPE_SECRET_KEY, 29 | // Setting the webhook secret is good practice in order to verify signatures. 30 | // After creating a webhook, click to reveal details and find your signing secret. 31 | webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, 32 | }, 33 | 34 | // Server port. 35 | port: process.env.PORT || 8000, 36 | }; 37 | -------------------------------------------------------------------------------- /stripe/inventory.js: -------------------------------------------------------------------------------- 1 | /** 2 | * inventory.js 3 | * Stripe Payments Demo. Created by Romain Huet (@romainhuet). 4 | * 5 | * Simple library to store and interact with orders and products. 6 | * These methods are using the Stripe Orders API, but we tried to abstract them 7 | * from the main code if you'd like to use your own order management system instead. 8 | */ 9 | 10 | const config = require('../config'); 11 | const stripe = require('stripe')(config.stripe.secretKey); 12 | stripe.setApiVersion(config.stripe.apiVersion); 13 | 14 | // Create an order. 15 | const createOrder = async (currency, items, email, shipping) => { 16 | return stripe.orders.create({ 17 | currency, 18 | items, 19 | email, 20 | shipping, 21 | metadata: { 22 | status: 'created', 23 | }, 24 | }); 25 | }; 26 | 27 | // Retrieve an order by ID. 28 | const retrieveOrder = async orderId => { 29 | return stripe.orders.retrieve(orderId); 30 | }; 31 | 32 | // Update an order. 33 | const updateOrder = async (orderId, properties) => { 34 | return stripe.orders.update(orderId, properties); 35 | }; 36 | 37 | // List all products. 38 | const listProducts = async () => { 39 | return stripe.products.list({limit: 3}); 40 | }; 41 | 42 | // Retrieve a product by ID. 43 | const retrieveProduct = async productId => { 44 | return stripe.products.retrieve(productId); 45 | }; 46 | 47 | // Validate that products exist. 48 | const productsExist = productList => { 49 | const validProducts = ['increment', 'shirt', 'pins']; 50 | return productList.data.reduce((accumulator, currentValue) => { 51 | return ( 52 | accumulator && 53 | productList.data.length === 3 && 54 | validProducts.includes(currentValue.id) 55 | ); 56 | }, !!productList.data.length); 57 | }; 58 | 59 | exports.orders = { 60 | create: createOrder, 61 | retrieve: retrieveOrder, 62 | update: updateOrder, 63 | }; 64 | 65 | exports.products = { 66 | list: listProducts, 67 | retrieve: retrieveProduct, 68 | exist: productsExist, 69 | }; 70 | -------------------------------------------------------------------------------- /stripe/setup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * setup.js 3 | * Stripe Payments Demo. Created by Romain Huet (@romainhuet). 4 | * 5 | * This is a one-time setup script for your server. It creates a set of fixtures, 6 | * namely products and SKUs, that can then used to create orders when completing the 7 | * checkout flow in the web interface. 8 | */ 9 | 10 | const config = require('../config'); 11 | const stripe = require('stripe')(config.stripe.secretKey); 12 | stripe.setApiVersion(config.stripe.apiVersion); 13 | 14 | module.exports = { 15 | running: false, 16 | run: async () => { 17 | if (this.running) { 18 | console.log('⚠️ Setup already in progress.'); 19 | } else { 20 | this.running = true; 21 | this.promise = new Promise(async resolve => { 22 | // Create a few products and SKUs assuming they don't already exist. 23 | try { 24 | // Increment Magazine. 25 | await stripe.products.create({ 26 | id: 'increment', 27 | name: 'Increment Magazine', 28 | attributes: ['issue'], 29 | }); 30 | await stripe.skus.create({ 31 | id: 'increment-03', 32 | product: 'increment', 33 | attributes: {issue: 'Issue #3 “Development”'}, 34 | price: 399, 35 | currency: config.currency, 36 | inventory: {type: 'infinite'}, 37 | }); 38 | 39 | // Stripe Shirt. 40 | await stripe.products.create({ 41 | id: 'shirt', 42 | name: 'Stripe Shirt', 43 | attributes: ['size', 'gender'], 44 | }); 45 | await stripe.skus.create({ 46 | id: 'shirt-small-woman', 47 | product: 'shirt', 48 | attributes: {size: 'Small Standard', gender: 'Woman'}, 49 | price: 999, 50 | currency: config.currency, 51 | inventory: {type: 'infinite'}, 52 | }); 53 | 54 | // Stripe Pins. 55 | await stripe.products.create({ 56 | id: 'pins', 57 | name: 'Stripe Pins', 58 | attributes: ['set'], 59 | }); 60 | await stripe.skus.create({ 61 | id: 'pins-collector', 62 | product: 'pins', 63 | attributes: {set: 'Collector Set'}, 64 | price: 499, 65 | currency: config.currency, 66 | inventory: {type: 'finite', quantity: 500}, 67 | }); 68 | console.log('Setup complete.'); 69 | resolve(); 70 | this.running = false; 71 | } catch (err) { 72 | if (err.message === 'Product already exists.') { 73 | console.log('⚠️ Products have already been registered.'); 74 | console.log('Delete them from your Dashboard to run this setup.'); 75 | } else { 76 | console.log('⚠️ An error occurred.', err); 77 | } 78 | } 79 | }); 80 | } 81 | return this.promise; 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /functions/orders/__notfound__.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config'); 2 | const {orders} = require('../../stripe/inventory'); 3 | const stripe = require('stripe')(config.stripe.secretKey); 4 | stripe.setApiVersion(config.stripe.apiVersion); 5 | 6 | /** 7 | * Handler for the routes /orders/{id}/ and orders/{id}/pay/. 8 | * @param {string} type 9 | * @param {boolean} livemode 10 | * @param {string} status 11 | * @param {string} id 12 | * @returns {object} 13 | */ 14 | module.exports = async ( 15 | type = null, 16 | livemode = null, 17 | status = null, 18 | id = null, 19 | context 20 | ) => { 21 | let orderId = context.path[1]; 22 | let order = await orders.retrieve(orderId); 23 | 24 | let pay = context.path[2]; 25 | if (!pay) { 26 | return order; 27 | } 28 | 29 | let source = JSON.parse(context.http.body); 30 | 31 | if (order.metadata.status === 'pending' || order.metadata.status === 'paid') { 32 | return {order, source}; 33 | } 34 | 35 | // Dynamically evaluate if 3D Secure should be used. 36 | if (source && type === 'card') { 37 | // A 3D Secure source may be created referencing the card source. 38 | source = await dynamic3DS(source, order, context.http.headers); 39 | } 40 | 41 | // Demo: In test mode, replace the source with a test token so charges can work. 42 | if (type === 'card' && !livemode) { 43 | source.id = 'tok_visa'; 44 | } 45 | 46 | // Pay the order using the Stripe source. 47 | if (source && status === 'chargeable') { 48 | let charge, status; 49 | try { 50 | charge = await stripe.charges.create( 51 | { 52 | source: id, 53 | amount: order.amount, 54 | currency: order.currency, 55 | receipt_email: order.email, 56 | }, 57 | { 58 | // Set a unique idempotency key based on the order ID. 59 | // This is to avoid any race conditions with your webhook handler. 60 | idempotency_key: order.id, 61 | } 62 | ); 63 | } catch (err) { 64 | // This is where you handle declines and errors. 65 | // For the demo we simply set to failed. 66 | status = 'failed'; 67 | } 68 | if (charge && charge.status === 'succeeded') { 69 | status = 'paid'; 70 | } else if (charge) { 71 | status = charge.status; 72 | } else { 73 | status = 'failed'; 74 | } 75 | // Update the order with the charge status. 76 | order = await orders.update(order.id, {metadata: {status}}); 77 | } 78 | 79 | return {order, source}; 80 | }; 81 | 82 | // Dynamically create a 3D Secure source. 83 | const dynamic3DS = async (source, order, headers) => { 84 | // Check if 3D Secure is required, or trigger it based on a custom rule (in this case, if the amount is above a threshold). 85 | if (source.card.three_d_secure === 'required' || order.amount > 5000) { 86 | source = await stripe.sources.create({ 87 | amount: order.amount, 88 | currency: order.currency, 89 | type: 'three_d_secure', 90 | three_d_secure: { 91 | card: source.id, 92 | }, 93 | metadata: { 94 | order: order.id, 95 | }, 96 | redirect: { 97 | return_url: headers.origin, 98 | }, 99 | }); 100 | } 101 | return source; 102 | }; 103 | -------------------------------------------------------------------------------- /functions/webhook.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | const {orders} = require('../stripe/inventory'); 3 | const stripe = require('stripe')(config.stripe.secretKey); 4 | stripe.setApiVersion(config.stripe.apiVersion); 5 | 6 | /** 7 | * Webhook handler to process payments for sources asynchronously. 8 | * @returns {any} 9 | */ 10 | module.exports = async context => { 11 | let data = context.params.data; 12 | 13 | if (config.stripe.webhookSecret) { 14 | // Retrieve the event by verifying the signature using the raw body and secret. 15 | let event; 16 | let signature = context.http.headers['stripe-signature']; 17 | try { 18 | event = stripe.webhooks.constructEvent( 19 | context.http.body, 20 | signature, 21 | config.stripe.webhookSecret 22 | ); 23 | } catch (err) { 24 | console.log(`⚠️ Webhook signature verification failed.`); 25 | return { 26 | body: 'Bad request', 27 | statusCode: 400, 28 | }; 29 | } 30 | // Extract the object from the event. 31 | data = event.data; 32 | } 33 | 34 | const object = data.object; 35 | 36 | // Monitor `source.chargeable` events. 37 | if ( 38 | object.object === 'source' && 39 | object.status === 'chargeable' && 40 | object.metadata.order 41 | ) { 42 | const source = object; 43 | console.log(`🔔 Webhook received! The source ${source.id} is chargeable.`); 44 | // Find the corresponding order this source is for by looking in its metadata. 45 | const order = await orders.retrieve(source.metadata.order); 46 | // Verify that this order actually needs to be paid. 47 | if ( 48 | order.metadata.status === 'pending' || 49 | order.metadata.status === 'paid' || 50 | order.metadata.status === 'failed' 51 | ) { 52 | return { 53 | body: 'Bad request', 54 | statusCode: 400, 55 | }; 56 | } 57 | 58 | // Note: We're setting an idempotency key below on the charge creation to 59 | // prevent any race conditions. It's set to the order ID, which protects us from 60 | // 2 different sources becoming `chargeable` simultaneously for the same order ID. 61 | // Depending on your use cases and your idempotency keys, you might need an extra 62 | // lock surrounding your webhook code to prevent other race conditions. 63 | // Read more on Stripe's best practices here for asynchronous charge creation: 64 | // https://stripe.com/docs/sources/best-practices#charge-creation 65 | 66 | // Pay the order using the source we just received. 67 | let charge, status; 68 | try { 69 | charge = await stripe.charges.create( 70 | { 71 | source: source.id, 72 | amount: order.amount, 73 | currency: order.currency, 74 | receipt_email: order.email, 75 | }, 76 | { 77 | // Set a unique idempotency key based on the order ID. 78 | // This is to avoid any race conditions with your webhook handler. 79 | idempotency_key: order.id, 80 | } 81 | ); 82 | } catch (err) { 83 | // This is where you handle declines and errors. 84 | // For the demo, we simply set the status to mark the order as failed. 85 | status = 'failed'; 86 | } 87 | if (charge && charge.status === 'succeeded') { 88 | status = 'paid'; 89 | } else if (charge) { 90 | status = charge.status; 91 | } else { 92 | status = 'failed'; 93 | } 94 | // Update the order status based on the charge status. 95 | await orders.update(order.id, {metadata: {status}}); 96 | } 97 | 98 | if ( 99 | object.object === 'charge' && 100 | object.status === 'succeeded' && 101 | object.source.metadata.order 102 | ) { 103 | const charge = object; 104 | console.log(`🔔 Webhook received! The charge ${charge.id} succeeded.`); 105 | // Find the corresponding order this source is for by looking in its metadata. 106 | const order = await orders.retrieve(charge.source.metadata.order); 107 | // Update the order status to mark it as paid. 108 | await orders.update(order.id, {metadata: {status: 'paid'}}); 109 | } 110 | 111 | // Monitor `source.failed`, `source.canceled`, and `charge.failed` events. 112 | if ( 113 | (object.object === 'source' || object.object === 'charge') && 114 | (object.status === 'failed' || object.status === 'canceled') 115 | ) { 116 | const source = object.source ? object.source : object; 117 | console.log(`🔔 Webhook received! Failure for ${object.id}.`); 118 | if (source.metadata.order) { 119 | // Find the corresponding order this source is for by looking in its metadata. 120 | const order = await orders.retrieve(source.metadata.order); 121 | // Update the order status to mark it as failed. 122 | await orders.update(order.id, {metadata: {status: 'failed'}}); 123 | } 124 | } 125 | 126 | return { 127 | body: '', 128 | statusCode: 200, 129 | }; 130 | }; 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > 2 | > 3 | > This project is deprecated and is no longer being actively maintained. 4 | 5 | # Stripe on StdLib Demo 6 | 7 | Open in Code.xyz 8 | 9 | Welcome to the Stripe on StdLib demo! This repository shows how to build frictionless Stripe integrations on top of StdLib’s serverless platform and their browser-based API development environment, [Code.xyz](https://code.xyz). 10 | 11 | This project extends our universal [Stripe Payments Demo](https://github.com/stripe/stripe-payments-demo), a sample e-commerce store that uses [Stripe Elements](https://stripe.com/docs/elements) and the [Sources API](https://stripe.com/docs/sources) to accept both card payments and additional payment methods on the web. Please refer to the main project to learn more and find backend code in more languages. 12 | 13 | StdLib allows anybody to quickly ship scalable and production-ready APIs. We’ve ported our payments demo from Express to a serverless-based approach on StdLib to serve API endpoints, the Stripe webhook integration, and the single-page app. You can [explore this repository on Code.xyz](https://code.xyz?github=stripe/stripe-stdlib-demo). We hope you enjoy! 14 | 15 | ## Overview 16 | 17 | Demo on Google ChromeDemo on Safari iPhone X 18 | 19 | You can read more about the features from this app on the [main payments repository](https://github.com/stripe/stripe-payments-demo#overview). 20 | 21 | ## Stripe and StdLib Integration 22 | 23 | This section gives you some details about how this Stripe and StdLib integration works. 24 | 25 | ### API Endpoints 26 | 27 | The `functions` directory contains the code for all the API endpoints consumed by the single-page demo. [FaaSlang](https://github.com/faaslang/faaslang#what-is-faaslang) defines semantics and rules for the deployment and execution of these serverless functions. 28 | 29 | The ability to create orders via a POST request on `/orders` is defined in [`functions/orders/__main__.js`](functions/orders/__main__.js), and the ability to list products via a GET request on `/products` is defined in [`functions/products/__main__.js`](functions/products/__main__.js). These functions rely on the [`stripe/inventory.js`](stripe/inventory.js) library which helps with product and order management on Stripe. 30 | 31 | The interesting thing to note is that for API endpoints which contain IDs in their path, such as `POST /orders/{id}/pay` to pay a specific order, the serverless function is defined in [`functions/orders/__notfound__.js`](functions/orders/__notfound__.js), which is called automatically since the endpoint has a variable. The same applies for fetch a specific product. 32 | 33 | The Stripe webhook handler is defined in [`functions/webhook.js`](functions/webhook.js). 34 | 35 | ### Single-Page App and Static Assets 36 | 37 | The frontend code for the demo lives in the `public` directory. In particular, [`public/javascripts/payments.js`](public/javascripts/payments.js) creates the payment experience using Stripe Elements, and [`public/javascripts/store.js`](public/javascripts/store.js) handles the inventory and order management on Stripe. 38 | 39 | The main app template is served by a function, specifically the main function defined in [`functions/__main__.js`](functions/__main__.js). All the static assets (scripts, stylesheets, and images) are also rendered by a function, specifically via the [`functions/public/__notfound__.js`](functions/public/__notfound__.js) function which relies on the [`fileio.js`](helpers/fileio.js) helper to read and map files from the `public` directory. 40 | 41 | ## Getting Started 42 | 43 | You’ll need the following: 44 | 45 | * [Node.js](http://nodejs.org) >= 8.x. 46 | * Modern browser that supports ES6 (Chrome to see the Payment Request, and Safari to see Apple Pay). 47 | * Stripe account to accept payments ([sign up](https://dashboard.stripe.com/register) for free). 48 | * StdLib account to deploy the project ([sign up](https://stdlib.com/) for free). 49 | 50 | In your Stripe Dashboard, you can [enable the payment methods](https://dashboard.stripe.com/payments/settings) you’d like to test with one click. Some payment methods require receiving a real-time webhook notification to complete a charge. This StdLib demo exposes an endpoint for webhooks which you can enter in your webhooks settings on Stripe (it should look like `https://.lib.id/@dev/webhook`). 51 | 52 | ## Deploying to StdLib 53 | 54 | The simplest way to test and deploy this project is to use the [Code.xyz](https://code.xyz/?github=stripe/stripe-stdlib-demo) development environment, but you can also clone this repository locally and use the StdLib command line tools. 55 | 56 | In both cases, you’ll need to copy the file `env.example.json` to `env.json` and fill in your own [Stripe API keys](https://dashboard.stripe.com/account/apikeys) and any other configuration details. 57 | 58 | To install StdLib command line tools, run the following command: 59 | 60 | npm install lib.cli -g 61 | 62 | Login or create a StdLib account from the command line: 63 | 64 | lib login 65 | 66 | You can now push the code to StdLib: 67 | 68 | lib up dev 69 | 70 | This Stripe demo should now be running at `https://.lib.id/@dev/`. You should also see in your Terminal the URL of your webhook endpoint to enter in your Stripe account if you’d like to try non-card payment methods which are asynchronous: `https://.lib.id/@dev/webhook`. 71 | 72 | ## More about StdLib 73 | 74 | If you want to join the StdLib community, you can visit [stdlib.com](https://stdlib.com) or [follow StdLib on Twitter](https://twitter.com/StdLibHQ). 75 | -------------------------------------------------------------------------------- /public/images/order.svg: -------------------------------------------------------------------------------- 1 | Order -------------------------------------------------------------------------------- /public/javascripts/store.js: -------------------------------------------------------------------------------- 1 | /* global fetch */ 2 | /* global localStorage */ 3 | 4 | /** 5 | * store.js 6 | * Stripe Payments Demo. Created by Romain Huet (@romainhuet). 7 | * 8 | * Representation of products, line items, and orders, and saving them on Stripe. 9 | * Please note this is overly simplified class for demo purposes (all products 10 | * are loaded for convenience, there is no cart management functionality, etc.). 11 | * A production app would need to handle this very differently. 12 | */ 13 | 14 | class Store { 15 | constructor() { 16 | this.lineItems = []; 17 | this.products = {}; 18 | this.displayOrderSummary(); 19 | } 20 | 21 | // Compute the total for the order based on the line items (SKUs and quantity). 22 | getOrderTotal() { 23 | return Object.values(this.lineItems).reduce( 24 | (total, {product, sku, quantity}) => 25 | total + quantity * this.products[product].skus.data[0].price, 26 | 0 27 | ); 28 | } 29 | 30 | // Expose the line items for the order (in a way that is friendly to the Stripe Orders API). 31 | getOrderItems() { 32 | let items = []; 33 | this.lineItems.forEach(item => 34 | items.push({ 35 | type: 'sku', 36 | parent: item.sku, 37 | quantity: item.quantity, 38 | }) 39 | ); 40 | return items; 41 | } 42 | 43 | // Retrieve the configuration from the API. 44 | async getConfig() { 45 | try { 46 | const response = await fetch('./config/'); 47 | const config = await response.json(); 48 | if (config.stripePublishableKey.includes('live')) { 49 | // Hide the demo notice if the publishable key is in live mode. 50 | document.querySelector('#order-total .demo').style.display = 'none'; 51 | } 52 | return config; 53 | } catch (err) { 54 | return {error: err.message}; 55 | } 56 | } 57 | 58 | // Load the product details. 59 | async loadProducts() { 60 | const productsResponse = await fetch('./products/'); 61 | const products = (await productsResponse.json()).data; 62 | products.forEach(product => (this.products[product.id] = product)); 63 | } 64 | 65 | // Create an order object to represent the line items. 66 | async createOrder(currency, items, email, shipping) { 67 | try { 68 | const response = await fetch('./orders/', { 69 | method: 'POST', 70 | headers: {'Content-Type': 'application/json'}, 71 | body: JSON.stringify({ 72 | currency, 73 | items, 74 | email, 75 | shipping, 76 | }), 77 | }); 78 | const data = await response.json(); 79 | if (data.error) { 80 | return {error: data.error}; 81 | } else { 82 | // Save the current order locally to lookup its status later. 83 | this.setActiveOrderId(data.order.id); 84 | return data.order; 85 | } 86 | } catch (err) { 87 | return {error: err.message}; 88 | } 89 | return order; 90 | } 91 | 92 | // Pay the specified order by sending a payment source alongside it. 93 | async payOrder(order, source) { 94 | try { 95 | const response = await fetch(`./orders/${order.id}/pay/`, { 96 | method: 'POST', 97 | headers: {'Content-Type': 'application/json'}, 98 | body: JSON.stringify(source), 99 | }); 100 | const data = await response.json(); 101 | if (data.error) { 102 | return {error: data.error}; 103 | } else { 104 | return data; 105 | } 106 | } catch (err) { 107 | return {error: err.message}; 108 | } 109 | } 110 | 111 | // Fetch an order status from the API. 112 | async getOrderStatus(orderId) { 113 | try { 114 | const response = await fetch(`./orders/${orderId}/`); 115 | return await response.json(); 116 | } catch (err) { 117 | return {error: err}; 118 | } 119 | } 120 | 121 | // Format a price (assuming a two-decimal currency like EUR or USD for simplicity). 122 | formatPrice(amount, currency) { 123 | let price = (amount / 100).toFixed(2); 124 | let numberFormat = new Intl.NumberFormat(['en-US'], { 125 | style: 'currency', 126 | currency: currency, 127 | currencyDisplay: 'symbol', 128 | }); 129 | return numberFormat.format(price); 130 | } 131 | 132 | // Set the active order ID in the local storage. 133 | setActiveOrderId(orderId) { 134 | localStorage.setItem('orderId', orderId); 135 | } 136 | 137 | // Get the active order ID from the local storage. 138 | getActiveOrderId() { 139 | return localStorage.getItem('orderId'); 140 | } 141 | 142 | // Manipulate the DOM to display the order summary on the right panel. 143 | // Note: For simplicity, we're just using template strings to inject data in the DOM, 144 | // but in production you would typically use a library like React to manage this effectively. 145 | async displayOrderSummary() { 146 | // Fetch the products from the store to get all the details (name, price, etc.). 147 | await this.loadProducts(); 148 | const orderItems = document.getElementById('order-items'); 149 | const orderTotal = document.getElementById('order-total'); 150 | let currency; 151 | // Build and append the line items to the order summary. 152 | for (let [id, product] of Object.entries(this.products)) { 153 | const randomQuantity = (min, max) => { 154 | min = Math.ceil(min); 155 | max = Math.floor(max); 156 | return Math.floor(Math.random() * (max - min + 1)) + min; 157 | }; 158 | const quantity = randomQuantity(1, 3); 159 | let sku = product.skus.data[0]; 160 | let skuPrice = this.formatPrice(sku.price, sku.currency); 161 | let lineItemPrice = this.formatPrice(sku.price * quantity, sku.currency); 162 | let lineItem = document.createElement('div'); 163 | lineItem.classList.add('line-item'); 164 | lineItem.innerHTML = ` 165 | 166 |
167 |

${product.name}

168 |

${Object.values(sku.attributes).join(' ')}

169 |
170 |

${quantity} x ${skuPrice}

171 |

${lineItemPrice}

`; 172 | orderItems.appendChild(lineItem); 173 | currency = sku.currency; 174 | this.lineItems.push({ 175 | product: product.id, 176 | sku: sku.id, 177 | quantity, 178 | }); 179 | } 180 | // Add the subtotal and total to the order summary. 181 | const total = this.formatPrice(this.getOrderTotal(), currency); 182 | orderTotal.querySelector('[data-subtotal]').innerText = total; 183 | orderTotal.querySelector('[data-total]').innerText = total; 184 | document.getElementById('main').classList.remove('loading'); 185 | } 186 | } 187 | 188 | window.store = new Store(); 189 | -------------------------------------------------------------------------------- /public/.well-known/apple-developer-merchantid-domain-association: -------------------------------------------------------------------------------- 1 | 7B227073704964223A2239373943394538343346343131343044463144313834343232393232313734313034353044314339464446394437384337313531303944334643463542433731222C2276657273696F6E223A312C22637265617465644F6E223A313437313435343137313137362C227369676E6174757265223A2233303830303630393261383634383836663730643031303730326130383033303830303230313031333130663330306430363039363038363438303136353033303430323031303530303330383030363039326138363438383666373064303130373031303030306130383033303832303365363330383230333862613030333032303130323032303836383630663639396439636361373066333030613036303832613836343863653364303430333032333037613331326533303263303630333535303430333063323534313730373036633635323034313730373036633639363336313734363936663665323034393665373436353637373236313734363936663665323034333431323032643230343733333331323633303234303630333535303430623063316434313730373036633635323034333635373237343639363636393633363137343639366636653230343137353734363836663732363937343739333131333330313130363033353530343061306330613431373037303663363532303439366536333265333130623330303930363033353530343036313330323535353333303165313730643331333633303336333033333331333833313336333433303561313730643332333133303336333033323331333833313336333433303561333036323331323833303236303630333535303430333063316636353633363332643733366437303264363237323666366236353732326437333639363736653566353534333334326435333431346534343432346635383331313433303132303630333535303430623063306236393466353332303533373937333734363536643733333131333330313130363033353530343061306330613431373037303663363532303439366536333265333130623330303930363033353530343036313330323535353333303539333031333036303732613836343863653364303230313036303832613836343863653364303330313037303334323030303438323330666461626333396366373565323032633530643939623435313265363337653261393031646436636233653062316364346235323637393866386366346562646538316132356138633231653463333364646365386532613936633266366166613139333033343563346538376134343236636539353162313239356133383230323131333038323032306433303435303630383262303630313035303530373031303130343339333033373330333530363038326230363031303530353037333030313836323936383734373437303361326632663666363337333730326536313730373036633635326536333666366432663666363337333730333033343264363137303730366336353631363936333631333333303332333031643036303335353164306530343136303431343032323433303062396165656564343633313937613461363561323939653432373138323163343533303063303630333535316431333031303166663034303233303030333031663036303335353164323330343138333031363830313432336632343963343466393365346566323765366334663632383663336661326262666432653462333038323031316430363033353531643230303438323031313433303832303131303330383230313063303630393261383634383836663736333634303530313330383166653330383163333036303832623036303130353035303730323032333038316236306338316233353236353663363936313665363336353230366636653230373436383639373332303633363537323734363936363639363336313734363532303632373932303631366537393230373036313732373437393230363137333733373536643635373332303631363336333635373037343631366536333635323036663636323037343638363532303734363836353665323036313730373036633639363336313632366336353230373337343631366536343631373236343230373436353732366437333230363136653634323036333666366536343639373436393666366537333230366636363230373537333635326332303633363537323734363936363639363336313734363532303730366636633639363337393230363136653634323036333635373237343639363636393633363137343639366636653230373037323631363337343639363336353230373337343631373436353664363536653734373332653330333630363038326230363031303530353037303230313136326136383734373437303361326632663737373737373265363137303730366336353265363336663664326636333635373237343639363636393633363137343635363137353734363836663732363937343739326633303334303630333535316431663034326433303262333032396130323761303235383632333638373437343730336132663266363337323663326536313730373036633635326536333666366432663631373037303663363536313639363336313333326536333732366333303065303630333535316430663031303166663034303430333032303738303330306630363039326138363438383666373633363430363164303430323035303033303061303630383261383634386365336430343033303230333439303033303436303232313030646131633633616538626535663634663865313165383635363933376239623639633437326265393365616333323333613136373933366534613864356538333032323130306264356166626638363966336330636132373462326664646534663731373135396362336264373139396232636130666634303964653635396138326232346433303832303265653330383230323735613030333032303130323032303834393664326662663361393864613937333030613036303832613836343863653364303430333032333036373331316233303139303630333535303430333063313234313730373036633635323035323666366637343230343334313230326432303437333333313236333032343036303335353034306230633164343137303730366336353230343336353732373436393636363936333631373436393666366532303431373537343638366637323639373437393331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333031653137306433313334333033353330333633323333333433363333333035613137306433323339333033353330333633323333333433363333333035613330376133313265333032633036303335353034303330633235343137303730366336353230343137303730366336393633363137343639366636653230343936653734363536373732363137343639366636653230343334313230326432303437333333313236333032343036303335353034306230633164343137303730366336353230343336353732373436393636363936333631373436393666366532303431373537343638366637323639373437393331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333035393330313330363037326138363438636533643032303130363038326138363438636533643033303130373033343230303034663031373131383431396437363438356435316135653235383130373736653838306132656664653762616534646530386466633462393365313333353664353636356233356165323264303937373630643232346537626261303866643736313763653838636237366262363637306265633865383239383466663534343561333831663733303831663433303436303630383262303630313035303530373031303130343361333033383330333630363038326230363031303530353037333030313836326136383734373437303361326632663666363337333730326536313730373036633635326536333666366432663666363337333730333033343264363137303730366336353732366636663734363336313637333333303164303630333535316430653034313630343134323366323439633434663933653465663237653663346636323836633366613262626664326534623330306630363033353531643133303130316666303430353330303330313031666633303166303630333535316432333034313833303136383031346262623064656131353833333838396161343861393964656265626465626166646163623234616233303337303630333535316431663034333033303265333032636130326161303238383632363638373437343730336132663266363337323663326536313730373036633635326536333666366432663631373037303663363537323666366637343633363136373333326536333732366333303065303630333535316430663031303166663034303430333032303130363330313030363061326138363438383666373633363430363032306530343032303530303330306130363038326138363438636533643034303330323033363730303330363430323330336163663732383335313136393962313836666233356333353663613632626666343137656464393066373534646132386562656631396338313565343262373839663839386637396235393966393864353431306438663964653963326665303233303332326464353434323162306133303537373663356466333338336239303637666431373763326332313664393634666336373236393832313236663534663837613764316239396362396230393839323136313036393930663039393231643030303033313832303136303330383230313563303230313031333038313836333037613331326533303263303630333535303430333063323534313730373036633635323034313730373036633639363336313734363936663665323034393665373436353637373236313734363936663665323034333431323032643230343733333331323633303234303630333535303430623063316434313730373036633635323034333635373237343639363636393633363137343639366636653230343137353734363836663732363937343739333131333330313130363033353530343061306330613431373037303663363532303439366536333265333130623330303930363033353530343036313330323535353330323038363836306636393964396363613730663330306430363039363038363438303136353033303430323031303530306130363933303138303630393261383634383836663730643031303930333331306230363039326138363438383666373064303130373031333031633036303932613836343838366637306430313039303533313066313730643331333633303338333133373331333733313336333133313561333032663036303932613836343838366637306430313039303433313232303432303733343832623432653665366332323264616536643963303961346336663332316534656136653666326661626631356430376562333338643264613435646233303061303630383261383634386365336430343033303230343438333034363032323130306564333264376438616131623536623036626164623162396639396264643063653662363931316530623032393232633934333362663564326130656135353830323231303066393433353637663030323361643061343561373236663238376636303062656334666566373335383832383935633733313531383337336163383934383137303030303030303030303030227D -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Stripe Payments Demo on StdLib 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | >Stripe Payments Demo 15 | View on GitHub 16 |
17 |
18 |
19 |
20 |
21 |
22 |

Complete your shipping and payment details below

23 |
24 |

Shipping & Billing Information

25 |
26 | 30 | 34 | 38 | 42 | 46 | 50 | 83 |
84 |

Select another country to see different payment options.

85 |
86 |
87 |

Payment Information

88 | 136 |
137 |
138 | 142 |
143 |
144 |
145 |
146 | 150 |
151 |

By providing your IBAN and confirming this payment, you’re authorizing Payments Demo and Stripe, our payment 152 | provider, to send instructions to your bank to debit your account. You’re entitled to a refund under the terms 153 | and conditions of your agreement with your bank.

154 |
155 |
156 |
157 | 161 |
162 |
163 |
164 |

You’ll be redirected to the banking site to complete your payment.

165 |
166 |
167 |

Payment information will be provided after you place the order.

168 |
169 |
170 |
171 |

Click the button below to generate a QR code for WeChat.

172 |
173 |
174 | 175 |
176 |
177 |
178 |
179 |
180 |
181 |

Completing your order…

182 |

We’re just waiting for the confirmation from your bank… This might take a moment but feel free to close this page.

183 |

We’ll send your receipt via email shortly.

184 |
185 |
186 |

Thanks for your order!

187 |

Woot! You successfully made a payment with Stripe.

188 |

We just sent your receipt to your email address, and your items will be on their way shortly.

189 |
190 |
191 |

Thanks! One last step!

192 |

Please make a payment using the details below to complete your order.

193 |
194 |
195 |
196 |

Oops, payment failed.

197 |

It looks like your order could not be paid at this time. Please try again or select a different payment option.

198 |

199 |
200 |
201 |
202 |
203 |
204 |

Order Summary

205 |
206 |
207 |
208 |
209 |

Subtotal

210 |

211 |
212 |
213 |

Shipping

214 |

Free

215 |
216 |
217 |
218 |

Demo in test mode

219 |

This app is running in test mode. You will 220 | not be charged.

221 |

Feel free to test payments using a real card or any 222 | Stripe test card. Non-card payments will redirect to test pages. 223 |

224 |

Run this app locally in live mode with your Stripe account to create real payments and see redirects to banking 225 | sites. 226 |

227 |
228 |
229 |
230 |

Total

231 |

232 |
233 |
234 |
235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | -------------------------------------------------------------------------------- /public/stylesheets/store.css: -------------------------------------------------------------------------------- 1 | /** 2 | * store.css 3 | * Stripe Payments Demo. Created by Romain Huet (@romainhuet). 4 | */ 5 | 6 | * { 7 | margin: 0; 8 | padding: 0; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | -webkit-text-size-adjust: none; 12 | box-sizing: border-box; 13 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 14 | Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; 15 | font-size: 15px; 16 | line-height: 1.4em; 17 | color: #525f7f; 18 | } 19 | 20 | body { 21 | display: flex; 22 | flex-direction: row; 23 | align-items: center; 24 | background: #f8fbfd; 25 | } 26 | 27 | /* Overall Container */ 28 | 29 | #main { 30 | width: 100%; 31 | height: 100vh; 32 | text-align: center; 33 | transition: width 0.3s ease-in-out; 34 | } 35 | 36 | #main.checkout:not(.success):not(.error) { 37 | width: calc(100% - 450px); 38 | } 39 | 40 | /* Header */ 41 | 42 | header { 43 | display: flex; 44 | flex-direction: row; 45 | justify-content: space-between; 46 | width: 100%; 47 | height: 80px; 48 | background: #fff url(../images/logo.svg) center center no-repeat; 49 | border-bottom: 1px solid #f3f3ff; 50 | } 51 | 52 | header a.shop, 53 | header a.github { 54 | margin: 30px; 55 | font-size: 13px; 56 | font-weight: 500; 57 | color: #666ee8; 58 | letter-spacing: 0.3px; 59 | text-transform: uppercase; 60 | text-decoration: none; 61 | } 62 | 63 | header a.github { 64 | padding-left: 20px; 65 | background: url(../images/github.svg) left center no-repeat; 66 | } 67 | 68 | header a:hover { 69 | text-decoration: underline; 70 | } 71 | 72 | /* Checkout */ 73 | 74 | #checkout { 75 | max-width: 480px; 76 | margin: 0 auto; 77 | padding: 30px 0; 78 | visibility: hidden; 79 | opacity: 0; 80 | transition: visibility 0s, opacity 0.5s linear 0.5s; 81 | } 82 | 83 | #main.checkout #checkout { 84 | visibility: visible; 85 | opacity: 1; 86 | } 87 | 88 | section { 89 | display: flex; 90 | flex-direction: column; 91 | position: relative; 92 | text-align: left; 93 | } 94 | 95 | h1 { 96 | margin: 0 0 20px 0; 97 | font-size: 20px; 98 | font-weight: 500; 99 | } 100 | 101 | h2 { 102 | margin: 15px 0; 103 | color: #32325d; 104 | text-transform: uppercase; 105 | letter-spacing: 0.3px; 106 | font-size: 13px; 107 | font-weight: 500; 108 | } 109 | 110 | /* Payment Request */ 111 | 112 | #payment-request { 113 | visibility: hidden; 114 | opacity: 0; 115 | min-height: 100px; 116 | padding: 20px 0; 117 | transition: visibility 0s, opacity 0.3s ease-in; 118 | } 119 | 120 | #payment-request.visible { 121 | visibility: visible; 122 | opacity: 1; 123 | } 124 | 125 | #payment-form { 126 | margin: 0 -30px; 127 | padding: 20px 30px 30px; 128 | border-radius: 4px; 129 | border: 1px solid #e8e8fb; 130 | } 131 | 132 | /* Form */ 133 | 134 | fieldset { 135 | margin-bottom: 20px; 136 | background: #fff; 137 | box-shadow: 0 1px 3px 0 rgba(50, 50, 93, 0.15), 138 | 0 4px 6px 0 rgba(112, 157, 199, 0.15); 139 | border-radius: 4px; 140 | border: none; 141 | font-size: 0; 142 | } 143 | 144 | fieldset label { 145 | position: relative; 146 | display: flex; 147 | flex-direction: row; 148 | height: 42px; 149 | padding: 10px 0; 150 | align-items: center; 151 | justify-content: center; 152 | color: #8898aa; 153 | font-weight: 400; 154 | } 155 | 156 | fieldset label:not(:last-child) { 157 | border-bottom: 1px solid #f0f5fa; 158 | } 159 | 160 | fieldset label.state { 161 | display: inline-flex; 162 | width: 75%; 163 | } 164 | 165 | fieldset:not(.with-state) label.state { 166 | display: none; 167 | } 168 | 169 | fieldset label.zip { 170 | display: inline-flex; 171 | width: 25%; 172 | padding-right: 60px; 173 | } 174 | 175 | fieldset:not(.with-state) label.zip { 176 | width: 100%; 177 | } 178 | 179 | fieldset label span { 180 | min-width: 125px; 181 | padding: 0 15px; 182 | text-align: right; 183 | } 184 | 185 | fieldset .redirect label span { 186 | width: 100%; 187 | text-align: center; 188 | } 189 | 190 | p.instruction { 191 | display: inline-table; 192 | margin-top: -32px; 193 | padding: 0 5px; 194 | text-align: center; 195 | background: #f8fbfd; 196 | } 197 | 198 | p.tip { 199 | margin: -10px auto 10px; 200 | padding: 5px 0 5px 30px; 201 | font-size: 14px; 202 | background: url(../images/tip.svg) left center no-repeat; 203 | } 204 | 205 | .field { 206 | flex: 1; 207 | padding: 0 15px; 208 | background: transparent; 209 | font-weight: 400; 210 | color: #31325f; 211 | outline: none; 212 | cursor: text; 213 | } 214 | 215 | .field::-webkit-input-placeholder { 216 | color: #aab7c4; 217 | } 218 | .field::-moz-placeholder { 219 | color: #aab7c4; 220 | } 221 | .field:-ms-input-placeholder { 222 | color: #aab7c4; 223 | } 224 | 225 | fieldset .select::after { 226 | content: ''; 227 | position: absolute; 228 | width: 9px; 229 | height: 5px; 230 | right: 20px; 231 | top: 50%; 232 | margin-top: -2px; 233 | background-image: url(../images/dropdown.svg); 234 | pointer-events: none; 235 | } 236 | 237 | input { 238 | flex: 1; 239 | border-style: none; 240 | outline: none; 241 | color: #313b3f; 242 | } 243 | 244 | select { 245 | flex: 1; 246 | border-style: none; 247 | outline: none; 248 | -webkit-appearance: none; 249 | -moz-appearance: none; 250 | appearance: none; 251 | outline: none; 252 | color: #313b3f; 253 | cursor: pointer; 254 | background: transparent; 255 | } 256 | 257 | select:focus { 258 | color: #666ee8; 259 | } 260 | 261 | ::-webkit-input-placeholder { 262 | color: #cfd7e0; 263 | } 264 | ::-moz-placeholder { 265 | color: #cfd7e0; 266 | opacity: 1; 267 | } 268 | :-ms-input-placeholder { 269 | color: #cfd7e0; 270 | } 271 | 272 | input:-webkit-autofill, 273 | select:-webkit-autofill { 274 | -webkit-text-fill-color: #666ee8; 275 | transition: background-color 100000000s; 276 | -webkit-animation: 1ms void-animation-out 1s; 277 | } 278 | 279 | .StripeElement--webkit-autofill { 280 | background: transparent !important; 281 | } 282 | 283 | #card-element { 284 | margin-top: -1px; 285 | } 286 | 287 | #ideal-bank-element { 288 | padding: 0; 289 | } 290 | 291 | button { 292 | display: block; 293 | background: #666ee8; 294 | color: #fff; 295 | box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11), 0 1px 3px rgba(0, 0, 0, 0.08); 296 | border-radius: 4px; 297 | border: 0; 298 | font-weight: 700; 299 | width: 100%; 300 | height: 40px; 301 | outline: none; 302 | cursor: pointer; 303 | transition: all 0.15s ease; 304 | } 305 | 306 | button:focus { 307 | background: #555abf; 308 | } 309 | 310 | button:hover { 311 | transform: translateY(-1px); 312 | box-shadow: 0 7px 14px 0 rgba(50, 50, 93, 0.1), 313 | 0 3px 6px 0 rgba(0, 0, 0, 0.08); 314 | } 315 | 316 | button:active { 317 | background: #43458b; 318 | } 319 | 320 | #country { 321 | display: flex; 322 | align-items: center; 323 | } 324 | 325 | #country select { 326 | margin: 0 -15px 0 -30px; 327 | padding: 0 15px 0 30px; 328 | } 329 | 330 | #country::before { 331 | display: inline-flex; 332 | content: ''; 333 | width: 21px; 334 | height: 15px; 335 | background: url(../images/flags.svg); 336 | background-position: -1000px -1000px; 337 | background-repeat: no-repeat; 338 | margin-right: 10px; 339 | } 340 | 341 | #country.at::before { 342 | background-position: -165px -10px; 343 | } 344 | #country.au::before { 345 | background-position: -196px -10px; 346 | } 347 | #country.be::before { 348 | background-position: -227px -10px; 349 | } 350 | #country.br::before { 351 | background-position: -351px -10px; 352 | } 353 | #country.ca::before { 354 | background-position: -382px -10px; 355 | } 356 | #country.ch::before { 357 | background-position: -475px -10px; 358 | } 359 | #country.cn::before { 360 | background-position: -41px -35px; 361 | } 362 | #country.de::before { 363 | background-position: -134px -35px; 364 | } 365 | #country.dk::before { 366 | background-position: -196px -35px; 367 | } 368 | #country.es::before { 369 | background-position: -320px -35px; 370 | } 371 | #country.eu::before { 372 | background-position: -351px -35px; 373 | } 374 | #country.fi::before { 375 | background-position: -382px -35px; 376 | } 377 | #country.fr::before { 378 | background-position: -413px -35px; 379 | } 380 | #country.gb::before { 381 | background-position: -475px -35px; 382 | } 383 | #country.hk::before { 384 | background-position: -41px -60px; 385 | } 386 | #country.ie::before { 387 | background-position: -196px -60px; 388 | } 389 | #country.it::before { 390 | background-position: -351px -60px; 391 | } 392 | #country.jp::before { 393 | background-position: -444px -60px; 394 | } 395 | #country.lu::before { 396 | background-position: -258px -85px; 397 | } 398 | #country.mx::before { 399 | background-position: -475px -85px; 400 | } 401 | #country.nl::before { 402 | background-position: -103px -110px; 403 | } 404 | #country.no::before { 405 | background-position: -134px -110px; 406 | } 407 | #country.nz::before { 408 | background-position: -165px -110px; 409 | } 410 | #country.pt::before { 411 | background-position: -413px -110px; 412 | } 413 | #country.se::before { 414 | background-position: -103px -135px; 415 | } 416 | #country.sg::before { 417 | background-position: -134px -135px; 418 | } 419 | #country.us::before { 420 | background-position: -475px -135px; 421 | } 422 | 423 | /* Payment Methods */ 424 | 425 | #payment-methods { 426 | margin: 0 0 20px; 427 | border-bottom: 2px solid #e8e8fb; 428 | } 429 | 430 | #payment-methods li { 431 | display: none; 432 | } 433 | 434 | #payment-methods li.visible { 435 | display: inline-block; 436 | margin: 0 20px 0 0; 437 | list-style: none; 438 | } 439 | 440 | #payment-methods input { 441 | display: none; 442 | } 443 | 444 | #payment-methods label { 445 | display: flex; 446 | flex: 1; 447 | cursor: pointer; 448 | } 449 | 450 | #payment-methods input + label { 451 | position: relative; 452 | padding: 5px 0; 453 | text-decoration: none; 454 | text-transform: uppercase; 455 | font-size: 13px; 456 | } 457 | 458 | #payment-methods label::before { 459 | content: ''; 460 | position: absolute; 461 | width: 100%; 462 | bottom: -2px; 463 | left: 0; 464 | border-bottom: 2px solid #6772e5; 465 | opacity: 0; 466 | transform: scaleX(0); 467 | transition: all 0.25s ease-in-out; 468 | } 469 | 470 | #payment-methods label:hover { 471 | color: #6772e5; 472 | cursor: pointer; 473 | } 474 | 475 | #payment-methods input:checked + label { 476 | color: #6772e5; 477 | } 478 | 479 | #payment-methods label:hover::before, 480 | #payment-methods input:checked + label::before { 481 | opacity: 1; 482 | transform: scaleX(1); 483 | } 484 | 485 | #payment-methods, 486 | .payment-info { 487 | display: none; 488 | } 489 | 490 | .payment-info:not(.card) { 491 | margin-bottom: 15px; 492 | } 493 | 494 | .payment-info.ideal { 495 | margin-bottom: 0; 496 | } 497 | 498 | #payment-methods.visible, 499 | .payment-info.visible { 500 | display: block; 501 | } 502 | 503 | .payment-info.card.visible, 504 | .payment-info.sepa_debit.visible { 505 | text-align: center; 506 | } 507 | 508 | .payment-info p.notice { 509 | font-size: 14px; 510 | color: #8898aa; 511 | text-align: left; 512 | } 513 | 514 | #wechat-qrcode img { 515 | margin: 0 auto; 516 | } 517 | 518 | .element-errors { 519 | display: inline-flex; 520 | height: 20px; 521 | margin: 15px auto 0; 522 | padding-left: 20px; 523 | color: #e25950; 524 | opacity: 0; 525 | transform: translateY(10px); 526 | transition-property: opacity, transform; 527 | transition-duration: 0.35s; 528 | transition-timing-function: cubic-bezier(0.165, 0.84, 0.44, 1); 529 | background: url(../images/error.svg) center left no-repeat; 530 | background-size: 15px 15px; 531 | } 532 | 533 | .element-errors.visible { 534 | opacity: 1; 535 | transform: none; 536 | } 537 | 538 | #iban-errors { 539 | margin-top: -20px; 540 | } 541 | 542 | /* Order Summary */ 543 | 544 | #summary { 545 | position: fixed; 546 | top: 0; 547 | right: -450px; 548 | bottom: 0; 549 | width: 450px; 550 | overflow: auto; 551 | height: 100%; 552 | background: #fff; 553 | box-shadow: 0 2px 19px 4px rgba(0, 0, 0, 0.04); 554 | transition: right 0.3s ease-in-out; 555 | } 556 | 557 | #main.checkout:not(.success):not(.error) + #summary { 558 | right: 0; 559 | } 560 | 561 | #summary header { 562 | background: #fff; 563 | } 564 | 565 | #summary h1 { 566 | margin: 0 30px; 567 | line-height: 80px; 568 | font-weight: 400; 569 | } 570 | 571 | #summary p { 572 | font-size: 16px; 573 | font-weight: 400; 574 | margin-top: 10px; 575 | } 576 | 577 | #summary .discount p { 578 | margin-top: 0; 579 | } 580 | 581 | #summary .line-item { 582 | display: flex; 583 | flex-direction: row; 584 | padding: 30px 30px 0 30px; 585 | } 586 | 587 | #summary .line-item .image { 588 | display: block; 589 | width: 80px; 590 | height: 80px; 591 | margin-right: 15px; 592 | background: #f6f9fc; 593 | border-radius: 3px; 594 | } 595 | 596 | #summary .line-item .label { 597 | flex: 1; 598 | text-align: left; 599 | } 600 | 601 | #summary .line-item .product { 602 | color: #424770; 603 | } 604 | 605 | #summary .line-item .sku { 606 | font-size: 14px; 607 | color: #8898aa; 608 | } 609 | 610 | #summary .line-item .count, 611 | #summary .line-item .price { 612 | font-size: 14px; 613 | padding-left: 10px; 614 | align-self: right; 615 | text-align: right; 616 | line-height: 24px; 617 | } 618 | 619 | #summary .line-item .count { 620 | color: #8898aa; 621 | } 622 | 623 | #summary .line-item .price { 624 | color: #8ba4fe; 625 | font-weight: bold; 626 | } 627 | 628 | #summary .line-item.subtotal { 629 | margin-top: 30px; 630 | margin-bottom: 0; 631 | padding-top: 10px; 632 | border-top: 1px solid #f3f3ff; 633 | } 634 | 635 | #summary .line-item.shipping { 636 | padding-top: 0; 637 | } 638 | 639 | #summary .line-item.total { 640 | margin-top: 15px; 641 | margin-bottom: 30px; 642 | padding-top: 15px; 643 | font-size: 21px; 644 | border-top: 1px solid #f3f3ff; 645 | } 646 | 647 | #summary .line-item.total .label, 648 | #summary .line-item.total .price { 649 | color: #424770; 650 | font-size: 24px; 651 | font-weight: 400; 652 | line-height: inherit; 653 | } 654 | 655 | #demo { 656 | padding: 15px; 657 | margin: -15px -15px 0; 658 | background: #f6f9fc; 659 | border-radius: 5px; 660 | } 661 | 662 | #demo p.label { 663 | margin: 0 0 10px; 664 | color: #666ee8; 665 | } 666 | 667 | #demo p.note { 668 | display: block; 669 | margin: 10px 0 0; 670 | font-size: 14px; 671 | } 672 | 673 | #demo p.note a, 674 | #demo p.note em { 675 | font-size: 14px; 676 | } 677 | 678 | #demo p.note a:hover { 679 | text-decoration: none; 680 | } 681 | 682 | /* Order Confirmation */ 683 | 684 | #confirmation { 685 | display: flex; 686 | align-items: center; 687 | position: absolute; 688 | top: 80px; 689 | left: 0; 690 | right: 0; 691 | bottom: 0; 692 | width: 100%; 693 | visibility: hidden; 694 | overflow-x: hidden; 695 | opacity: 0; 696 | background: #f8fbfd; 697 | text-align: left; 698 | transition: visibility 0s, opacity 0.5s linear 0.5s; 699 | } 700 | 701 | #main.success #confirmation, 702 | #main.error #confirmation { 703 | visibility: visible; 704 | opacity: 1; 705 | } 706 | 707 | #main.success #order, 708 | #main.error #order { 709 | visibility: hidden; 710 | opacity: 0; 711 | } 712 | 713 | #confirmation h1 { 714 | font-size: 42px; 715 | font-weight: 300; 716 | color: #6863d8; 717 | letter-spacing: 0.3px; 718 | margin-bottom: 30px; 719 | } 720 | 721 | #confirmation .status { 722 | display: flex; 723 | flex-direction: column; 724 | justify-content: center; 725 | padding: 0 75px 0 275px; 726 | max-width: 75%; 727 | height: 350px; 728 | margin: 100px auto; 729 | background: #fff url(../images/order.svg) 75px center no-repeat; 730 | box-shadow: 0 1px 3px 0 rgba(50, 50, 93, 0.15); 731 | border-radius: 6px; 732 | } 733 | 734 | #confirmation .status.error { 735 | display: none; 736 | } 737 | 738 | #confirmation .status p { 739 | margin: 0 0 15px; 740 | } 741 | 742 | #confirmation .status li { 743 | margin-bottom: 5px; 744 | list-style: none; 745 | } 746 | 747 | #main.success:not(.processing) #confirmation .status.processing, 748 | #main.success:not(.receiver) #confirmation .status.receiver { 749 | display: none; 750 | } 751 | 752 | #main.processing #confirmation .status.success, 753 | #main.receiver #confirmation .status.success { 754 | display: none; 755 | } 756 | 757 | #main.error #confirmation .status.success, 758 | #main.error #confirmation .status.processing, 759 | #main.error #confirmation .status.receiver { 760 | display: none; 761 | } 762 | 763 | #main.error #confirmation .status.error { 764 | display: flex; 765 | } 766 | 767 | #main.error #confirmation .error-message { 768 | font-family: monospace; 769 | } 770 | 771 | /* Media Queries */ 772 | 773 | @media only screen and (max-width: 1024px) { 774 | #main.checkout:not(.success):not(.error) { 775 | width: calc(100% - 320px); 776 | } 777 | #summary { 778 | width: 320px; 779 | right: -320px; 780 | } 781 | #main.checkout:not(.success):not(.error) + #summary { 782 | right: 0; 783 | } 784 | #summary .line-item p { 785 | margin-top: 0; 786 | } 787 | #summary .line-item .image { 788 | width: 40px; 789 | height: 40px; 790 | } 791 | #summary .line-item .label { 792 | margin: 0; 793 | } 794 | } 795 | 796 | @media only screen and (max-width: 800px) { 797 | #main.checkout:not(.success):not(.error) { 798 | width: 100%; 799 | } 800 | #payment-request { 801 | padding-top: 0; 802 | min-height: 80px; 803 | } 804 | #summary { 805 | display: none; 806 | } 807 | #confirmation .status { 808 | width: auto; 809 | height: auto; 810 | margin: 30px; 811 | } 812 | } 813 | 814 | @media only screen and (max-width: 500px) { 815 | header { 816 | height: 60px; 817 | background-size: 40px 40px; 818 | } 819 | header a.shop, 820 | header a.github { 821 | display: none; 822 | } 823 | #payment-request { 824 | min-height: 0; 825 | padding-right: 15px; 826 | padding-left: 15px; 827 | } 828 | #payment-form { 829 | margin: 0; 830 | padding: 0 15px; 831 | border-width: 2px 0 0 0; 832 | border-radius: 0; 833 | } 834 | .payment-info span { 835 | display: none; 836 | } 837 | fieldset { 838 | margin-bottom: 15px; 839 | } 840 | fieldset label.state, 841 | fieldset label.zip { 842 | display: flex; 843 | width: inherit; 844 | padding: 10px 0; 845 | } 846 | p.instruction { 847 | margin-top: -12px; 848 | font-size: 14px; 849 | } 850 | p.tip { 851 | margin-bottom: 0; 852 | font-size: 13px; 853 | } 854 | #country::before { 855 | display: none; 856 | } 857 | #checkout { 858 | margin-bottom: 0; 859 | } 860 | #confirmation .status { 861 | width: auto; 862 | height: auto; 863 | padding: 120px 15px 15px; 864 | background: #fff url(../images/order.svg) center 15px no-repeat; 865 | background-size: 68px 86px; 866 | box-shadow: 0 1px 3px 0 rgba(50, 50, 93, 0.15); 867 | border-radius: 6px; 868 | } 869 | #confirmation h1 { 870 | text-align: center; 871 | } 872 | } 873 | -------------------------------------------------------------------------------- /public/javascripts/payments.js: -------------------------------------------------------------------------------- 1 | /* global Stripe */ 2 | /* global QRCode */ 3 | /* global store */ 4 | 5 | /** 6 | * payments.js 7 | * Stripe Payments Demo. Created by Romain Huet (@romainhuet). 8 | * 9 | * This modern JavaScript file handles the checkout process using Stripe. 10 | * 11 | * 1. It shows how to accept card payments with the `card` Element, and 12 | * the `paymentRequestButton` Element for Payment Request and Apple Pay. 13 | * 2. It shows how to use the Stripe Sources API to accept non-card payments, 14 | * such as iDEAL, SOFORT, SEPA Direct Debit, and more. 15 | */ 16 | 17 | (async () => { 18 | 'use strict'; 19 | 20 | // Retrieve the configuration for the store. 21 | const config = await store.getConfig(); 22 | 23 | // Create references to the main form and its submit button. 24 | const form = document.getElementById('payment-form'); 25 | const submitButton = form.querySelector('button[type=submit]'); 26 | 27 | /** 28 | * Setup Stripe Elements. 29 | */ 30 | 31 | // Create a Stripe client. 32 | const stripe = Stripe(config.stripePublishableKey); 33 | 34 | // Create an instance of Elements. 35 | const elements = stripe.elements(); 36 | 37 | // Prepare the styles for Elements. 38 | const style = { 39 | base: { 40 | iconColor: '#666ee8', 41 | color: '#31325f', 42 | fontWeight: 400, 43 | fontFamily: 44 | '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif', 45 | fontSmoothing: 'antialiased', 46 | fontSize: '15px', 47 | '::placeholder': { 48 | color: '#aab7c4', 49 | }, 50 | ':-webkit-autofill': { 51 | color: '#666ee8', 52 | }, 53 | }, 54 | }; 55 | 56 | /** 57 | * Implement a Stripe Card Element that matches the look-and-feel of the app. 58 | * 59 | * This makes it easy to collect debit and credit card payments information. 60 | */ 61 | 62 | // Create a Card Element and pass some custom styles to it. 63 | const card = elements.create('card', {style}); 64 | 65 | // Mount the Card Element on the page. 66 | card.mount('#card-element'); 67 | 68 | // Monitor change events on the Card Element to display any errors. 69 | card.on('change', ({error}) => { 70 | const cardErrors = document.getElementById('card-errors'); 71 | if (error) { 72 | cardErrors.textContent = error.message; 73 | cardErrors.classList.add('visible'); 74 | } else { 75 | cardErrors.classList.remove('visible'); 76 | } 77 | // Re-enable the Pay button. 78 | submitButton.disabled = false; 79 | }); 80 | 81 | /** 82 | * Implement a Stripe IBAN Element that matches the look-and-feel of the app. 83 | * 84 | * This makes it easy to collect bank account information. 85 | */ 86 | 87 | // Create a IBAN Element and pass the right options for styles and supported countries. 88 | const ibanOptions = { 89 | style, 90 | supportedCountries: ['SEPA'], 91 | }; 92 | const iban = elements.create('iban', ibanOptions); 93 | 94 | // Mount the IBAN Element on the page. 95 | iban.mount('#iban-element'); 96 | 97 | // Monitor change events on the IBAN Element to display any errors. 98 | iban.on('change', ({error, bankName}) => { 99 | const ibanErrors = document.getElementById('iban-errors'); 100 | if (error) { 101 | ibanErrors.textContent = error.message; 102 | ibanErrors.classList.add('visible'); 103 | } else { 104 | ibanErrors.classList.remove('visible'); 105 | if (bankName) { 106 | updateButtonLabel('sepa_debit', bankName); 107 | } 108 | } 109 | // Re-enable the Pay button. 110 | submitButton.disabled = false; 111 | }); 112 | 113 | /** 114 | * Add an iDEAL Bank selection Element that matches the look-and-feel of the app. 115 | * 116 | * This allows you to send the customer directly to their iDEAL enabled bank. 117 | */ 118 | 119 | // Create a iDEAL Bank Element and pass the style options, along with an extra `padding` property. 120 | const idealBank = elements.create('idealBank', { 121 | style: {base: {...style.base, padding: '10px 15px'}}, 122 | }); 123 | 124 | // Mount the iDEAL Bank Element on the page. 125 | idealBank.mount('#ideal-bank-element'); 126 | 127 | /** 128 | * Implement a Stripe Payment Request Button Element. 129 | * 130 | * This automatically supports the Payment Request API (already live on Chrome), 131 | * as well as Apple Pay on the Web on Safari, Google Pay, and Microsoft Pay. 132 | * When of these two options is available, this element adds a “Pay” button on top 133 | * of the page to let users pay in just a click (or a tap on mobile). 134 | */ 135 | 136 | // Make sure all data is loaded from the store to compute the order amount. 137 | await store.loadProducts(); 138 | 139 | // Create the payment request. 140 | const paymentRequest = stripe.paymentRequest({ 141 | country: config.stripeCountry, 142 | currency: config.currency, 143 | total: { 144 | label: 'Total', 145 | amount: store.getOrderTotal(), 146 | }, 147 | requestShipping: true, 148 | requestPayerEmail: true, 149 | shippingOptions: [ 150 | { 151 | id: 'free', 152 | label: 'Free Shipping', 153 | detail: 'Delivery within 5 days', 154 | amount: 0, 155 | }, 156 | ], 157 | }); 158 | 159 | // Callback when a source is created. 160 | paymentRequest.on('source', async event => { 161 | try { 162 | // Create the order using the email and shipping information from the Payment Request callback. 163 | const order = await store.createOrder( 164 | config.currency, 165 | store.getOrderItems(), 166 | event.payerEmail, 167 | { 168 | name: event.shippingAddress.recipient, 169 | address: { 170 | line1: event.shippingAddress.addressLine[0], 171 | city: event.shippingAddress.city, 172 | country: event.shippingAddress.country, 173 | postal_code: event.shippingAddress.postalCode, 174 | state: event.shippingAddress.region, 175 | }, 176 | } 177 | ); 178 | // Complete the order using the payment source generated by Payment Request. 179 | await handleOrder(order, event.source); 180 | event.complete('success'); 181 | } catch (error) { 182 | event.complete('fail'); 183 | } 184 | }); 185 | 186 | // Callback when the shipping address is updated. 187 | paymentRequest.on('shippingaddresschange', event => { 188 | event.updateWith({status: 'success'}); 189 | }); 190 | 191 | // Create the Payment Request Button. 192 | const paymentRequestButton = elements.create('paymentRequestButton', { 193 | paymentRequest, 194 | }); 195 | 196 | // Check if the Payment Request is available (or Apple Pay on the Web). 197 | const paymentRequestSupport = await paymentRequest.canMakePayment(); 198 | if (paymentRequestSupport) { 199 | // Display the Pay button by mounting the Element in the DOM. 200 | paymentRequestButton.mount('#payment-request-button'); 201 | // Replace the instruction. 202 | document.querySelector('.instruction').innerText = 203 | 'Or enter your shipping and payment details below'; 204 | // Show the payment request section. 205 | document.getElementById('payment-request').classList.add('visible'); 206 | } 207 | 208 | /** 209 | * Handle the form submission. 210 | * 211 | * This creates an order and either sends the card information from the Element 212 | * alongside it, or creates a Source and start a redirect to complete the purchase. 213 | * 214 | * Please note this form is not submitted when the user chooses the "Pay" button 215 | * or Apple Pay, Google Pay, and Microsoft Pay since they provide name and 216 | * shipping information directly. 217 | */ 218 | 219 | // Listen to changes to the user-selected country. 220 | form 221 | .querySelector('select[name=country]') 222 | .addEventListener('change', event => { 223 | event.preventDefault(); 224 | const country = event.target.value; 225 | const zipLabel = form.querySelector('label.zip'); 226 | // Only show the state input for the United States. 227 | zipLabel.parentElement.classList.toggle('with-state', country === 'US'); 228 | // Update the ZIP label to make it more relevant for each country. 229 | form.querySelector('label.zip span').innerText = 230 | country === 'US' 231 | ? 'ZIP' 232 | : country === 'UK' 233 | ? 'Postcode' 234 | : 'Postal Code'; 235 | event.target.parentElement.className = `field ${country}`; 236 | showRelevantPaymentMethods(country); 237 | }); 238 | 239 | // Submit handler for our payment form. 240 | form.addEventListener('submit', async event => { 241 | event.preventDefault(); 242 | 243 | // Retrieve the user information from the form. 244 | const payment = form.querySelector('input[name=payment]:checked').value; 245 | const name = form.querySelector('input[name=name]').value; 246 | const country = form.querySelector('select[name=country] option:checked') 247 | .value; 248 | const email = form.querySelector('input[name=email]').value; 249 | const shipping = { 250 | name, 251 | address: { 252 | line1: form.querySelector('input[name=address]').value, 253 | city: form.querySelector('input[name=city]').value, 254 | postal_code: form.querySelector('input[name=postal_code]').value, 255 | state: form.querySelector('input[name=state]').value, 256 | country, 257 | }, 258 | }; 259 | // Disable the Pay button to prevent multiple click events. 260 | submitButton.disabled = true; 261 | 262 | // Create the order using the email and shipping information from the form. 263 | const order = await store.createOrder( 264 | config.currency, 265 | store.getOrderItems(), 266 | email, 267 | shipping 268 | ); 269 | 270 | if (payment === 'card') { 271 | // Create a Stripe source from the card information and the owner name. 272 | const {source} = await stripe.createSource(card, { 273 | owner: { 274 | name, 275 | }, 276 | }); 277 | await handleOrder(order, source); 278 | } else if (payment === 'sepa_debit') { 279 | // Create a SEPA Debit source from the IBAN information. 280 | const sourceData = { 281 | type: payment, 282 | currency: order.currency, 283 | owner: { 284 | name, 285 | email, 286 | }, 287 | mandate: { 288 | // Automatically send a mandate notification email to your customer 289 | // once the source is charged. 290 | notification_method: 'email', 291 | }, 292 | }; 293 | const {source} = await stripe.createSource(iban, sourceData); 294 | await handleOrder(order, source); 295 | } else { 296 | // Prepare all the Stripe source common data. 297 | const sourceData = { 298 | type: payment, 299 | amount: order.amount, 300 | currency: order.currency, 301 | owner: { 302 | name, 303 | email, 304 | }, 305 | redirect: { 306 | return_url: window.location.href, 307 | }, 308 | statement_descriptor: 'Stripe Payments Demo', 309 | metadata: { 310 | order: order.id, 311 | }, 312 | }; 313 | 314 | // Add extra source information which are specific to a payment method. 315 | switch (payment) { 316 | case 'ideal': 317 | // iDEAL: Add the selected Bank from the iDEAL Bank Element. 318 | const {source, error} = await stripe.createSource( 319 | idealBank, 320 | sourceData 321 | ); 322 | await handleOrder(order, source, error); 323 | return; 324 | break; 325 | case 'sofort': 326 | // SOFORT: The country is required before redirecting to the bank. 327 | sourceData.sofort = { 328 | country, 329 | }; 330 | break; 331 | case 'ach_credit_transfer': 332 | // ACH Bank Transfer: Only supports USD payments, edit the default config to try it. 333 | // In test mode, we can set the funds to be received via the owner email. 334 | sourceData.owner.email = `amount_${order.amount}@example.com`; 335 | break; 336 | } 337 | 338 | // Create a Stripe source with the common data and extra information. 339 | const {source, error} = await stripe.createSource(sourceData); 340 | await handleOrder(order, source, error); 341 | } 342 | }); 343 | 344 | // Handle the order and source activation if required 345 | const handleOrder = async (order, source, error = null) => { 346 | const mainElement = document.getElementById('main'); 347 | const confirmationElement = document.getElementById('confirmation'); 348 | if (error) { 349 | mainElement.classList.remove('processing'); 350 | mainElement.classList.remove('receiver'); 351 | confirmationElement.querySelector('.error-message').innerText = 352 | error.message; 353 | mainElement.classList.add('error'); 354 | } 355 | switch (order.metadata.status) { 356 | case 'created': 357 | switch (source.status) { 358 | case 'chargeable': 359 | submitButton.textContent = 'Processing Payment…'; 360 | const response = await store.payOrder(order, source); 361 | await handleOrder(response.order, response.source); 362 | break; 363 | case 'pending': 364 | switch (source.flow) { 365 | case 'none': 366 | // Normally, sources with a `flow` value of `none` are chargeable right away, 367 | // but there are exceptions, for instance for WeChat QR codes just below. 368 | if (source.type === 'wechat') { 369 | // Display the QR code. 370 | const qrCode = new QRCode('wechat-qrcode', { 371 | text: source.wechat.qr_code_url, 372 | width: 128, 373 | height: 128, 374 | colorDark: '#424770', 375 | colorLight: '#f8fbfd', 376 | correctLevel: QRCode.CorrectLevel.H, 377 | }); 378 | // Hide the previous text and update the call to action. 379 | form.querySelector('.payment-info.wechat p').style.display = 380 | 'none'; 381 | let amount = store.formatPrice( 382 | store.getOrderTotal(), 383 | config.currency 384 | ); 385 | submitButton.textContent = `Scan this QR code on WeChat to pay ${amount}`; 386 | // Start polling the order status. 387 | pollOrderStatus(order.id, 300000); 388 | } else { 389 | console.log('Unhandled none flow.', source); 390 | } 391 | break; 392 | case 'redirect': 393 | // Immediately redirect the customer. 394 | submitButton.textContent = 'Redirecting…'; 395 | window.location.replace(source.redirect.url); 396 | break; 397 | case 'code_verification': 398 | // Display a code verification input to verify the source. 399 | break; 400 | case 'receiver': 401 | // Display the receiver address to send the funds to. 402 | mainElement.classList.add('success', 'receiver'); 403 | const receiverInfo = confirmationElement.querySelector( 404 | '.receiver .info' 405 | ); 406 | let amount = store.formatPrice(source.amount, config.currency); 407 | switch (source.type) { 408 | case 'ach_credit_transfer': 409 | // Display the ACH Bank Transfer information to the user. 410 | const ach = source.ach_credit_transfer; 411 | receiverInfo.innerHTML = ` 412 |
    413 |
  • 414 | Amount: 415 | ${amount} 416 |
  • 417 |
  • 418 | Bank Name: 419 | ${ach.bank_name} 420 |
  • 421 |
  • 422 | Account Number: 423 | ${ach.account_number} 424 |
  • 425 |
  • 426 | Routing Number: 427 | ${ach.routing_number} 428 |
  • 429 |
`; 430 | break; 431 | case 'multibanco': 432 | // Display the Multibanco payment information to the user. 433 | const multibanco = source.multibanco; 434 | receiverInfo.innerHTML = ` 435 |
    436 |
  • 437 | Amount (Montante): 438 | ${amount} 439 |
  • 440 |
  • 441 | Entity (Entidade): 442 | ${multibanco.entity} 443 |
  • 444 |
  • 445 | Reference (Referencia): 446 | ${multibanco.reference} 447 |
  • 448 |
`; 449 | break; 450 | default: 451 | console.log('Unhandled receiver flow.', source); 452 | } 453 | // Poll the backend and check for an order status. 454 | // The backend updates the status upon receiving webhooks, 455 | // specifically the `source.chargeable` and `charge.succeeded` events. 456 | pollOrderStatus(order.id); 457 | break; 458 | default: 459 | // Order is received, pending payment confirmation. 460 | break; 461 | } 462 | break; 463 | case 'failed': 464 | case 'canceled': 465 | // Authentication failed, offer to select another payment method. 466 | break; 467 | default: 468 | // Order is received, pending payment confirmation. 469 | break; 470 | } 471 | break; 472 | 473 | case 'pending': 474 | // Success! Now waiting for payment confirmation. Update the interface to display the confirmation screen. 475 | mainElement.classList.remove('processing'); 476 | // Update the note about receipt and shipping (the payment is not yet confirmed by the bank). 477 | confirmationElement.querySelector('.note').innerText = 478 | 'We’ll send your receipt and ship your items as soon as your payment is confirmed.'; 479 | mainElement.classList.add('success'); 480 | break; 481 | 482 | case 'failed': 483 | // Payment for the order has failed. 484 | mainElement.classList.remove('success'); 485 | mainElement.classList.remove('processing'); 486 | mainElement.classList.remove('receiver'); 487 | mainElement.classList.add('error'); 488 | break; 489 | 490 | case 'paid': 491 | // Success! Payment is confirmed. Update the interface to display the confirmation screen. 492 | mainElement.classList.remove('processing'); 493 | mainElement.classList.remove('receiver'); 494 | // Update the note about receipt and shipping (the payment has been fully confirmed by the bank). 495 | confirmationElement.querySelector('.note').innerText = 496 | 'We just sent your receipt to your email address, and your items will be on their way shortly.'; 497 | mainElement.classList.add('success'); 498 | break; 499 | } 500 | }; 501 | 502 | /** 503 | * Monitor the status of a source after a redirect flow. 504 | * 505 | * This means there is a `source` parameter in the URL, and an active order. 506 | * When this happens, we'll monitor the status of the order and present real-time 507 | * information to the user. 508 | */ 509 | 510 | const pollOrderStatus = async ( 511 | orderId, 512 | timeout = 30000, 513 | interval = 500, 514 | start = null 515 | ) => { 516 | start = start ? start : Date.now(); 517 | const endStates = ['paid', 'failed']; 518 | // Retrieve the latest order status. 519 | const order = await store.getOrderStatus(orderId); 520 | await handleOrder(order, {status: null}); 521 | if ( 522 | !endStates.includes(order.metadata.status) && 523 | Date.now() < start + timeout 524 | ) { 525 | // Not done yet. Let's wait and check again. 526 | setTimeout(pollOrderStatus, interval, orderId, timeout, interval, start); 527 | } else { 528 | if (!endStates.includes(order.metadata.status)) { 529 | // Status has not changed yet. Let's time out. 530 | console.warn(new Error('Polling timed out.')); 531 | } 532 | } 533 | }; 534 | 535 | const orderId = store.getActiveOrderId(); 536 | const mainElement = document.getElementById('main'); 537 | if (orderId && window.location.search.includes('source')) { 538 | // Update the interface to display the processing screen. 539 | mainElement.classList.add('success', 'processing'); 540 | 541 | // Poll the backend and check for an order status. 542 | // The backend updates the status upon receiving webhooks, 543 | // specifically the `source.chargeable` and `charge.succeeded` events. 544 | pollOrderStatus(orderId); 545 | } else { 546 | // Update the interface to display the checkout form. 547 | mainElement.classList.add('checkout'); 548 | } 549 | 550 | /** 551 | * Display the relevant payment methods for a selected country. 552 | */ 553 | 554 | // List of relevant countries for the payment methods supported in this demo. 555 | // Read the Stripe guide: https://stripe.com/payments/payment-methods-guide 556 | const paymentMethods = { 557 | ach_credit_transfer: { 558 | name: 'Bank Transfer', 559 | flow: 'receiver', 560 | countries: ['US'], 561 | }, 562 | alipay: { 563 | name: 'Alipay', 564 | flow: 'redirect', 565 | countries: ['CN', 'HK', 'SG', 'JP'], 566 | }, 567 | bancontact: { 568 | name: 'Bancontact', 569 | flow: 'redirect', 570 | countries: ['BE'], 571 | }, 572 | card: { 573 | name: 'Card', 574 | flow: 'none', 575 | }, 576 | eps: { 577 | name: 'EPS', 578 | flow: 'redirect', 579 | countries: ['AT'], 580 | }, 581 | ideal: { 582 | name: 'iDEAL', 583 | flow: 'redirect', 584 | countries: ['NL'], 585 | }, 586 | giropay: { 587 | name: 'Giropay', 588 | flow: 'redirect', 589 | countries: ['DE'], 590 | }, 591 | multibanco: { 592 | name: 'Multibanco', 593 | flow: 'receiver', 594 | countries: ['PT'], 595 | }, 596 | sepa_debit: { 597 | name: 'SEPA Direct Debit', 598 | flow: 'none', 599 | countries: ['FR', 'DE', 'ES', 'BE', 'NL', 'LU', 'IT', 'PT', 'AT', 'IE'], 600 | }, 601 | sofort: { 602 | name: 'SOFORT', 603 | flow: 'redirect', 604 | countries: ['DE', 'AT'], 605 | }, 606 | wechat: { 607 | name: 'WeChat', 608 | flow: 'none', 609 | countries: ['CN', 'HK', 'SG', 'JP'], 610 | }, 611 | }; 612 | 613 | // Update the main button to reflect the payment method being selected. 614 | const updateButtonLabel = (paymentMethod, bankName) => { 615 | let amount = store.formatPrice(store.getOrderTotal(), config.currency); 616 | let name = paymentMethods[paymentMethod].name; 617 | let label = `Pay ${amount}`; 618 | if (paymentMethod !== 'card') { 619 | label = `Pay ${amount} with ${name}`; 620 | } 621 | if (paymentMethod === 'wechat') { 622 | label = `Generate QR code to pay ${amount} with ${name}`; 623 | } 624 | if (paymentMethod === 'sepa_debit' && bankName) { 625 | label = `Debit ${amount} from ${bankName}`; 626 | } 627 | submitButton.innerText = label; 628 | }; 629 | 630 | // Show only the payment methods that are relevant to the selected country. 631 | const showRelevantPaymentMethods = country => { 632 | if (!country) { 633 | country = form.querySelector('select[name=country] option:checked').value; 634 | } 635 | const paymentInputs = form.querySelectorAll('input[name=payment]'); 636 | for (let i = 0; i < paymentInputs.length; i++) { 637 | let input = paymentInputs[i]; 638 | input.parentElement.classList.toggle( 639 | 'visible', 640 | input.value === 'card' || 641 | paymentMethods[input.value].countries.includes(country) 642 | ); 643 | } 644 | 645 | // Hide the tabs if card is the only available option. 646 | const paymentMethodsTabs = document.getElementById('payment-methods'); 647 | paymentMethodsTabs.classList.toggle( 648 | 'visible', 649 | paymentMethodsTabs.querySelectorAll('li.visible').length > 1 650 | ); 651 | 652 | // Check the first payment option again. 653 | paymentInputs[0].checked = 'checked'; 654 | form.querySelector('.payment-info.card').classList.add('visible'); 655 | form.querySelector('.payment-info.ideal').classList.remove('visible'); 656 | form.querySelector('.payment-info.sepa_debit').classList.remove('visible'); 657 | form.querySelector('.payment-info.wechat').classList.remove('visible'); 658 | form.querySelector('.payment-info.redirect').classList.remove('visible'); 659 | updateButtonLabel(paymentInputs[0].value); 660 | }; 661 | 662 | // Listen to changes to the payment method selector. 663 | for (let input of document.querySelectorAll('input[name=payment]')) { 664 | input.addEventListener('change', event => { 665 | event.preventDefault(); 666 | const payment = form.querySelector('input[name=payment]:checked').value; 667 | const flow = paymentMethods[payment].flow; 668 | 669 | // Update button label. 670 | updateButtonLabel(event.target.value); 671 | 672 | // Show the relevant details, whether it's an extra element or extra information for the user. 673 | form 674 | .querySelector('.payment-info.card') 675 | .classList.toggle('visible', payment === 'card'); 676 | form 677 | .querySelector('.payment-info.ideal') 678 | .classList.toggle('visible', payment === 'ideal'); 679 | form 680 | .querySelector('.payment-info.sepa_debit') 681 | .classList.toggle('visible', payment === 'sepa_debit'); 682 | form 683 | .querySelector('.payment-info.wechat') 684 | .classList.toggle('visible', payment === 'wechat'); 685 | form 686 | .querySelector('.payment-info.redirect') 687 | .classList.toggle('visible', flow === 'redirect'); 688 | form 689 | .querySelector('.payment-info.receiver') 690 | .classList.toggle('visible', flow === 'receiver'); 691 | document 692 | .getElementById('card-errors') 693 | .classList.remove('visible', payment !== 'card'); 694 | }); 695 | } 696 | 697 | // Select the default country from the config on page load. 698 | const countrySelector = document.getElementById('country'); 699 | countrySelector.querySelector(`option[value=${config.country}]`).selected = 700 | 'selected'; 701 | countrySelector.className = `field ${config.country}`; 702 | 703 | // Trigger the method to show relevant payment methods on page load. 704 | showRelevantPaymentMethods(); 705 | })(); 706 | -------------------------------------------------------------------------------- /public/images/flags.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------