Add recurring transaction detection, subscriptions page, and UK tax reporting

- Recurring service: auto-detects direct debits/subscriptions from CSV imports
  using frequency analysis; manual toggle in transaction detail drawer
- Subscriptions page (/subscriptions): groups recurring payments with monthly
  cost equivalents, next-payment badges, and re-scan trigger
- UK Tax page (/tax): payslips/P60 entry, income tax + NI + CGT + dividend tax
  calculations, configurable rate tables per tax year (pre-seeded 2024/25 and
  2025/26), editable in-app so Budget changes need no rebuild
- Migration 0006: tax_rate_configs, tax_profiles, payslips, manual_cgt_disposals
  with RLS; seeds 2025/2026 rate configs for existing users
- Chart tooltip fix: all Recharts tooltips now use TOOLTIP_STYLE constant so
  they render correctly across all dark/light themes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-23 21:40:02 +00:00
parent 0b326cbd87
commit afb5e99bb2
48 changed files with 6238 additions and 39 deletions

View file

@ -0,0 +1,28 @@
import { api } from "./client";
export interface Subscription {
name: string;
amount: number;
frequency: string;
next_expected: string | null;
last_paid: string | null;
account_id: string;
account_name: string | null;
transaction_ids: string[];
latest_transaction_id: string;
monthly_equivalent: number;
confidence: number;
manually_set: boolean;
}
export interface SubscriptionsSummary {
total_monthly_equivalent: number;
currency: string;
subscriptions: Subscription[];
}
export const getSubscriptions = (): Promise<SubscriptionsSummary> =>
api.get("/subscriptions").then((r: { data: SubscriptionsSummary }) => r.data);
export const triggerDetection = (): Promise<{ newly_tagged: number; total_recurring: number }> =>
api.post("/transactions/detect-recurring").then((r: { data: { newly_tagged: number; total_recurring: number } }) => r.data);

309
frontend/src/api/tax.ts Normal file
View file

