enhancement: add ability to set tax rate for individual items

This commit is contained in:
ajaysehwal
2025-07-02 17:08:33 +05:30
parent 84b4a1890b
commit c7666bdc43
9 changed files with 415 additions and 81 deletions
+4
View File
@@ -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",
+1
View File
@@ -14,6 +14,7 @@ export interface LineItem {
name: string;
quantity: number;
base_price_money: BasePriceMoney;
taxPercentage?: string;
}
export default class Order extends BaseModel {
+1 -1
View File
@@ -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';
}
+1
View File
@@ -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);
}
+49 -10
View File
@@ -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) =>
+278 -60
View File
@@ -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<OrderProps, OrderState> {
.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<OrderProps, OrderState> {
'sats'
);
}
templateHtml += receiptHtmlRow(keyValue, displayValue);
});
@@ -392,13 +452,55 @@ export default class OrderView extends React.Component<OrderProps, OrderState> {
);
}
// 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<string>();
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<OrderProps, OrderState> {
? `${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<OrderProps, OrderState> {
.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<OrderProps, OrderState> {
.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 = () => (
<Text
@@ -743,28 +892,72 @@ export default class OrderView extends React.Component<OrderProps, OrderState> {
: 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' ? (
<Amount
fixedUnits="sats"
sats={new BigNumber(unitPrice)
.multipliedBy(item.quantity)
.toString()}
/>
) : (
<Amount
fixedUnits="BTC"
sats={new BigNumber(unitPrice)
.multipliedBy(item.quantity)
.toString()}
/>
);
totalDisplayValue = (
<View
style={{
display: 'flex',
flexDirection: 'row'
}}
>
<Text
style={{
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{baseDisplayValue}
</Text>
<Text
style={{
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{itemTaxRate}
</Text>
</View>
);
}
@@ -1091,22 +1284,47 @@ export default class OrderView extends React.Component<OrderProps, OrderState> {
</>
)}
<KeyValue
keyValue={`${localeString('pos.views.Order.tax')}${
taxPercentage && Number(taxPercentage) > 0
? ` (${taxPercentage}%)`
: ''
}`}
value={
fiatEnabled ? (
order.getTaxMoneyDisplay
) : bitcoinUnits === 'sats' ? (
<Amount fixedUnits="sats" sats={taxSats} />
) : (
<Amount fixedUnits="BTC" sats={taxSats} />
)
}
/>
{fiatEnabled && (
<KeyValue
keyValue={localeString('pos.views.Order.taxFiat')}
value={FiatStore.formatAmountForDisplay(
new BigNumber(taxSats)
.multipliedBy(rate)
.dividedBy(SATS_PER_BTC)
.toFixed(2)
)}
/>
)}
<TouchableOpacity
onPress={() => {
this.setState({
bitcoinUnits:
this.state.bitcoinUnits === 'sats'
? 'BTC'
: 'sats'
});
}}
>
<KeyValue
keyValue={
lineItems?.some(
(item: any) => item.taxPercentage
)
? localeString('pos.views.Order.taxBitcoin')
: `${localeString(
'pos.views.Order.tax'
)} (${taxPercentage || '0'}%)`
}
value={
bitcoinUnits === 'sats' ? (
<Amount fixedUnits="sats" sats={taxSats} />
) : (
<Amount fixedUnits="BTC" sats={taxSats} />
)
}
/>
</TouchableOpacity>
{fiatEnabled && (
<KeyValue
+61 -3
View File
@@ -22,6 +22,7 @@ import LoadingIndicator from '../../components/LoadingIndicator';
import TextInput from '../../components/TextInput';
import AmountInput from '../../components/AmountInput';
import Switch from '../../components/Switch';
import Text from '../../components/Text';
import { themeColor } from '../../utils/ThemeUtils';
import { localeString } from '../../utils/LocaleUtils';
@@ -108,7 +109,8 @@ export default class ProductDetails extends React.Component<
pricedIn: PricedIn.Fiat,
price: '',
category: '',
status: ProductStatus.Active
status: ProductStatus.Active,
taxPercentage: ''
}),
isLoading: false,
isExisting: false
@@ -124,20 +126,35 @@ export default class ProductDetails extends React.Component<
if (product) {
if (this.props.UnitsStore.units !== product.pricedIn) {
// change unit to match product
while (
this.props.UnitsStore.units !== product.pricedIn
) {
this.props.UnitsStore.changeUnits();
}
}
this.setState({
categories: categoryOptions,
product,
isLoading: false,
isExisting: true
});
} else {
this.setState({
categories: categoryOptions,
product: new Product({
id: uuidv4(),
name: '',
sku: '',
pricedIn: PricedIn.Fiat,
price: '',
category: '',
status: ProductStatus.Active,
taxPercentage: ''
}),
isLoading: false,
isExisting: false
});
return;
}
}
} catch (error) {
@@ -164,6 +181,16 @@ export default class ProductDetails extends React.Component<
}
value = value;
break;
case 'taxPercentage':
if (value.includes('-')) {
return;
}
value = value.replace(',', '.');
if (value !== '' && isNaN(parseFloat(value))) {
return;
}
break;
}
product[field] = value;
this.setState({ product });
@@ -350,6 +377,37 @@ export default class ProductDetails extends React.Component<
this.setValue('price', price);
}}
/>
<Text
style={{
color: themeColor(
'secondaryText'
),
fontFamily:
'PPNeueMontreal-Book',
marginBottom: 5
}}
infoModalText={localeString(
'views.Settings.POS.taxPercentage.info'
)}
>
{localeString(
'views.Settings.POS.taxPercentage'
)}
</Text>
<TextInput
placeholder="0"
value={product?.taxPercentage}
keyboardType="decimal-pad"
onChangeText={(text: string) => {
this.setValue(
'taxPercentage',
text
);
}}
suffix="%"
right={20}
style={styles.textInput}
/>
<DropdownSetting
title={localeString(
'views.Settings.POS.Category.name'
+5 -1
View File
@@ -1,5 +1,5 @@
import * as React from 'react';
import { Platform, ScrollView, Text, View } from 'react-native';
import { Platform, ScrollView, View } from 'react-native';
import { Icon, ListItem } from 'react-native-elements';
import { inject, observer } from 'mobx-react';
import { StackNavigationProp } from '@react-navigation/stack';
@@ -16,6 +16,7 @@ import {
} from '../../components/SuccessErrorMessage';
import Screen from '../../components/Screen';
import Switch from '../../components/Switch';
import Text from '../../components/Text';
import TextInput from '../../components/TextInput';
import SettingsStore, {
@@ -518,6 +519,9 @@ export default class PointOfSale extends React.Component<
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book'
}}
infoModalText={localeString(
'views.Settings.POS.taxPercentage.global.info'
)}
>
{localeString(
'views.Settings.POS.taxPercentage'
+15 -6
View File
@@ -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