From d40ea0e3d6c02114d37271dc172982a11c9b96b7 Mon Sep 17 00:00:00 2001 From: ZakApp Agent Date: Fri, 13 Feb 2026 16:17:39 +0000 Subject: [PATCH 1/4] feat: add retirement Zakat methodology selection (zakapp-qz0) - Add RetirementMethodology type: collectible_value, preserved_growth, manual - Add RetirementConfig interface with methodology, withdrawalPenalty, estimatedTaxRate - Implement calculateRetirementZakat() supporting all three methodologies - Add parseRetirementConfig() for metadata parsing - Preserve backward compatibility with legacy retirementDetails format Opinion A (collectible_value): Net after penalty/tax * 2.5% Opinion B (preserved_growth): 0.5% rule (Dr. Salah Al-Sawy) Default: manual - standard 2.5% on full value --- .beads/issues.jsonl | 1 + client/src/core/calculations/zakat.ts | 124 ++++++++++++++++++++------ client/src/types/asset.types.ts | 26 ++++-- 3 files changed, 115 insertions(+), 36 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index d34ac71a..1baaa58d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -50,6 +50,7 @@ {"id":"zakapp-o1w","title":"Refine Docker workflow: Main=Latest, Tags=Release, Remove Noise","description":"Update CI/CD to only build on main/releases. Remove SHA tags. Ensure main maps to latest and tags map to semver.","status":"closed","priority":2,"issue_type":"task","owner":"agent@zakapp.dev","created_at":"2026-02-07T12:37:40.045500964Z","created_by":"ZakApp Agent","updated_at":"2026-02-07T12:38:31.859470573Z","closed_at":"2026-02-07T12:38:31.859470573Z","close_reason":"Closed"} {"id":"zakapp-o8e","title":"CRITICAL: Fix failing test suite - 170+ failing tests (74% pass rate)","description":"Test suite has 170+ failing tests preventing reliable deployment. Critical tests failing include authentication, asset management, and payment processing.\n\nFailing test categories:\n1. Asset creation/management tests failing due to auth token issues\n2. Rate limiting tests inconsistent\n3. Contract tests missing required fields\n4. Payment record validation failing\n\nRequired fixes:\n1. Fix authentication token generation in tests\n2. Implement missing validation in payment endpoints\n3. Resolve rate limiting test inconsistencies\n4. Update test data and assertions\n\nVerification: npm test passes with \u003e95% success rate.","notes":"Major progress: Fixed 5 more contract test files. Current status: 56 failed test files (down from 77), 150 failed tests (down from 170+), 726/899 tests passing (81% pass rate). Fixed: encryption.test.ts async bug, assets-post/put/get contract tests, auth-register contract tests, SimpleValidation non-negative value check.","status":"closed","priority":1,"issue_type":"task","owner":"agent@zakapp.dev","created_at":"2026-02-08T00:55:24.240278535Z","created_by":"ZakApp Agent","updated_at":"2026-02-09T16:54:31.874242431Z","closed_at":"2026-02-09T16:54:31.874242431Z","close_reason":"Closed","labels":["critical","quality","testing"]} {"id":"zakapp-o95","title":"Resolve merge conflicts in feature/prebuilt-docker-images","description":"Resolve merge conflicts in README.md, docker-compose.prod.yml, and docker-compose.yml on feature/prebuilt-docker-images branch.","status":"closed","priority":1,"issue_type":"task","owner":"agent@zakapp.dev","created_at":"2026-02-06T22:02:31.190750175Z","created_by":"ZakApp Agent","updated_at":"2026-02-06T22:03:51.382804555Z","closed_at":"2026-02-06T22:03:51.382804555Z","close_reason":"Closed"} +{"id":"zakapp-qz0","title":"Advanced Retirement Zakat Logic (Fiqh Compliance)","description":"Implement specific calculation methodologies for retirement assets - 'Collectible Value' vs 'Invested Growth' based on scholarly analysis.","status":"in_progress","priority":1,"issue_type":"feature","owner":"agent@zakapp.dev","created_at":"2026-02-13T15:18:58.778767861Z","created_by":"ZakApp Agent","updated_at":"2026-02-13T15:19:06.271771896Z"} {"id":"zakapp-tba","title":"CLEANUP: Resolve 100+ TODO/FIXME comments indicating incomplete features","description":"Codebase contains 100+ TODO/FIXME comments indicating rushed development and incomplete core functionality.\n\nCritical TODOs to resolve:\n- AnnualSummaryService missing payment calculations\n- Zakat routes with stubbed liability handling\n- Missing unique recipient counting in summaries\n- Incomplete methodology handling\n- Push notification service database schema\n- Missing export functionality\n\nApproach:\n1. Prioritize business-critical TODOs\n2. Implement missing functionality or remove dead code\n3. Add proper error handling for incomplete features\n4. Update documentation\n\nVerification: TODO/FIXME count reduced to \u003c20, all critical features implemented.","status":"open","priority":2,"issue_type":"task","owner":"agent@zakapp.dev","created_at":"2026-02-08T00:55:28.830212251Z","created_by":"ZakApp Agent","updated_at":"2026-02-08T00:55:28.830212251Z","labels":["cleanup","maintainability"]} {"id":"zakapp-vz1","title":"Finalize deployment script cleanup","status":"closed","priority":2,"issue_type":"task","owner":"agent@zakapp.dev","created_at":"2026-02-06T20:33:18.776788604Z","created_by":"ZakApp Agent","updated_at":"2026-02-06T20:33:23.061741955Z","closed_at":"2026-02-06T20:33:23.061741955Z","close_reason":"Closed"} {"id":"zakapp-wlu","title":"SECURITY: Remove exposed production secrets from git history","description":"Multiple .env.backup.* files containing live JWT secrets, encryption keys, and database credentials are committed to the repository despite being in .gitignore. This represents an immediate security breach requiring emergency remediation.\n\nImmediate actions needed:\n1. Remove .env.backup.* files from git tracking\n2. Rotate all exposed secrets in production\n3. Clean git history if secrets were exposed\n4. Update .gitignore to prevent future occurrences\n\nVerification: Run 'git log --all --full-history -- .env*' and confirm no secret exposure.","status":"closed","priority":0,"issue_type":"task","owner":"agent@zakapp.dev","created_at":"2026-02-08T00:55:17.971892975Z","created_by":"ZakApp Agent","updated_at":"2026-02-09T13:51:02.209817035Z","closed_at":"2026-02-09T13:51:02.209817035Z","close_reason":"Closed","labels":["critical","emergency","security"]} diff --git a/client/src/core/calculations/zakat.ts b/client/src/core/calculations/zakat.ts index a5fcc5b6..b525bb2e 100644 --- a/client/src/core/calculations/zakat.ts +++ b/client/src/core/calculations/zakat.ts @@ -17,11 +17,16 @@ import { Asset, AssetType } from '../../types/index'; import { MethodologyName, getMethodology } from './methodology'; +import type { RetirementMethodology, RetirementConfig } from '../../types/asset.types'; import { Decimal } from 'decimal.js'; export type ZakatMethodology = MethodologyName; +// Retirement methodology constants +const PRESERVED_GROWTH_RATE = 0.005; // 0.5% rule (Dr. Salah Al-Sawy) +const STANDARD_ZAKAT_RATE = 0.025; // 2.5% + export interface CalculationResult { totalAssets: number; zakatableAssets: number; @@ -66,37 +71,100 @@ export function isAssetZakatable(asset: Asset, methodologyName: ZakatMethodology return true; } -// Net Withdrawable Logic for Retirement -function calculateNetWithdrawable(asset: Asset): number { - // Access metadata for retirement-specific settings - let penalty = new Decimal(0); - let tax = new Decimal(0); - - // Try metadata string parse for retirement details - if (typeof asset.metadata === 'string' && asset.metadata.startsWith('{')) { - try { - const meta = JSON.parse(asset.metadata); - if (meta.retirementDetails) { - penalty = new Decimal(meta.retirementDetails.withdrawalPenalty || 0); - tax = new Decimal(meta.retirementDetails.taxRate || 0); - } - } catch (e) { - // ignore - } - } else if ((asset as any).retirementDetails) { - // Fallback: Check if retirementDetails is already on the object (pre-parsed) - const details = (asset as any).retirementDetails; - penalty = new Decimal(details.withdrawalPenalty || 0); - tax = new Decimal(details.taxRate || 0); +// Parse retirement config from asset metadata +function parseRetirementConfig(asset: Asset): RetirementConfig | null { + // Try metadata string parse + if (typeof asset.metadata === 'string' && asset.metadata.startsWith('{')) { + try { + const meta = JSON.parse(asset.metadata); + if (meta.retirementConfig) { + return meta.retirementConfig; + } + // Legacy support: check for retirementDetails + if (meta.retirementDetails) { + return { + methodology: 'collectible_value', + withdrawalPenalty: meta.retirementDetails.withdrawalPenalty || 0, + estimatedTaxRate: meta.retirementDetails.taxRate || 0, + }; + } + } catch (e) { + // ignore } + } - // netFactor = 1 - penalty - tax - const one = new Decimal(1); - const netFactor = one.minus(penalty).minus(tax); + // Fallback: Check if retirementConfig is already on the object (pre-parsed) + const config = (asset as any).retirementConfig; + if (config) { + return config; + } - // asset.value * max(0, netFactor) - const factor = Decimal.max(0, netFactor); - return new Decimal(asset.value || 0).times(factor).toNumber(); + // Legacy support: check for retirementDetails + const details = (asset as any).retirementDetails; + if (details) { + return { + methodology: 'collectible_value', + withdrawalPenalty: details.withdrawalPenalty || 0, + estimatedTaxRate: details.taxRate || 0, + }; + } + + return null; +} + +// Calculate retirement Zakat based on methodology +function calculateRetirementZakat(asset: Asset): number { + const config = parseRetirementConfig(asset); + const grossBalance = new Decimal(asset.value || 0); + + // If no config, default to 'manual' (treat as regular asset) + if (!config || config.methodology === 'manual') { + // Fall back to standard 2.5% calculation (or use calculationModifier if set) + if (typeof asset.calculationModifier === 'number' && asset.calculationModifier !== 1.0) { + return grossBalance.times(asset.calculationModifier).times(STANDARD_ZAKAT_RATE).toNumber(); + } + return grossBalance.times(STANDARD_ZAKAT_RATE).toNumber(); + } + + // Opinion B: Preserved Growth (0.5% Rule - Dr. Salah Al-Sawy) + if (config.methodology === 'preserved_growth') { + return grossBalance.times(PRESERVED_GROWTH_RATE).toNumber(); + } + + // Opinion A: Collectible Value (Withdrawal Method) + // Formula: (GrossBalance - (GrossBalance * (Penalty + Tax))) * 0.025 + const penalty = new Decimal(config.withdrawalPenalty || 0); + const tax = new Decimal(config.estimatedTaxRate || 0); + const one = new Decimal(1); + const netFactor = one.minus(penalty).minus(tax); + const factor = Decimal.max(0, netFactor); + const netBalance = grossBalance.times(factor); + + return netBalance.times(STANDARD_ZAKAT_RATE).toNumber(); +} + +// Legacy function kept for backward compatibility +function calculateNetWithdrawable(asset: Asset): number { + const config = parseRetirementConfig(asset); + const grossBalance = new Decimal(asset.value || 0); + + // If no config or manual, return full value (will get 2.5% applied elsewhere) + if (!config || config.methodology === 'manual') { + return grossBalance.toNumber(); + } + + // If preserved growth, return 20% of value (equivalent to 0.5% vs 2.5%) + if (config.methodology === 'preserved_growth') { + return grossBalance.times(0.20).toNumber(); + } + + // Collectible value: apply penalty and tax + const penalty = new Decimal(config.withdrawalPenalty || 0); + const tax = new Decimal(config.estimatedTaxRate || 0); + const one = new Decimal(1); + const netFactor = one.minus(penalty).minus(tax); + const factor = Decimal.max(0, netFactor); + return grossBalance.times(factor).toNumber(); } export function getAssetZakatableValue(asset: Asset, methodologyName: ZakatMethodology): number { diff --git a/client/src/types/asset.types.ts b/client/src/types/asset.types.ts index fefdce0e..b05ef320 100644 --- a/client/src/types/asset.types.ts +++ b/client/src/types/asset.types.ts @@ -22,6 +22,22 @@ import type { Asset } from './index'; +/** + * Retirement Zakat calculation methodology + * Based on scholarly analysis of US retirement plans + */ +export type RetirementMethodology = + | 'collectible_value' // Opinion A: Net after penalty/tax * 2.5% + | 'preserved_growth' // Opinion B: 0.5% rule (Dr. Salah Al-Sawy) + | 'manual'; // User-defined or default 2.5% on full + +export interface RetirementConfig { + methodology: RetirementMethodology; + // For 'collectible_value' only + withdrawalPenalty: number; // Percentage (0-1), e.g., 0.10 = 10% + estimatedTaxRate: number; // Percentage (0-1), e.g., 0.25 = 25% +} + export interface CreateAssetDto { name: string; category: string; @@ -32,10 +48,7 @@ export interface CreateAssetDto { notes?: string; isPassiveInvestment?: boolean; isRestrictedAccount?: boolean; - retirementDetails?: { - withdrawalPenalty: number; // Percentage (0-1) - taxRate: number; // Percentage (0-1) - }; + retirementConfig?: RetirementConfig; } export type UpdateAssetDto = Partial; @@ -59,8 +72,5 @@ export interface AssetFormState { notes?: string; isPassiveInvestment: boolean; isRestrictedAccount: boolean; - retirementDetails?: { - withdrawalPenalty: number; - taxRate: number; - }; + retirementConfig?: RetirementConfig; } From fe60018c9285184f70bfcc3d072244074e36c636 Mon Sep 17 00:00:00 2001 From: ZakApp Agent Date: Fri, 13 Feb 2026 16:35:55 +0000 Subject: [PATCH 2/4] feat: update AssetForm with new retirement methodology UI (zakapp-qz0) - Replace old retirementTreatment with new retirementConfig approach - Add new RetirementTreatmentSection with three methodology options: - Option 1: Withdrawal Value (collectible_value) with penalty/tax inputs - Option 2: Invested Growth (preserved_growth) - 0.5% rule - Option 3: Standard Calculation (manual) - 2.5% on full - Add tax bracket dropdown (10%-37%) and penalty dropdown (0%-35%) - Add real-time calculation preview showing Zakatable amount and due - Update AssetForm to use retirementConfig state and parse from metadata - Preserve backward compatibility with legacy retirementDetails format --- client/src/components/assets/AssetForm.tsx | 68 +++--- .../RetirementTreatmentSection.tsx | 227 ++++++++++++++---- 2 files changed, 206 insertions(+), 89 deletions(-) diff --git a/client/src/components/assets/AssetForm.tsx b/client/src/components/assets/AssetForm.tsx index 4bd13be9..81e1a5fd 100644 --- a/client/src/components/assets/AssetForm.tsx +++ b/client/src/components/assets/AssetForm.tsx @@ -22,6 +22,7 @@ import { useAssetRepository } from '../../hooks/useAssetRepository'; import { useAuth } from '../../contexts/AuthContext'; import { METHODOLOGIES, MethodologyName } from '../../core/calculations/methodology'; import type { Asset, AssetType } from '../../types'; +import type { RetirementConfig } from '../../types/asset.types'; import { Button, Input } from '../ui'; import { EncryptedBadge } from '../ui/EncryptedBadge'; import { @@ -76,23 +77,6 @@ export const AssetForm: React.FC = ({ asset, onSuccess, onCancel return map[assetType] || 'cash'; }; - // Parse initial retirement treatment from metadata - const getInitialRetirementTreatment = (asset: Asset | undefined): string => { - if (!asset || !asset.metadata) return 'net_value'; - try { - const meta = typeof asset.metadata === 'string' ? JSON.parse(asset.metadata) : asset.metadata; - const details = meta.retirementDetails; - if (!details) return 'net_value'; - - if (details.withdrawalPenalty === 1.0) return 'deferred'; - if (details.withdrawalPenalty === 0.7) return 'passive'; - if (details.withdrawalPenalty === 0 && details.taxRate === 0) return 'full'; - return 'net_value'; - } catch (e) { - return 'net_value'; - } - }; - const [formData, setFormData] = useState({ name: asset?.name || '', category: getInitialCategory(asset?.type as unknown as string), @@ -105,7 +89,6 @@ export const AssetForm: React.FC = ({ asset, onSuccess, onCancel isPassiveInvestment: (asset as any)?.isPassiveInvestment || false, isRestrictedAccount: (asset as any)?.isRestrictedAccount || false, isEligibilityManual: (asset as any)?.isEligibilityManual || false, - retirementTreatment: getInitialRetirementTreatment(asset), }); const [errors, setErrors] = useState>({}); @@ -113,6 +96,16 @@ export const AssetForm: React.FC = ({ asset, onSuccess, onCancel const [calculationModifier, setCalculationModifier] = useState( (asset as any)?.calculationModifier || 1.0 ); + const [retirementConfig, setRetirementConfig] = useState(() => { + // Parse initial retirement config from asset metadata + if (!asset?.metadata) return undefined; + try { + const meta = typeof asset.metadata === 'string' ? JSON.parse(asset.metadata) : asset.metadata; + return meta.retirementConfig; + } catch { + return undefined; + } + }); const { addAsset, updateAsset } = useAssetRepository(); @@ -141,12 +134,18 @@ export const AssetForm: React.FC = ({ asset, onSuccess, onCancel let newModifier = 1.0; - if (isRetirement) { - switch (formData.retirementTreatment) { - case 'deferred': newModifier = 0.0; break; - case 'passive': newModifier = 0.3; break; - case 'net_value': newModifier = 0.7; break; // Approx - case 'full': default: newModifier = 1.0; break; + if (isRetirement && retirementConfig) { + // Use the methodology from retirementConfig to determine modifier + switch (retirementConfig.methodology) { + case 'preserved_growth': newModifier = 0.20; break; // 0.5% is equivalent to 2.5% on 20% + case 'collectible_value': { + // Calculate net factor: 1 - penalty - tax + const penalty = retirementConfig.withdrawalPenalty || 0; + const tax = retirementConfig.estimatedTaxRate || 0; + newModifier = Math.max(0, 1 - penalty - tax); + break; + } + case 'manual': default: newModifier = 1.0; break; } } else if (formData.isRestrictedAccount) { newModifier = 0.0; @@ -168,7 +167,7 @@ export const AssetForm: React.FC = ({ asset, onSuccess, onCancel if (!RESTRICTED_ACCOUNT_TYPES.includes(formData.category as any)) { setFormData(prev => ({ ...prev, isRestrictedAccount: false })); } - }, [formData.category, formData.subCategory, formData.isRestrictedAccount, formData.isPassiveInvestment, formData.retirementTreatment]); + }, [formData.category, formData.subCategory, formData.isRestrictedAccount, formData.isPassiveInvestment, retirementConfig]); const handleChange = (field: string, value: string | number | boolean) => { setFormData(prev => ({ ...prev, [field]: value })); @@ -254,17 +253,9 @@ export const AssetForm: React.FC = ({ asset, onSuccess, onCancel isEligibilityManual: formData.isEligibilityManual }; - if (isRetirement) { - const treatment = formData.retirementTreatment || 'net_value'; - if (treatment === 'net_value') { - metadataObj.retirementDetails = { withdrawalPenalty: 0.1, taxRate: 0.2 }; - } else if (treatment === 'deferred') { - metadataObj.retirementDetails = { withdrawalPenalty: 1.0, taxRate: 0 }; - } else if (treatment === 'passive') { - metadataObj.retirementDetails = { withdrawalPenalty: 0.7, taxRate: 0 }; - } else { // Full - metadataObj.retirementDetails = { withdrawalPenalty: 0, taxRate: 0 }; - } + // Include retirement config if present + if (isRetirement && retirementConfig) { + metadataObj.retirementConfig = retirementConfig; } const commonData = { @@ -626,10 +617,9 @@ export const AssetForm: React.FC = ({ asset, onSuccess, onCancel {/* Modifier Section: Retirement Treatment (Radio Group) */} {formData.category === 'stocks' && formData.subCategory?.startsWith('retirement') && ( handleChange('retirementTreatment', treatment)} + onConfigChange={setRetirementConfig} /> )} diff --git a/client/src/components/assets/form-sections/RetirementTreatmentSection.tsx b/client/src/components/assets/form-sections/RetirementTreatmentSection.tsx index a84fb6ca..81b6752b 100644 --- a/client/src/components/assets/form-sections/RetirementTreatmentSection.tsx +++ b/client/src/components/assets/form-sections/RetirementTreatmentSection.tsx @@ -1,94 +1,221 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Decimal } from 'decimal.js'; +import type { RetirementMethodology, RetirementConfig } from '../../../types/asset.types'; interface RetirementTreatmentSectionProps { - retirementTreatment: string; + retirementConfig?: RetirementConfig; value: string; - calculationModifier: number; - onTreatmentChange: (treatment: string) => void; + onConfigChange: (config: RetirementConfig | undefined) => void; } +// Tax bracket options (simplified from full table) +const TAX_BRACKET_OPTIONS = [ + { value: 0.10, label: '10%' }, + { value: 0.12, label: '12%' }, + { value: 0.22, label: '22%' }, + { value: 0.24, label: '24%' }, + { value: 0.32, label: '32%' }, + { value: 0.35, label: '35%' }, + { value: 0.37, label: '37%' }, +]; + +// Penalty rate options +const PENALTY_OPTIONS = [ + { value: 0.0, label: '0%' }, + { value: 0.10, label: '10%' }, + { value: 0.20, label: '20%' }, + { value: 0.30, label: '30%' }, + { value: 0.35, label: '35%' }, +]; + export const RetirementTreatmentSection: React.FC = ({ - retirementTreatment, + retirementConfig, value, - calculationModifier, - onTreatmentChange + onConfigChange }) => { + const [methodology, setMethodology] = useState( + retirementConfig?.methodology || 'collectible_value' + ); + const [withdrawalPenalty, setWithdrawalPenalty] = useState( + retirementConfig?.withdrawalPenalty ?? 0.10 + ); + const [estimatedTaxRate, setEstimatedTaxRate] = useState( + retirementConfig?.estimatedTaxRate ?? 0.25 + ); + + // Update parent when values change + useEffect(() => { + if (methodology === 'manual') { + onConfigChange(undefined); + } else if (methodology === 'preserved_growth') { + onConfigChange({ + methodology: 'preserved_growth', + withdrawalPenalty: 0, + estimatedTaxRate: 0, + }); + } else { + // collectible_value + onConfigChange({ + methodology: 'collectible_value', + withdrawalPenalty, + estimatedTaxRate, + }); + } + }, [methodology, withdrawalPenalty, estimatedTaxRate]); + + // Calculate preview values + const numericValue = parseFloat(String(value)) || 0; + const gross = new Decimal(numericValue); + + let zatakatableValue = gross; + let zatakatRate = 0.025; + let explanation = ''; + + if (methodology === 'preserved_growth') { + // 0.5% rule + zatakatableValue = gross.times(0.20); // 20% is equivalent to 0.5% vs 2.5% + zatakatRate = 0.005; + explanation = 'Based on Dr. Salah Al-Sawy\'s opinion: Zakat is due on the liquid assets of the underlying companies (0.5% of total).'; + } else if (methodology === 'collectible_value') { + // Net after penalty/tax * 2.5% + const penalty = new Decimal(withdrawalPenalty); + const tax = new Decimal(estimatedTaxRate); + const netFactor = Decimal.max(0, new Decimal(1).minus(penalty).minus(tax)); + zatakatableValue = gross.times(netFactor); + explanation = `Zakat is due only on what you would receive after paying ${(withdrawalPenalty * 100).toFixed(0)}% penalty and ${(estimatedTaxRate * 100).toFixed(0)}% taxes.`; + } else { + explanation = 'Standard 2.5% Zakat applied to the full balance.'; + } + + const zatakatDue = zatakatableValue.times(zatakatRate); + return ( -
-

Zakat Treatment:

+
+
+

How should we calculate Zakat on this retirement account?

+

+ Based on scholarly analysis of US retirement plans +

+
-