├── .babelrc ├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── build.sh ├── env.example ├── items.yaml.example ├── npm-shrinkwrap.json ├── package.json ├── src ├── app.js ├── cli.js ├── client.js └── util.js ├── start.sh └── views ├── index.pug ├── payment.pug └── success.pug /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "browsers": ["last 3 versions"] 6 | , "node": "6" 7 | } 8 | }] 9 | ] 10 | , "plugins": ["transform-object-rest-spread"] 11 | } 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | items.yaml 3 | .env 4 | dist 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:carbon 2 | WORKDIR /opt/nanopos 3 | COPY . . 4 | ENV HOST=0.0.0.0 5 | RUN npm install 6 | RUN npm run dist 7 | EXPOSE 9116 8 | CMD ["node", "./dist/cli.js"] 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to deal 3 | in the Software without restriction, including without limitation the rights 4 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 5 | copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in 9 | all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 17 | THE SOFTWARE. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nanopos 2 | 3 | [![npm release](https://img.shields.io/npm/v/nanopos.svg)](https://www.npmjs.com/package/nanopos) 4 | [![MIT license](https://img.shields.io/github/license/ElementsProject/paypercall.svg)](https://github.com/ElementsProject/paypercall/blob/master/LICENSE) 5 | [![Pull Requests Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](http://makeapullrequest.com) 6 | [![IRC](https://img.shields.io/badge/chat-on%20freenode-brightgreen.svg)](https://webchat.freenode.net/?channels=lightning-charge) 7 | 8 | A simple Lightning :zap: point-of-sale system with a clean & minimal web UI. 9 | 10 | * Optimized for places selling fixed-price items (like coffee shops, falafel stands or barber shops), but also has an option for billing custom amounts. 11 | * Small codebase (~60 server-side LoC + ~70 client-side LoC), great starting point for developing your own Lightning Charge apps! 12 | 13 | Powered by :zap: [Lightning Charge](https://github.com/ElementsProject/lightning-charge). 14 | 15 | 16 | 17 | 18 | [See demo video here ➤](https://www.youtube.com/watch?v=ckYGyhbovrg) 19 | 20 | ## Setup 21 | 22 | ```bash 23 | $ npm install -g nanopos 24 | 25 | $ edit items.yaml # see file format below 26 | 27 | $ nanopos --items-path items.yaml --charge-token mySecretToken --currency USD 28 | HTTP server running on localhost:9116 29 | ``` 30 | 31 | ## Running with Docker 32 | 33 | Nanopos includes a Dockerfile to allow for fast setup using a docker container based on node:carbon. To run from the container with port 9116 exposed, first setup [Lightning Charge](https://github.com/ElementsProject/lightning-charge), then build the image with: 34 | 35 | ```bash 36 | $ docker build -t elements_project/nanopos . 37 | ``` 38 | 39 | and then run with: 40 | 41 | ```bash 42 | $ docker run -p9116:9116 -e CHARGE_URL=http://[charge-url]/ -e CHARGE_TOKEN=[access-token] elements_project/nanopos 43 | ``` 44 | 45 | That's it! The web server should now be running on port 9116 and ready to accept payments. 46 | 47 | ## Example `items.yaml` file 48 | 49 | ``` 50 | tea: 51 | price: 0.02 # denominated in the currency specified by --currency 52 | title: Green Tea # title is optional, defaults to the key 53 | 54 | coffee: 55 | price: 1 56 | 57 | bamba: 58 | price: 3 59 | 60 | beer: 61 | price: 7 62 | 63 | hat: 64 | price: 15 65 | 66 | tshirt (S): 67 | price: 25 68 | metadata: 69 | shirt_size: S 70 | ``` 71 | 72 | The `metadata` object will be added into the metadata of invoices for this item. 73 | 74 | ## CLI options 75 | 76 | ```bash 77 | $ nanopos --help 78 | 79 | A simple Lightning point-of-sale system, powered by Lightning Charge. 80 | 81 | Usage 82 | $ nanopos [options] 83 | 84 | Options 85 | -c, --charge-url lightning charge server url [default: http://localhost:9112] 86 | -t, --charge-token lightning charge access token [required] 87 | 88 | -y, --items-path path to yaml file with item config [default: ./items.yaml, file is required] 89 | -x, --currency currency to use for item prices [default: BTC] 90 | -m, --theme pick theme from bootswatch.com [default: yeti] 91 | -l, --title website title [default: Lightning Nano POS] 92 | --no-custom disable custom amount field [default: false] 93 | --show-bolt11 display bolt11 as text and button [default: false] 94 | 95 | -p, --port http server port [default: 9115] 96 | -i, --host http server listen address [default: 127.0.0.1] 97 | -h, --help output usage information 98 | -v, --version output version number 99 | 100 | Example 101 | $ nanopos -t chargeSecretToken -x EUR -y items.yaml 102 | ``` 103 | 104 | ## License 105 | 106 | MIT 107 | 108 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xeo pipefail 3 | 4 | rm -rf dist/* 5 | 6 | babel -d dist src 7 | 8 | browserify src/client.js | uglifyjs -cm > dist/client.bundle.min.js 9 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | export CHARGE_TOKEN=myAccessTokenForCharge 2 | export CHARGE_URL=http://localhost:9112 3 | 4 | #export PORT=9116 5 | #export HOST=127.0.0.1 6 | 7 | #export TITLE='My Lightning Shop' 8 | #export CURRENCY=USD 9 | #export ITEMS_PATH=items.yaml 10 | 11 | # see available themes on https://bootswatch.com 12 | #export THEME=yeti 13 | 14 | #export DEBUG=lightning-charge-client 15 | 16 | # disables the custom amount field 17 | #export NO_CUSTOM=1 18 | 19 | -------------------------------------------------------------------------------- /items.yaml.example: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | tea: 4 | price: 0.02 5 | title: Green Tea # title is optional, defaults to the keys 6 | 7 | coffee: 8 | price: 1 9 | 10 | bamba: 11 | price: 3 12 | 13 | beer: 14 | price: 7 15 | 16 | hat: 17 | price: 15 18 | 19 | tshirt (S): 20 | price: 25 21 | metadata: 22 | shirt_size: S 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nanopos", 3 | "version": "0.1.6", 4 | "description": "A simple Lightning point-of-sale system, powered by Lightning Charge.", 5 | "bin": "dist/cli.js", 6 | "scripts": { 7 | "start": "./start.sh", 8 | "dist": "./build.sh", 9 | "prepublishOnly": "npm run dist" 10 | }, 11 | "files": [ 12 | "dist", 13 | "views" 14 | ], 15 | "repository": "https://github.com/ElementsProject/nanopos.git", 16 | "keywords": [ 17 | "bitcoin", 18 | "lightning", 19 | "lightning-charge", 20 | "PoS", 21 | "point-of-sale" 22 | ], 23 | "author": "Nadav Ivgi", 24 | "license": "MIT", 25 | "dependencies": { 26 | "babel-polyfill": "^6.26.0", 27 | "body-parser": "^1.19.0", 28 | "bootswatch": "^4.3.1", 29 | "cookie-parser": "^1.4.4", 30 | "csurf": "^1.10.0", 31 | "currency-formatter": "^1.5.5", 32 | "express": "^4.17.1", 33 | "fmtbtc": "0.0.3", 34 | "js-yaml": "^3.13.1", 35 | "lightning-charge-client": "^0.1.12", 36 | "meow": "^4.0.1", 37 | "morgan": "^1.9.1", 38 | "only": "0.0.2", 39 | "popper.js": "^1.16.0", 40 | "pug": "^2.0.4" 41 | }, 42 | "devDependencies": { 43 | "babel-cli": "^6.26.0", 44 | "babel-plugin-syntax-object-rest-spread": "^6.13.0", 45 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 46 | "babel-preset-env": "^1.7.0", 47 | "babel-watch": "^2.0.8", 48 | "babelify": "^8.0.0", 49 | "bootstrap": "^4.3.1", 50 | "browserify-middleware": "^8.1.1", 51 | "jquery": "^3.4.1", 52 | "pugify": "^2.2.0", 53 | "qrcode": "^1.4.4" 54 | }, 55 | "browserify": { 56 | "transform": [ 57 | "babelify", 58 | "pugify" 59 | ] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import only from 'only' 4 | import { pwrap, fiatFormatter } from './util' 5 | 6 | const app = require('express')() 7 | , items = require('js-yaml').safeLoad(fs.readFileSync(process.env.ITEMS_PATH || 'items.yaml')) 8 | , charge = require('lightning-charge-client')(process.env.CHARGE_URL, process.env.CHARGE_TOKEN) 9 | 10 | Object.keys(items).filter(k => !items[k].title).forEach(k => items[k].title = k) 11 | 12 | app.set('port', process.env.PORT || 9116) 13 | app.set('host', process.env.HOST || 'localhost') 14 | app.set('title', process.env.TITLE || 'Lightning Nano POS') 15 | app.set('currency', process.env.CURRENCY || 'BTC') 16 | app.set('theme', process.env.THEME || 'yeti') 17 | app.set('views', path.join(__dirname, '..', 'views')) 18 | app.set('trust proxy', process.env.PROXIED || 'loopback') 19 | 20 | app.set('custom_amount', !process.env.NO_CUSTOM) 21 | app.set('show_bolt11', !!process.env.SHOW_BOLT11) 22 | 23 | app.locals.formatFiat = fiatFormatter(app.settings.currency) 24 | 25 | app.use(require('cookie-parser')()) 26 | app.use(require('body-parser').json()) 27 | app.use(require('body-parser').urlencoded({ extended: true })) 28 | 29 | app.use(require('morgan')('dev')) 30 | app.use(require('csurf')({ cookie: true })) 31 | 32 | app.get('/', (req, res) => res.render('index.pug', { req, items })) 33 | 34 | app.use('/bootswatch', require('express').static(path.resolve(require.resolve('bootswatch/package'), '..', 'dist'))) 35 | 36 | // use pre-compiled browserify bundle when available, or live-compile for dev 37 | const compiledBundle = path.join(__dirname, 'client.bundle.min.js') 38 | if (fs.existsSync(compiledBundle)) app.get('/script.js', (req, res) => res.sendFile(compiledBundle)) 39 | else app.get('/script.js', require('browserify-middleware')(require.resolve('./client'))) 40 | 41 | app.post('/invoice', pwrap(async (req, res) => { 42 | const item = req.body.item ? items[req.body.item] 43 | : app.enabled('custom_amount') ? { price: req.body.amount } 44 | : null 45 | 46 | if (!item) return res.sendStatus(404) 47 | 48 | const metadata = { source: 'nanopos', item: req.body.item } 49 | 50 | if (item.metadata) Object.assign(metadata, item.metadata) 51 | 52 | const inv = await charge.invoice({ 53 | amount: item.price 54 | , currency: item.price ? app.settings.currency : null 55 | , description: `${ app.settings.title }${ item.title ? ': ' + item.title : '' }` 56 | , expiry: 599 57 | , metadata 58 | }) 59 | res.send(only(inv, 'id payreq msatoshi quoted_currency quoted_amount expires_at')) 60 | })) 61 | 62 | app.get('/invoice/:invoice/wait', pwrap(async (req, res) => { 63 | const paid = await charge.wait(req.params.invoice, 100) 64 | res.sendStatus(paid === null ? 402 : paid ? 204 : 410) 65 | })) 66 | 67 | app.listen(app.settings.port, app.settings.host, _ => 68 | console.log(`HTTP server running on ${ app.settings.host }:${ app.settings.port }`)) 69 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const args = require('meow')(` 4 | Usage 5 | $ nanopos [options] 6 | 7 | Options 8 | -c, --charge-url lightning charge server url [default: http://localhost:9112] 9 | -t, --charge-token lightning charge access token [required] 10 | 11 | -y, --items-path path to yaml file with item config [default: ./items.yaml, file is required] 12 | -x, --currency currency to use for item prices [default: BTC] 13 | -m, --theme pick theme from bootswatch.com [default: yeti] 14 | -l, --title website title [default: Lightning Nano POS] 15 | --no-custom disable custom amount field [default: false] 16 | --show-bolt11 display bolt11 as text and button [default: false] 17 | 18 | -p, --port http server port [default: 9115] 19 | -i, --host http server listen address [default: 127.0.0.1] 20 | -h, --help output usage information 21 | -v, --version output version number 22 | 23 | Example 24 | $ nanopos -t chargeSecretToken -x EUR -y items.yaml 25 | 26 | `, { flags: { chargeUrl: {alias:'c'}, chargeToken: {alias:'t'} 27 | , itemsPath: {alias:'y'}, currency: {alias:'x'}, theme: {alias:'m'}, title: {alias:'l'} 28 | , port: {alias:'p'}, host: {alias:'i'} } } 29 | ).flags 30 | 31 | Object.keys(args).filter(k => k.length > 1) 32 | .map(k => [ k.replace(/([A-Z])/g, '_$1').toUpperCase(), args[k] ]) 33 | .forEach(([ k, v ]) => v !== false ? process.env[k] = v 34 | : process.env[`NO_${k}`] = true) 35 | 36 | process.env.NODE_ENV || (process.env.NODE_ENV = 'production') 37 | 38 | require('babel-polyfill') 39 | require('./app') 40 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | require('babel-polyfill') 2 | 3 | const $ = require('jquery') 4 | , qrcode = require('qrcode') 5 | 6 | require('popper.js') 7 | require('bootstrap') 8 | 9 | const payDialog = require('../views/payment.pug') 10 | , paidDialog = require('../views/success.pug') 11 | 12 | const csrf = $('meta[name=csrf]').attr('content') 13 | , show_bolt11 = !!$('meta[name=show-bolt11]').attr('content') 14 | 15 | $('[data-buy-item]').click(e => { 16 | e.preventDefault() 17 | pay({ item: $(e.target).data('buy-item') }) 18 | }) 19 | $('[data-buy]').submit(e => { 20 | e.preventDefault() 21 | pay({ amount: $(e.target).find('[name=amount]').val() }) 22 | }) 23 | 24 | const pay = async data => { 25 | $('[data-buy-item], [data-buy] :input').prop('disabled', true) 26 | 27 | try { 28 | const inv = await $.post('invoice', { ...data, _csrf: csrf }) 29 | , qr = await qrcode.toDataURL(`lightning:${ inv.payreq }`.toUpperCase(), { margin: 2, width: 300 }) 30 | , diag = $(payDialog({ ...inv, qr, show_bolt11 })).modal() 31 | 32 | updateExp(diag.find('[data-countdown-to]')) 33 | 34 | const unlisten = listen(inv.id, paid => (diag.modal('hide'), paid && success())) 35 | diag.on('hidden.bs.modal', _ => { 36 | unlisten() 37 | $('[data-buy] :input').val('') 38 | }) 39 | } 40 | finally { $(':disabled').attr('disabled', false) } 41 | } 42 | 43 | const listen = (invid, cb) => { 44 | let retry = _ => listen(invid, cb) 45 | const req = $.get(`invoice/${ invid }/wait`) 46 | 47 | req.then(_ => cb(true)) 48 | .catch(err => 49 | err.status === 402 ? retry() // long polling timed out, invoice is still payable 50 | : err.status === 410 ? cb(false) // invoice expired and can no longer be paid 51 | : err.statusText === 'abort' ? null // user aborted, do nothing 52 | : setTimeout(retry, 10000)) // unknown error, re-poll after delay 53 | 54 | return _ => (retry = _ => null, req.abort()) 55 | } 56 | 57 | const success = _ => { 58 | const diag = $(paidDialog()).modal() 59 | setTimeout(_ => diag.modal('hide'), 5000) 60 | } 61 | 62 | const updateExp = el => { 63 | const left = +el.data('countdown-to') - (Date.now()/1000|0) 64 | if (left > 0) el.text(formatDur(left)) 65 | else el.closest('.modal').modal('hide') 66 | } 67 | 68 | const formatDur = x => { 69 | const h=x/3600|0, m=x%3600/60|0, s=x%60 70 | return ''+(h>0?h+':':'')+(m<10&&h>0?'0':'')+m+':'+(s<10?'0':'')+s 71 | } 72 | 73 | setInterval(_ => 74 | $('[data-countdown-to]').each((_, el) => 75 | updateExp($(el))) 76 | , 1000) 77 | 78 | $(document).on('hidden.bs.modal', '.modal', e => $(e.target).remove()) 79 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import Currency from 'currency-formatter' 2 | 3 | const pwrap = fn => (req, res, next) => fn(req, res).catch(next) 4 | 5 | const fiatFormatter = currency => amount => Currency.format(amount, { code: currency.toUpperCase() }) 6 | 7 | module.exports = { pwrap, fiatFormatter } 8 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | [ -f .env ] && source .env 3 | babel-watch --extensions .js,.pug,.yaml --watch src --watch views --watch . --exclude node_modules src/cli.js -- "$@" 4 | -------------------------------------------------------------------------------- /views/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | 3 | html.h-100 4 | title= settings.title 5 | meta(charset='utf-8') 6 | meta(name='viewport', content='width=device-width, initial-scale=1') 7 | meta(name='csrf', content=req.csrfToken()) 8 | meta(name='show-bolt11', content=settings.show_bolt11?1:'') 9 | meta(name='apple-mobile-web-app-capable', content='yes') 10 | link(rel='stylesheet', href='bootswatch/'+settings.theme+'/bootstrap.min.css') 11 | 12 | body.h-100 13 | .container.d-flex.h-100: .justify-content-center.align-self-center.text-center.mx-auto.w-100 14 | 15 | h1.mb-4= settings.title 16 | 17 | .row 18 | for item, itemid in items 19 | .col-sm-4.mb-3 20 | h3= item.title 21 | button.btn.btn-secondary(data-buy-item=itemid) Buy for #{ formatFiat(item.price) } 22 | 23 | if settings.custom_amount 24 | .row.mt-4: .col-md-4.offset-md-4.col-sm-6.offset-sm-3 25 | h3 Something else 26 | form(data-buy): .input-group 27 | input.form-control(type='number', step='0.00001', name='amount', placeholder=settings.currency+' (optional)') 28 | .input-group-append 29 | button.btn.btn-primary(type='submit') Pay 30 | 31 | script(src='script.js') 32 | -------------------------------------------------------------------------------- /views/payment.pug: -------------------------------------------------------------------------------- 1 | - msat2sat = require('fmtbtc').msat2sat 2 | 3 | .modal.fade 4 | .modal-dialog.modal-sm 5 | .modal-content 6 | .modal-body.text-center 7 | h5 Pay with Lightning 8 | if msatoshi 9 | if quoted_currency && quoted_currency != 'BTC' 10 | p.font-weight-light.small #{ quoted_amount } #{ quoted_currency } ≈ #{ msat2sat(msatoshi, true) } satoshis 11 | else 12 | p.font-weight-light.small #{ msat2sat(msatoshi, true) } satoshis 13 | img.d-block.w-100.mb-3(src=qr) 14 | if show_bolt11 15 | .input-group 16 | input.form-control(type='text', value=payreq, readonly) 17 | .input-group-append: a.btn.btn-primary(href='lightning:'+payreq) ⚡ 18 | p.text-muted.small.font-weight-light.mb-0 Invoice expires in #[span(data-countdown-to=expires_at)] 19 | -------------------------------------------------------------------------------- /views/success.pug: -------------------------------------------------------------------------------- 1 | .modal.fade 2 | .modal-dialog.modal-sm 3 | .modal-content.alert.alert-success 4 | .modal-body.text-center 5 | h5 Payment successful! 6 | p.mb-0 Thank you for using Lightning. 7 | --------------------------------------------------------------------------------