Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions includes/Common.php
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,11 @@ public function get_tax_location( $args, $order ) {
$args['state'] = $order->get_shipping_state();
$args['postcode'] = $order->get_shipping_postcode();
$args['city'] = $order->get_shipping_city();
} else {
// Default to store base address for POS orders.
}

// Walk-in POS orders often carry no billing/shipping address; an empty
// country resolves no tax rates at all. Fall back to the store base.
if ( empty( $args['country'] ) ) {
$args['country'] = \WC()->countries->get_base_country();
$args['state'] = \WC()->countries->get_base_state();
$args['postcode'] = \WC()->countries->get_base_postcode();
Expand Down
100 changes: 99 additions & 1 deletion includes/REST/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public function __construct() {
add_filter( 'woocommerce_rest_prepare_product_cat', [ $this, 'category_response' ], 10, 3 );
add_filter( 'woocommerce_rest_prepare_tax', [ $this, 'tax_response' ], 10, 3 );
add_filter( 'woocommerce_rest_pre_insert_shop_order_object', [ $this, 'validate_item_stock_before_order' ], 10, 3 );
add_filter( 'woocommerce_rest_pre_insert_shop_order_object', [ $this, 'convert_inclusive_line_totals' ], 20, 3 );
}

/**
Expand Down Expand Up @@ -164,7 +165,13 @@ public function validate_item_stock_before_order( $order, $request, $creating )
$items = $order->get_items();

foreach ( $items as $item ) {
$product = $item->get_product();
$product = $item->get_product();

// Custom/misc lines have no product and no stock to validate.
if ( ! $product ) {
continue;
}

$is_manage_stock = $product->get_manage_stock();
$is_backorder_allowed = $product->get_backorders();

Expand All @@ -182,4 +189,95 @@ public function validate_item_stock_before_order( $order, $request, $creating )

return $order;
}

/**
* Net out bundled tax from POS line item totals when prices include tax.
*
* WC order line totals are always tax-exclusive, but the POS frontend
* posts line totals in entry mode — the raw (gross) product price when the
* store enters prices inclusive of tax. Without this conversion WC adds
* tax on top of the gross amount and the order is taxed twice.
*
* Only payloads that explicitly post line totals are converted: the legacy
* Vue frontend omits `total`/`subtotal`, letting WC derive the correct net
* price itself, and must not be netted a second time.
*
* @since WEPOS_LITE_SINCE
*
* @param \WC_Order $order The order being saved.
* @param \WP_REST_Request $request Request data.
* @param bool $creating True when creating, false when updating.
*
* @return \WC_Order
*/
public function convert_inclusive_line_totals( $order, $request, $creating ) {
if ( empty( $request['line_items'] ) || ! wc_tax_enabled() || ! wc_prices_include_tax() ) {
return $order;
}

$is_pos_order = false;

foreach ( (array) ( $request['meta_data'] ?? [] ) as $meta ) {
$key = is_array( $meta ) ? ( $meta['key'] ?? '' ) : ( $meta->key ?? '' );
$value = is_array( $meta ) ? ( $meta['value'] ?? '' ) : ( $meta->value ?? '' );

if ( '_wepos_is_pos_order' === $key && $value ) {
$is_pos_order = true;
break;
}
}

if ( ! $is_pos_order ) {
return $order;
}

// Gross totals are only posted explicitly; payloads without them (legacy
// Vue frontend) already carry WC-derived net totals.
$posts_gross_totals = false;
foreach ( (array) $request['line_items'] as $posted_item ) {
if ( is_array( $posted_item ) && isset( $posted_item['total'] ) ) {
$posts_gross_totals = true;
break;
}
}

if ( ! $posts_gross_totals ) {
return $order;
}

foreach ( $order->get_items() as $item ) {
$product = $item->get_product();
$tax_class = $item->get_tax_class();

if ( $product ) {
if ( ! $product->is_taxable() ) {
continue;
}
} else {
// Custom/misc line — tax config travels in _wepos_pos_data meta.
$pos_data = json_decode( (string) $item->get_meta( '_wepos_pos_data' ), true );

if ( ! is_array( $pos_data ) || 'taxable' !== ( $pos_data['tax_status'] ?? 'taxable' ) ) {
continue;
}

$tax_class = $pos_data['tax_class'] ?? $tax_class;
}

// Prices entered inclusive of tax are defined against base rates.
$rates = \WC_Tax::get_base_tax_rates( $tax_class );

if ( empty( $rates ) ) {
continue;
}

$total_tax = array_sum( \WC_Tax::calc_inclusive_tax( (float) $item->get_total(), $rates ) );
$subtotal_tax = array_sum( \WC_Tax::calc_inclusive_tax( (float) $item->get_subtotal(), $rates ) );

$item->set_total( (float) $item->get_total() - $total_tax );
$item->set_subtotal( (float) $item->get_subtotal() - $subtotal_tax );
}

return $order;
}
}
62 changes: 47 additions & 15 deletions src/frontend/components/Cart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import AddFeeModal from './AddFeeModal';
import OrderMetaModal from './OrderMetaModal';
import { Slot } from '@wordpress/components';
import { PluginArea } from '@wordpress/plugins';
import { formatPrice, toFiniteNumber } from '../utils/helpers';
import { formatPrice, toFiniteNumber, cartItemDisplayPrices } from '../utils/helpers';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
import { CART_STORE_NAME } from '../store/cart';
import { PRODUCTS_STORE_NAME } from '../store/products';
import CustomerSearch, { CustomerSearchHandle } from '../components/CustomerSearch';
Expand Down Expand Up @@ -127,6 +127,8 @@ const Cart = forwardRef<CartHandle, CartProps>(({
serverOrder,
isServerOrderDirty,
taxDisplayMode,
pricesIncludeTax,
availableTax,
} = useSelect((select) => {
const store = select(CART_STORE_NAME) as any;
return {
Expand All @@ -148,6 +150,8 @@ const Cart = forwardRef<CartHandle, CartProps>(({
serverOrder: store.getServerOrder(),
isServerOrderDirty: store.isServerOrderDirty(),
taxDisplayMode: store.getTaxDisplayMode(),
pricesIncludeTax: store.getPricesIncludeTax(),
availableTax: store.getAvailableTax(),
};
}, []);

Expand Down Expand Up @@ -250,6 +254,18 @@ const Cart = forwardRef<CartHandle, CartProps>(({
tax_class: string;
tax_status: 'taxable' | 'none';
}) => {
// Cashier enters the price in the store's entry mode (gross when prices
// include tax). Derive the per-unit tax from the selected tax class so
// display/net conversion works like catalog products.
const rate = product.tax_status === 'taxable'
? findTaxRate(availableTax || [], product.tax_class)
: 0;
const taxAmount = !rate
? 0
: pricesIncludeTax
? (product.price * rate) / (100 + rate)
: (product.price * rate) / 100;

const cartItem: POSCartItem = {
id: Date.now(),
product_id: 0,
Expand All @@ -264,7 +280,9 @@ const Cart = forwardRef<CartHandle, CartProps>(({
on_sale: false,
type: 'simple',
attribute: [],
tax_amount: 0,
tax_amount: taxAmount,
tax_class: product.tax_class,
tax_status: product.tax_status,
};
addToCart(cartItem);
};
Expand Down Expand Up @@ -304,9 +322,13 @@ const Cart = forwardRef<CartHandle, CartProps>(({
const cartFormatPrice = (price: number | string): string | number =>
formatPrice(price, orderCurrencySymbol || '');

// Single source of truth: store value, synced from woocommerce_tax_display_cart by Home.tsx.
// Single source of truth: store values, synced from WC tax settings by Home.tsx.
const isTaxInclusive = taxDisplayMode === 'incl';

// Line tax bundled in the displayed prices (inclusive mode) — shown as a
// WC-style "Including Tax X" note under the subtotal, never as a row.
const includedTaxTotal = isTaxInclusive ? totalLineTax : 0;

// Count visible columns for colSpan
const visibleColumnCount = cartSettings.columns.filter((c) => c.enabled).length || 1;

Expand Down Expand Up @@ -399,12 +421,20 @@ const Cart = forwardRef<CartHandle, CartProps>(({
<tbody>
{cartItems.length > 0 ? (
cartItems.map((item: POSCartItem, index: number) => {
const itemTotal = item.quantity * (item.on_sale ? item.sale_price : item.regular_price);
const itemSubtotal = item.quantity * item.regular_price;
// Mode-aware display prices — stored prices go stale when tax display settings change.
const displayPrice = cartItemDisplayPrices(
item,
isTaxInclusive ? 'incl' : 'excl',
pricesIncludeTax,
);
const itemTotal = item.quantity * displayPrice.unit;
const itemSubtotal = item.quantity * displayPrice.regular;

// Server tax when available; otherwise client-computed from product tax_amount so the breakdown shows pre-save.
// Line-id match first — custom lines all share product_id 0.
const serverLineItem = serverOrder && !isServerOrderDirty
? serverOrder.line_items?.find(
? serverOrder.line_items?.find((li: any) => li.id === item.id)
|| serverOrder.line_items?.find(
(li: any) => li.product_id === item.product_id && li.variation_id === (item.variation_id || 0)
)
: null;
Expand Down Expand Up @@ -558,22 +588,22 @@ const Cart = forwardRef<CartHandle, CartProps>(({
{item.on_sale && isSubOptionEnabled('price', 'on_sale') ? (
<div className="flex flex-col">
<span className="text-xs text-muted-foreground line-through">
{cartFormatPrice(item.regular_price)}
{cartFormatPrice(displayPrice.regular)}
</span>
<span className="font-medium text-destructive">
{cartFormatPrice(item.sale_price)}
{cartFormatPrice(displayPrice.sale)}
</span>
</div>
) : (
<span>{cartFormatPrice(item.regular_price)}</span>
<span>{cartFormatPrice(displayPrice.regular)}</span>
)}
</td>
)}

{/* REGULAR PRICE Column */}
{isColumnEnabled('regular_price') && (
<td className="p-3 text-sm text-muted-foreground">
{cartFormatPrice(item.regular_price)}
{cartFormatPrice(displayPrice.regular)}
</td>
)}

Expand Down Expand Up @@ -657,9 +687,9 @@ const Cart = forwardRef<CartHandle, CartProps>(({
<div className="flex items-center justify-between border-b border-border p-[9px_12px]">
<div className="flex-1 text-sm">
{__('Subtotal', 'wepos')}
{isTaxInclusive && totalLineTax > 0 && (
{isTaxInclusive && includedTaxTotal > 0 && (
<span className="block text-xs font-normal text-muted-foreground">
{__('Including Tax', 'wepos')}
{__('Including Tax', 'wepos')} {cartFormatPrice(includedTaxTotal)}
</span>
)}
</div>
Expand Down Expand Up @@ -768,8 +798,9 @@ const Cart = forwardRef<CartHandle, CartProps>(({
</div>
))}

{/* Tax Lines (from server, only when not stale) */}
{serverOrder && !isServerOrderDirty && serverOrder.tax_lines.length > 0 && (
{/* Tax Lines (from server, only when not stale). Inclusive display: tax is
part of the prices, so no separate rows — WC-style note instead below. */}
{!isTaxInclusive && serverOrder && !isServerOrderDirty && serverOrder.tax_lines.length > 0 && (
<>
{serverOrder.tax_lines.map((taxLine: any) => (
<div
Expand All @@ -793,7 +824,8 @@ const Cart = forwardRef<CartHandle, CartProps>(({
</>
)}

{/* Total Tax (fallback when no detailed tax lines) */}
{/* Total Tax (fallback when no detailed tax lines). In inclusive display
this only carries additive fee tax. */}
{totalTax > 0 && (!serverOrder || isServerOrderDirty || serverOrder.tax_lines.length === 0) && (
<div className="flex items-center justify-between border-b border-border p-[9px_12px]">
<div className="flex-1 text-sm font-medium text-foreground">
Expand Down
26 changes: 20 additions & 6 deletions src/frontend/components/PaymentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { __ } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { LoaderCircle, ArrowLeft, CreditCard } from 'lucide-react';
import { POSGateway, POSCartItem, POSDiscountLine, POSFeeLine, POSShippingLine } from '../types';
import { formatPrice } from '../utils/helpers';
import { cartItemDisplayPrices, formatPrice } from '../utils/helpers';
import { CART_STORE_NAME } from '../store/cart';
import { PRODUCTS_STORE_NAME } from '../store/products';
import { applyFilters } from '../hooks/useExtensions';
Expand Down Expand Up @@ -49,7 +49,7 @@ const PaymentModal: React.FC<PaymentModalProps> = ({
cashAmountRef,
}) => {
// Get cart data from cart store
const { cartItems, subtotal, total, discountLines, feeLines, shippingLines, totalTax, orderCurrencySymbol } =
const { cartItems, subtotal, total, discountLines, feeLines, shippingLines, totalTax, totalLineTax, taxDisplayMode, pricesIncludeTax, orderCurrencySymbol } =
useSelect((select) => {
const store = select(CART_STORE_NAME) as any;
return {
Expand All @@ -60,10 +60,18 @@ const PaymentModal: React.FC<PaymentModalProps> = ({
feeLines: store.getFeeLines(),
shippingLines: store.getShippingLines(),
totalTax: store.getTotalTax(),
totalLineTax: store.getTotalLineTax(),
taxDisplayMode: store.getTaxDisplayMode(),
pricesIncludeTax: store.getPricesIncludeTax(),
orderCurrencySymbol: store.getOrderCurrencySymbol(),
};
}, []);

// Inclusive display: line tax is part of the subtotal/total, not added on
// top — shown as a WC-style "(Including Tax X)" note, never as a row.
const isTaxInclusive = taxDisplayMode === 'incl';
const includedTax = isTaxInclusive ? totalLineTax : 0;

// Get gateways from products store
const { availableGateways } = useSelect((select) => {
const store = select(PRODUCTS_STORE_NAME) as any;
Expand Down Expand Up @@ -126,9 +134,9 @@ const PaymentModal: React.FC<PaymentModalProps> = ({
return parseFloat(fee.value);
};

// Get item price
// Mode-aware display price — matches the cart rows and getSubtotal.
const getItemPrice = (item: POSCartItem): number => {
return item.on_sale ? item.sale_price : item.regular_price;
return cartItemDisplayPrices(item, taxDisplayMode, pricesIncludeTax).unit;
};

if (!show) return null;
Expand Down Expand Up @@ -257,8 +265,8 @@ const PaymentModal: React.FC<PaymentModalProps> = ({
</div>
))}

{/* Tax */}
{totalTax > 0 && (
{/* Tax — additive row only when prices exclude tax (WC cart behavior) */}
{!isTaxInclusive && totalTax > 0 && (
<div className="flex justify-between py-1">
<span className="text-sm text-muted-foreground">
{__('Tax', 'wepos')}
Expand All @@ -280,6 +288,12 @@ const PaymentModal: React.FC<PaymentModalProps> = ({
{paymentFormatPrice(total)}
</span>
</div>
{/* WC-style note: "(includes Tax X)" under the total in inclusive display */}
{includedTax > 0 && (
<div className="flex justify-end pb-1 text-xs text-muted-foreground">
({__('Including Tax', 'wepos')} {paymentFormatPrice(includedTax)})
</div>
)}
</div>
</div>

Expand Down
Loading
Loading