@ -0,0 +1,309 @@
import { api } from "./client";
// ---------------------------------------------------------------------------
// Query key constants
// ---------------------------------------------------------------------------
export const TAX_QUERY_KEYS = {
configuredYears: ["tax-configured-years"] as const,
rateConfig: (taxYear: number) => ["tax-rate-config", taxYear] as const,
profile: (taxYear: number) => ["tax-profile", taxYear] as const,
payslips: (taxYear: number) => ["tax-payslips", taxYear] as const,
cgtDisposals: (taxYear: number) => ["tax-cgt-disposals", taxYear] as const,
report: (taxYear: number) => ["tax-report", taxYear] as const,
};
// ---------------------------------------------------------------------------
// Interfaces
// ---------------------------------------------------------------------------
export interface TaxRateConfig {
tax_year: number;
rates: {
income_tax?: { bands: RateBand[] };
ni?: { bands: RateBand[] };
cgt?: { exempt: number; basic_rate: number; higher_rate: number };
dividend?: {
allowance: number;
basic_rate: number;
higher_rate: number;
additional_rate: number;
};
};
updated_at: string;
}
export interface RateBand {
from: number;
to: number | null;
rate: number;
}
export interface TaxRateConfigUpdate {
income_tax?: { bands: RateBand[] };
ni?: { bands: RateBand[] };
cgt?: { exempt: number; basic_rate: number; higher_rate: number };
dividend?: {
allowance: number;
basic_rate: number;
higher_rate: number;
additional_rate: number;
};
}
export interface TaxProfile {
id: string;
tax_year: number;
tax_code: string;
employer_name: string | null;
is_cumulative: boolean;
created_at: string;
updated_at: string;
}
export interface TaxProfileCreate {
tax_code?: string;
employer_name?: string | null;
is_cumulative?: boolean;
}
export interface Payslip {
id: string;
tax_profile_id: string;
period_month: number | null;
period_year: number;
gross_pay: string;
income_tax_withheld: string;
ni_withheld: string;
net_pay: string;
is_p60: boolean;
notes: string | null;
created_at: string;
}
export interface PayslipCreate {
period_month?: number | null;
period_year: number;
gross_pay: number;
income_tax_withheld: number;
ni_withheld: number;
net_pay: number;
notes?: string | null;
}
export interface P60Entry {
gross_pay: number;
income_tax_withheld: number;
ni_withheld: number;
net_pay: number;
}
export interface ManualDisposal {
id: string;
tax_year: number;
disposal_date: string;
asset_description: string;
proceeds: string;
cost_basis: string;
gain_loss: string;
notes: string | null;
created_at: string;
}
export interface ManualDisposalCreate {
disposal_date: string;
asset_description: string;
proceeds: number;
cost_basis: number;
notes?: string | null;
}
// ---------------------------------------------------------------------------
// Tax report nested types
// ---------------------------------------------------------------------------
export interface BandBreakdown {
rate: number;
taxable: number;
tax: number;
from?: number;
to?: number | null;
}
export interface InvestmentDisposalItem {
date: string;
asset: string;
symbol: string;
quantity: string;
proceeds: string;
cost_basis: string;
fees: string;
gain_loss: string;
}
export interface DividendTransactionItem {
date: string;
asset: string;
symbol: string;
amount: string;
}
export interface TaxReport {
tax_year: number;
tax_year_display: string;
profile: TaxProfile | null;
income: {
gross_income: string;
income_tax_withheld: string;
ni_withheld: string;
payslips: Payslip[];
};
income_tax: {
personal_allowance: string;
taxable_income: string;
liability: string;
band_breakdown: BandBreakdown[];
withheld: string;
owed: string;
};
ni: {
liability: string;
band_breakdown: BandBreakdown[];
withheld: string;
owed: string;
};
cgt: {
gross_gain: string;
exempt: string;
taxable_gain: string;
liability: string;
band_breakdown: BandBreakdown[];
investment_disposals: InvestmentDisposalItem[];
manual_disposals: ManualDisposal[];
total_gain: string;
};
dividends: {
gross_dividends: string;
allowance: string;
taxable_dividends: string;
liability: string;
band_breakdown: BandBreakdown[];
dividend_transactions: DividendTransactionItem[];
};
summary: {
total_liability: string;
total_withheld: string;
net_owed: string;
overpaid: boolean;
};
}
// ---------------------------------------------------------------------------
// API functions — rate configs
// ---------------------------------------------------------------------------
export async function getConfiguredYears(): Promise<number[]> {
const res = await api.get("/tax/rate-configs");
return res.data;
}
export async function getRateConfig(taxYear: number): Promise<TaxRateConfig> {
const res = await api.get(`/tax/rate-configs/${taxYear}`);
return res.data;
}
export async function upsertRateConfig(
taxYear: number,
data: TaxRateConfigUpdate
): Promise<TaxRateConfig> {
const res = await api.put(`/tax/rate-configs/${taxYear}`, data);
return res.data;
}
// ---------------------------------------------------------------------------
// API functions — tax profile
// ---------------------------------------------------------------------------
export async function getTaxProfile(taxYear: number): Promise<TaxProfile> {
const res = await api.get(`/tax/profile/${taxYear}`);
return res.data;
}
export async function upsertTaxProfile(
taxYear: number,
data: TaxProfileCreate
): Promise<TaxProfile> {
const res = await api.put(`/tax/profile/${taxYear}`, data);
return res.data;
}
// ---------------------------------------------------------------------------
// API functions — payslips
// ---------------------------------------------------------------------------
export async function getPayslips(taxYear: number): Promise<Payslip[]> {
const res = await api.get(`/tax/payslips/${taxYear}`);
return res.data;
}
export async function createPayslip(
taxYear: number,
data: PayslipCreate
): Promise<Payslip> {
const res = await api.post(`/tax/payslips/${taxYear}`, data);
return res.data;
}
export async function updatePayslip(
id: string,
data: Partial<PayslipCreate>
): Promise<Payslip> {
const res = await api.put(`/tax/payslips/${id}`, data);
return res.data;
}
export async function deletePayslip(id: string): Promise<void> {
await api.delete(`/tax/payslips/${id}`);
}
export async function enterP60(taxYear: number, data: P60Entry): Promise<void> {
await api.post(`/tax/payslips/${taxYear}/p60`, data);
}
// ---------------------------------------------------------------------------
// API functions — manual CGT disposals
// ---------------------------------------------------------------------------
export async function getCgtDisposals(taxYear: number): Promise<ManualDisposal[]> {
const res = await api.get(`/tax/cgt-disposals/${taxYear}`);
return res.data;
}
export async function createCgtDisposal(
taxYear: number,
data: ManualDisposalCreate
): Promise<ManualDisposal> {
const res = await api.post(`/tax/cgt-disposals/${taxYear}`, data);
return res.data;
}
export async function updateCgtDisposal(
id: string,
data: Partial<ManualDisposalCreate>
): Promise<ManualDisposal> {
const res = await api.put(`/tax/cgt-disposals/${id}`, data);
return res.data;
}
export async function deleteCgtDisposal(id: string): Promise<void> {
await api.delete(`/tax/cgt-disposals/${id}`);
}
// ---------------------------------------------------------------------------
// API functions — report
// ---------------------------------------------------------------------------
export async function getTaxReport(taxYear: number): Promise<TaxReport> {
const res = await api.get(`/tax/report/${taxYear}`);
return res.data;
}

View file

@ -25,6 +25,7 @@ export interface Transaction {
notes: string | null;
tags: string[];
is_recurring: boolean;
recurring_rule: Record<string, unknown> | null;
attachment_refs: AttachmentRef[];
created_at: string;
updated_at: string;
@ -43,6 +44,8 @@ export interface TransactionCreate {
merchant?: string;
notes?: string;
tags?: string[];
is_recurring?: boolean;
recurring_rule?: Record<string, unknown> | null;
}
export interface TransactionPage {