├── .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 | [](https://www.npmjs.com/package/nanopos)
4 | [](https://github.com/ElementsProject/paypercall/blob/master/LICENSE)
5 | [](http://makeapullrequest.com)
6 | [](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 |
--------------------------------------------------------------------------------