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:
parent
0b326cbd87
commit
afb5e99bb2
48 changed files with 6238 additions and 39 deletions
28
frontend/src/api/subscriptions.ts
Normal file
28
frontend/src/api/subscriptions.ts
Normal 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
309
frontend/src/api/tax.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue