diff --git a/locales/en.json b/locales/en.json index b1a3ae9e7..72e00175a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -890,6 +890,8 @@ "views.Settings.Help.telegram": "Telegram (we will not DM you)", "views.Settings.Help.email": "Email support", "views.Settings.POS.enableSquare": "Enable Square POS integration", + "views.Settings.POS.taxPercentage.global.info": "Global tax rate applied to all products. Individual product tax rates will override this setting when specified.", + "views.Settings.POS.taxPercentage.info": "Individual tax rate for this product. When set, this will override the global tax rate. Leave empty to use the global tax rate.", "views.Settings.POS.enablePos": "Enable POS integration", "views.Settings.POS.squareAccessToken": "Square Access token", "views.Settings.POS.squareLocationId": "Square Location ID", @@ -1238,6 +1240,8 @@ "pos.views.Wallet.PosPane.fetchingRates": "Fetching exchange rates", "pos.views.Wallet.PosPane.uncategorized": "Uncategorized", "pos.views.Order.tax": "Tax", + "pos.views.Order.taxFiat": "Tax (fiat)", + "pos.views.Order.taxBitcoin": "Tax (Bitcoin)", "pos.views.Order.subtotalBitcoin": "Subtotal (Bitcoin)", "pos.views.Order.subtotalFiat": "Subtotal (fiat)", "pos.views.Order.addTip": "Add Tip", diff --git a/models/Order.ts b/models/Order.ts index 675d7fc99..931581307 100644 --- a/models/Order.ts +++ b/models/Order.ts @@ -14,6 +14,7 @@ export interface LineItem { name: string; quantity: number; base_price_money: BasePriceMoney; + taxPercentage?: string; } export default class Order extends BaseModel { diff --git a/models/Product.ts b/models/Product.ts index 120ceabdd..a913b4da0 100644 --- a/models/Product.ts +++ b/models/Product.ts @@ -20,7 +20,7 @@ export default class Product extends BaseModel { @observable public price: string; @observable public category: string; @observable public status: ProductStatus; - + @observable public taxPercentage?: string; @computed public get model(): string { return 'Product'; } diff --git a/stores/InventoryStore.ts b/stores/InventoryStore.ts index f9df322f8..cb09b9bfd 100644 --- a/stores/InventoryStore.ts +++ b/stores/InventoryStore.ts @@ -107,6 +107,7 @@ export default class InventoryStore { found.pricedIn = newProduct.pricedIn; found.category = newProduct.category; found.status = newProduct.status; + found.taxPercentage = newProduct.taxPercentage; } else { existingProducts.push(newProduct); } diff --git a/stores/PosStore.ts b/stores/PosStore.ts index b0a9abe37..a3d3149af 100644 --- a/stores/PosStore.ts +++ b/stores/PosStore.ts @@ -190,21 +190,60 @@ export default class PosStore { this.currentOrder.total_money.amount = totalFiat.toNumber(); this.currentOrder.total_money.sats = totalSats.toNumber(); - // calculate taxes + // calculate taxes using individual product rates when available const { settings } = this.settingsStore; const { taxPercentage } = settings.pos; + // Check if any line items have individual tax rates + const hasIndividualTaxRates = this.currentOrder.line_items.some( + (item) => item.taxPercentage && item.taxPercentage !== '' + ); + if ( - taxPercentage && - taxPercentage !== '0' && - taxPercentage !== '' + hasIndividualTaxRates || + (taxPercentage && taxPercentage !== '0' && taxPercentage !== '') ) { - this.currentOrder.total_tax_money.amount = new BigNumber( - totalFiat - ) - .div(100) - .multipliedBy(taxPercentage) - .toNumber(); + let totalTaxFiat = new BigNumber(0); + + if (hasIndividualTaxRates) { + this.currentOrder.line_items.forEach((item) => { + const itemTaxRate = + item.taxPercentage && item.taxPercentage !== '' + ? item.taxPercentage + : taxPercentage || '0'; + + let itemSubtotalFiat: BigNumber; + + if (item.base_price_money.sats! > 0) { + const satsAmount = new BigNumber( + item.base_price_money.sats || 0 + ).times(item.quantity); + itemSubtotalFiat = new BigNumber( + this.calcFiatAmountFromSats( + satsAmount.toNumber() + ) + ).div(100); + } else { + itemSubtotalFiat = new BigNumber( + item.base_price_money.amount || 0 + ).times(item.quantity); + } + + const itemTaxFiat = itemSubtotalFiat + .multipliedBy(new BigNumber(itemTaxRate)) + .dividedBy(100); + + totalTaxFiat = totalTaxFiat.plus(itemTaxFiat); + }); + } else { + totalTaxFiat = totalFiat + .div(100) + .multipliedBy(Number(taxPercentage) || 0); + } + + this.currentOrder.total_tax_money.amount = + totalTaxFiat.toNumber(); + if (this.fiatStore.fiatRates) { const fiatEntry = this.fiatStore.fiatRates.filter( (entry: any) => diff --git a/views/Order.tsx b/views/Order.tsx index 29cd5b4fd..2c1a70e05 100644 --- a/views/Order.tsx +++ b/views/Order.tsx @@ -24,9 +24,9 @@ import TextInput from '../components/TextInput'; import { localeString } from '../utils/LocaleUtils'; import { themeColor } from '../utils/ThemeUtils'; import { SATS_PER_BTC } from '../utils/UnitsUtils'; -import { calculateTotalSats } from '../utils/TipUtils'; import BackendUtils from '../utils/BackendUtils'; +import { calculateTotalSats } from '../utils/TipUtils'; import FiatStore from '../stores/FiatStore'; import SettingsStore, { PosEnabled } from '../stores/SettingsStore'; import UnitsStore from '../stores/UnitsStore'; @@ -186,16 +186,77 @@ export default class OrderView extends React.Component { .dividedBy(SATS_PER_BTC) .toFixed(2); - const taxSats = fiatEnabled // Use the defined fiatEnabled - ? new BigNumber(order.total_tax_money.amount) - .div(100) - .div(rate) - .multipliedBy(SATS_PER_BTC) - .toFixed(0) - : new BigNumber(subTotalSats) - .multipliedBy(new BigNumber(taxPercentage || '0')) - .dividedBy(100) - .toFixed(0); + // Calculate tax using individual product rates when available (for receipt) + const calculateTaxSatsForReceipt = () => { + const hasIndividualTaxRatesForReceipt = lineItems?.some( + (item: any) => item.taxPercentage + ); + + if (fiatEnabled && !hasIndividualTaxRatesForReceipt) { + return new BigNumber(order.total_tax_money.amount) + .div(100) + .div(rate) + .multipliedBy(SATS_PER_BTC) + .toFixed(0); + } + + // Check if any line items have individual tax rates + const hasIndividualTaxRates = lineItems?.some( + (item: any) => item.taxPercentage + ); + + if (hasIndividualTaxRates) { + let totalTaxSats = new BigNumber(0); + lineItems?.forEach((item: any) => { + // Use individual tax rate if set and not empty, otherwise use global rate + const itemTaxRate = + item.taxPercentage || taxPercentage || '0'; + + const validTaxRate = itemTaxRate || '0'; + + const fiatPriced = item.base_price_money.amount > 0; + let itemSubtotalSats: string; + + if (fiatPriced) { + let fiatAmount = new BigNumber( + item.base_price_money.amount + ).multipliedBy(item.quantity); + if (settings.pos.posEnabled === PosEnabled.Square) { + fiatAmount = fiatAmount.div(100); + } + itemSubtotalSats = fiatAmount + .div(rate) + .multipliedBy(SATS_PER_BTC) + .integerValue(BigNumber.ROUND_HALF_UP) + .toFixed(0); + } else { + itemSubtotalSats = new BigNumber( + item.base_price_money.sats || 0 + ) + .multipliedBy(item.quantity) + .toFixed(0); + } + + const itemTaxSats = new BigNumber(itemSubtotalSats) + .multipliedBy(new BigNumber(validTaxRate)) + .dividedBy(100) + .integerValue(BigNumber.ROUND_HALF_UP) + .toFixed(0); + + totalTaxSats = totalTaxSats.plus(itemTaxSats); + }); + + return totalTaxSats.toFixed(0); + } else { + // Use global tax rate for all items + return new BigNumber(subTotalSats) + .multipliedBy(new BigNumber(taxPercentage || '0')) + .dividedBy(100) + .toFixed(0); + } + }; + + const taxSats = calculateTaxSatsForReceipt(); // sats // total amount is subtotal + tip + tax @@ -345,7 +406,6 @@ export default class OrderView extends React.Component { 'sats' ); } - templateHtml += receiptHtmlRow(keyValue, displayValue); }); @@ -392,13 +452,55 @@ export default class OrderView extends React.Component { ); } + // Determine tax display label for receipt + const getReceiptTaxLabel = () => { + if (!lineItems || lineItems.length === 0) { + return localeString('pos.views.Order.tax'); + } + + // Get all unique tax rates used in the order + const taxRates = new Set(); + lineItems.forEach((item: any) => { + // Use individual tax rate if set and not empty, otherwise use global rate + const itemTaxRate = item.taxPercentage || taxPercentage || '0'; + + // Ensure valid rate for display + const displayTaxRate = itemTaxRate || '0'; + taxRates.add(displayTaxRate); + }); + + const uniqueRates = Array.from(taxRates); + + if (uniqueRates.length === 1) { + // All items have the same tax rate + const rate = uniqueRates[0]; + if (rate === '0') { + return localeString('pos.views.Order.tax'); + } + return `${localeString('pos.views.Order.tax')} (${rate}%)`; + } else { + // Multiple different tax rates - show breakdown + const rateList = uniqueRates + .filter((rate) => rate !== '0') + .map((rate) => `${rate}%`) + .join(', '); + + if (rateList === '') { + return localeString('pos.views.Order.tax'); + } + + return `${localeString('pos.views.Order.tax')} (${rateList})`; + } + }; + templateHtml += receiptHtmlRow( - `${localeString('pos.views.Order.tax')}${ - taxPercentage && Number(taxPercentage) > 0 - ? ` (${taxPercentage}%)` - : '' - }`, - order.getTaxMoneyDisplay + getReceiptTaxLabel(), + this.props.FiatStore.formatAmountForDisplay( + new BigNumber(taxSats) + .multipliedBy(rate) + .dividedBy(SATS_PER_BTC) + .toFixed(2) + ) ); templateHtml += receiptHtmlRow( @@ -500,15 +602,14 @@ export default class OrderView extends React.Component { ? `${merchantName} POS powered by ZEUS - Order ${order?.id}` : `ZEUS POS - Order ${order?.id}`; - // round to nearest sat let subTotalSats: string; if (settings.pos.posEnabled === PosEnabled.Square) { subTotalSats = new BigNumber(order?.total_money.amount) - // subtract tax for subtotal if using Square .minus(order?.total_tax_money.amount) .div(100) .div(rate) .multipliedBy(SATS_PER_BTC) + .integerValue(BigNumber.ROUND_HALF_UP) .toFixed(0); } else { subTotalSats = @@ -518,6 +619,7 @@ export default class OrderView extends React.Component { .div(100) .div(rate) .multipliedBy(SATS_PER_BTC) + .integerValue(BigNumber.ROUND_HALF_UP) .toFixed(0); } @@ -526,16 +628,63 @@ export default class OrderView extends React.Component { .dividedBy(SATS_PER_BTC) .toFixed(2); - const taxSats = fiatEnabled - ? new BigNumber(order?.total_tax_money.amount) - .div(100) - .div(rate) - .multipliedBy(SATS_PER_BTC) - .toFixed(0) - : new BigNumber(subTotalSats) - .multipliedBy(new BigNumber(taxPercentage || '0')) - .dividedBy(100) - .toFixed(0); + const calculateTaxSats = () => { + const hasIndividualTaxRates = lineItems?.some( + (item: any) => item.taxPercentage + ); + + if (hasIndividualTaxRates) { + let totalTaxSats = new BigNumber(0); + lineItems?.forEach((item: any) => { + const itemTaxRate = + item.taxPercentage || taxPercentage || '0'; + + const validTaxRate = itemTaxRate || '0'; + + const fiatPriced = item.base_price_money.amount > 0; + let itemSubtotalSats: string; + + if (fiatPriced) { + let fiatAmount = new BigNumber( + item.base_price_money.amount + ).multipliedBy(item.quantity); + // Only divide by 100 if using Square (amount is in cents) + if (settings.pos.posEnabled === PosEnabled.Square) { + fiatAmount = fiatAmount.div(100); + } + itemSubtotalSats = fiatAmount + .div(rate) + .multipliedBy(SATS_PER_BTC) + .integerValue(BigNumber.ROUND_HALF_UP) + .toFixed(0); + } else { + itemSubtotalSats = new BigNumber( + item.base_price_money.sats || 0 + ) + .multipliedBy(item.quantity) + .toFixed(0); + } + + const itemTaxSats = new BigNumber(itemSubtotalSats) + .multipliedBy(new BigNumber(validTaxRate)) + .dividedBy(100) + .integerValue(BigNumber.ROUND_HALF_UP) + .toFixed(0); + + totalTaxSats = totalTaxSats.plus(itemTaxSats); + }); + + return totalTaxSats.toFixed(0); + } else { + return new BigNumber(subTotalSats) + .multipliedBy(new BigNumber(taxPercentage || '0')) + .dividedBy(100) + .integerValue(BigNumber.ROUND_HALF_UP) + .toFixed(0); + } + }; + + const taxSats = calculateTaxSats(); const twentyPercentButton = () => ( { : item.base_price_money.amount : item.base_price_money.sats; + const itemTaxRate = item.taxPercentage + ? ` + ${item.taxPercentage}% ${localeString( + 'pos.views.Order.tax' + )}` + : taxPercentage + ? ` + ${taxPercentage}% ${localeString( + 'pos.views.Order.tax' + )}` + : ''; + let unitDisplayValue, totalDisplayValue; if (fiatPriced) { - unitDisplayValue = UnitsStore.getFormattedAmount( - new BigNumber(unitPrice).toFixed(2), - 'fiat' - ); - totalDisplayValue = UnitsStore.getFormattedAmount( - new BigNumber(unitPrice) - .multipliedBy(item.quantity) - .toFixed(2), - 'fiat' + unitDisplayValue = FiatStore.formatAmountForDisplay( + new BigNumber(unitPrice).toFixed(2) ); + totalDisplayValue = + FiatStore.formatAmountForDisplay( + new BigNumber(unitPrice) + .multipliedBy(item.quantity) + .toFixed(2) + ) + itemTaxRate; } else { unitDisplayValue = UnitsStore.getFormattedAmount( unitPrice, 'sats' ); - totalDisplayValue = UnitsStore.getFormattedAmount( - new BigNumber(unitPrice) - .multipliedBy(item.quantity) - .toString(), - 'sats' + const baseDisplayValue = + bitcoinUnits === 'sats' ? ( + + ) : ( + + ); + totalDisplayValue = ( + + + {baseDisplayValue} + + + {itemTaxRate} + + ); } @@ -1091,22 +1284,47 @@ export default class OrderView extends React.Component { )} - 0 - ? ` (${taxPercentage}%)` - : '' - }`} - value={ - fiatEnabled ? ( - order.getTaxMoneyDisplay - ) : bitcoinUnits === 'sats' ? ( - - ) : ( - - ) - } - /> + {fiatEnabled && ( + + )} + + { + this.setState({ + bitcoinUnits: + this.state.bitcoinUnits === 'sats' + ? 'BTC' + : 'sats' + }); + }} + > + item.taxPercentage + ) + ? localeString('pos.views.Order.taxBitcoin') + : `${localeString( + 'pos.views.Order.tax' + )} (${taxPercentage || '0'}%)` + } + value={ + bitcoinUnits === 'sats' ? ( + + ) : ( + + ) + } + /> + {fiatEnabled && ( + + {localeString( + 'views.Settings.POS.taxPercentage' + )} + + { + this.setValue( + 'taxPercentage', + text + ); + }} + suffix="%" + right={20} + style={styles.textInput} + /> {localeString( 'views.Settings.POS.taxPercentage' diff --git a/views/Wallet/StandalonePosPane.tsx b/views/Wallet/StandalonePosPane.tsx index b2e7863af..77e315dcc 100644 --- a/views/Wallet/StandalonePosPane.tsx +++ b/views/Wallet/StandalonePosPane.tsx @@ -307,12 +307,20 @@ export default class StandalonePosPane extends React.PureComponent< product.price.toString().replace(/,/g, '.') ); - const item = order.line_items.find( - (item) => - item.name === product.name && - (item.base_price_money.amount === productCalcPrice || - item.base_price_money.sats === productCalcPrice) - ); + const item = order.line_items.find((item) => { + const nameMatches = item.name === product.name; + const taxMatches = item.taxPercentage === product.taxPercentage; + + let priceMatches = false; + if (product.pricedIn === PricedIn.Fiat) { + priceMatches = + item.base_price_money.amount === productCalcPrice; + } else { + priceMatches = item.base_price_money.sats === productCalcPrice; + } + + return nameMatches && priceMatches && taxMatches; + }); if (item) { item.quantity++; @@ -320,6 +328,7 @@ export default class StandalonePosPane extends React.PureComponent< order.line_items.push({ name: product.name, quantity: 1, + taxPercentage: product.taxPercentage || '', base_price_money: { amount: product.pricedIn === PricedIn.Fiat