diff --git a/admin-spa/src/routes/Settings/Tax.tsx b/admin-spa/src/routes/Settings/Tax.tsx index 5bc5581..f6b1a21 100644 --- a/admin-spa/src/routes/Settings/Tax.tsx +++ b/admin-spa/src/routes/Settings/Tax.tsx @@ -1,11 +1,16 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api'; import { SettingsLayout } from './components/SettingsLayout'; import { SettingsCard } from './components/SettingsCard'; +import { SettingsSection } from './components/SettingsSection'; import { ToggleField } from './components/ToggleField'; import { Button } from '@/components/ui/button'; -import { ExternalLink, RefreshCw } from 'lucide-react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { SearchableSelect } from '@/components/ui/searchable-select'; +import { ExternalLink, RefreshCw, Plus, Pencil, Trash2, Check } from 'lucide-react'; import { toast } from 'sonner'; import { __ } from '@/lib/i18n'; @@ -13,12 +18,23 @@ export default function TaxSettings() { const queryClient = useQueryClient(); const wcAdminUrl = (window as any).WNW_CONFIG?.wpAdminUrl || '/wp-admin'; + const [showAddRate, setShowAddRate] = useState(false); + const [editingRate, setEditingRate] = useState(null); + const [deletingRate, setDeletingRate] = useState(null); + // Fetch tax settings - const { data: settings, isLoading, refetch } = useQuery({ + const { data: settings, isLoading } = useQuery({ queryKey: ['tax-settings'], queryFn: () => api.get('/settings/tax'), }); + // Fetch suggested rates + const { data: suggested } = useQuery({ + queryKey: ['tax-suggested'], + queryFn: () => api.get('/settings/tax/suggested'), + enabled: settings?.calc_taxes === 'yes', + }); + // Toggle tax calculation const toggleMutation = useMutation({ mutationFn: async (enabled: boolean) => { @@ -26,13 +42,103 @@ export default function TaxSettings() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tax-settings'] }); - toast.success(__('Tax settings updated')); + toast.success(__('Tax calculation updated')); }, onError: (error: any) => { toast.error(error?.message || __('Failed to update tax settings')); }, }); + // Create tax rate + const createMutation = useMutation({ + mutationFn: async (data: any) => { + return api.post('/settings/tax/rates', data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tax-settings'] }); + setShowAddRate(false); + toast.success(__('Tax rate created')); + }, + onError: (error: any) => { + toast.error(error?.message || __('Failed to create tax rate')); + }, + }); + + // Update tax rate + const updateMutation = useMutation({ + mutationFn: async ({ id, data }: any) => { + return api.put(`/settings/tax/rates/${id}`, data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tax-settings'] }); + setEditingRate(null); + toast.success(__('Tax rate updated')); + }, + onError: (error: any) => { + toast.error(error?.message || __('Failed to update tax rate')); + }, + }); + + // Delete tax rate + const deleteMutation = useMutation({ + mutationFn: async (id: number) => { + return api.del(`/settings/tax/rates/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tax-settings'] }); + setDeletingRate(null); + toast.success(__('Tax rate deleted')); + }, + onError: (error: any) => { + toast.error(error?.message || __('Failed to delete tax rate')); + }, + }); + + // Quick add suggested rate + const quickAddMutation = useMutation({ + mutationFn: async (suggestedRate: any) => { + return api.post('/settings/tax/rates', { + country: suggestedRate.code, + state: '', + rate: suggestedRate.rate, + name: suggestedRate.name, + tax_class: '', + priority: 1, + compound: 0, + shipping: 1, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tax-settings'] }); + toast.success(__('Tax rate added')); + }, + onError: (error: any) => { + toast.error(error?.message || __('Failed to add tax rate')); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + const data = { + country: formData.get('country') as string, + state: formData.get('state') as string || '', + rate: parseFloat(formData.get('rate') as string), + name: formData.get('name') as string, + tax_class: formData.get('tax_class') as string || '', + priority: 1, + compound: 0, + shipping: 1, + }; + + if (editingRate) { + updateMutation.mutate({ id: editingRate.id, data }); + } else { + createMutation.mutate(data); + } + }; + if (isLoading) { return ( { + return allRates.some((rate: any) => rate.country === countryCode); + }; + return ( refetch()} - > - - {__('Refresh')} - - } + description={__('Configure tax calculation and rates for your store')} >
{/* Enable Tax Calculation */} @@ -69,241 +176,326 @@ export default function TaxSettings() { > toggleMutation.mutate(checked)} disabled={toggleMutation.isPending} /> - {/* Tax Rates */} - {settings?.calc_taxes === 'yes' && ( + {/* Suggested Tax Rates (when enabled) */} + {settings?.calc_taxes === 'yes' && suggested?.suggested && suggested.suggested.length > 0 && ( -
-
-
-
-

{__('Standard Rates')}

-

- {__('Tax rates applied to standard products')} -

- {settings?.standard_rates && settings.standard_rates.length > 0 ? ( -
- {settings.standard_rates.map((rate: any, index: number) => ( -
- {rate.country} {rate.state && `- ${rate.state}`} - {rate.rate}% -
- ))} +
+ {suggested.suggested.map((rate: any) => { + const added = isRateAdded(rate.code); + return ( +
+
+
+

{rate.country}

+ {added && ( + + + {__('Added')} + + )}
- ) : ( -

- {__('No standard rates configured')} +

+ {rate.rate}% • {rate.name}

+
+ {!added && ( + )}
- -
-
- -
-
-
-

{__('Reduced Rates')}

-

- {__('Lower tax rates for specific products')} -

- {settings?.reduced_rates && settings.reduced_rates.length > 0 ? ( -
- {settings.reduced_rates.map((rate: any, index: number) => ( -
- {rate.country} {rate.state && `- ${rate.state}`} - {rate.rate}% -
- ))} -
- ) : ( -

- {__('No reduced rates configured')} -

- )} -
- -
-
- -
-
-
-

{__('Zero Rates')}

-

- {__('No tax for specific products or locations')} -

- {settings?.zero_rates && settings.zero_rates.length > 0 ? ( -
- {settings.zero_rates.map((rate: any, index: number) => ( -
- {rate.country} {rate.state && `- ${rate.state}`} - 0% -
- ))} -
- ) : ( -

- {__('No zero rates configured')} -

- )} -
- -
-
+ ); + })}
)} - {/* Tax Options */} + {/* Tax Rates */} {settings?.calc_taxes === 'yes' && ( setShowAddRate(true)} + > + + {__('Add Tax Rate')} + + } > -
-
-
-

{__('Prices entered with tax')}

-

- {settings?.prices_include_tax === 'yes' - ? __('Product prices include tax') - : __('Product prices exclude tax')} -

-
- + {allRates.length === 0 ? ( +
+

{__('No tax rates configured yet')}

+

{__('Add a tax rate to get started')}

+ ) : ( +
+ {allRates.map((rate: any) => ( +
+
+
+ {rate.name} + + {rate.country} {rate.state && `- ${rate.state}`} + +
+

+ {rate.rate}% tax rate +

+
+
+ + +
+
+ ))} +
+ )} + + )} -
-
-

{__('Calculate tax based on')}

-

- {settings?.tax_based_on === 'shipping' && __('Customer shipping address')} - {settings?.tax_based_on === 'billing' && __('Customer billing address')} - {settings?.tax_based_on === 'base' && __('Shop base address')} -

-
-
)} {/* Advanced Settings Link */} -
-

- {__('For advanced tax configuration, use the WooCommerce settings page')} -

+ -
+
+ + {/* Add/Edit Tax Rate Dialog */} + { + if (!open) { + setShowAddRate(false); + setEditingRate(null); + } + }}> + + + + {editingRate ? __('Edit Tax Rate') : __('Add Tax Rate')} + + +
+
+
+ + +

