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:
dwindown
2025-11-10 12:23:44 +07:00
parent c1f09041ef
commit 28bbce5434
3 changed files with 783 additions and 216 deletions

View File

@@ -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&section=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&section=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&section=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>
);
}