fix(flags): close CRM/accounting feature-gating gaps from the audit

A sweep of every CRM/accounting toggle found surfaces still reachable
with their flag OFF. Adds a shared requireFeatureFlag middleware (the two
existing per-file copies predate it) and closes the gaps:

- Hours logging: only createEntry checked the flag — edit/delete/bill and
  the list/summary routes were permission-only. Gate all six
  /hour-entries routes on the hoursLogging master so a disabled feature
  can't be read, mutated, or invoiced via a direct API hit.
- Installment plans: PUT /deals/:uuid/installment-plan mutates invoices
  but wasn't bills-gated; add requireFeatureFlag('bills').
- Customer invoice PDF: /invoices/:id/pdf lacked the feature_bills check
  the list + quotes routes have. Also fixes the quotes-PDF gate, which
  read req.customer.feature_quotes (never populated → silent no-op).
- Customer contracts: /contracts + /contracts/:id/pdf were gated by
  neither the master nor a per-customer column.

Per-customer contracts override (the missing counterpart):
- Migration 131 adds customer_accounts.feature_contracts, default TRUE so
  existing customers keep their Contracts tab (preserve-visuals).
- Effective resolver now contractsMaster AND feature_contracts; admin
  detail page gains the toggle; service/validator/serializer wired.

Cleanups:
- Drop stale `taxReport` from the sidebar's Clients-reveal list (Tax moved
  to Accounting); add the missing `projects` so it mirrors the context
  derivation.
- SettingsPage tab-snap effect now depends on flags.accounting.
- Fix stale taxReport "forced off when bills off" comment (it's accounting).
This commit is contained in:
Luca
2026-06-16 13:12:26 +02:00
parent 873be910a5
commit 03fa3d8296
11 changed files with 141 additions and 17 deletions
@@ -92,9 +92,12 @@ const navigation: NavItem[] = [
// can't disagree: any sub-feature on lights up the entry, all off
// hides it. Future siblings (e.g. `messaging`) get appended here
// AND in the context derivation.
// taxReport intentionally excluded — Tax moved to the Accounting section
// and is not a Clients sub-nav item, so it must not reveal Clients (would
// open an empty ClientsLayout). Mirrors the context's `clients` derivation.
featureFlagsAny: [
'customerPortal', 'crmDevelopment', 'quotes', 'bills',
'taxReport', 'hoursLogging', 'contracts', 'calendar',
'hoursLogging', 'contracts', 'calendar', 'projects',
],
},
// Accounting section (migration 122) — inbound supplier invoices,
@@ -39,7 +39,7 @@ type EditableFields =
| 'phone' | 'companyName' | 'billingEmail' | 'vatId'
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging'
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts'
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled';
// `fmtDate` (from useLocalizedDate, below) is the single canonical date
@@ -137,6 +137,9 @@ export const CustomerDetailPage: React.FC = () => {
featureQuotes: customer.featureQuotes ?? false,
featureBills: customer.featureBills ?? false,
featureHoursLogging: customer.featureHoursLogging ?? false,
// Contracts is opt-OUT (default on) — preserve the tab for customers
// saved before the per-customer override existed.
featureContracts: customer.featureContracts ?? true,
hourlyRateMinor: customer.hourlyRateMinor ?? null,
billingCadence: customer.billingCadence ?? 'per_event',
billingCycleDay: customer.billingCycleDay ?? 1,
@@ -145,7 +148,7 @@ export const CustomerDetailPage: React.FC = () => {
}
}, [customer, form]);
const toggleFeature = (key: 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging') => {
const toggleFeature = (key: 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts') => {
setForm((prev) => ({ ...prev, [key]: !prev[key] }) as any);
};
@@ -570,7 +573,7 @@ export const CustomerDetailPage: React.FC = () => {
toggle inside it is OFF — an empty "Customer features" card
with just a title + hint reads as broken. The Card reappears
the moment any master flag is re-enabled. */}
{(flags.calendar || flags.quotes || flags.bills || flags.hoursLogging) && (
{(flags.calendar || flags.quotes || flags.bills || flags.hoursLogging || flags.contracts) && (
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
<ToggleLeft className="w-5 h-5" />
@@ -609,6 +612,9 @@ export const CustomerDetailPage: React.FC = () => {
...(flags.hoursLogging
? [{ key: 'featureHoursLogging' as const, labelKey: 'customers.field.featureHoursLogging', fallback: 'Hours logging', badge: 'new' as const }]
: []),
...(flags.contracts
? [{ key: 'featureContracts' as const, labelKey: 'customer.nav.contracts', fallback: 'Contracts', badge: 'new' as const }]
: []),
] as const).map(({ key, labelKey, fallback, badge }) => {
const enabled = !!form[key];
return (
+1 -1
View File
@@ -281,7 +281,7 @@ export const SettingsPage: React.FC = () => {
setActiveTab(visibleKeys[0]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, activeTab]);
}, [flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, activeTab]);
// For tabs that mount existing top-level pages OR bring their own
// header (FeaturesTab has its own icon+title+description block), skip
@@ -30,6 +30,9 @@ export interface CustomerAccountSummary {
/** Per-customer hour logging (migration 129). When on, the customer
* detail page renders the "Hours" section card. */
featureHoursLogging?: boolean;
/** Per-customer contracts override (migration 131). Defaults true —
* existing customers keep their Contracts tab. */
featureContracts?: boolean;
/** Default hourly rate in minor units (e.g. CHF 150.00 = 15000).
* null when admin hasn't set one — each entry then requires a
* per-block override. */
@@ -157,6 +160,7 @@ export const customerAdminService = {
featureQuotes: 'feature_quotes',
featureBills: 'feature_bills',
featureHoursLogging: 'feature_hours_logging',
featureContracts: 'feature_contracts',
// Hour-logging default rate (migration 129).
hourlyRateMinor: 'hourly_rate_minor',
// CRM billing cadence (migration 102 + 128).