Files
2026-02-19 20:22:52 +05:30

738 lines
26 KiB
TypeScript

import { action, observable, runInAction } from 'mobx';
import ReactNativeBlobUtil from 'react-native-blob-util';
import BigNumber from 'bignumber.js';
import { v4 as uuidv4 } from 'uuid';
import FiatStore from './FiatStore';
import SettingsStore, { PosEnabled } from './SettingsStore';
import UnitsStore from './UnitsStore';
import Order from '../models/Order';
import { SATS_PER_BTC } from '../utils/UnitsUtils';
import { calculateTaxSats } from '../utils/PosUtils';
import BackendUtils from '../utils/BackendUtils';
import { localeString } from '../utils/LocaleUtils';
import Storage from '../storage';
export interface orderPaymentInfo {
orderId: string;
orderTotal: string;
orderTip: string;
exchangeRate: string;
rate: number;
type: string; // ln OR onchain
tx: string; // txid OR payment request
preimage?: string;
}
export interface orderInvoiceInfo {
orderId: string;
payment_request: string;
rHash: string;
exchangeRate: string;
amount: string;
tip: string;
createdAt: number;
expirySeconds: string;
}
export const LEGACY_POS_HIDDEN_KEY = 'pos-hidden';
export const LEGACY_POS_STANDALONE_KEY = 'pos-standalone';
export const POS_HIDDEN_KEY = 'zeus-pos-hidden';
export const POS_STANDALONE_KEY = 'zeus-pos-standalone';
export default class PosStore {
@observable public currentOrder: Order | null = null;
@observable public openOrders: Array<Order> = [];
@observable public paidOrders: Array<Order> = [];
@observable public filteredOpenOrders: Array<Order> = [];
@observable public filteredPaidOrders: Array<Order> = [];
// recon
@observable public completedOrders: Array<Order> = [];
@observable public reconTotal: string = '0.00';
@observable public reconTax: string = '0.00';
@observable public reconTips: string = '0.00';
@observable public reconExport: string = '';
//
@observable public loading = false;
@observable public error = false;
settingsStore: SettingsStore;
fiatStore: FiatStore;
unitsStore: UnitsStore;
constructor(
settingsStore: SettingsStore,
fiatStore: FiatStore,
unitsStore: UnitsStore
) {
this.settingsStore = settingsStore;
this.fiatStore = fiatStore;
this.unitsStore = unitsStore;
}
private _enrichAndFilterOrders = async (orders: Order[]) => {
// fetch hidden orders - orders customers couldn't pay
const hiddenOrdersItem = await Storage.getItem(POS_HIDDEN_KEY);
const hiddenOrders = JSON.parse(hiddenOrdersItem || '[]');
const enrichedOrders = await Promise.all(
orders.map(async (order: any) => {
const payment = await Storage.getItem(`pos-${order.id}`);
if (hiddenOrders.includes(order.id)) {
order.hidden = true;
}
if (payment) {
order.payment = JSON.parse(payment);
}
return order;
})
);
const openOrders = enrichedOrders.filter(
(order: any) => !order.payment && !order.hidden
);
const paidOrders = enrichedOrders.filter((order: any) => order.payment);
runInAction(() => {
this.openOrders = openOrders;
this.filteredOpenOrders = openOrders;
this.paidOrders = paidOrders;
this.filteredPaidOrders = paidOrders;
this.loading = false;
});
};
@action
public processCheckout = async (
navigation: any,
isQuickPay: boolean = false
) => {
const currentOrder = this.currentOrder;
if (!currentOrder) return;
await this.saveStandaloneOrder(currentOrder);
if (!isQuickPay) {
navigation.navigate('Order', { order: currentOrder });
return;
}
const { settings } = this.settingsStore;
const { fiatRates, getRate } = this.fiatStore;
const { units } = this.unitsStore;
const { pos, fiat } = settings;
const merchantName = pos?.merchantName;
const taxPercentage = pos?.taxPercentage;
const lineItems = currentOrder.line_items;
const memo = merchantName
? `${merchantName} ${localeString(
'views.Settings.POS.poweredByZEUS'
)} - ${localeString('general.order')} ${currentOrder?.id}`
: `${localeString('views.Settings.POS')} - ${localeString(
'general.order'
)} ${currentOrder?.id}`;
const fiatEntry =
fiat && fiatRates
? fiatRates.filter((entry: any) => entry.code === fiat)[0]
: null;
const rate = fiat && fiatRates && fiatEntry ? fiatEntry.rate : 0;
const subTotalSats =
(currentOrder?.total_money?.sats ?? 0) > 0
? currentOrder.total_money.sats
: rate > 0
? new BigNumber(currentOrder?.total_money?.amount)
.div(100)
.div(rate)
.multipliedBy(SATS_PER_BTC)
.toFixed(0)
: '0';
const taxSats = Number(
calculateTaxSats(lineItems, subTotalSats, rate, taxPercentage)
);
const totalSats = new BigNumber(subTotalSats || 0)
.plus(taxSats)
.toFixed(0);
const totalFiat = new BigNumber(totalSats ?? 0)
.multipliedBy(rate)
.dividedBy(SATS_PER_BTC)
.toFixed(2);
const destination =
settings?.ecash?.enableCashu && BackendUtils.supportsCashuWallet()
? 'ReceiveEcash'
: 'Receive';
navigation.navigate(destination, {
amount:
units === 'sats'
? totalSats
: units === 'BTC'
? new BigNumber(totalSats || 0).div(SATS_PER_BTC).toFixed(8)
: totalFiat,
autoGenerate: true,
memo,
order: currentOrder,
orderId: currentOrder.id,
orderTip: 0,
orderTotal: totalSats,
exchangeRate: getRate(),
rate
});
};
@action
public hideOrder = async (orderId: string) => {
const hiddenOrdersItem = await Storage.getItem(POS_HIDDEN_KEY);
const hiddenOrders = JSON.parse(hiddenOrdersItem || '[]');
hiddenOrders.push(orderId);
await Storage.setItem(POS_HIDDEN_KEY, hiddenOrders);
};
@action
public updateSearch = (value: string) => {
this.filteredOpenOrders = this.openOrders.filter(
(item: any) =>
item.getItemsList.includes(value) ||
item.getItemsList.toLowerCase().includes(value)
);
this.filteredPaidOrders = this.paidOrders.filter(
(item: any) =>
item.getItemsList.includes(value) ||
item.getItemsList.toLowerCase().includes(value)
);
};
public recordPayment = ({
orderId,
orderTotal,
orderTip,
exchangeRate,
rate,
type,
tx,
preimage
}: orderPaymentInfo) =>
Storage.setItem(`pos-${orderId}`, {
orderId,
orderTotal,
orderTip,
exchangeRate,
rate,
type,
tx,
preimage
});
public recordInvoice = ({
orderId,
payment_request,
rHash,
exchangeRate,
amount,
tip,
createdAt,
expirySeconds
}: orderInvoiceInfo) => {
Storage.setItem(`pos-invoice-${orderId}`, {
orderId,
payment_request,
rHash,
exchangeRate,
amount,
tip,
createdAt,
expirySeconds
});
};
public clearCurrentOrder = () => (this.currentOrder = null);
public createCurrentOrder = (currency: string) => {
this.currentOrder = new Order({
id: uuidv4(),
created_at: new Date(Date.now()).toISOString(),
updated_at: new Date(Date.now()).toISOString(),
line_items: [],
total_tax_money: { amount: 0, currency },
total_money: { amount: 0, currency }
});
};
private calcFiatAmountFromSats = (amount: string | number) => {
const { fiatRates } = this.fiatStore;
const { settings } = this.settingsStore;
const { fiat } = settings;
// replace , with . for unit separator
const value = amount || '0';
const fiatEntry =
fiat && fiatRates
? fiatRates.filter((entry: any) => entry.code === fiat)[0]
: null;
const rate = fiat && fiatRates && fiatEntry ? fiatEntry.rate : 0;
const fiatAmount: string | number = rate
? new BigNumber(value.toString().replace(/,/g, '.'))
.dividedBy(SATS_PER_BTC)
.multipliedBy(rate)
.multipliedBy(100)
.toNumber()
.toFixed(0)
: 0;
return fiatAmount;
};
private calcSatsAmountFromFiat = (amount: string | number) => {
const { fiatRates } = this.fiatStore;
const { settings } = this.settingsStore;
const { fiat } = settings;
// replace , with . for unit separator
const value = amount || '0';
const fiatEntry =
fiat && fiatRates
? fiatRates.filter((entry: any) => entry.code === fiat)[0]
: null;
const rate = fiat && fiatRates && fiatEntry ? fiatEntry.rate : 0;
const satsAmount: string | number = rate
? new BigNumber(value.toString().replace(/,/g, '.'))
.dividedBy(rate)
.multipliedBy(SATS_PER_BTC)
.toNumber()
.toFixed(0)
: 0;
return satsAmount;
};
@action
public recalculateCurrentOrder = () => {
if (this.currentOrder) {
let totalFiat = new BigNumber(0);
let totalSats = new BigNumber(0);
this.currentOrder.line_items.forEach((item) => {
if (item.base_price_money.sats! > 0) {
const addedAmount = new BigNumber(
item.base_price_money.sats || 0
).times(item.quantity);
totalSats = totalSats.plus(addedAmount);
totalFiat = totalFiat.plus(
this.calcFiatAmountFromSats(addedAmount.toNumber())
);
} else {
const addedAmount = new BigNumber(
item.base_price_money.amount || 0
).times(item.quantity);
totalFiat = totalFiat.plus(addedAmount.times(100));
totalSats = totalSats.plus(
this.calcSatsAmountFromFiat(addedAmount.toNumber())
);
}
});
this.currentOrder.total_money.amount = totalFiat.toNumber();
this.currentOrder.total_money.sats = totalSats.toNumber();
// 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 (
hasIndividualTaxRates ||
(taxPercentage && taxPercentage !== '0' && taxPercentage !== '')
) {
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) =>
entry.code === this.settingsStore.settings.fiat
)[0];
const { code } = fiatEntry;
this.currentOrder.total_tax_money.currency = code;
}
}
}
};
public saveStandaloneOrder = async (updateOrder: Order) => {
const order = this.openOrders.find((o) => o.id === updateOrder.id);
if (order) {
order.line_items = updateOrder.line_items;
order.updated_at = new Date(Date.now()).toISOString();
order.total_money = updateOrder.total_money;
} else {
this.openOrders.push(updateOrder);
}
await Storage.setItem(
POS_STANDALONE_KEY,
this.openOrders.concat(this.paidOrders)
);
this.clearCurrentOrder();
};
public getOrders = async () => {
switch (this.settingsStore.settings.pos.posEnabled) {
case PosEnabled.Square:
return this.getSquareOrders();
case PosEnabled.Standalone:
return this.getStandaloneOrders();
default:
this.resetOrders();
break;
}
};
@action
private getStandaloneOrders = async () => {
this.loading = true;
this.error = false;
const soOrders = await Storage.getItem(POS_STANDALONE_KEY);
const orders = JSON.parse(soOrders || '[]').map(
(order: any) => new Order(order)
);
await this._enrichAndFilterOrders(orders);
};
@action
private getSquareOrders = async () => {
const { squareAccessToken, squareLocationId, squareDevMode } =
this.settingsStore.settings.pos;
this.loading = true;
this.error = false;
const apiHost = squareDevMode
? 'https://connect.squareupsandbox.com'
: 'https://connect.squareup.com';
ReactNativeBlobUtil.fetch(
'POST',
`${apiHost}/v2/orders/search`,
{
Authorization: `Bearer ${squareAccessToken}`,
'Content-Type': 'application/json'
},
JSON.stringify({
location_ids: [squareLocationId]
})
)
.then(async (response: any) => {
const status = response.info().status;
if (status == 200) {
this.loading = false;
let orders = response
.json()
.orders.map((order: any) => new Order(order))
.filter((order: any) => {
return (
order.tenders &&
order.tenders[0] &&
order.tenders[0].note &&
(order.tenders[0].note
.toLowerCase()
.includes('zeus') ||
order.tenders[0].note
.toLowerCase()
.includes('zues') ||
order.tenders[0].note
.toLowerCase()
.includes('bitcoin') ||
order.tenders[0].note
.toLowerCase()
.includes('btc') ||
order.tenders[0].note
.toLowerCase()
.includes('bit coin'))
);
});
await this._enrichAndFilterOrders(orders);
} else {
runInAction(() => {
this.openOrders = [];
this.paidOrders = [];
this.loading = false;
this.error = true;
});
}
})
.catch((err) => {
console.error('POS get orders err', err);
runInAction(() => {
this.openOrders = [];
this.paidOrders = [];
this.loading = false;
this.error = true;
});
});
};
@action
public getOrdersHistorical = async (hours = 24) => {
const { squareAccessToken, squareLocationId, squareDevMode } =
this.settingsStore.settings.pos;
this.loading = true;
this.error = false;
const apiHost = squareDevMode
? 'https://connect.squareupsandbox.com'
: 'https://connect.squareup.com';
ReactNativeBlobUtil.fetch(
'POST',
`${apiHost}/v2/orders/search`,
{
Authorization: `Bearer ${squareAccessToken}`,
'Content-Type': 'application/json'
},
JSON.stringify({
limit: 10000,
location_ids: [squareLocationId],
query: {
filter: {
date_time_filter: {
created_at: {
start_at: new Date(
Date.now() - hours * 60 * 60 * 1000
).toISOString(),
end_at: new Date().toISOString()
}
},
state_filter: {
states: ['COMPLETED']
}
}
},
sort: {
sort_field: 'CREATED_AT',
sort_order: 'DESC'
}
})
)
.then(async (response: any) => {
const status = response.info().status;
if (status == 200) {
this.loading = false;
let orders = response
.json()
.orders.map((order: any) => new Order(order))
.filter((order: any) => {
return (
order.tenders &&
order.tenders[0] &&
order.tenders[0].note &&
(order.tenders[0].note
.toLowerCase()
.includes('zeus') ||
order.tenders[0].note
.toLowerCase()
.includes('zues') ||
order.tenders[0].note
.toLowerCase()
.includes('bitcoin') ||
order.tenders[0].note
.toLowerCase()
.includes('btc') ||
order.tenders[0].note
.toLowerCase()
.includes('bit coin'))
);
});
let total = 0;
let tax = 0;
let tips = 0;
let exportString =
'orderId, totalSats, tipSats, rateFull, rateNumerical, type, tx\n';
// fetch hidden orders - orders customers couldn't pay
const hiddenOrdersItem = await Storage.getItem(
POS_HIDDEN_KEY
);
const hiddenOrders = JSON.parse(hiddenOrdersItem || '[]');
const enrichedOrders = await Promise.all(
orders.map(async (order: any) => {
const payment = await Storage.getItem(
`pos-${order.id}`
);
let tip;
// mark order if hidden
if (hiddenOrders.includes(order.id)) {
order.hidden = true;
}
if (payment) {
order.payment = JSON.parse(payment);
const {
orderId,
orderTotal,
orderTip,
exchangeRate,
rate,
type,
tx
} = order.payment;
tip = new BigNumber(orderTip)
.multipliedBy(rate)
.dividedBy(SATS_PER_BTC)
.toFixed(2);
exportString += `${orderId}, ${orderTotal}, ${orderTip}, ${exchangeRate}, ${rate}, ${type}, ${tx}\n`;
}
// tally totals
total +=
Number(order.getTotalMoney) +
Number(order.getTaxMoney);
tax += Number(order.getTaxMoney);
tips += tip
? Number(tip)
: order.autoGratuity
? Number(order.autoGratuity)
: 0;
return order;
})
);
runInAction(() => {
this.completedOrders = enrichedOrders;
this.reconTotal = total.toFixed(2);
this.reconTax = tax.toFixed(2);
this.reconTips = tips.toFixed(2);
this.reconExport = exportString;
});
} else {
runInAction(() => {
this.completedOrders = [];
this.loading = false;
this.error = true;
});
}
})
.catch((err) => {
console.error('POS get historical orders err', err);
runInAction(() => {
this.completedOrders = [];
this.loading = false;
this.error = true;
});
});
};
@action
public getOrderPaymentById = async (
orderId: string
): Promise<Order | undefined> => {
const payment = await Storage.getItem(`pos-${orderId}`);
if (payment) {
return JSON.parse(payment);
}
};
public getOrderInvoiceById = async (
orderId: string
): Promise<orderInvoiceInfo | undefined> => {
const invoice = await Storage.getItem(`pos-invoice-${orderId}`);
if (invoice) {
return JSON.parse(invoice);
}
};
public clearOrderInvoice = (orderId: string) =>
Storage.removeItem(`pos-invoice-${orderId}`);
public isInvoiceValid = (invoiceInfo: orderInvoiceInfo): boolean => {
const now = Date.now() / 1000;
const expiryTime =
invoiceInfo.createdAt + parseInt(invoiceInfo.expirySeconds, 10);
return expiryTime > now + 60;
};
public canReuseInvoice = (
invoiceInfo: orderInvoiceInfo,
currentAmount: string,
currentTip: string,
currentExchangeRate: string
): boolean => {
return (
this.isInvoiceValid(invoiceInfo) &&
invoiceInfo.amount === currentAmount &&
invoiceInfo.tip === currentTip &&
invoiceInfo.exchangeRate === currentExchangeRate
);
};
@action
private resetOrders = () => {
this.openOrders = [];
this.paidOrders = [];
this.loading = false;
};
}