From dfca1accf2c2af4fac5f81055293898c83387e90 Mon Sep 17 00:00:00 2001 From: Michael Little Date: Sun, 28 Jan 2024 19:01:39 +1100 Subject: [PATCH 1/8] Initial receipt printer implementation --- locales/en.json | 2 + package.json | 1 + stores/PosStore.ts | 8 ++ stores/SettingsStore.ts | 4 +- views/Order.tsx | 168 ++++++++++++++++++++++++++++++++- views/Receive.tsx | 30 ++++++ views/Settings/PointOfSale.tsx | 85 +++++++++++++++-- yarn.lock | 5 + 8 files changed, 289 insertions(+), 14 deletions(-) diff --git a/locales/en.json b/locales/en.json index bd808ded8..8d5b0d4fb 100644 --- a/locales/en.json +++ b/locales/en.json @@ -727,6 +727,7 @@ "views.Settings.POS.taxPercentage": "Tax percentage", "views.Settings.POS.devMode": "Developer mode", "views.Settings.POS.showKeypad": "Show keypad", + "views.Settings.POS.disablePrinter": "Disable printer", "views.Settings.POS.recon": "Reconciliation", "views.Settings.POS.reconExport": "Reconciliation Export", "views.Settings.POS.Categories": "Categories", @@ -934,6 +935,7 @@ "pos.views.Order.totalBitcoin": "Total (Bitcoin)", "pos.views.Order.total": "Total", "pos.views.Order.paymentType": "Payment type", + "pos.views.Order.printReceipt": "Print Receipt", "pos.views.Settings.PointOfSale.authWarning": "Warning: no password or PIN set", "pos.views.Settings.PointOfSale.backendWarning": "Warning: currently only LND nodes are able to mark orders as paid", "pos.views.Settings.PointOfSale.currencyError": "Error: currency must be set first", diff --git a/package.json b/package.json index 0d84bfede..34c5997ce 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "react-native-notifications": "5.1.0", "react-native-os": "aprock/react-native-os#5/head", "react-native-permissions": "3.8.0", + "react-native-print": "^0.11.0", "react-native-qrcode-svg": "6.2.0", "react-native-randombytes": "3.5.3", "react-native-reanimated": "3.5.4", diff --git a/stores/PosStore.ts b/stores/PosStore.ts index d64130f9a..153b74d71 100644 --- a/stores/PosStore.ts +++ b/stores/PosStore.ts @@ -543,6 +543,14 @@ export default class PosStore { }); }; + @action + public getOrderById = (orderId: string): Order | undefined => { + return ( + this.openOrders.find((order) => order.id === orderId) || + this.paidOrders.find((order) => order.id === orderId) + ); + }; + resetOrders = () => { this.openOrders = []; this.paidOrders = []; diff --git a/stores/SettingsStore.ts b/stores/SettingsStore.ts index 17c0de5ea..75d11f52e 100644 --- a/stores/SettingsStore.ts +++ b/stores/SettingsStore.ts @@ -59,6 +59,7 @@ interface PosSettings { squareDevMode?: boolean; showKeypad?: boolean; taxPercentage?: string; + disablePrinter?: boolean; } interface PaymentsSettings { @@ -763,7 +764,8 @@ export default class SettingsStore { disableTips: false, squareDevMode: false, showKeypad: true, - taxPercentage: '0' + taxPercentage: '0', + disablePrinter: false }, payments: { defaultFeeMethod: 'fixed', // deprecated diff --git a/views/Order.tsx b/views/Order.tsx index 624d18aad..716e879ea 100644 --- a/views/Order.tsx +++ b/views/Order.tsx @@ -4,7 +4,8 @@ import { ScrollView, Text, View, - TouchableOpacity + TouchableOpacity, + Platform } from 'react-native'; import { ButtonGroup } from 'react-native-elements'; import { inject, observer } from 'mobx-react'; @@ -25,6 +26,8 @@ import SettingsStore, { PosEnabled } from '../stores/SettingsStore'; import FiatStore from '../stores/FiatStore'; import UnitsStore, { SATS_PER_BTC } from '../stores/UnitsStore'; +import RNPrint from 'react-native-print'; + interface OrderProps { navigation: any; SettingsStore: SettingsStore; @@ -39,6 +42,7 @@ interface OrderState { customAmount: string; customType: string; bitcoinUnits: string; + print: boolean; } @inject('FiatStore', 'SettingsStore', 'UnitsStore') @@ -48,9 +52,13 @@ export default class OrderView extends React.Component { super(props); const { SettingsStore, navigation } = props; const order = navigation.getParam('order', null); + const print = navigation.getParam('print', false); + const { settings } = SettingsStore; const disableTips: boolean = settings && settings.pos && settings.pos.disableTips; + const disablePrinter: boolean = + settings && settings.pos && settings.pos.disablePrinter; this.state = { order, @@ -58,10 +66,28 @@ export default class OrderView extends React.Component { customPercentage: disableTips ? '0' : '21', customAmount: '', customType: 'percentage', - bitcoinUnits: 'sats' + bitcoinUnits: 'sats', + print }; } + componentDidUpdate(prevProps: OrderProps) { + if ( + this.props.navigation.getParam('print', false) !== + prevProps.navigation.getParam('print', false) + ) { + const order = this.props.navigation.getParam( + 'order', + this.state.order + ); + const print = this.props.navigation.getParam('print', false); + this.setState({ + order, + print + }); + } + } + render() { const { navigation, FiatStore, SettingsStore, UnitsStore } = this.props; const { @@ -77,6 +103,7 @@ export default class OrderView extends React.Component { const { changeUnits, units } = UnitsStore; const fiat = settings.fiat; const disableTips: boolean = settings?.pos?.disableTips || false; + const disablePrinter: boolean = settings?.pos?.disablePrinter || false; const merchantName = settings?.pos?.merchantName; const taxPercentage = settings?.pos?.taxPercentage; @@ -102,7 +129,7 @@ export default class OrderView extends React.Component { : `ZEUS POS - Order ${order.id}`; // round to nearest sat - let subTotalSats; + let subTotalSats: string; if (settings.pos.posEnabled === PosEnabled.Square) { subTotalSats = new BigNumber(order.total_money.amount) // subtract tax for subtotal if using Square @@ -312,6 +339,132 @@ export default class OrderView extends React.Component { .dividedBy(SATS_PER_BTC) .toFixed(2); + const receiptHtmlRow = (key: string, value: any) => { + return ` + + ${key} + ${value} + + `; + }; + + const receiptDivider = () => { + return ` + +
+ + `; + }; + + const receiptFormatAmount = (units: string, amount: number) => { + return UnitsStore.getFormattedAmount(amount, units); + }; + + const printReceipt = () => { + let templateHtml = ` + +

Tax receipt

+ `; + + lineItems.forEach((item: any) => { + const keyValue = + item.quantity > 1 + ? `${item.name} (x${item.quantity})` + : item.name; + + const fiatPriced = item.base_price_money.amount > 0; + + const unitPrice = fiatPriced + ? item.base_price_money.amount + : item.base_price_money.sats; + + let displayValue; + if (fiatPriced) { + displayValue = UnitsStore.getFormattedAmount( + unitPrice, + 'fiat' + ); + } else { + displayValue = UnitsStore.getFormattedAmount( + unitPrice, + 'sats' + ); + } + + templateHtml += receiptHtmlRow(keyValue, displayValue); + }); + + templateHtml += receiptDivider(); + templateHtml += receiptHtmlRow( + localeString('general.conversionRate'), + exchangeRate + ); + templateHtml += receiptHtmlRow( + localeString('pos.views.Order.subtotalFiat'), + FiatStore.formatAmountForDisplay(subTotalFiat) + ); + templateHtml += receiptHtmlRow( + localeString('pos.views.Order.subtotalBitcoin'), + receiptFormatAmount(bitcoinUnits, Number(subTotalSats)) + ); + + templateHtml += receiptDivider(); + + if (!disableTips) { + templateHtml += receiptHtmlRow( + localeString('pos.views.Order.tipFiat'), + FiatStore.formatAmountForDisplay(tipFiat) + ); + templateHtml += receiptHtmlRow( + localeString('pos.views.Order.tipBitcoin'), + receiptFormatAmount(bitcoinUnits, Number(tipSats)) + ); + } + + templateHtml += receiptHtmlRow( + localeString('pos.views.Order.paymentType'), + order.payment.type === 'ln' + ? localeString('general.lightning') + : localeString('general.onchain') + ); + /* + templateHtml += receiptHtmlRow( + order.payment.type === 'ln' + ? localeString('views.Send.lnPayment') + : localeString('views.SendingOnChain.txid'), + order.payment.tx + );*/ + + templateHtml += receiptHtmlRow( + `${localeString('pos.views.Order.tax')}${ + taxPercentage && Number(taxPercentage) > 0 + ? ` (${taxPercentage}%)` + : '' + }`, + order.getTaxMoneyDisplay + ); + + templateHtml += receiptHtmlRow( + localeString('pos.views.Order.totalFiat'), + FiatStore.formatAmountForDisplay(totalFiat) + ); + + templateHtml += receiptHtmlRow( + localeString('pos.views.Order.totalBitcoin'), + receiptFormatAmount(bitcoinUnits, Number(totalSats)) + ); + + templateHtml += `
`; + + RNPrint.print({ + html: templateHtml + }); + }; + + if (this.state.print && isPaid && !disablePrinter) { + printReceipt(); + } + return (
{ disabled={isNaN(Number(totalSats))} /> )} + + {isPaid && Platform.OS === 'android' && !disablePrinter && ( +