+ {__('2-letter country code (e.g., ID, MY, SG)')} +

+
+ +
+ + +

+ {__('Leave empty for country-wide rate')} +

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + + + +
+
+
+ + {/* Delete Confirmation */} + !open && setDeletingRate(null)}> + + + {__('Delete Tax Rate?')} + + {__('Are you sure you want to delete this tax rate? This action cannot be undone.')} + + + + {__('Cancel')} + deletingRate && deleteMutation.mutate(deletingRate.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {__('Delete')} + + + + ); } diff --git a/includes/Api/CheckoutController.php b/includes/Api/CheckoutController.php index b417946..99bf661 100644 --- a/includes/Api/CheckoutController.php +++ b/includes/Api/CheckoutController.php @@ -27,6 +27,11 @@ class CheckoutController { 'callback' => [ new self(), 'submit' ], 'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ], // consider capability/nonce ]); + register_rest_route($namespace, '/checkout/fields', [ + 'methods' => 'POST', + 'callback' => [ new self(), 'get_fields' ], + 'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ], + ]); } /** @@ -240,6 +245,108 @@ class CheckoutController { ]; } + /** + * Get checkout fields with all filters applied + * Accepts: { items: [...], is_digital_only?: bool } + * Returns fields with required, hidden, etc. based on addons + cart context + */ + public function get_fields(WP_REST_Request $r): array { + $json = $r->get_json_params(); + $items = isset($json['items']) && is_array($json['items']) ? $json['items'] : []; + $is_digital_only = isset($json['is_digital_only']) ? (bool) $json['is_digital_only'] : false; + + // Initialize WooCommerce checkout if not already + if (!WC()->checkout()) { + WC()->initialize_session(); + WC()->initialize_cart(); + } + + // Get checkout fields with all filters applied + $fields = WC()->checkout()->get_checkout_fields(); + + $formatted = []; + + foreach ($fields as $fieldset_key => $fieldset) { + foreach ($fieldset as $key => $field) { + // Check if field should be hidden + $hidden = false; + + // Hide shipping fields if digital only (your existing logic) + if ($is_digital_only && $fieldset_key === 'shipping') { + $hidden = true; + } + + // Check if addon/filter explicitly hides this field + if (isset($field['class']) && is_array($field['class'])) { + if (in_array('hidden', $field['class']) || in_array('hide', $field['class'])) { + $hidden = true; + } + } + + // Respect 'enabled' flag if set by addons + if (isset($field['enabled']) && !$field['enabled']) { + $hidden = true; + } + + $formatted[] = [ + 'key' => $key, + 'fieldset' => $fieldset_key, // billing, shipping, account, order + 'type' => $field['type'] ?? 'text', + 'label' => $field['label'] ?? '', + 'placeholder' => $field['placeholder'] ?? '', + 'required' => $field['required'] ?? false, + 'hidden' => $hidden, + 'class' => $field['class'] ?? [], + 'priority' => $field['priority'] ?? 10, + 'options' => $field['options'] ?? null, // For select fields + 'custom' => !in_array($key, $this->get_standard_field_keys()), // Flag custom fields + 'autocomplete'=> $field['autocomplete'] ?? '', + 'validate' => $field['validate'] ?? [], + ]; + } + } + + // Sort by priority + usort($formatted, function($a, $b) { + return $a['priority'] <=> $b['priority']; + }); + + return [ + 'ok' => true, + 'fields' => $formatted, + 'is_digital_only' => $is_digital_only, + ]; + } + + /** + * Get list of standard WooCommerce field keys + */ + private function get_standard_field_keys(): array { + return [ + 'billing_first_name', + 'billing_last_name', + 'billing_company', + 'billing_country', + 'billing_address_1', + 'billing_address_2', + 'billing_city', + 'billing_state', + 'billing_postcode', + 'billing_phone', + 'billing_email', + 'shipping_first_name', + 'shipping_last_name', + 'shipping_company', + 'shipping_country', + 'shipping_address_1', + 'shipping_address_2', + 'shipping_city', + 'shipping_state', + 'shipping_postcode', + 'order_comments', + ]; + } + /** ----------------- Helpers ----------------- **/ private function accurate_quote_via_wc_cart(array $payload): array { diff --git a/includes/Api/TaxController.php b/includes/Api/TaxController.php index 36029a0..9d0c629 100644 --- a/includes/Api/TaxController.php +++ b/includes/Api/TaxController.php @@ -49,6 +49,50 @@ class TaxController extends WP_REST_Controller { ), ) ); + + // Get suggested tax rates based on selling locations + register_rest_route( + $namespace, + '/settings/tax/suggested', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_suggested_rates' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // Create tax rate + register_rest_route( + $namespace, + '/settings/tax/rates', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_rate' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // Update tax rate + register_rest_route( + $namespace, + '/settings/tax/rates/(?P\d+)', + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_rate' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + // Delete tax rate + register_rest_route( + $namespace, + '/settings/tax/rates/(?P\d+)', + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_rate' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); } /** @@ -153,4 +197,228 @@ class TaxController extends WP_REST_Controller { return $formatted_rates; } + + /** + * Get suggested tax rates based on WooCommerce selling locations + */ + public function get_suggested_rates( WP_REST_Request $request ) { + try { + // Predefined tax rates by country + $predefined_rates = array( + 'ID' => array( 'country' => 'Indonesia', 'rate' => 11, 'name' => 'PPN (VAT)' ), + 'MY' => array( 'country' => 'Malaysia', 'rate' => 6, 'name' => 'SST' ), + 'SG' => array( 'country' => 'Singapore', 'rate' => 9, 'name' => 'GST' ), + 'TH' => array( 'country' => 'Thailand', 'rate' => 7, 'name' => 'VAT' ), + 'PH' => array( 'country' => 'Philippines', 'rate' => 12, 'name' => 'VAT' ), + 'VN' => array( 'country' => 'Vietnam', 'rate' => 10, 'name' => 'VAT' ), + 'AU' => array( 'country' => 'Australia', 'rate' => 10, 'name' => 'GST' ), + 'NZ' => array( 'country' => 'New Zealand', 'rate' => 15, 'name' => 'GST' ), + 'GB' => array( 'country' => 'United Kingdom', 'rate' => 20, 'name' => 'VAT' ), + 'DE' => array( 'country' => 'Germany', 'rate' => 19, 'name' => 'VAT' ), + 'FR' => array( 'country' => 'France', 'rate' => 20, 'name' => 'VAT' ), + 'IT' => array( 'country' => 'Italy', 'rate' => 22, 'name' => 'VAT' ), + 'ES' => array( 'country' => 'Spain', 'rate' => 21, 'name' => 'VAT' ), + 'CA' => array( 'country' => 'Canada', 'rate' => 5, 'name' => 'GST' ), + ); + + // Get WooCommerce selling locations + $selling_locations = get_option( 'woocommerce_allowed_countries', 'all' ); + $suggested = array(); + + if ( $selling_locations === 'specific' ) { + // User selected specific countries + $countries = get_option( 'woocommerce_specific_allowed_countries', array() ); + + foreach ( $countries as $country_code ) { + if ( isset( $predefined_rates[ $country_code ] ) ) { + $suggested[] = array_merge( + array( 'code' => $country_code ), + $predefined_rates[ $country_code ] + ); + } + } + } else { + // Sell to all countries - suggest store base country + $store_country = get_option( 'woocommerce_default_country', '' ); + $country_code = substr( $store_country, 0, 2 ); // Extract country code (e.g., 'ID' from 'ID:JB') + + if ( isset( $predefined_rates[ $country_code ] ) ) { + $suggested[] = array_merge( + array( 'code' => $country_code ), + $predefined_rates[ $country_code ] + ); + } + } + + return new WP_REST_Response( + array( + 'suggested' => $suggested, + 'all_rates' => $predefined_rates, + ), + 200 + ); + } catch ( \Exception $e ) { + return new WP_REST_Response( + array( + 'error' => 'fetch_failed', + 'message' => $e->getMessage(), + ), + 500 + ); + } + } + + /** + * Create tax rate + */ + public function create_rate( WP_REST_Request $request ) { + try { + global $wpdb; + + $country = $request->get_param( 'country' ); + $state = $request->get_param( 'state' ) ?? ''; + $rate = $request->get_param( 'rate' ); + $name = $request->get_param( 'name' ); + $tax_class = $request->get_param( 'tax_class' ) ?? ''; + $priority = $request->get_param( 'priority' ) ?? 1; + $compound = $request->get_param( 'compound' ) ?? 0; + $shipping = $request->get_param( 'shipping' ) ?? 1; + + $wpdb->insert( + $wpdb->prefix . 'woocommerce_tax_rates', + array( + 'tax_rate_country' => $country, + 'tax_rate_state' => $state, + 'tax_rate' => $rate, + 'tax_rate_name' => $name, + 'tax_rate_priority' => $priority, + 'tax_rate_compound' => $compound, + 'tax_rate_shipping' => $shipping, + 'tax_rate_order' => 0, + 'tax_rate_class' => $tax_class, + ), + array( '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s' ) + ); + + $rate_id = $wpdb->insert_id; + + // Clear cache + \WC_Cache_Helper::invalidate_cache_group( 'taxes' ); + + return new WP_REST_Response( + array( + 'success' => true, + 'id' => $rate_id, + 'message' => __( 'Tax rate created', 'woonoow' ), + ), + 201 + ); + } catch ( \Exception $e ) { + return new WP_REST_Response( + array( + 'error' => 'create_failed', + 'message' => $e->getMessage(), + ), + 500 + ); + } + } + + /** + * Update tax rate + */ + public function update_rate( WP_REST_Request $request ) { + try { + global $wpdb; + + $rate_id = $request->get_param( 'id' ); + $country = $request->get_param( 'country' ); + $state = $request->get_param( 'state' ) ?? ''; + $rate = $request->get_param( 'rate' ); + $name = $request->get_param( 'name' ); + $tax_class = $request->get_param( 'tax_class' ) ?? ''; + $priority = $request->get_param( 'priority' ) ?? 1; + $compound = $request->get_param( 'compound' ) ?? 0; + $shipping = $request->get_param( 'shipping' ) ?? 1; + + $wpdb->update( + $wpdb->prefix . 'woocommerce_tax_rates', + array( + 'tax_rate_country' => $country, + 'tax_rate_state' => $state, + 'tax_rate' => $rate, + 'tax_rate_name' => $name, + 'tax_rate_priority' => $priority, + 'tax_rate_compound' => $compound, + 'tax_rate_shipping' => $shipping, + 'tax_rate_class' => $tax_class, + ), + array( 'tax_rate_id' => $rate_id ), + array( '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ), + array( '%d' ) + ); + + // Clear cache + \WC_Cache_Helper::invalidate_cache_group( 'taxes' ); + + return new WP_REST_Response( + array( + 'success' => true, + 'message' => __( 'Tax rate updated', 'woonoow' ), + ), + 200 + ); + } catch ( \Exception $e ) { + return new WP_REST_Response( + array( + 'error' => 'update_failed', + 'message' => $e->getMessage(), + ), + 500 + ); + } + } + + /** + * Delete tax rate + */ + public function delete_rate( WP_REST_Request $request ) { + try { + global $wpdb; + + $rate_id = $request->get_param( 'id' ); + + $wpdb->delete( + $wpdb->prefix . 'woocommerce_tax_rates', + array( 'tax_rate_id' => $rate_id ), + array( '%d' ) + ); + + // Also delete rate locations + $wpdb->delete( + $wpdb->prefix . 'woocommerce_tax_rate_locations', + array( 'tax_rate_id' => $rate_id ), + array( '%d' ) + ); + + // Clear cache + \WC_Cache_Helper::invalidate_cache_group( 'taxes' ); + + return new WP_REST_Response( + array( + 'success' => true, + 'message' => __( 'Tax rate deleted', 'woonoow' ), + ), + 200 + ); + } catch ( \Exception $e ) { + return new WP_REST_Response( + array( + 'error' => 'delete_failed', + 'message' => $e->getMessage(), + ), + 500 + ); + } + } }