diff --git a/frontend/app/asset-owner/plans/[planId]/edit/page.tsx b/frontend/app/asset-owner/plans/[planId]/edit/page.tsx index 9eb1898bd..9306ae4ab 100644 --- a/frontend/app/asset-owner/plans/[planId]/edit/page.tsx +++ b/frontend/app/asset-owner/plans/[planId]/edit/page.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from "react"; import { useRouter, useParams } from "next/navigation"; -import { plansAPI } from "@/app/lib/api/plans"; import type { Plan } from "@/app/lib/api/plans"; import { getPlan, useMockData } from "@/lib/api/dataSource"; import { EditInheritancePlanPanel } from "@/components/plans/EditInheritancePlanPanel"; @@ -32,14 +31,11 @@ export default function EditPlanPage() { const handleClose = () => router.back(); const handleSaved = (updated: Plan) => { - const save = useMockData - ? Promise.resolve(updated) - : plansAPI.updatePlan(planId, updated); - save.then((savedPlan) => { - if (useMockData) require("@/lib/mockStore").mockStore.updatePlan(planId, savedPlan); - setPlan(savedPlan); - router.back(); - }).catch((err) => setError(err instanceof Error ? err.message : "Failed to save plan.")); + // EditInheritancePlanPanel already persists the change via plansAPI.updatePlan + // and hands back the backend-confirmed plan — just sync local state here. + if (useMockData) require("@/lib/mockStore").mockStore.updatePlan(planId, updated); + setPlan(updated); + router.back(); }; if (loading) { diff --git a/frontend/app/lib/api/index.ts b/frontend/app/lib/api/index.ts index de696319f..f7a70a502 100644 --- a/frontend/app/lib/api/index.ts +++ b/frontend/app/lib/api/index.ts @@ -26,7 +26,6 @@ import { PlansAPI } from "./plans"; export { PlansAPI } from "./plans"; export type { Plan, - Beneficiary, CreatePlanRequest, UpdatePlanRequest, ClaimPlanRequest, diff --git a/frontend/app/lib/api/inheritance.ts b/frontend/app/lib/api/inheritance.ts index cb09b9952..f650e30b2 100644 --- a/frontend/app/lib/api/inheritance.ts +++ b/frontend/app/lib/api/inheritance.ts @@ -49,6 +49,17 @@ export interface CreatePlanRequest { is_active: boolean; } +export interface UpdatePlanRequest { + /** Full replacement list of beneficiaries with allocations */ + beneficiaries: PlanBeneficiaryRequest[]; + /** Grace period in seconds before the plan becomes claimable */ + grace_period?: number; + /** Whether this plan earns yield via AMM / lending pools */ + earn_yield?: boolean; + /** Annualised yield rate in basis points (e.g. 500 = 5%) */ + yield_rate_bps?: number; +} + export interface PingRequest { /** Owner Stellar address */ owner: string; @@ -203,6 +214,20 @@ export class InheritanceAPI { return apiClient.get(endpoint, config); } + /** + * Update an existing inheritance plan's beneficiaries, grace period, or + * yield settings. + * + * Requires signature auth (X-Public-Key + X-Signature headers). + */ + async updatePlan( + planId: string, + request: UpdatePlanRequest, + config?: RequestConfig + ): Promise { + return apiClient.put(`/api/plans/${planId}`, request, config); + } + /** * Send a liveness ping to keep the plan active. * diff --git a/frontend/app/lib/api/plans.ts b/frontend/app/lib/api/plans.ts index c7ea2582b..3c3f65a3a 100644 --- a/frontend/app/lib/api/plans.ts +++ b/frontend/app/lib/api/plans.ts @@ -4,6 +4,9 @@ */ import { apiClient, ApiResponse, PaginatedResponse } from "./client"; +import type { PlanBeneficiaryRequest, PlanResponse } from "./inheritance"; + +export type { PlanBeneficiaryRequest, PlanResponse }; export interface Plan { id: string; @@ -37,32 +40,41 @@ export interface Plan { last_ping?: number; } -export interface Beneficiary { - id?: string; - wallet_address: string; - name: string; - allocation_percentage: number; -} - +/** + * Request DTOs below mirror the Axum backend's `Plan` and `UpdatePlanRequest` + * structs exactly (see backend/src/api.rs) so beneficiary allocations, + * wallet addresses, and fiat anchor info are never dropped on submission. + */ export interface CreatePlanRequest { - title: string; - description?: string; - fee: number; - net_amount: number; - beneficiary_name?: string; - bank_account_number?: string; - bank_name?: string; - currency_preference: string; - two_fa_code: string; + /** Owner Stellar address */ + owner: string; + /** Token contract address on Stellar */ + token: string; + /** Amount to deposit (in token units) */ + amount: number; + /** Full list of beneficiaries with allocations. Must sum to 10000 bps */ + beneficiaries: PlanBeneficiaryRequest[]; + /** Unix timestamp of the last liveness ping */ + last_ping: number; + /** Grace period in seconds before the plan becomes claimable */ + grace_period: number; + /** Whether this plan earns yield via AMM / lending pools */ + earn_yield: boolean; + /** Annualised yield rate in basis points (e.g. 500 = 5%) */ + yield_rate_bps: number; + /** Whether the plan is active immediately */ + is_active: boolean; } export interface UpdatePlanRequest { - title?: string; - description?: string; - beneficiaries?: Beneficiary[]; - inactivity_period_days?: number; - yield_harvesting_enabled?: boolean; - signed_transaction?: string; + /** Full replacement list of beneficiaries with allocations. Must sum to 10000 bps */ + beneficiaries: PlanBeneficiaryRequest[]; + /** Grace period in seconds before the plan becomes claimable */ + grace_period?: number; + /** Whether this plan earns yield via AMM / lending pools */ + earn_yield?: boolean; + /** Annualised yield rate in basis points (e.g. 500 = 5%) */ + yield_rate_bps?: number; } export interface ClaimPlanRequest { @@ -86,12 +98,8 @@ export class PlansAPI { /** * Create a new plan */ - async createPlan(request: CreatePlanRequest): Promise { - const response = await apiClient.post>( - "/api/plans", - request - ); - return response.data!; + async createPlan(request: CreatePlanRequest): Promise { + return apiClient.post("/api/plans", request); } /** @@ -151,14 +159,13 @@ export class PlansAPI { } /** - * Update an existing plan + * Update an existing plan's beneficiaries, grace period, or yield settings */ - async updatePlan(planId: string, request: UpdatePlanRequest): Promise { - const response = await apiClient.put>( - `/api/plans/${planId}`, - request - ); - return response.data!; + async updatePlan( + planId: string, + request: UpdatePlanRequest + ): Promise { + return apiClient.put(`/api/plans/${planId}`, request); } /** diff --git a/frontend/components/plans/BeneficiaryAllocationRow.tsx b/frontend/components/plans/BeneficiaryAllocationRow.tsx new file mode 100644 index 000000000..076673bb5 --- /dev/null +++ b/frontend/components/plans/BeneficiaryAllocationRow.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ArrowLeftRight, Trash2 } from "lucide-react"; +import { isValidStellarAccount } from "@/app/lib/validation/inheritancePlan"; +import type { PlanBeneficiaryRequest } from "@/app/lib/api/inheritance"; + +export interface BeneficiaryDraft { + address: string; + name: string; + /** Allocation in basis points. 10000 bps = 100%. */ + allocationBps: number; + isFiat: boolean; + fiatBank: string; + fiatAccount: string; + fiatCurrency: string; + /** Optional daily fiat payout limit, entered as a plain decimal string. */ + fiatDailyLimit: string; +} + +export const DEFAULT_BENEFICIARY_DRAFT: BeneficiaryDraft = { + address: "", + name: "", + allocationBps: 0, + isFiat: false, + fiatBank: "", + fiatAccount: "", + fiatCurrency: "USD", + fiatDailyLimit: "", +}; + +export function totalAllocationBps(beneficiaries: BeneficiaryDraft[]): number { + return beneficiaries.reduce((sum, b) => sum + (b.allocationBps || 0), 0); +} + +/** Formats basis points as a percentage string, e.g. 3333 -> "33.33". */ +export function bpsToPercentageLabel(bps: number): string { + return (bps / 100).toFixed(2).replace(/\.00$/, ""); +} + +/** Converts a user-entered percentage (up to 2 decimals) into basis points. */ +export function percentageToBps(percentage: number): number { + return Math.round(percentage * 100); +} + +interface BeneficiaryValidation { + /** Per-row error message, keyed by beneficiary index. */ + rowErrors: Record; + /** Set when the total allocation doesn't equal exactly 10,000 bps. */ + totalError?: string; +} + +export function validateBeneficiaryDrafts( + beneficiaries: BeneficiaryDraft[] +): BeneficiaryValidation { + const rowErrors: Record = {}; + const seenAddresses = new Set(); + + beneficiaries.forEach((b, index) => { + const address = b.address.trim(); + + if (!b.name.trim()) { + rowErrors[index] = "Name is required."; + return; + } + if (!isValidStellarAccount(address)) { + rowErrors[index] = "Enter a valid Stellar wallet address (starts with G)."; + return; + } + if (seenAddresses.has(address)) { + rowErrors[index] = "This wallet address is already used by another beneficiary."; + return; + } + seenAddresses.add(address); + + if (!b.allocationBps || b.allocationBps <= 0) { + rowErrors[index] = "Allocation must be greater than 0%."; + return; + } + if (b.isFiat && !b.fiatBank.trim()) { + rowErrors[index] = "Bank name is required for fiat off-ramp payouts."; + return; + } + if (b.isFiat && !b.fiatAccount.trim()) { + rowErrors[index] = "Account number is required for fiat off-ramp payouts."; + return; + } + }); + + const total = totalAllocationBps(beneficiaries); + const totalError = + total !== 10000 + ? `Allocations must total exactly 100% (10,000 bps) — currently ${bpsToPercentageLabel(total)}%.` + : undefined; + + return { rowErrors, totalError }; +} + +/** Builds the fiat_anchor_info payload the backend parses on payout. Empty string means crypto payout. */ +export function buildFiatAnchorInfo(b: BeneficiaryDraft): string { + if (!b.isFiat) return ""; + return JSON.stringify({ + name: b.name.trim(), + currency: b.fiatCurrency.trim() || "USD", + bank: b.fiatBank.trim(), + account: b.fiatAccount.trim(), + ...(b.fiatDailyLimit.trim() ? { daily_limit: b.fiatDailyLimit.trim() } : {}), + }); +} + +export function beneficiaryDraftToRequest(b: BeneficiaryDraft): PlanBeneficiaryRequest { + return { + address: b.address.trim(), + name: b.name.trim(), + allocation_bps: b.allocationBps, + fiat_anchor_info: buildFiatAnchorInfo(b), + }; +} + +interface BeneficiaryAllocationRowProps { + beneficiary: BeneficiaryDraft; + index: number; + error?: string; + onChange: ( + index: number, + field: keyof BeneficiaryDraft, + value: string | number | boolean + ) => void; + onRemove: (index: number) => void; + canRemove: boolean; +} + +export function BeneficiaryAllocationRow({ + beneficiary, + index, + error, + onChange, + onRemove, + canRemove, +}: BeneficiaryAllocationRowProps) { + return ( + +
+
+ + onChange(index, "name", e.target.value)} + placeholder="Alice Smith" + className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors" + /> +
+ +
+ + onChange(index, "address", e.target.value)} + placeholder="G..." + className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors font-mono" + /> +
+ +
+ + + onChange(index, "allocationBps", percentageToBps(Number(e.target.value) || 0)) + } + placeholder="0" + className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors" + /> +
+ +
+ + +
+ + +
+ + {beneficiary.isFiat && ( +
+
+ + onChange(index, "fiatBank", e.target.value)} + placeholder="First Bank" + className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors" + /> +
+
+ + onChange(index, "fiatAccount", e.target.value)} + placeholder="0123456789" + className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors" + /> +
+
+ + onChange(index, "fiatCurrency", e.target.value.toUpperCase())} + placeholder="NGN" + maxLength={3} + className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors uppercase" + /> +
+
+ + onChange(index, "fiatDailyLimit", e.target.value)} + placeholder="Unlimited" + className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors" + /> +
+
+ )} + + {error &&

{error}

} +
+ ); +} diff --git a/frontend/components/plans/CreateInheritancePlanPanel.tsx b/frontend/components/plans/CreateInheritancePlanPanel.tsx index d5bed0d71..5f32b66f5 100644 --- a/frontend/components/plans/CreateInheritancePlanPanel.tsx +++ b/frontend/components/plans/CreateInheritancePlanPanel.tsx @@ -1,62 +1,59 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { - AlertCircle, - CheckCircle, - Loader2, - Plus, - Trash2, -} from "lucide-react"; +import { AlertCircle, CheckCircle, Loader2, Plus } from "lucide-react"; import { plansAPI } from "@/app/lib/api/plans"; -import type { Beneficiary } from "@/app/lib/api/plans"; +import type { CreatePlanRequest } from "@/app/lib/api/plans"; +import { + getSelectedTokenIdentifier, + isValidTokenIdentifier, +} from "@/app/lib/validation/inheritancePlan"; import { useWallet } from "@/context/WalletContext"; import { CrossChainDepositSection } from "./CrossChainDepositSection"; +import { + BeneficiaryAllocationRow, + DEFAULT_BENEFICIARY_DRAFT, + beneficiaryDraftToRequest, + bpsToPercentageLabel, + totalAllocationBps, + validateBeneficiaryDrafts, + type BeneficiaryDraft, +} from "./BeneficiaryAllocationRow"; -const DEFAULT_BENEFICIARY: Omit = { - wallet_address: "", - name: "", - allocation_percentage: 100, -}; - -function totalAllocation(beneficiaries: Beneficiary[]): number { - return beneficiaries.reduce( - (sum, b) => sum + (b.allocation_percentage || 0), - 0 - ); -} - -function isAllocationValid(beneficiaries: Beneficiary[]): boolean { - const total = totalAllocation(beneficiaries); - return ( - total === 100 && - beneficiaries.every((b) => (b.allocation_percentage || 0) > 0) - ); -} +const TOKEN_OPTIONS = ["XLM", "USDC", "CUSTOM"] as const; type SubmitStatus = "idle" | "creating" | "success" | "error"; export function CreateInheritancePlanPanel() { - const { isConnected, openModal } = useWallet(); + const { address, isConnected, openModal } = useWallet(); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [depositAmount, setDepositAmount] = useState(""); const [inactivityDays, setInactivityDays] = useState(180); - const [beneficiaries, setBeneficiaries] = useState([ - { ...DEFAULT_BENEFICIARY }, + const [tokenType, setTokenType] = useState<(typeof TOKEN_OPTIONS)[number]>("XLM"); + const [customTokenAddress, setCustomTokenAddress] = useState(""); + const [beneficiaries, setBeneficiaries] = useState([ + { ...DEFAULT_BENEFICIARY_DRAFT, allocationBps: 10000 }, ]); const [bridgeTransferId, setBridgeTransferId] = useState(null); const [status, setStatus] = useState("idle"); const [errorMessage, setErrorMessage] = useState(""); + const [touched, setTouched] = useState(false); - const allocationTotal = totalAllocation(beneficiaries); - const allocationOk = isAllocationValid(beneficiaries); + const allocationTotalBps = totalAllocationBps(beneficiaries); + const { rowErrors, totalError } = useMemo( + () => validateBeneficiaryDrafts(beneficiaries), + [beneficiaries] + ); + const beneficiariesValid = Object.keys(rowErrors).length === 0 && !totalError; const parsedDeposit = Number.parseFloat(depositAmount) || 0; + const selectedToken = getSelectedTokenIdentifier(tokenType, customTokenAddress); + const tokenValid = isValidTokenIdentifier(tokenType, customTokenAddress); const handleBeneficiaryChange = useCallback( - (index: number, field: keyof Beneficiary, value: string | number) => { + (index: number, field: keyof BeneficiaryDraft, value: string | number | boolean) => { setBeneficiaries((prev) => prev.map((b, i) => (i === index ? { ...b, [field]: value } : b)) ); @@ -65,7 +62,7 @@ export function CreateInheritancePlanPanel() { ); const addBeneficiary = () => { - setBeneficiaries((prev) => [...prev, { ...DEFAULT_BENEFICIARY }]); + setBeneficiaries((prev) => [...prev, { ...DEFAULT_BENEFICIARY_DRAFT }]); }; const removeBeneficiary = (index: number) => { @@ -76,14 +73,39 @@ export function CreateInheritancePlanPanel() { setBridgeTransferId(transferId); }, []); + const canSubmit = + !!title.trim() && + !!address && + tokenValid && + beneficiariesValid && + parsedDeposit > 0 && + !!bridgeTransferId; + const handleCreatePlan = async () => { - if (!title.trim() || !allocationOk || parsedDeposit <= 0) return; + setTouched(true); - if (!isConnected) { + if (!isConnected || !address) { openModal(); return; } + if (!tokenValid) { + setErrorMessage("Choose XLM, USDC, or enter a valid custom Stellar contract address."); + return; + } + + if (!beneficiariesValid) { + setErrorMessage( + totalError || "Resolve the highlighted beneficiary fields before creating the plan." + ); + return; + } + + if (parsedDeposit <= 0) { + setErrorMessage("Enter a deposit amount greater than zero."); + return; + } + if (!bridgeTransferId) { setErrorMessage( "Complete the cross-chain deposit before creating the plan." @@ -95,16 +117,18 @@ export function CreateInheritancePlanPanel() { setStatus("creating"); try { - const feeEstimate = parsedDeposit * 0.01; - await plansAPI.createPlan({ - title: title.trim(), - description: description.trim() || undefined, - fee: feeEstimate, - net_amount: parsedDeposit - feeEstimate, - currency_preference: "USD", - two_fa_code: "000000", - beneficiary_name: beneficiaries[0]?.name, - }); + const request: CreatePlanRequest = { + owner: address, + token: selectedToken, + amount: parsedDeposit, + beneficiaries: beneficiaries.map(beneficiaryDraftToRequest), + last_ping: Math.floor(Date.now() / 1000), + grace_period: inactivityDays * 86400, + earn_yield: false, + yield_rate_bps: 0, + is_active: true, + }; + await plansAPI.createPlan(request); setStatus("success"); } catch (error) { setStatus("error"); @@ -114,6 +138,8 @@ export function CreateInheritancePlanPanel() { } }; + const showErrors = touched || status === "error"; + return (
@@ -155,6 +181,39 @@ export function CreateInheritancePlanPanel() { className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-4 py-2.5 text-sm text-slate-200 resize-none focus:outline-none focus:border-[#33C5E0] transition-colors" />
+
+ +
+ {TOKEN_OPTIONS.map((token) => ( + + ))} +
+ {tokenType === "CUSTOM" && ( + setCustomTokenAddress(e.target.value)} + placeholder="C..." + className="mt-1 bg-[#0A0F11] border border-[#2A3338] rounded-lg px-4 py-2.5 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors font-mono" + /> + )} + {showErrors && !tokenValid && ( +

+ Choose XLM, USDC, or enter a valid custom Stellar contract address. +

+ )} +
@@ -165,83 +224,29 @@ export function CreateInheritancePlanPanel() { - {allocationTotal}% / 100% + {bpsToPercentageLabel(allocationTotalBps)}% / 100%
- {beneficiaries.map((beneficiary, index) => ( -
-
- - - handleBeneficiaryChange(index, "name", e.target.value) - } - placeholder="Alice Smith" - className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors" - /> -
-
- - - handleBeneficiaryChange( - index, - "wallet_address", - e.target.value - ) - } - placeholder="G..." - className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors font-mono" - /> -
-
- - - handleBeneficiaryChange( - index, - "allocation_percentage", - Number(e.target.value) - ) - } - className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-[#33C5E0] transition-colors" - /> -
- -
- ))} + + {beneficiaries.map((beneficiary, index) => ( + 1} + /> + ))} +
+ + {showErrors && totalError && ( +

{totalError}

+ )}
@@ -324,10 +333,7 @@ export function CreateInheritancePlanPanel() { type="button" onClick={handleCreatePlan} disabled={ - !title.trim() || - !allocationOk || - parsedDeposit <= 0 || - !bridgeTransferId || + (touched && !canSubmit) || status === "creating" || status === "success" } diff --git a/frontend/components/plans/EditInheritancePlanPanel.tsx b/frontend/components/plans/EditInheritancePlanPanel.tsx index 6e5f45d40..926b6c653 100644 --- a/frontend/components/plans/EditInheritancePlanPanel.tsx +++ b/frontend/components/plans/EditInheritancePlanPanel.tsx @@ -1,19 +1,22 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { Plus, Trash2, X, Save, AlertCircle, CheckCircle, Loader2, ArrowLeftRight } from "lucide-react"; +import { Plus, X, Save, AlertCircle, CheckCircle, Loader2 } from "lucide-react"; import { plansAPI } from "@/app/lib/api/plans"; -import type { Plan, Beneficiary, UpdatePlanRequest } from "@/app/lib/api/plans"; +import type { Plan, UpdatePlanRequest } from "@/app/lib/api/plans"; import { useWallet } from "@/context/WalletContext"; import { AllocationFlowChart } from "@/components/plans/AllocationFlowChart"; import type { BeneficiaryFlow } from "@/components/plans/AllocationFlowChart"; - -// ─── Local type with fiat off‑ramp flag ───────────────────────────────────── - -interface BeneficiaryLocal extends Beneficiary { - isFiat: boolean; -} +import { + BeneficiaryAllocationRow, + DEFAULT_BENEFICIARY_DRAFT, + beneficiaryDraftToRequest, + bpsToPercentageLabel, + totalAllocationBps, + validateBeneficiaryDrafts, + type BeneficiaryDraft, +} from "@/components/plans/BeneficiaryAllocationRow"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -25,122 +28,45 @@ interface EditInheritancePlanPanelProps { type TxStatus = "idle" | "signing" | "saving" | "success" | "error"; -// ─── Helpers ───────────────────────────────────────────────────────────────── - -const DEFAULT_BENEFICIARY: Omit = { - wallet_address: "", - name: "", - allocation_percentage: 0, - isFiat: false, -}; +const SECONDS_PER_DAY = 86_400; +const DEFAULT_YIELD_RATE_BPS = 500; -function totalAllocation(beneficiaries: Beneficiary[]): number { - return beneficiaries.reduce((sum, b) => sum + (b.allocation_percentage || 0), 0); -} - -function isAllocationDistributionValid(beneficiaries: Beneficiary[]): boolean { - const total = totalAllocation(beneficiaries); - const allPositive = beneficiaries.every((b) => (b.allocation_percentage || 0) > 0); - return total === 100 && allPositive; -} +// ─── Helpers ───────────────────────────────────────────────────────────────── -// ─── Sub-components ────────────────────────────────────────────────────────── - -function BeneficiaryRow({ - beneficiary, - index, - onChange, - onRemove, - canRemove, -}: { - beneficiary: BeneficiaryLocal; - index: number; - onChange: (index: number, field: keyof BeneficiaryLocal, value: string | number | boolean) => void; - onRemove: (index: number) => void; - canRemove: boolean; -}) { - return ( - -
- - onChange(index, "name", e.target.value)} - placeholder="Alice Smith" - className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors" - /> -
- -
- - onChange(index, "wallet_address", e.target.value)} - placeholder="G..." - className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors font-mono" - /> -
- -
- - - onChange(index, "allocation_percentage", Number(e.target.value)) - } - placeholder="0" - className="bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-[#4A5568] focus:outline-none focus:border-[#33C5E0] transition-colors" - /> -
- -
- - -
- - -
- ); +function seedBeneficiaries(plan: Plan): BeneficiaryDraft[] { + if (Array.isArray(plan.beneficiaries) && plan.beneficiaries.length > 0) { + return plan.beneficiaries.map((b: any) => { + const fiatAnchorInfo: string = b.fiat_anchor_info ?? ""; + const isFiat = fiatAnchorInfo.trim().length > 0; + let parsed: { name?: string; currency?: string; bank?: string; account?: string; daily_limit?: string } | null = + null; + if (isFiat) { + try { + parsed = JSON.parse(fiatAnchorInfo); + } catch { + parsed = null; + } + } + + return { + address: b.wallet_address ?? b.address ?? "", + name: b.name ?? parsed?.name ?? "", + allocationBps: b.allocation_bps ?? 0, + isFiat, + fiatBank: parsed?.bank ?? "", + fiatAccount: parsed?.account ?? "", + fiatCurrency: parsed?.currency ?? "USD", + fiatDailyLimit: + parsed?.daily_limit ?? (b.fiat_daily_limit ? String(b.fiat_daily_limit) : ""), + }; + }); + } + + if (plan.beneficiary_name) { + return [{ ...DEFAULT_BENEFICIARY_DRAFT, name: plan.beneficiary_name, allocationBps: 10000 }]; + } + + return [{ ...DEFAULT_BENEFICIARY_DRAFT }]; } // ─── Main Panel ─────────────────────────────────────────────────────────────── @@ -154,43 +80,46 @@ export function EditInheritancePlanPanel({ const [title, setTitle] = useState(plan.title); const [description, setDescription] = useState(plan.description ?? ""); - const [inactivityDays, setInactivityDays] = useState( - plan.contract_created_at ? 180 : 180 + const [inactivityDays, setInactivityDays] = useState(() => + plan.grace_period_seconds + ? Math.max(1, Math.round(plan.grace_period_seconds / SECONDS_PER_DAY)) + : 180 ); - const [yieldEnabled, setYieldEnabled] = useState( - plan.risk_override_enabled ?? false + const [yieldEnabled, setYieldEnabled] = useState(plan.earn_yield ?? false); + const [yieldRateBps, setYieldRateBps] = useState( + plan.yield_rate_bps || DEFAULT_YIELD_RATE_BPS + ); + const [beneficiaries, setBeneficiaries] = useState(() => + seedBeneficiaries(plan) ); - const [beneficiaries, setBeneficiaries] = useState(() => { - if (plan.beneficiary_name) { - return [ - { - id: "existing-0", - wallet_address: "", - name: plan.beneficiary_name, - allocation_percentage: 100, - isFiat: false, - }, - ]; - } - return [{ ...DEFAULT_BENEFICIARY }]; - }); const [txStatus, setTxStatus] = useState("idle"); const [errorMessage, setErrorMessage] = useState(""); + const [touched, setTouched] = useState(false); const flowBeneficiaries: BeneficiaryFlow[] = beneficiaries.map((b) => ({ name: b.name || "Unnamed", - allocation_percentage: b.allocation_percentage || 0, + allocation_percentage: b.allocationBps / 100 || 0, isFiat: b.isFiat, })); const payoutAmount = plan.net_amount ?? plan.fee ?? 0; - const allocationTotal = totalAllocation(beneficiaries); - const isAllocationValid = isAllocationDistributionValid(beneficiaries); + const allocationTotalBps = totalAllocationBps(beneficiaries); + const { rowErrors, totalError } = useMemo( + () => validateBeneficiaryDrafts(beneficiaries), + [beneficiaries] + ); + const beneficiariesValid = Object.keys(rowErrors).length === 0 && !totalError; + // Gates the Save button: allocations must total exactly 10,000 bps. + // Per-field issues (name/address/fiat details) are caught in handleSave + // so their messages can be surfaced without blocking the click itself. + const allocationOnlyValid = + !totalError && beneficiaries.every((b) => (b.allocationBps || 0) > 0); + const showErrors = touched || txStatus === "error"; const handleBeneficiaryChange = useCallback( - (index: number, field: keyof BeneficiaryLocal, value: string | number | boolean) => { + (index: number, field: keyof BeneficiaryDraft, value: string | number | boolean) => { setBeneficiaries((prev) => prev.map((b, i) => (i === index ? { ...b, [field]: value } : b)) ); @@ -199,7 +128,7 @@ export function EditInheritancePlanPanel({ ); const addBeneficiary = useCallback(() => { - setBeneficiaries((prev) => [...prev, { ...DEFAULT_BENEFICIARY }]); + setBeneficiaries((prev) => [...prev, { ...DEFAULT_BENEFICIARY_DRAFT }]); }, []); const removeBeneficiary = useCallback((index: number) => { @@ -222,47 +151,63 @@ export function EditInheritancePlanPanel({ }; const handleSave = async () => { - if (!isAllocationValid) return; + setTouched(true); - const invalidBeneficiary = beneficiaries.find( - (b) => !b.name.trim() || (!b.id && !b.wallet_address.trim()) - ); - if (invalidBeneficiary) { - setErrorMessage("All beneficiaries must have a name and wallet address."); + if (!title.trim()) { + setErrorMessage("Title is required."); + return; + } + + if (!beneficiariesValid) { + const firstRowError = Object.values(rowErrors)[0]; + setErrorMessage( + firstRowError || + totalError || + "All beneficiaries must have a name and a valid Stellar wallet address." + ); return; } setErrorMessage(""); setTxStatus("signing"); - let signedTransaction: string | undefined; try { const xdr = buildXdr(); - signedTransaction = await signWithWallet(xdr); + await signWithWallet(xdr); } catch { // Wallet signing rejected or unavailable — proceed without signed XDR in dev. - signedTransaction = undefined; } setTxStatus("saving"); - const apiBeneficiaries: Beneficiary[] = beneficiaries.map( - ({ isFiat: _, ...rest }) => rest - ); - const updateRequest: UpdatePlanRequest = { - title, - description: description || undefined, - beneficiaries: apiBeneficiaries, - inactivity_period_days: inactivityDays, - yield_harvesting_enabled: yieldEnabled, - signed_transaction: signedTransaction, + beneficiaries: beneficiaries.map(beneficiaryDraftToRequest), + grace_period: inactivityDays * SECONDS_PER_DAY, + earn_yield: yieldEnabled, + yield_rate_bps: yieldEnabled ? yieldRateBps : 0, }; try { const updated = await plansAPI.updatePlan(plan.id, updateRequest); + const merged: Plan = { + ...plan, + title, + description: description || undefined, + status: updated.status, + is_active: updated.is_active, + amount: Number(updated.amount), + owner_address: updated.owner_address, + token_address: updated.token_address, + grace_period_seconds: updated.grace_period_seconds, + yield_rate_bps: updated.yield_rate_bps, + earn_yield: updated.earn_yield, + accrued_yield: updated.accrued_yield, + last_ping: updated.last_ping, + beneficiaries: updated.beneficiaries, + updated_at: new Date().toISOString(), + }; setTxStatus("success"); - setTimeout(() => onSaved(updated), 1200); + setTimeout(() => onSaved(merged), 1200); } catch (err) { setTxStatus("error"); setErrorMessage( @@ -360,22 +305,23 @@ export function EditInheritancePlanPanel({ - {allocationTotal}% / 100% + {bpsToPercentageLabel(allocationTotalBps)}% / 100%
{beneficiaries.map((b, i) => ( - 1} @@ -392,6 +338,10 @@ export function EditInheritancePlanPanel({ Add beneficiary + + {showErrors && totalError && ( +

{totalError}

+ )}
{/* Allocation Flow Chart */} @@ -445,21 +395,39 @@ export function EditInheritancePlanPanel({

Yield Harvesting

- + > + + + {yieldEnabled && ( +
+ + setYieldRateBps(Number(e.target.value))} + className="w-24 bg-[#0A0F11] border border-[#2A3338] rounded-lg px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-[#33C5E0] transition-colors" + /> +
+ )} +

{yieldEnabled ? "Yield harvesting is enabled — idle assets earn interest via Stellar lending pools." @@ -518,7 +486,7 @@ export function EditInheritancePlanPanel({ type="button" onClick={handleSave} disabled={ - !isAllocationValid || + !allocationOnlyValid || !title.trim() || txStatus === "signing" || txStatus === "saving" || diff --git a/frontend/tests/api/plans.test.ts b/frontend/tests/api/plans.test.ts index a70ea5440..8730dd28c 100644 --- a/frontend/tests/api/plans.test.ts +++ b/frontend/tests/api/plans.test.ts @@ -15,24 +15,35 @@ beforeEach(() => { describe("PlansAPI - Update Plan", () => { it("successfully updates a plan with new beneficiaries and settings", async () => { const updateRequest: UpdatePlanRequest = { - title: "Updated Family Trust", beneficiaries: [ - { wallet_address: "GABC123", name: "Alice", allocation_percentage: 60 }, - { wallet_address: "GDEF456", name: "Bob", allocation_percentage: 40 }, + { + address: "GCDFLQR2SGPDRQ473YUJ3Z5Z64BAJOH7EFFF4DC6ZEABSUWNLAR7Q7KJ", + name: "Alice", + allocation_bps: 6000, + fiat_anchor_info: "", + }, + { + address: "GDP2PVYRRAB35TQMJ4DPJOV5BBBM6PJUHWKMSTF6YYXUDXUT7BCLCIFE", + name: "Bob", + allocation_bps: 4000, + fiat_anchor_info: "", + }, ], - inactivity_period_days: 365, - yield_harvesting_enabled: true, + grace_period: 365 * 86400, + earn_yield: true, + yield_rate_bps: 500, }; const result = await api.updatePlan("plan_1", updateRequest); expect(result).toBeDefined(); expect(result.id).toBe("plan_1"); - expect(result.title).toBe("Updated Family Trust"); + expect(result.beneficiaries).toHaveLength(2); + expect(result.earn_yield).toBe(true); }); it("throws an error when updating a non-existent plan", async () => { await expect( - api.updatePlan("plan_nonexistent", { title: "Ghost" }) + api.updatePlan("plan_nonexistent", { beneficiaries: [] }) ).rejects.toThrow(); }); @@ -42,9 +53,9 @@ describe("PlansAPI - Update Plan", () => { HttpResponse.json({ error: "Internal server error" }, { status: 500 }) ) ); - await expect(api.updatePlan("plan_1", { title: "X" })).rejects.toThrow( - "Internal server error" - ); + await expect( + api.updatePlan("plan_1", { beneficiaries: [] }) + ).rejects.toThrow("Internal server error"); }); }); diff --git a/frontend/tests/components/EditInheritancePlanPanel.test.tsx b/frontend/tests/components/EditInheritancePlanPanel.test.tsx index 868f915d5..d1e71791d 100644 --- a/frontend/tests/components/EditInheritancePlanPanel.test.tsx +++ b/frontend/tests/components/EditInheritancePlanPanel.test.tsx @@ -27,6 +27,9 @@ vi.mock("framer-motion", () => ({ AnimatePresence: ({ children }: any) => <>{children}, })); +const VALID_BENEFICIARY_ADDRESS = + "GCDFLQR2SGPDRQ473YUJ3Z5Z64BAJOH7EFFF4DC6ZEABSUWNLAR7Q7KJ"; + const mockPlan: Plan = { id: "plan_1", user_id: "user_1", @@ -39,6 +42,14 @@ const mockPlan: Plan = { risk_override_enabled: false, created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", + beneficiaries: [ + { + wallet_address: VALID_BENEFICIARY_ADDRESS, + name: "Alice Smith", + allocation_bps: 10000, + fiat_anchor_info: "", + }, + ], }; beforeEach(() => { @@ -268,6 +279,7 @@ describe("EditInheritancePlanPanel", () => { const planWithoutName: Plan = { ...mockPlan, beneficiary_name: undefined, + beneficiaries: [], }; render( @@ -289,9 +301,7 @@ describe("EditInheritancePlanPanel", () => { await user.click(screen.getByRole("button", { name: /save changes/i })); await waitFor(() => { - expect( - screen.getByText(/all beneficiaries must have a name and wallet address/i) - ).toBeInTheDocument(); + expect(screen.getAllByText(/name is required/i).length).toBeGreaterThan(0); }); }); }); diff --git a/frontend/tests/mocks/handlers.ts b/frontend/tests/mocks/handlers.ts index 9cbfef86b..4ce70de7e 100644 --- a/frontend/tests/mocks/handlers.ts +++ b/frontend/tests/mocks/handlers.ts @@ -219,14 +219,62 @@ export const plansHandlers = [ }); }), + // Update plan — matches the Axum PUT /api/plans/:id signature http.put("/api/plans/:id", async ({ params, request }) => { const body = (await request.json()) as Record; - const plan = mockPlans.find((p) => p.id === params.id); + const plan = mockPlans.find((p) => p.id === params.id) as + | Record + | undefined; if (!plan) { - return HttpResponse.json({ status: "error", message: "Plan not found" }, { status: 404 }); + return HttpResponse.json({ error: "Plan not found" }, { status: 404 }); } - const updated = { ...plan, ...body, updated_at: new Date().toISOString() }; - return HttpResponse.json({ status: "ok", data: updated }); + + const beneficiaries = (body.beneficiaries as Array>) || []; + if (beneficiaries.length > 0) { + const totalBps = beneficiaries.reduce( + (sum: number, b: Record) => sum + ((b.allocation_bps as number) || 0), + 0 + ); + if (totalBps !== 10000) { + return HttpResponse.json( + { error: `Total allocation_bps must be exactly 10000 (100%), got ${totalBps}` }, + { status: 400 } + ); + } + + plan.beneficiaries = beneficiaries.map((b, i) => ({ + id: `ben_${plan.id}_${i}`, + plan_id: plan.id, + wallet_address: b.address as string, + allocation_bps: b.allocation_bps as number, + fiat_anchor_info: (b.fiat_anchor_info as string) || "", + fiat_daily_limit: "0", + })); + } + + if (body.grace_period !== undefined) { + plan.grace_period = body.grace_period; + plan.grace_period_seconds = body.grace_period; + } + if (body.earn_yield !== undefined) plan.earn_yield = body.earn_yield; + if (body.yield_rate_bps !== undefined) plan.yield_rate_bps = body.yield_rate_bps; + + return HttpResponse.json({ + id: plan.id, + owner_address: plan.owner_address, + token_address: plan.token_address, + amount: String(plan.amount ?? "0"), + grace_period: plan.grace_period ?? 0, + grace_period_seconds: plan.grace_period_seconds ?? 0, + earn_yield: plan.earn_yield ?? false, + last_ping: plan.last_ping ?? 0, + is_active: plan.is_active ?? true, + status: plan.status ?? "ACTIVE", + yield_rate_bps: plan.yield_rate_bps ?? 0, + accrued_yield: plan.accrued_yield ?? 0, + created_at: plan.created_at, + beneficiaries: plan.beneficiaries ?? [], + }); }), http.post("/api/plans/:id/trigger", ({ params }) => {