diff --git a/includes/Common.php b/includes/Common.php index 2edb694..ad4be3f 100644 --- a/includes/Common.php +++ b/includes/Common.php @@ -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(); diff --git a/includes/REST/Manager.php b/includes/REST/Manager.php index 9dcd43a..345ddc8 100644 --- a/includes/REST/Manager.php +++ b/includes/REST/Manager.php @@ -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 ); } /** @@ -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(); @@ -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; + } } diff --git a/issue.md b/issue.md deleted file mode 100644 index 3bf93d6..0000000 --- a/issue.md +++ /dev/null @@ -1,31 +0,0 @@ -### New Scale of Update -1. We need to update the **General Setting** of Admin Setting with [these features](https://prnt.sc/7zC6Lh73_5tI) -2. The **General Settings** features available [here in POS](https://prnt.sc/OlmLQVb1FQIk) need to be available in wePOS Admin Panel settings. Reason is if any admin runs his/her own Store then these settings need to be available for that store. That will sync with POS level **General Setting** and update accordingly. Therefore we need to update the **General Settings** of the current wePOS which we have right now -3. The **Tax Settings** features available [here in POS](https://prnt.sc/7bHKoQge1fhH) need to be available in wePOS Admin Panel settings. Reason is if any admin runs his/her own Store then these settings need to be available for that store. That will sync with POS level **Tax Setting** and update accordingly. Therefore we need to update the **Tax Settings** of the current wePOS which we have right now -4. The **Barcode Settings** features available [here in POS](https://prnt.sc/7N24o_S9HE22 ) need to be available in wePOS Admin Panel settings. Reason is if any admin runs his/her own Store then these settings need to be available for that store. That will sync with POS level **Barcode Setting** and update accordingly. Therefore we need to update the **Barcode Settings** of the current wePOS which we have right now -5. When **Dokan** plugin will be available then [these 2](https://prnt.sc/vxG8gi2kJBeV) should be available in Admin Panel > Settings > Access and these options should be available for toggle on/off -6. From Admin Panel side, Shop Manager, Cashier, Vendor and Vendor Staff --> for these 4 roles [there will be a new section](https://prnt.sc/_p9EdvkUYKxj) available in named as settings which will give the following accessibility option towards these users. -- view_general_settings -- edit_general_settings -- view_tax_settings -- edit_tax_settings -- view_barcode_settings -- edit_barcode_settings - -The control of these will be toggled by Admin. The above mentioned points are basically revamping [this section](https://prnt.sc/cWHtZW6qrNpB). - -7. From Admin Panel to the level of Vendor Staff implement the following logic carefully - -> - If an Admin setup any settings that is applicable for that Admins store and outlets only (While only WooCommerce) -When - -> - If an Admin setup any settings while having Dokan as a Plugin, that propagates to Vendor and Vendor Staff. As per the conditions of the setting option updated from Admin according to these -> - view_general_settings -> - edit_general_settings -> - view_tax_settings -> - edit_tax_settings -> - view_barcode_settings -> - edit_barcode_settings -> Then Vendors and Vendor staff can view or take action on the settings. However if Vendor updates any settings that will be only applicable for that Vendor Store only and won't take effect on Admin's entire Marketplace or other Vendors. Following to the update Vendor will get similar settings from Vendor Dashboard. If Vendor give permission to update settings on any Vendor Staff for POS specific then actions from that Cashier will take effect only on Vendor's store and outlets of that Vendor only, not any other Vendor Staff outlet. ---- -1. In Vendor Dashboard, wePOS Settings > [Store Information are not getting updated](https://prnt.sc/7BdJi2Z1suqB) from Vendor Store Settings \ No newline at end of file diff --git a/plan.md b/plan.md deleted file mode 100644 index 4c20a75..0000000 --- a/plan.md +++ /dev/null @@ -1,197 +0,0 @@ -# wePos Vue.js to React Migration Plan - -## Overview -Migrate the existing Vue.js 2.7 frontend POS application to React with TypeScript, using Tailwind CSS and WordPress core React components, while maintaining the ability to easily switch between implementations. **Focus on frontend POS first, admin migration later.** - -## Updated Project Structure - -### New Directory Structure -``` -wepos/ -├── src/ -│ └── frontend/ # New React POS application -│ ├── components/ # React components -│ ├── pages/ # Page components (Products, Cart, Orders, etc.) -│ ├── hooks/ # Custom React hooks -│ ├── utils/ # Utilities and helpers -│ ├── types/ # TypeScript type definitions -│ ├── api/ # API layer for REST endpoints -│ ├── store/ # WordPress data stores -│ ├── styles/ # Tailwind CSS and custom styles -│ ├── index.tsx # React app entry point -│ └── App.tsx # Main App component -├── assets/src/frontend/ # Existing Vue.js POS code (untouched) -├── assets/src/admin/ # Existing Vue.js admin code (untouched) -├── includes/ -│ ├── Assets.php # Existing assets class -│ └── ReactAssets.php # New React assets class -└── webpack.config.js # Updated for wp-scripts -``` - -## Revised Migration Strategy - -### Focus Areas -1. **Primary**: Frontend POS interface only -2. **Secondary**: Admin interface (future phase) -3. **Approach**: Incremental page-by-page migration -4. **Development**: Run Vue.js and React simultaneously for comparison - -## Phase 1: Infrastructure Setup ✅ START HERE - -### 1.1 Build System Migration -- [ ] Install wp-scripts and required dependencies -- [ ] Configure webpack.config.js for wp-scripts compatibility -- [ ] Set up TypeScript configuration -- [ ] Configure Tailwind CSS (recreate existing UI design) -- [ ] Set up development and production build scripts - -### 1.2 WordPress Integration -- [ ] Create new `ReactAssets.php` class for React asset management -- [ ] Implement asset switching mechanism in `wepos.php` -- [ ] Configure wp-scripts to output to correct directories -- [ ] Set up WordPress script dependencies and localization - -### 1.3 Development Environment -- [ ] Configure hot reloading for development -- [ ] Set up TypeScript compilation -- [ ] Configure ESLint and Prettier for React/TypeScript -- [ ] Enable simultaneous Vue.js and React development - -## Phase 2: Core React Application Scaffolding - -### 2.1 Base Application Setup -- [ ] Create main React POS application structure -- [ ] Set up routing with React Router (maintain existing routes) -- [ ] Implement base layout components -- [ ] Configure state management with `@wordpress/data` - -### 2.2 WordPress React Components Integration -- [ ] Set up `@wordpress/components` usage -- [ ] Implement `@wordpress/data` stores for POS functionality -- [ ] Configure `@wordpress/api-fetch` for existing REST API calls -- [ ] Set up `@wordpress/i18n` for translations - -### 2.3 API Layer -- [ ] Create TypeScript interfaces for existing `/wepos/v1/` endpoints -- [ ] Implement API service layer (reuse existing endpoints) -- [ ] Set up error handling and loading states -- [ ] Configure authentication and nonces - -## Phase 3: Incremental Page Migration - -### 3.1 Page 1: Product Selection/Catalog -- [ ] Convert product listing components -- [ ] Migrate product search and filtering -- [ ] Implement product selection interface -- [ ] Convert barcode scanning functionality -- [ ] Recreate UI design with Tailwind CSS - -### 3.2 Page 2: Shopping Cart -- [ ] Migrate shopping cart components -- [ ] Convert add/remove product functionality -- [ ] Implement quantity adjustments -- [ ] Convert discount and tax calculations - -### 3.3 Page 3: Checkout Flow -- [ ] Convert customer selection/creation -- [ ] Migrate payment method selection -- [ ] Implement payment gateway integration -- [ ] Convert receipt generation and printing - -### 3.4 Page 4: Order Management -- [ ] Convert order listing and search -- [ ] Migrate order details view -- [ ] Implement order status management -- [ ] Convert order history functionality - -### 3.5 Additional Pages (Incremental) -- [ ] Dashboard/Home page -- [ ] Reports page -- [ ] Settings page -- [ ] User management (if applicable) - -## Phase 4: Testing & Polish - -### 4.1 Testing -- [ ] Set up Jest and React Testing Library -- [ ] Write unit tests for migrated components -- [ ] Test feature parity with Vue.js version -- [ ] Cross-browser testing - -### 4.2 Performance & UX -- [ ] Optimize bundle size -- [ ] Ensure touch-friendly interface (POS usage) -- [ ] Test on various screen sizes -- [ ] Performance comparison with Vue.js version - -## Implementation Details - -### Technical Specifications -```json -{ - "@wordpress/scripts": "^27.0.0", - "@wordpress/components": "^27.0.0", - "@wordpress/data": "^9.0.0", - "@wordpress/api-fetch": "^6.0.0", - "@wordpress/i18n": "^4.0.0", - "react": "^18.0.0", - "react-dom": "^18.0.0", - "typescript": "^5.0.0", - "tailwindcss": "^3.0.0", - "react-router-dom": "^6.0.0" -} -``` - -### Asset Switching Strategy -```php -// In wepos.php - easy switching between implementations -$use_react_frontend = get_option('wepos_use_react_frontend', false); -if ($use_react_frontend) { - $this->container['assets'] = new WeDevs\WePOS\ReactAssets(); -} else { - $this->container['assets'] = new WeDevs\WePOS\Assets(); -} -``` - -### Page-by-Page Migration Approach -1. **Scaffold**: Set up React app with basic routing -2. **Page 1**: Migrate one core page (e.g., Products) -3. **Test**: Ensure feature parity before moving to next page -4. **Repeat**: Continue with remaining pages incrementally -5. **Switch**: Enable React frontend once all pages are migrated - -## Revised Timeline -- **Phase 1**: 1 week (Infrastructure) -- **Phase 2**: 1 week (Scaffolding) -- **Phase 3**: 6-8 weeks (Page-by-page migration) -- **Phase 4**: 1-2 weeks (Testing & Polish) - -**Total Estimated Timeline**: 9-12 weeks - -## Success Criteria for Each Page -- [ ] Visual parity with Vue.js version -- [ ] Functional parity with existing features -- [ ] Performance equivalent or better -- [ ] Accessibility maintained or improved -- [ ] Integration with existing API endpoints working - -## Next Steps -1. Start with Phase 1.1 - Build System Migration -2. Set up the React frontend structure -3. Create asset switching mechanism -4. Begin with Product Selection page migration - -## Risk Mitigation -1. **Backward Compatibility**: Keep Vue.js implementation intact -2. **Feature Parity**: Comprehensive testing against existing functionality -3. **Performance**: Monitor and compare bundle sizes -4. **User Training**: Maintain similar UX patterns where possible -5. **Rollback Plan**: Easy switching mechanism between implementations - -## Success Criteria -- [ ] Feature parity with existing Vue.js application -- [ ] Improved performance and bundle size -- [ ] Better TypeScript coverage and type safety -- [ ] Enhanced accessibility compliance -- [ ] Successful integration with WordPress ecosystem -- [ ] Positive user feedback and adoption diff --git a/src/frontend/components/Cart.tsx b/src/frontend/components/Cart.tsx index d13bfc7..b8ed9d0 100644 --- a/src/frontend/components/Cart.tsx +++ b/src/frontend/components/Cart.tsx @@ -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, findTaxRate } from '../utils/helpers'; import { CART_STORE_NAME } from '../store/cart'; import { PRODUCTS_STORE_NAME } from '../store/products'; import CustomerSearch, { CustomerSearchHandle } from '../components/CustomerSearch'; @@ -127,6 +127,8 @@ const Cart = forwardRef(({ serverOrder, isServerOrderDirty, taxDisplayMode, + pricesIncludeTax, + availableTax, } = useSelect((select) => { const store = select(CART_STORE_NAME) as any; return { @@ -148,6 +150,8 @@ const Cart = forwardRef(({ serverOrder: store.getServerOrder(), isServerOrderDirty: store.isServerOrderDirty(), taxDisplayMode: store.getTaxDisplayMode(), + pricesIncludeTax: store.getPricesIncludeTax(), + availableTax: store.getAvailableTax(), }; }, []); @@ -250,6 +254,18 @@ const Cart = forwardRef(({ 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, @@ -264,7 +280,9 @@ const Cart = forwardRef(({ on_sale: false, type: 'simple', attribute: [], - tax_amount: 0, + tax_amount: taxAmount, + tax_class: product.tax_class, + tax_status: product.tax_status, }; addToCart(cartItem); }; @@ -304,9 +322,13 @@ const Cart = forwardRef(({ 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; @@ -399,12 +421,20 @@ const Cart = forwardRef(({ {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; @@ -558,14 +588,14 @@ const Cart = forwardRef(({ {item.on_sale && isSubOptionEnabled('price', 'on_sale') ? (
- {cartFormatPrice(item.regular_price)} + {cartFormatPrice(displayPrice.regular)} - {cartFormatPrice(item.sale_price)} + {cartFormatPrice(displayPrice.sale)}
) : ( - {cartFormatPrice(item.regular_price)} + {cartFormatPrice(displayPrice.regular)} )} )} @@ -573,7 +603,7 @@ const Cart = forwardRef(({ {/* REGULAR PRICE Column */} {isColumnEnabled('regular_price') && ( - {cartFormatPrice(item.regular_price)} + {cartFormatPrice(displayPrice.regular)} )} @@ -657,9 +687,9 @@ const Cart = forwardRef(({
{__('Subtotal', 'wepos')} - {isTaxInclusive && totalLineTax > 0 && ( + {isTaxInclusive && includedTaxTotal > 0 && ( - {__('Including Tax', 'wepos')} + {__('Including Tax', 'wepos')} {cartFormatPrice(includedTaxTotal)} )}
@@ -768,8 +798,9 @@ const Cart = forwardRef(({
))} - {/* 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) => (
(({ )} - {/* 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) && (
@@ -922,7 +954,7 @@ const Cart = forwardRef(({ - {__('Cancel', 'wepos')} + {__('Cancel', 'wepos')} {voiding && } {__('Void', 'wepos')} diff --git a/src/frontend/components/PaymentModal.tsx b/src/frontend/components/PaymentModal.tsx index 6b3e6ac..0cc1c6d 100644 --- a/src/frontend/components/PaymentModal.tsx +++ b/src/frontend/components/PaymentModal.tsx @@ -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'; @@ -49,7 +49,7 @@ const PaymentModal: React.FC = ({ 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 { @@ -60,10 +60,18 @@ const PaymentModal: React.FC = ({ 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; @@ -126,9 +134,9 @@ const PaymentModal: React.FC = ({ 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; @@ -257,8 +265,8 @@ const PaymentModal: React.FC = ({
))} - {/* Tax */} - {totalTax > 0 && ( + {/* Tax — additive row only when prices exclude tax (WC cart behavior) */} + {!isTaxInclusive && totalTax > 0 && (
{__('Tax', 'wepos')} @@ -280,6 +288,12 @@ const PaymentModal: React.FC = ({ {paymentFormatPrice(total)}
+ {/* WC-style note: "(includes Tax X)" under the total in inclusive display */} + {includedTax > 0 && ( +
+ ({__('Including Tax', 'wepos')} {paymentFormatPrice(includedTax)}) +
+ )}
diff --git a/src/frontend/components/ReceiptModal.tsx b/src/frontend/components/ReceiptModal.tsx index 6fbf2eb..14e3028 100644 --- a/src/frontend/components/ReceiptModal.tsx +++ b/src/frontend/components/ReceiptModal.tsx @@ -201,7 +201,6 @@ const ReceiptModal: React.FC = ({ }; const isTaxInclusive = settings?.woo_tax?.wc_tax_display_cart === 'incl'; - const isFeeTaxEnabled = settings?.wepos_general?.enable_fee_tax === 'yes'; const receiptHeader = settings?.wepos_receipts?.receipt_header || ''; const receiptFooter = settings?.wepos_receipts?.receipt_footer || ''; @@ -377,21 +376,29 @@ const ReceiptModal: React.FC = ({ )} - {/* Tax */} - {Number(printdata.taxtotal) > 0 && ( + {/* Tax — additive row only when prices exclude tax (WC cart behavior) */} + {!isTaxInclusive && Number(printdata.taxtotal) > 0 && ( - {isTaxInclusive && isFeeTaxEnabled - ? __('Fee Tax', 'wepos') - : __('Tax', 'wepos')} + {__('Tax', 'wepos')} {formatPrice(printdata.taxtotal)} )} - {/* Order Total */} + {/* Order Total — inclusive display folds the tax note into the same row (WC-style). + Shipping tax already has its own row, so it is excluded from the note. */} - {__('Order Total', 'wepos')} + + {__('Order Total', 'wepos')} + {isTaxInclusive && + Number(printdata.taxtotal) - Number(printdata.shippingtaxtotal || 0) > 0 && ( + + {' '}({__('Including Tax', 'wepos')}{' '} + {formatPrice(Number(printdata.taxtotal) - Number(printdata.shippingtaxtotal || 0))}) + + )} + {formatPrice(printdata.ordertotal || 0)} diff --git a/src/frontend/pages/Home.tsx b/src/frontend/pages/Home.tsx index 792a3a1..5aa8146 100644 --- a/src/frontend/pages/Home.tsx +++ b/src/frontend/pages/Home.tsx @@ -27,6 +27,7 @@ import { ProductViewType, } from '../types'; import { + cartItemDisplayPrices, formatPrice, getFromLocalStorage, getProductImage, @@ -230,12 +231,30 @@ const buildRestoredCartState = ( return { line_items: (order.line_items || []).map((line) => { - const price = Number( + // WC order line prices are always net of tax. + const netPrice = Number( line.price || (line.quantity ? parseFloat(line.subtotal || line.total || '0') / line.quantity : 0), ); + const unitTax = line.quantity + ? parseFloat(line.total_tax || '0') / line.quantity + : 0; + // Restore the entry-mode raw price (gross when the store enters prices + // inclusive of tax) so display + order payload stay consistent. + const pricesIncludeTax = settings?.woo_tax?.wc_prices_include_tax === 'yes'; + const rawPrice = pricesIncludeTax ? netPrice + unitTax : netPrice; + // Custom/misc lines carry their tax config in _wepos_pos_data item meta. + let posData: { tax_status?: 'taxable' | 'none'; tax_class?: string } = {}; + if (line.product_id === 0) { + try { + const raw = (line.meta_data || []).find((m: any) => m.key === '_wepos_pos_data'); + posData = raw ? JSON.parse(String(raw.value)) : {}; + } catch { + posData = {}; + } + } return { id: line.id, product_id: line.product_id, @@ -245,8 +264,17 @@ const buildRestoredCartState = ( quantity: line.quantity, type: line.product_id === 0 ? 'custom' : 'simple', on_sale: false, - sale_price: price, - regular_price: price, + sale_price: rawPrice, + regular_price: rawPrice, + raw_sale_price: rawPrice, + raw_regular_price: rawPrice, + tax_amount: unitTax, + ...(line.product_id === 0 + ? { + tax_status: posData.tax_status || 'taxable', + tax_class: posData.tax_class || '', + } + : {}), editQuantity: false, attribute: [], total_tax: parseFloat(line.total_tax || '0'), @@ -373,14 +401,19 @@ const HomePage: React.FC = () => { clearServerOrder, hydrateCart, setTaxDisplayMode, + setPricesIncludeTax, setAvailableTax, } = useDispatch(CART_STORE_NAME) as any; - // Mirror woocommerce_tax_display_cart into the store — drives the inclusive-tax path in getTotalTax. + // Mirror WC tax settings into the store: tax_display_cart drives the + // inclusive-tax path in getTotalTax, prices_include_tax drives raw→display + // price conversion. Wait for settings so the persisted flags aren't + // clobbered with wrong defaults mid-load. useEffect(() => { - const mode = settings?.woo_tax?.wc_tax_display_cart === 'incl' ? 'incl' : 'excl'; - setTaxDisplayMode(mode); - }, [settings?.woo_tax?.wc_tax_display_cart, setTaxDisplayMode]); + if (!settings?.woo_tax) return; + setTaxDisplayMode(settings.woo_tax.wc_tax_display_cart === 'incl' ? 'incl' : 'excl'); + setPricesIncludeTax(settings.woo_tax.wc_prices_include_tax === 'yes'); + }, [settings?.woo_tax, setTaxDisplayMode, setPricesIncludeTax]); // Pre-fetch tax rates so selectors can compute fee/coupon tax locally before save. useEffect(() => { @@ -807,10 +840,21 @@ const HomePage: React.FC = () => { if (paymentResponse.result === 'success') { // Receipt mirrors cart selectors, so its tax/total carries the same WC-silent fallback — receipt matches the cart row in every prices_include_tax × tax_display_cart combination. const printDataToSet = { - line_items: cartItems.map((cartItem: POSCartItem) => ({ - ...cartItem, - total_tax: toFiniteNumber(cartItem.tax_amount) * cartItem.quantity, - })), + // Receipt line prices go through the same mode-aware conversion the + // cart rows use, so the printed lines sum to the printed subtotal. + line_items: cartItems.map((cartItem: POSCartItem) => { + const display = cartItemDisplayPrices( + cartItem, + settings?.woo_tax?.wc_tax_display_cart === 'incl' ? 'incl' : 'excl', + settings?.woo_tax?.wc_prices_include_tax === 'yes', + ); + return { + ...cartItem, + regular_price: display.regular, + sale_price: display.sale, + total_tax: toFiniteNumber(cartItem.tax_amount) * cartItem.quantity, + }; + }), fee_lines: feeLines, coupon_lines: discountLines, shipping_lines: shippingLines, @@ -894,6 +938,9 @@ const HomePage: React.FC = () => { cartItems.forEach((item: POSCartItem) => { // Raw prices (stored values) drive the order payload; falls back to display prices for legacy in-memory carts. + // Totals go out in entry mode — gross when the store enters prices inclusive + // of tax. Manager.php::convert_inclusive_line_totals nets them server-side + // with exact WC_Tax math before WC calculates tax on top. const rawRegular = item.raw_regular_price ?? item.regular_price, rawSale = item.raw_sale_price ?? item.sale_price; const unitPrice = item.on_sale ? rawSale : rawRegular; @@ -903,21 +950,37 @@ const HomePage: React.FC = () => { total: (unitPrice * item.quantity).toFixed(2), }; if (item.product_id === 0) { - // Misc/custom product: send name + price, no product_id + // Misc/custom product: send name + price, no product_id. lineItem.name = item.name; lineItem.price = unitPrice; - if (item.sku) { - lineItem.sku = item.sku; - } + // WC requires a product reference on line create; an unknown SKU + // resolves to product 0 and keeps this a custom line. + lineItem.sku = item.sku || 'wepos-misc-product'; + // No product to derive tax config from — send the cashier's choice. + // Common.php clears taxes server-side when tax_status is 'none'. + lineItem.tax_class = item.tax_class || ''; + lineItem.meta_data = [ + { + key: '_wepos_pos_data', + value: JSON.stringify({ + tax_status: item.tax_status || 'taxable', + tax_class: item.tax_class || '', + }), + }, + ]; } else { lineItem.product_id = item.product_id; if (item.variation_id) { lineItem.variation_id = item.variation_id; } } - // Match to existing server line item by product_id + variation_id + // Match to existing server line item — by stored line id first (restored + // carts carry it, which disambiguates multiple custom lines that all + // share product_id 0), then by product_id + variation_id. if (isUpdate && serverOrder) { const match = serverOrder.line_items.find( + (sl: any) => !matchedServerIds.has(sl.id) && sl.id === item.id + ) || serverOrder.line_items.find( (sl: any) => !matchedServerIds.has(sl.id) && sl.product_id === item.product_id && sl.variation_id === (item.variation_id || 0) diff --git a/src/frontend/store/cart/actions.ts b/src/frontend/store/cart/actions.ts index aad6b4d..1ae756a 100644 --- a/src/frontend/store/cart/actions.ts +++ b/src/frontend/store/cart/actions.ts @@ -143,6 +143,13 @@ export const actions = { }; }, + setPricesIncludeTax(includes: boolean) { + return { + type: 'SET_PRICES_INCLUDE_TAX' as const, + includes, + }; + }, + setAvailableTax(rates: TaxRate[]) { return { type: 'SET_AVAILABLE_TAX' as const, diff --git a/src/frontend/store/cart/reducer.ts b/src/frontend/store/cart/reducer.ts index 4cb84f4..12120dd 100644 --- a/src/frontend/store/cart/reducer.ts +++ b/src/frontend/store/cart/reducer.ts @@ -24,11 +24,15 @@ export const createReducer = (preloadedState: CartState = initialState) => ( ): CartState => { switch (action.type) { case 'ADD_TO_CART': { - const existingItemIndex = state.line_items.findIndex( - (item) => - item.product_id === action.item.product_id && - item.variation_id === action.item.variation_id, - ); + // Custom/misc lines all share product_id 0 but are distinct products — + // never merge them into one line. + const existingItemIndex = action.item.product_id === 0 + ? -1 + : state.line_items.findIndex( + (item) => + item.product_id === action.item.product_id && + item.variation_id === action.item.variation_id, + ); if (existingItemIndex >= 0) { const updatedItems = [...state.line_items]; @@ -65,12 +69,13 @@ export const createReducer = (preloadedState: CartState = initialState) => ( return { ...state, line_items: updatedItems, server_order_dirty: true }; } - // Preserve tax_display_cart and available_tax across both: store-wide reference data, not part of a cart snapshot. + // Preserve tax_display_cart, prices_include_tax and available_tax across both: store-wide reference data, not part of a cart snapshot. case 'HYDRATE_CART': return { ...action.state, available_tax: state.available_tax, tax_display_cart: state.tax_display_cart, + prices_include_tax: state.prices_include_tax, }; case 'CLEAR_CART': @@ -78,6 +83,7 @@ export const createReducer = (preloadedState: CartState = initialState) => ( ...initialState, available_tax: state.available_tax, tax_display_cart: state.tax_display_cart, + prices_include_tax: state.prices_include_tax, }; case 'ADD_DISCOUNT': { @@ -211,6 +217,14 @@ export const createReducer = (preloadedState: CartState = initialState) => ( tax_display_cart: action.mode, }; + case 'SET_PRICES_INCLUDE_TAX': + // Reference data — does not mark the order dirty. + if (state.prices_include_tax === action.includes) return state; + return { + ...state, + prices_include_tax: action.includes, + }; + case 'SET_AVAILABLE_TAX': // Reference data — does not mark the order dirty. return { diff --git a/src/frontend/store/cart/selectors.ts b/src/frontend/store/cart/selectors.ts index 35cc2eb..21cc8b5 100644 --- a/src/frontend/store/cart/selectors.ts +++ b/src/frontend/store/cart/selectors.ts @@ -1,6 +1,6 @@ import { CartState, ServerOrderData } from './types'; import { POSCartItem, POSDiscountLine, POSFeeLine, POSShippingLine, POSOrderMetaItem, Customer } from '../../types'; -import { toFiniteNumber } from '../../utils/helpers'; +import { toFiniteNumber, cartItemDisplayPrices, findTaxRate } from '../../utils/helpers'; import { applyFilters } from '../../hooks/useExtensions'; export const selectors = { @@ -13,9 +13,15 @@ export const selectors = { getCustomerNote: (state: CartState): string => state.customer_note, getSubtotal: (state: CartState): number => { + // Derive display prices from raw price + tax_amount so stored (possibly + // stale) display prices can't drift from the current tax display settings. return state.line_items.reduce((total: number, item: POSCartItem) => { - const price = item.on_sale ? item.sale_price : item.regular_price; - return total + price * item.quantity; + const { unit } = cartItemDisplayPrices( + item, + state.tax_display_cart === 'incl' ? 'incl' : 'excl', + !!state.prices_include_tax, + ); + return total + unit * item.quantity; }, 0); }, @@ -53,6 +59,10 @@ export const selectors = { getTaxDisplayMode: (state: CartState): 'incl' | 'excl' => state.tax_display_cart === 'incl' ? 'incl' : 'excl', + getPricesIncludeTax: (state: CartState): boolean => !!state.prices_include_tax, + + getAvailableTax: (state: CartState) => state.available_tax, + // Drives the "Including Tax" UI hint — never zeroed in incl mode. getTotalLineTax: (state: CartState): number => { return state.line_items.reduce((total: number, item: POSCartItem) => { @@ -81,12 +91,7 @@ export const selectors = { const lineTax = state.tax_display_cart === 'incl' ? 0 : selectors.getTotalLineTax(state); const subtotal = selectors.getSubtotal(state); - // Empty tax class maps to WC's 'standard'; returns 0 when no class matches. - const findRate = (taxClass: string): number => { - const slug = taxClass === '' ? 'standard' : taxClass; - const match = state.available_tax.find((r) => r.class === slug); - return match ? toFiniteNumber(match.rate) : 0; - }; + const findRate = (taxClass: string): number => findTaxRate(state.available_tax, taxClass); const feeTax = state.fee_lines.reduce((sum: number, fee: POSFeeLine) => { if (fee.tax_status !== 'taxable') return sum; diff --git a/src/frontend/store/cart/store.ts b/src/frontend/store/cart/store.ts index 74bd57f..f923de5 100644 --- a/src/frontend/store/cart/store.ts +++ b/src/frontend/store/cart/store.ts @@ -63,6 +63,10 @@ subscribe(() => { customer: storeSelect.getCustomer(), server_order: storeSelect.getServerOrder(), server_order_dirty: storeSelect.isServerOrderDirty(), + // Persist tax flags so totals math is correct from first render, before + // the settings request resolves (they re-sync once settings load). + tax_display_cart: storeSelect.getTaxDisplayMode(), + prices_include_tax: storeSelect.getPricesIncludeTax(), }; const serialized = JSON.stringify(currentState); diff --git a/src/frontend/store/cart/types.ts b/src/frontend/store/cart/types.ts index 87e932a..7fc3fd8 100644 --- a/src/frontend/store/cart/types.ts +++ b/src/frontend/store/cart/types.ts @@ -72,6 +72,8 @@ export interface CartState { currency_symbol: string; // Mirrors WC's `woocommerce_tax_display_cart` — drives the inclusive-tax path in `getTotalTax`. tax_display_cart?: 'incl' | 'excl'; + // Mirrors WC's `woocommerce_prices_include_tax` — drives raw→display price conversion. + prices_include_tax?: boolean; // Available tax rates — powers local fee tax and coupon tax adjustment pre-save. available_tax: TaxRate[]; } @@ -102,4 +104,5 @@ export type CartAction = | { type: 'SET_SERVER_ORDER'; server_order: ServerOrderData } | { type: 'CLEAR_SERVER_ORDER' } | { type: 'SET_TAX_DISPLAY_MODE'; mode: 'incl' | 'excl' } + | { type: 'SET_PRICES_INCLUDE_TAX'; includes: boolean } | { type: 'SET_AVAILABLE_TAX'; rates: TaxRate[] }; diff --git a/src/frontend/types/index.ts b/src/frontend/types/index.ts index 6c94b6c..2f9dab8 100644 --- a/src/frontend/types/index.ts +++ b/src/frontend/types/index.ts @@ -195,6 +195,9 @@ export interface POSCartItem { option: string; }>; tax_amount?: number; + // Misc/custom products only — catalog products carry tax config on the server. + tax_class?: string; + tax_status?: 'taxable' | 'none'; manage_stock?: boolean; stock_status?: string; backorders_allowed?: boolean; diff --git a/src/frontend/utils/helpers.ts b/src/frontend/utils/helpers.ts index 4dddfce..fd987f3 100644 --- a/src/frontend/utils/helpers.ts +++ b/src/frontend/utils/helpers.ts @@ -186,6 +186,57 @@ export const pickRegularDisplayPrice = (source: PricedSource): number => export const pickSaleDisplayPrice = (source: PricedSource): number => firstPresentNumber(source.sales_display_price, source.sale_price, source.regular_price) ?? 0; +// Tax rate (percent) for a WC tax class from the loaded rate list; empty class +// maps to WC's 'standard'. Returns 0 when no rate matches. +export const findTaxRate = ( + rates: Array<{ class: string; rate?: string | number }>, + taxClass: string, +): number => { + const slug = taxClass === '' ? 'standard' : taxClass; + const match = (rates || []).find((r) => r.class === slug); + return match ? toFiniteNumber(match.rate) : 0; +}; + +interface CartPricedItem { + on_sale: boolean; + sale_price: number; + regular_price: number; + raw_sale_price?: number; + raw_regular_price?: number; + tax_amount?: string | number; +} + +// WC-parity display prices for a cart line item. Stored display prices go +// stale when the tax display settings change after the item was added, so +// derive them at render time from the entry-mode raw price + per-unit tax. +export const cartItemDisplayPrices = ( + item: CartPricedItem, + taxDisplay: 'incl' | 'excl', + pricesIncludeTax: boolean, +): { regular: number; sale: number; unit: number } => { + const rawRegular = toFiniteNumber(item.raw_regular_price ?? item.regular_price); + const rawSale = toFiniteNumber(item.raw_sale_price ?? item.sale_price); + const rawUnit = item.on_sale ? rawSale : rawRegular; + const tax = toFiniteNumber(item.tax_amount); + + // Entry mode matches display mode (or nothing to convert) — raw prices display as-is. + if ((taxDisplay === 'incl') === pricesIncludeTax || tax <= 0 || rawUnit <= 0) { + return { regular: rawRegular, sale: rawSale, unit: rawUnit }; + } + + // tax_amount belongs to the effective (sale when on sale) price; scale both + // prices by the effective factor so the strikethrough stays proportional. + const factor = taxDisplay === 'incl' + ? (rawUnit + tax) / rawUnit + : (rawUnit - tax) / rawUnit; + + return { + regular: rawRegular * factor, + sale: rawSale * factor, + unit: rawUnit * factor, + }; +}; + /** * Create a delay for async operations */ diff --git a/update_planning.md b/update_planning.md deleted file mode 100644 index 74aefe8..0000000 --- a/update_planning.md +++ /dev/null @@ -1,221 +0,0 @@ -# wePOS Settings Revamp — Plan v3 - -## Pivot from v2 -- Admin-side **General** subpage stays unchanged (its fields continue to live in `wepos_general`). -- **POS Settings** is a new subpage built with the **`@wedevs/plugin-ui` `Settings` schema renderer**, adding custom field variants via `addFilter("wepos_settings_{variant}_field", ...)` (the extensibility pattern documented in plugin-ui's `Settings.mdx`). No hand-rolled Tabs UI. -- Vendor dashboard gets a matching **POS Settings** page (same schema, via same bundle) plus a new **POS Access** page for managing staff/cashier permissions. - -## Architecture assumptions - -- **WooCommerce + wePOS (no Dokan)** — admin = one store, one POS. All saves global. -- **WooCommerce + Dokan + wePOS** — each vendor = one store, one POS. Vendor saves go to vendor user meta; global WC options stay untouched. Admin still controls role-level capability baseline. - -## Admin panel structure (3 subpages) - -``` -wePOS → Settings -├── General (UNCHANGED) -│ └── wepos_general fields: POS Layout, Fee Tax, Barcode Scanner Field -├── Receipts (UNCHANGED) -├── Access (EXTENDED — adds Settings cap group) -└── POS Settings (NEW subpage, schema-driven) - ├── Tab: General — store info, country, address, currency, default customer - ├── Tab: Tax — WC tax config + enable_fee_tax - └── Tab: Barcode — prefix, suffix, averageTimeThreshold, minimumLength -``` - -## Vendor dashboard structure (Dokan active) - -``` -Vendor Dashboard → wePos -├── View POS (existing lite) -├── POS Settings (NEW — core; pro's pos/settings stays for outlet-aware flows) -└── POS Access (NEW — core; staff/cashier perms matrix) -``` - -If wepos-pro is active, its `pos/settings` page (outlet-aware) takes precedence and core's `pos-settings` page is suppressed — only `pos-access` is mounted by core. - -## Data scope - -| Section key | Admin scope | Vendor scope | Outlet scope (pro) | Personal (user meta) | -|---|---|---|---|---| -| `woo_general` | `blogname` + `woocommerce_*` options | `_wepos_vendor_settings` | `_wepos_outlet_settings_{id}` | — | -| `woo_tax` | `woocommerce_*_tax*` options | vendor meta | outlet meta | — | -| `wepos_general` | `wepos_general` option | vendor meta | outlet meta | — | -| `wepos_barcode` | `wepos_barcode` option | vendor meta | outlet meta | — | -| `wepos_receipts`| `wepos_receipts` option | — | — | — | -| `wepos_cashier` | — | — | — | `_wepos_cashier_settings` | -| `wepos_theme` | — | — | — | `_wepos_theme_settings` | - -Read merge precedence: `global → vendor → outlet`. Personal sections are always user-meta, independent. - -## Capability matrix defaults - -| Cap | admin | shop_manager | cashier | seller (dokandar) | vendor_staff | -|---|---|---|---|---|---| -| access_wepos | ✓ (locked) | ✓ | ✓ | ✓ | off | -| manage_wepos | ✓ (locked) | ✓ | off | ✓ | off | -| view_general_settings | ✓ (locked) | ✓ | off | ✓ | off | -| edit_general_settings | ✓ (locked) | ✓ | off | ✓ | off | -| view_tax_settings | ✓ (locked) | ✓ | off | ✓ | off | -| edit_tax_settings | ✓ (locked) | ✓ | off | ✓ | off | -| view_barcode_settings | ✓ (locked) | ✓ | off | ✓ | off | -| edit_barcode_settings | ✓ (locked) | ✓ | off | ✓ | off | - -Vendor cascade enforced by `Settings\Caps`: -``` -effective_access_pos(u) = user_cap AND parent_vendor_access_pos(u) -effective_view_X(u) = user_cap AND effective_access_pos(u) -effective_edit_X(u) = user_cap AND effective_manage_pos(u) -``` -`parent_vendor` of a `vendor_staff` comes from `_vendor_id` meta (via `wepos_resolve_vendor_id`). - -## POS Settings schema — built with plugin-ui - -Subpage with 3 tabs. Each tab owns one REST section. Save handler posts `{ [section_id]: flatValues }` to `/wepos/v1/settings` — section dropped if user lacks `edit_*` cap. - -### General tab (section: `woo_general`) -- `store_name` — text -- `store_address` — text -- `store_address_2` — text -- `store_city` — text -- `store_postcode` — text -- `default_country` — **custom variant `country_state`** (dependent dropdown, US:CA format) -- `default_customer` — **custom variant `customer_search`** (async SmartSelect via WC REST `/wc/v3/customers?search=`) -- `default_customer_is_cashier` — switch -- `currency` — **custom variant `currency_select`** (label `Name (SYMBOL)`) -- `currency_pos` — select -- `price_decimal_sep` — text -- `price_thousand_sep` — text -- `price_num_decimals` — number -- `thousands_group_style` — select - -### Tax tab (section: `woo_tax`) -- `wc_tax_enabled` — switch -- `wc_prices_include_tax` — switch -- `wc_tax_based_on` — select (Shipping / Billing / Base) -- `wc_shipping_tax_class` — select (tax class list — **custom variant `tax_class_select`** sourced from `settings.tax_classes`) -- `wc_tax_round_at_subtotal` — switch -- `wc_tax_total_display` — radio_capsule -- `enable_fee_tax` — switch - -### Barcode tab (section: `wepos_barcode`) -- `prefix` — text -- `suffix` — text -- `averageTimeThreshold` — number (min 1) -- `minimumLength` — number (min 1) - -## Custom field registration pattern - -Following plugin-ui docs, each custom variant is registered once at app bootstrap: - -```ts -import { addFilter } from '@wordpress/hooks'; - -addFilter('wepos_settings_country_state_field', 'wepos/country-state', - (element, fieldData) => -); - -addFilter('wepos_settings_customer_search_field', 'wepos/customer-search', - (element, fieldData) => -); - -addFilter('wepos_settings_currency_select_field', 'wepos/currency-select', - (element, fieldData) => -); - -addFilter('wepos_settings_tax_class_select_field', 'wepos/tax-class-select', - (element, fieldData) => -); -``` - -The `` wrapper in `Settings.tsx` / POS Settings page receives `applyFilters` + `hookPrefix="wepos"` so these filter names fire. - -## REST contract - -`GET /wepos/v1/settings?outlet_id={id?}` — merged settings + reference data (already works): -```json -{ - "woo_general": { ... }, - "woo_tax": { ... }, - "wepos_general": { ... }, - "wepos_barcode": { ... }, - "wepos_receipts":{ ... }, - "wepos_cashier": { ... }, - "wepos_theme": { ... }, - "currencies": { "USD": { "name": "US Dollar", "symbol": "$" }, ... }, - "tax_classes": [ { "slug": "standard", "name": "Standard" }, ... ], - "outlets": [ ... ] -} -``` - -Sections gated by per-user `view_*` cap (reference data always returned). - -`POST /wepos/v1/settings` — `{ section_id: { field: value }, _outlet_id?: number }`. -Each section gated by `edit_*`. Personal sections route to user meta regardless of outlet_id. - -Vendor-dashboard staff access is handled as a plain PHP form POST (no REST round-trip) — the page posts back to itself, `wepos_pos_access_nonce` guards the request, and `add_cap` / `remove_cap` run inside `Dokan::handle_pos_access_submit()`. Dropped the REST endpoint because the page doesn't need client-side interactivity beyond checkboxes. - -## File changes - -### Keep (from prior passes) -- `includes/Settings/Caps.php` — section-level + cascade helpers -- `includes/REST/SettingController.php` — section gating, personal meta, `wepos_barcode` overridable -- `includes/REST/AccessController.php` — settings cap group, Dokan roles -- `includes/Installer.php` — default caps -- `includes/Dokan.php` — vendor overlay/save, profile sync, staff matrix hooks -- `src/frontend/hooks/useBarcodeSettings.ts` / `useCartSettings.ts` / `useThemeSettings.ts` - -### Revert -- `includes/functions.php` → restore original `wepos_get_settings_sections()` + `wepos_get_settings_fields()` (3 sections). Drop the flat woo_* fields from the admin schema — they're REST-only and rendered by the custom POS Settings subpage. -- `src/admin/pages/Settings.tsx` → drop flat woo_* field mapping from `buildStandardSchema`. Standard subpages: General + Receipts + Access only. Access still gets the Settings cap group. - -### New -- `src/admin/pages/pos-settings/schema.ts` — schema builder returning `SettingsElement[]` for the POS Settings subpage (`page → subpage → tab → section → field` hierarchy). Field keys are dot-namespaced (`woo_general.store_name`) so saves group by section. -- `src/admin/pages/pos-settings/index.tsx` — loads `/wepos/v1/settings`, renders `` with schema + `hookPrefix="wepos"` + `applyFilters`, saves grouped payload. -- `src/admin/pages/pos-settings/reference-data.ts` — React context for `currencies` / `tax_classes` so field components can consume without re-fetching. -- `src/admin/pages/pos-settings/fields/CountryStateField.tsx` — dependent dropdown (reads `window.weposAdmin.countries` / `states`). -- `src/admin/pages/pos-settings/fields/CustomerSearchField.tsx` — async SmartSelect via WC REST. -- `src/admin/pages/pos-settings/fields/CurrencySelectField.tsx` — formatted label select from reference-data context. -- `src/admin/pages/pos-settings/fields/TaxClassSelectField.tsx` — tax class select from reference-data context. -- `src/admin/pages/pos-settings/register.ts` — registers the four custom variants via `addFilter("wepos_settings_{variant}_field", ...)`. -- `src/admin/App.tsx` — register `/pos-settings` route + page_key `pos_settings`. -- `includes/Admin/Admin.php` — add POS Settings submenu entry. -- `includes/Admin/Dashboard.php` — localize `countries` + `states` into `weposAdmin`. -- `includes/Installer.php` — include `wepos_page_pos_settings` in default page caps. -- `includes/REST/AccessController.php` — include `wepos_page_pos_settings` in the access matrix. -- `includes/Dokan.php` — add `POS Access` submenu + `pos/access` query var + template renderer + save handler + `get_vendor_pos_users()`. -- `templates/dokan/pos-access.php` (NEW) — server-rendered vendor dashboard POS Access page (pure PHP form, no React bundle required). - -## Verification - -**Admin** -- [ ] wePOS → Settings shows General / Receipts / Access subpages with pre-revamp fields intact. -- [ ] wePOS → POS Settings shows General / Tax / Barcode tabs (schema-rendered, plugin-ui). -- [ ] Country → state dropdown updates in real time. -- [ ] Customer search populates async via WC REST. -- [ ] Saving General → `blogname` + `woocommerce_*` options updated. -- [ ] Saving Tax → `woocommerce_calc_taxes` etc. updated. -- [ ] Saving Barcode → `wepos_barcode` option updated. -- [ ] Access → new Settings group with 6 caps per role. - -**POS (cashier)** -- [ ] Theme + cashier settings persist across devices (user meta). -- [ ] Legacy localStorage values migrated once, then cleared. - -**Vendor dashboard (Dokan)** -- [ ] wePos → POS Access page lists vendor's staff + cashiers. -- [ ] Toggling access_wepos / manage_wepos persists via REST + add_cap/remove_cap. -- [ ] When admin disables vendor's access_wepos, staff toggles disabled + cascade banner shows. -- [ ] When pro absent: wePos → POS Settings page renders the same schema, saves to `_wepos_vendor_settings`. -- [ ] `dokan_profile_settings` kept in sync when vendor saves General (store info). - -**Regression** -- [ ] Admin General subpage (POS Layout + Fee Tax + Barcode field) renders and saves identically. -- [ ] Receipts subpage unchanged. -- [ ] Pro's outlet-aware POS settings page continues to work (untouched). - -## Out of scope -- Vendor dashboard outlet CRUD (pro). -- Renaming cap symbols — labels only (Access POS / Manage POS). -- Network / multisite.