Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/tax-packs.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ It must not embed scripts, call network endpoints, or introduce a second tax-cal
## Authoring a new pack

1. Copy the shape of the bundled pack — `main/tax-packs/generic.json` — as your starting point, then add real `categories` and `rules` for your country.
2. Give it a unique `id` using the lowercase, hyphenated full country name (for example, `official-india`, `official-thailand`, or `official-united-states`). Keep `country` as its ISO alpha-2 code (for example, `IN`, `TH`, or `US`), because FloCafe uses that field to match the store. Set `publisher` to your name/org (anything other than `local` — `local` is reserved for the in-app manual/unbundled pack and can never be published), and fill in `jurisdiction`, `currency`, `taxRounding`, `payableRounding`.
2. Give it a unique `id` using the lowercase, hyphenated full country name (for example, `official-argentina`, `official-india`, `official-thailand`, or `official-united-states`). Keep `country` as its ISO alpha-2 code (for example, `AR`, `IN`, `TH`, or `US`), because FloCafe uses that field to match the store. Set `publisher` to your name/org (anything other than `local` — `local` is reserved for the in-app manual/unbundled pack and can never be published), and fill in `jurisdiction`, `currency`, `taxRounding`, `payableRounding`.
3. Define `categories` and `rules`. Every category referenced by `defaultCategories` or by a product must exist. `unclassifiedCategoryId` must point at a real category (usually a zero-rate one). A rate table can use multiple regional components (each its own rule) or a single flat rule, depending on how the country's tax works.
4. Add the file to `main/tax-packs/` in this repo (not a new repo — see "Where packs live" below) and open a PR. Only the generic/manual no-tax pack is bundled with and auto-activated by a new installation. Every official country pack is catalog-only: an owner explicitly enables the matching pack from Settings → Tax Configuration, where FloCafe downloads, verifies, installs, and activates it.
5. Add test vectors: extend `tests/tax-pack-management.test.ts` (activation validation) and, ideally, `tests/tax-engine.test.ts` / `tests/integration-tax.test.ts` with a scenario proving your rules produce the expected components, totals, and rounding for at least one representative order.
Expand Down
27 changes: 25 additions & 2 deletions frontend/src/app/(dashboard)/products/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export default function ProductsPage() {

const [taxCategories, setTaxCategories] = useState<{ id: string; label: string; rate_percent?: number | null; rate_label?: string | null }[]>([]);
const [defaultTaxCategoryId, setDefaultTaxCategoryId] = useState('');
const [defaultTaxInclusive, setDefaultTaxInclusive] = useState<boolean | null>(null);
const [showBulkTaxModal, setShowBulkTaxModal] = useState(false);
const [bulkTaxCategoryId, setBulkTaxCategoryId] = useState('');
const [bulkTaxApplying, setBulkTaxApplying] = useState(false);
Expand Down Expand Up @@ -146,9 +147,10 @@ export default function ProductsPage() {
.finally(() => { setLoading(false); });
api.get('/tax/categories', { signal: controller.signal })
.then((res) => {
const data = res.data as { categories?: { id: string; label: string; rate_percent?: number | null; rate_label?: string | null }[]; default_category_id?: string | null };
const data = res.data as { categories?: { id: string; label: string; rate_percent?: number | null; rate_label?: string | null }[]; default_category_id?: string | null; default_inclusive?: boolean | null };
setTaxCategories(data.categories || []);
setDefaultTaxCategoryId(data.default_category_id || '');
setDefaultTaxInclusive(data.default_inclusive ?? null);
})
.catch((err: unknown) => {
if (!(err instanceof Error && (err.name === 'CanceledError' || err.name === 'AbortError'))) setTaxCategories([]);
Expand Down Expand Up @@ -582,7 +584,19 @@ export default function ProductsPage() {
</td>
<td className="p-4 text-sm text-gray-600">
<div className="flex flex-col gap-0.5">
<span>{taxLabel}</span>
<span className="flex items-center gap-1.5 flex-wrap">
{taxLabel}
{product.tax_behavior === 'inclusive' && (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 bg-emerald-50 border border-emerald-200" title={t('products.taxInclusiveHint')}>
{t('products.taxInclusiveShort')}
</span>
)}
{product.tax_behavior === 'exclusive' && (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium text-blue-700 bg-blue-50 border border-blue-200" title={t('products.taxExclusiveHint')}>
{t('products.taxExclusiveShort')}
</span>
)}
</span>
{!product.tax_category_id && taxCategories.length > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded px-1.5 py-0.5 w-fit" title={t('products.notTaxedTooltip')}>
<AlertTriangle size={11} className="shrink-0" /> {t('products.notTaxedBadge')}
Expand Down Expand Up @@ -753,6 +767,15 @@ export default function ProductsPage() {
<option value="exempt">Exempt</option>
</select>
<p className="text-xs text-gray-400 mt-1">The rate is resolved from the active tax profile for this category, not entered manually.</p>
{form.tax_behavior === 'country_default' && defaultTaxInclusive != null ? (
<p className="text-xs text-gray-400 mt-1">
{t('products.taxBehaviorDefaultHint', { behavior: defaultTaxInclusive ? t('products.taxInclusive') : t('products.taxExclusive') })}
</p>
) : form.tax_behavior === 'inclusive' ? (
<p className="text-xs text-gray-400 mt-1">{t('products.taxInclusiveHint')}</p>
) : form.tax_behavior === 'exclusive' ? (
<p className="text-xs text-gray-400 mt-1">{t('products.taxExclusiveHint')}</p>
) : null}
</div>
) : (
<p className="text-xs text-gray-400 -mt-2">
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/components/settings/TaxConfigurationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import toast from 'react-hot-toast';
import api from '@/lib/api';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/hooks/useI18n';

type PackSummary = {
id: string;
Expand Down Expand Up @@ -221,6 +222,7 @@ function auditDescription(row: AuditRow): string {
}

export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
const { t } = useI18n();
const [packs, setPacks] = useState<PackSummary[]>([]);
const [storeCountry, setStoreCountry] = useState('');
const [selectedPackId, setSelectedPackId] = useState('');
Expand Down Expand Up @@ -717,6 +719,33 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
}
}

// Worked example for the "Menu prices" radios: show what a product priced
// 100 actually costs the customer at the rate of the default product
// category (falls back to the first category's first percent component).
const exampleRate = useMemo(() => {
const defaultCategory = manualCategories.find((category) => category.tempId === manualDefaults.product)
|| manualCategories[0];
if (!defaultCategory) return null;
const percent = defaultCategory.components.find((component) => component.type === 'percent');
if (!percent) return null;
const rate = Number(percent.value);
if (!Number.isFinite(rate) || rate <= 0) return null;
return rate;
}, [manualCategories, manualDefaults.product]);
const example = useMemo(() => {
if (exampleRate == null) return null;
if (manualInclusive) {
const tax = 100 - 100 / (1 + exampleRate / 100);
return t('settings.taxConfigExampleInclusive', { rate: exampleRate, tax: tax.toFixed(2) });
}
const tax = 100 * (exampleRate / 100);
return t('settings.taxConfigExampleExclusive', {
rate: exampleRate,
total: (100 + tax).toFixed(2),
tax: tax.toFixed(2),
});
}, [exampleRate, manualInclusive, t]);

if (loading && !detail) {
return <div className="py-16 text-center text-sm text-gray-500">Loading tax configuration…</div>;
}
Expand Down Expand Up @@ -819,6 +848,15 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
no official tax pack for your country yet, or to replace one with your own rates.
</p>

{taxMode === 'official' && !manualOverrideConfirm && (
<div className="mt-3 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<span>
{t('settings.taxConfigOfficialPackReplaceWarning', { country: storeCountry })}
</span>
</div>
)}

<div className="mt-4 space-y-3">
{manualCategories.map((category) => (
<div key={category.tempId} className="rounded-lg border border-gray-100 bg-gray-50 p-3">
Expand Down Expand Up @@ -898,6 +936,7 @@ export function TaxConfigurationPanel({ isOwner }: { isOwner: boolean }) {
Tax-inclusive (already baked into the menu price)
</label>
</div>
{example && <p className="mt-2 text-xs text-gray-500">{example}</p>}
</div>

<div className="mt-5 border-t border-gray-100 pt-4">
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,9 @@
"products.tagPlaceholder": "Type a tag and press Enter",
"products.taxExclusive": "Exclusive",
"products.taxInclusive": "Inclusive",
"products.taxExclusiveHint": "Exclusive: the tax is added on top of the menu price, so the customer pays more than the price shown.",
"products.taxInclusiveHint": "Inclusive: the menu price already contains the tax, so the customer pays exactly the price shown.",
"products.taxBehaviorDefaultHint": "Country default: follows the active tax profile ({behavior}).",
"products.taxNone": "No Tax",
"products.notTaxedBadge": "Not taxed",
"products.notTaxedTooltip": "No tax category assigned — this product charges no tax and prints no tax line.",
Expand Down Expand Up @@ -781,6 +784,9 @@
"settings.status": "Status",
"settings.storeDetails": "Store Details",
"settings.taxConfiguration": "Tax Config",
"settings.taxConfigExampleExclusive": "At {rate}%: a product priced 100 costs the customer {total} — {tax} of tax is added at checkout.",
"settings.taxConfigExampleInclusive": "At {rate}%: a product priced 100 still costs the customer 100, of which {tax} is tax.",
"settings.taxConfigOfficialPackReplaceWarning": "An official tax pack is active for {country}. Saving this manual configuration will replace it.",
"settings.orderNumberFormat": "Order Number Format",
"settings.orderNumberPrefix": "Prefix",
"settings.orderNumberPreview": "Preview",
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,9 @@
"products.tagPlaceholder": "Escribí una etiqueta y presioná Enter",
"products.taxExclusive": "Excluido",
"products.taxInclusive": "Incluido",
"products.taxExclusiveHint": "Excluido: el impuesto se agrega sobre el precio del menú, por lo que el cliente paga más que el precio mostrado.",
"products.taxInclusiveHint": "Incluido: el precio del menú ya contiene el impuesto, por lo que el cliente paga exactamente el precio mostrado.",
"products.taxBehaviorDefaultHint": "Predeterminado del país: sigue el perfil fiscal activo ({behavior}).",
"products.taxNone": "Sin impuesto",
"products.notTaxedBadge": "Sin impuestos",
"products.notTaxedTooltip": "No hay ninguna categoría fiscal asignada: este producto no cobra impuestos ni imprime ninguna línea de impuestos.",
Expand Down Expand Up @@ -780,6 +783,9 @@
"settings.status": "Estado",
"settings.storeDetails": "Datos del Negocio",
"settings.taxConfiguration": "Config. fiscal",
"settings.taxConfigExampleExclusive": "Al {rate}%: un producto con precio 100 le cuesta al cliente {total} — {tax} de impuestos se agregan al pagar.",
"settings.taxConfigExampleInclusive": "Al {rate}%: un producto con precio 100 le sigue costando 100 al cliente, de los cuales {tax} son impuestos.",
"settings.taxConfigOfficialPackReplaceWarning": "Hay un paquete fiscal oficial activo para {country}. Guardar esta configuración manual lo reemplazará.",
"settings.orderNumberFormat": "Formato del número de pedido",
"settings.orderNumberPrefix": "Prefijo",
"settings.orderNumberPreview": "Vista previa",
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/i18n/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,9 @@
"products.tagPlaceholder": "یک برچسب بنویسید و Enter را بزنید",
"products.taxExclusive": "بدون مالیات",
"products.taxInclusive": "همراه مالیات",
"products.taxExclusiveHint": "بدون مالیات: مالیات به قیمت منو اضافه می‌شود، بنابراین مشتری بیش از قیمت نمایش‌داده‌شده پرداخت می‌کند.",
"products.taxInclusiveHint": "همراه مالیات: قیمت منو از قبل شامل مالیات است، بنابراین مشتری دقیقاً همان قیمت نمایش‌داده‌شده را پرداخت می‌کند.",
"products.taxBehaviorDefaultHint": "پیش‌فرض کشور: از نمایه مالیاتی فعال پیروی می‌کند ({behavior}).",
"products.taxNone": "بدون مالیات",
"products.notTaxedBadge": "مشمول مالیات نیست",
"products.notTaxedTooltip": "هیچ دسته مالیاتی برای این کالا برگزیده نشده است — این کالا مالیاتی ندارد و ردیف مالیات در رسید چاپ نمی‌شود.",
Expand Down Expand Up @@ -781,6 +784,9 @@
"settings.status": "Status",
"settings.storeDetails": "Store Details",
"settings.taxConfiguration": "Tax Config",
"settings.taxConfigExampleExclusive": "با نرخ {rate}٪: کالایی با قیمت ۱۰۰ برای مشتری {total} هزینه دارد — هنگام تسویه {tax} مالیات اضافه می‌شود.",
"settings.taxConfigExampleInclusive": "با نرخ {rate}٪: کالایی با قیمت ۱۰۰ همچنان برای مشتری ۱۰۰ هزینه دارد که {tax} از آن مالیات است.",
"settings.taxConfigOfficialPackReplaceWarning": "یک بسته مالیاتی رسمی برای {country} فعال است. ذخیره این پیکربندی دستی جایگزین آن خواهد شد.",
"settings.orderNumberFormat": "Order Number Format",
"settings.orderNumberPrefix": "Prefix",
"settings.orderNumberPreview": "Preview",
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/i18n/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,9 @@
"products.tagPlaceholder": "Digite uma etiqueta e pressione Enter",
"products.taxExclusive": "Exclusivo",
"products.taxInclusive": "Inclusivo",
"products.taxExclusiveHint": "Exclusivo: o imposto é adicionado sobre o preço do menu, então o cliente paga mais que o preço mostrado.",
"products.taxInclusiveHint": "Inclusivo: o preço do menu já contém o imposto, então o cliente paga exatamente o preço mostrado.",
"products.taxBehaviorDefaultHint": "Padrão do país: segue o perfil fiscal ativo ({behavior}).",
"products.taxNone": "Sem Imposto",
"products.notTaxedBadge": "Sem imposto",
"products.notTaxedTooltip": "Nenhuma categoria de imposto atribuída — este produto não cobra imposto nem imprime uma linha de imposto.",
Expand Down Expand Up @@ -780,6 +783,9 @@
"settings.status": "Status",
"settings.storeDetails": "Detalhes da Loja",
"settings.taxConfiguration": "Config. tributária",
"settings.taxConfigExampleExclusive": "A {rate}%: um produto com preço 100 custa ao cliente {total} — {tax} de impostos são adicionados no checkout.",
"settings.taxConfigExampleInclusive": "A {rate}%: um produto com preço 100 ainda custa 100 ao cliente, dos quais {tax} são impostos.",
"settings.taxConfigOfficialPackReplaceWarning": "Há um pacote fiscal oficial ativo para {country}. Salvar esta configuração manual o substituirá.",
"settings.orderNumberFormat": "Formato do Número do Pedido",
"settings.orderNumberPrefix": "Prefixo",
"settings.orderNumberPreview": "Pré-visualização",
Expand Down
5 changes: 5 additions & 0 deletions main/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ export function registerRoutes(app: Express): void {
})
: [],
default_category_id: configurationReady ? pack.defaultCategories.product : null,
// Whether the active pack treats menu prices as already containing
// tax. The products page uses it to explain what a product's
// "Country default" tax behavior resolves to, instead of leaving
// the label opaque.
default_inclusive: configurationReady ? pack.inclusivePricingDefault : null,
configuration_ready: configurationReady,
unclassified_category_id: pack.unclassifiedCategoryId,
});
Expand Down
66 changes: 66 additions & 0 deletions main/tax-packs/argentina.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"schemaVersion": 1,
"id": "official-argentina",
"publisher": "official-argentina",
"version": "0.1.0",
"country": "AR",
"jurisdiction": "*",
"currency": "ARS",
"effectiveFrom": "2026-08-07",
"publishedAt": "2026-08-07",
"minFloVersion": "2.4.0",
"taxPoint": "finalized_at",
"inclusivePricingDefault": true,
"registrationNumberLabel": "CUIT",
"categories": [
{ "id": "iva_21", "label": "IVA 21%", "ruleIds": ["iva-21"] },
{ "id": "iva_105", "label": "IVA 10.5%", "ruleIds": ["iva-105"] },
{ "id": "iva_27", "label": "IVA 27%", "ruleIds": ["iva-27"] },
{ "id": "packaging", "label": "Packaging", "ruleIds": [], "defaultBehavior": "exempt" },
{ "id": "delivery", "label": "Delivery", "ruleIds": [], "defaultBehavior": "exempt" },
{ "id": "service_charge", "label": "Service charge", "ruleIds": [], "defaultBehavior": "exempt" },
{ "id": "addon", "label": "Add-on", "ruleIds": [], "defaultBehavior": "exempt" },
{ "id": "iva_exempt", "label": "Exento", "ruleIds": [], "defaultBehavior": "exempt" }
],
"defaultCategories": {
"product": "iva_21",
"packaging": "packaging",
"delivery": "delivery",
"service_charge": "service_charge",
"addon": "addon"
},
"unclassifiedCategoryId": "iva_exempt",
"rules": [
{
"id": "iva-21",
"label": "IVA 21%",
"type": "percent",
"categoryIds": ["iva_21"],
"rate": "21"
},
{
"id": "iva-105",
"label": "IVA 10.5%",
"type": "percent",
"categoryIds": ["iva_105"],
"rate": "10.5"
},
{
"id": "iva-27",
"label": "IVA 27%",
"type": "percent",
"categoryIds": ["iva_27"],
"rate": "27"
}
],
"taxRounding": {
"scope": "line",
"method": "half_up",
"decimalPlaces": 2,
"remainderAllocation": "largest_remainder"
},
"payableRounding": {
"increment": "0.01",
"method": "half_up"
}
}
Loading
Loading