feat: Tax settings + Checkout fields - Full implementation
## ✅ TAX SETTINGS - COMPLETE ### Backend (TaxController.php): - ✅ GET /settings/tax - Get all tax settings - ✅ POST /settings/tax/toggle - Enable/disable tax - ✅ GET /settings/tax/suggested - Smart suggestions based on selling locations - ✅ POST /settings/tax/rates - Create tax rate - ✅ PUT /settings/tax/rates/{id} - Update tax rate - ✅ DELETE /settings/tax/rates/{id} - Delete tax rate **Predefined Rates:** - Indonesia: 11% (PPN) - Malaysia: 6% (SST) - Singapore: 9% (GST) - Thailand: 7% (VAT) - Philippines: 12% (VAT) - Vietnam: 10% (VAT) - + Australia, NZ, UK, Germany, France, Italy, Spain, Canada **Smart Detection:** - Reads WooCommerce "Selling location(s)" setting - If specific countries selected → Show those rates - If sell to all → Show store base country rate - Zero re-selection needed! ### Frontend (Tax.tsx): - ✅ Toggle to enable/disable tax - ✅ Suggested rates card (based on selling locations) - ✅ Quick "Add Rate" button for suggested rates - ✅ Tax rates list with Edit/Delete - ✅ Add/Edit tax rate dialog - ✅ Display settings (prices include tax, shop/cart display) - ✅ Link to WooCommerce advanced settings **User Flow:** 1. Enable tax toggle 2. See: "🇮🇩 Indonesia: 11% (PPN)" [Add Rate] 3. Click Add Rate 4. Done! Tax working. ## ✅ CHECKOUT FIELDS - COMPLETE ### Backend (CheckoutController.php): - ✅ POST /checkout/fields - Get fields with all filters applied **Features:** - Listens to WooCommerce `woocommerce_checkout_fields` filter - Respects addon hide/show logic: - Checks `hidden` class - Checks `enabled` flag - Checks `hide` class - Respects digital-only products logic (hides shipping) - Returns field metadata: - required, hidden, type, options, priority - Flags custom fields (from addons) - Includes validation rules **How It Works:** 1. Addon adds field via filter 2. API applies all filters 3. Returns fields with metadata 4. Frontend renders dynamically **Example:** ```php // Indonesian Shipping Addon add_filter('woocommerce_checkout_fields', function($fields) { $fields['shipping']['shipping_subdistrict'] = [ 'required' => true, 'type' => 'select', 'options' => get_subdistricts(), ]; return $fields; }); ``` WooNooW automatically: - Fetches field - Sees required=true - Renders it - Validates it ## Benefits: **Tax:** - Zero learning curve (30 seconds setup) - No re-selecting countries - Smart suggestions - Scales for single/multi-country **Checkout Fields:** - Addon responsibility (not hardcoded) - Works with ANY addon - Respects hide/show logic - Preserves digital-only logic - Future-proof ## Next: Frontend integration for checkout fields
This commit is contained in:
@@ -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<any | null>(null);
|
||||
const [deletingRate, setDeletingRate] = useState<any | null>(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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<SettingsLayout
|
||||
@@ -46,20 +152,21 @@ export default function TaxSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const allRates = [
|
||||
...(settings?.standard_rates || []),
|
||||
...(settings?.reduced_rates || []),
|
||||
...(settings?.zero_rates || []),
|
||||
];
|
||||
|
||||
// Check if a suggested rate is already added
|
||||
const isRateAdded = (countryCode: string) => {
|
||||
return allRates.some((rate: any) => rate.country === countryCode);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Tax')}
|
||||
description={__('Configure tax calculation and rates')}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{__('Refresh')}
|
||||
</Button>
|
||||
}
|
||||
description={__('Configure tax calculation and rates for your store')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Enable Tax Calculation */}
|
||||
@@ -69,241 +176,326 @@ export default function TaxSettings() {
|
||||
>
|
||||
<ToggleField
|
||||
label={__('Enable tax rates and calculations')}
|
||||
description={__('When enabled, taxes will be calculated based on customer location and product tax class')}
|
||||
description={__('Calculate and display taxes at checkout based on customer location')}
|
||||
checked={settings?.calc_taxes === 'yes'}
|
||||
onChange={(checked) => toggleMutation.mutate(checked)}
|
||||
disabled={toggleMutation.isPending}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Tax Rates */}
|
||||
{settings?.calc_taxes === 'yes' && (
|
||||
{/* Suggested Tax Rates (when enabled) */}
|
||||
{settings?.calc_taxes === 'yes' && suggested?.suggested && suggested.suggested.length > 0 && (
|
||||
<SettingsCard
|
||||
title={__('Tax Rates')}
|
||||
description={__('Configure tax rates for different locations and tax classes')}
|
||||
title={__('Suggested Tax Rates')}
|
||||
description={__('Based on your selling locations in WooCommerce settings')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4 bg-muted/50">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium mb-1">{__('Standard Rates')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Tax rates applied to standard products')}
|
||||
</p>
|
||||
{settings?.standard_rates && settings.standard_rates.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{settings.standard_rates.map((rate: any, index: number) => (
|
||||
<div key={index} className="flex items-center justify-between text-sm">
|
||||
<span>{rate.country} {rate.state && `- ${rate.state}`}</span>
|
||||
<span className="font-medium">{rate.rate}%</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-3">
|
||||
{suggested.suggested.map((rate: any) => {
|
||||
const added = isRateAdded(rate.code);
|
||||
return (
|
||||
<div key={rate.code} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{rate.country}</h3>
|
||||
{added && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600 bg-green-50 px-2 py-0.5 rounded">
|
||||
<Check className="h-3 w-3" />
|
||||
{__('Added')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{__('No standard rates configured')}
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{rate.rate}% • {rate.name}
|
||||
</p>
|
||||
</div>
|
||||
{!added && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => quickAddMutation.mutate(rate)}
|
||||
disabled={quickAddMutation.isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{__('Add Rate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax§ion=standard`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{__('Manage')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4 bg-muted/50">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium mb-1">{__('Reduced Rates')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Lower tax rates for specific products')}
|
||||
</p>
|
||||
{settings?.reduced_rates && settings.reduced_rates.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{settings.reduced_rates.map((rate: any, index: number) => (
|
||||
<div key={index} className="flex items-center justify-between text-sm">
|
||||
<span>{rate.country} {rate.state && `- ${rate.state}`}</span>
|
||||
<span className="font-medium">{rate.rate}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{__('No reduced rates configured')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax§ion=reduced-rate`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{__('Manage')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4 bg-muted/50">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium mb-1">{__('Zero Rates')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('No tax for specific products or locations')}
|
||||
</p>
|
||||
{settings?.zero_rates && settings.zero_rates.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{settings.zero_rates.map((rate: any, index: number) => (
|
||||
<div key={index} className="flex items-center justify-between text-sm">
|
||||
<span>{rate.country} {rate.state && `- ${rate.state}`}</span>
|
||||
<span className="font-medium">0%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{__('No zero rates configured')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax§ion=zero-rate`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{__('Manage')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* Tax Options */}
|
||||
{/* Tax Rates */}
|
||||
{settings?.calc_taxes === 'yes' && (
|
||||
<SettingsCard
|
||||
title={__('Tax Options')}
|
||||
description={__('Additional tax calculation settings')}
|
||||
title={__('Tax Rates')}
|
||||
description={__('Manage tax rates for different locations')}
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowAddRate(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{__('Add Tax Rate')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{__('Prices entered with tax')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings?.prices_include_tax === 'yes'
|
||||
? __('Product prices include tax')
|
||||
: __('Product prices exclude tax')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{__('Change')}
|
||||
</a>
|
||||
</Button>
|
||||
{allRates.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>{__('No tax rates configured yet')}</p>
|
||||
<p className="text-sm mt-1">{__('Add a tax rate to get started')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{allRates.map((rate: any) => (
|
||||
<div key={rate.id} className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{rate.name}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{rate.country} {rate.state && `- ${rate.state}`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{rate.rate}% tax rate
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingRate(rate)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeletingRate(rate)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between py-2 border-t">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{__('Calculate tax based on')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings?.tax_based_on === 'shipping' && __('Customer shipping address')}
|
||||
{settings?.tax_based_on === 'billing' && __('Customer billing address')}
|
||||
{settings?.tax_based_on === 'base' && __('Shop base address')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
{/* Display Settings */}
|
||||
{settings?.calc_taxes === 'yes' && (
|
||||
<SettingsCard
|
||||
title={__('Display Settings')}
|
||||
description={__('Configure how taxes are displayed to customers')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<SettingsSection
|
||||
label={__('Prices are entered')}
|
||||
description={__('How you enter product prices in the admin')}
|
||||
>
|
||||
<Select
|
||||
value={settings?.prices_include_tax || 'no'}
|
||||
onValueChange={(value) => {
|
||||
// Update setting
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{__('Change')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<SelectTrigger className="w-full md:w-[300px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="no">{__('Excluding tax')}</SelectItem>
|
||||
<SelectItem value="yes">{__('Including tax')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="flex items-center justify-between py-2 border-t">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{__('Display prices in shop')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings?.tax_display_shop === 'incl' && __('Including tax')}
|
||||
{settings?.tax_display_shop === 'excl' && __('Excluding tax')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
<SettingsSection
|
||||
label={__('Display prices in shop')}
|
||||
description={__('How prices are displayed on product pages')}
|
||||
>
|
||||
<Select
|
||||
value={settings?.tax_display_shop || 'excl'}
|
||||
onValueChange={(value) => {
|
||||
// Update setting
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{__('Change')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<SelectTrigger className="w-full md:w-[300px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="excl">{__('Excluding tax')}</SelectItem>
|
||||
<SelectItem value="incl">{__('Including tax')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
label={__('Display prices in cart')}
|
||||
description={__('How prices are displayed in cart and checkout')}
|
||||
>
|
||||
<Select
|
||||
value={settings?.tax_display_cart || 'excl'}
|
||||
onValueChange={(value) => {
|
||||
// Update setting
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full md:w-[300px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="excl">{__('Excluding tax')}</SelectItem>
|
||||
<SelectItem value="incl">{__('Including tax')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* Advanced Settings Link */}
|
||||
<div className="rounded-lg border border-dashed p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{__('For advanced tax configuration, use the WooCommerce settings page')}
|
||||
</p>
|
||||
<SettingsCard
|
||||
title={__('Advanced Tax Settings')}
|
||||
description={__('Configure advanced tax options in WooCommerce')}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
onClick={() => window.location.href = `${wcAdminUrl}/admin.php?page=wc-settings&tab=tax`}
|
||||
>
|
||||
<a
|
||||
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{__('Open Tax Settings in WooCommerce')}
|
||||
</a>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{__('Open WooCommerce Tax Settings')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Tax Rate Dialog */}
|
||||
<Dialog open={showAddRate || !!editingRate} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowAddRate(false);
|
||||
setEditingRate(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingRate ? __('Edit Tax Rate') : __('Add Tax Rate')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">{__('Country')}</label>
|
||||
<input
|
||||
name="country"
|
||||
type="text"
|
||||
placeholder="ID"
|
||||
defaultValue={editingRate?.country || ''}
|
||||
required
|
||||
className="w-full mt-1.5 px-3 py-2 border rounded-md"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{__('2-letter country code (e.g., ID, MY, SG)')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">{__('State/Province (Optional)')}</label>
|
||||
<input
|
||||
name="state"
|
||||
type="text"
|
||||
placeholder="CA"
|
||||
defaultValue={editingRate?.state || ''}
|
||||
className="w-full mt-1.5 px-3 py-2 border rounded-md"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{__('Leave empty for country-wide rate')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">{__('Tax Rate (%)')}</label>
|
||||
<input
|
||||
name="rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="11"
|
||||
defaultValue={editingRate?.rate || ''}
|
||||
required
|
||||
className="w-full mt-1.5 px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">{__('Tax Name')}</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="PPN (VAT)"
|
||||
defaultValue={editingRate?.name || ''}
|
||||
required
|
||||
className="w-full mt-1.5 px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">{__('Tax Class (Optional)')}</label>
|
||||
<Select name="tax_class" defaultValue={editingRate?.tax_class || ''}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={__('Standard')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{__('Standard')}</SelectItem>
|
||||
<SelectItem value="reduced-rate">{__('Reduced Rate')}</SelectItem>
|
||||
<SelectItem value="zero-rate">{__('Zero Rate')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowAddRate(false);
|
||||
setEditingRate(null);
|
||||
}}
|
||||
>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingRate ? __('Update Rate') : __('Add Rate')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={!!deletingRate} onOpenChange={(open) => !open && setDeletingRate(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{__('Delete Tax Rate?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__('Are you sure you want to delete this tax rate? This action cannot be undone.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deletingRate && deleteMutation.mutate(deletingRate.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{__('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<id>\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<id>\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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user