diff --git a/contracts/inheritance-contract/src/lib.rs b/contracts/inheritance-contract/src/lib.rs index 0760c51c2..0db320107 100644 --- a/contracts/inheritance-contract/src/lib.rs +++ b/contracts/inheritance-contract/src/lib.rs @@ -204,6 +204,9 @@ impl InheritanceContract { /// using the stored basis points, and transfers tokens safely. /// Remaining dust from integer division is allocated to the last beneficiary. /// Aborts the entire transaction if any single transfer fails. + /// Emits a `payout` event per beneficiary and, when `fiat_anchor_info` is + /// non-empty, an additional `anchor_payout` event for the backend to pick up + /// and execute a Stellar Anchor SEP off-ramp flow. pub fn trigger_payout(env: Env, owner: Address) -> Result<(), Error> { let key = DataKey::Plan(owner.clone()); let plan: Plan = env @@ -237,11 +240,26 @@ impl InheritanceContract { remaining -= amount; amount }; + token_client.transfer( &env.current_contract_address(), &beneficiary.address, &share, ); + + // Emit general payout event + env.events().publish( + (soroban_sdk::symbol_short!("payout"), owner.clone(), beneficiary.address.clone()), + share, + ); + + // Emit fiat anchor off-ramp event when beneficiary has routing info + if beneficiary.fiat_anchor_info.len() > 0 { + env.events().publish( + (soroban_sdk::symbol_short!("anchor_pay"), owner.clone(), beneficiary.address.clone()), + (share, beneficiary.fiat_anchor_info.clone()), + ); + } } Ok(()) diff --git a/contracts/inheritance-contract/src/test.rs b/contracts/inheritance-contract/src/test.rs index 3765cc18b..167fe4105 100644 --- a/contracts/inheritance-contract/src/test.rs +++ b/contracts/inheritance-contract/src/test.rs @@ -539,3 +539,183 @@ fn test_trigger_payout_no_plan() { let result = client.try_trigger_payout(&owner); assert_eq!(result, Err(Ok(Error::PlanNotFound))); } + +#[test] +fn test_trigger_payout_emits_anchor_event_for_fiat_beneficiary() { + use soroban_sdk::testutils::Events; + + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(&env, &contract_id); + + let token_id = env.register_contract(None, mock_token::MockToken); + let token_client = mock_token::MockTokenClient::new(&env, &token_id); + + let owner = Address::generate(&env); + let beneficiary = Address::generate(&env); + + token_client.mint(&owner, &1000); + + let b = Beneficiary { + address: beneficiary.clone(), + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, "NGN_BANK:0123456789"), + }; + + env.ledger().set_timestamp(1_000_000); + client.create_plan( + &owner, + &token_id, + &1000, + &Vec::from_array(&env, [b]), + &3600, + &false, + &0, + ); + client.close_plan(&owner); + env.ledger().set_timestamp(1_000_000 + 4000); + client.trigger_payout(&owner); + + let payout_sym: soroban_sdk::Val = soroban_sdk::symbol_short!("payout").into(); + let anchor_sym: soroban_sdk::Val = soroban_sdk::symbol_short!("anchor_pay").into(); + + let mut has_payout = false; + let mut has_anchor = false; + + for (contract, topics, _data) in env.events().all().iter() { + if contract != contract_id { + continue; + } + if let Some(topic0) = topics.get(0) { + if topic0 == payout_sym { + has_payout = true; + } + if topic0 == anchor_sym { + has_anchor = true; + } + } + } + + assert!(has_payout, "expected payout event"); + assert!(has_anchor, "expected anchor_pay event for fiat beneficiary"); +} + +#[test] +fn test_trigger_payout_no_anchor_event_for_non_fiat_beneficiary() { + use soroban_sdk::testutils::Events; + + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(&env, &contract_id); + + let token_id = env.register_contract(None, mock_token::MockToken); + let token_client = mock_token::MockTokenClient::new(&env, &token_id); + + let owner = Address::generate(&env); + let beneficiary = Address::generate(&env); + + token_client.mint(&owner, &1000); + + // Empty fiat_anchor_info → no anchor event should be emitted + let b = Beneficiary { + address: beneficiary.clone(), + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, ""), + }; + + env.ledger().set_timestamp(1_000_000); + client.create_plan( + &owner, + &token_id, + &1000, + &Vec::from_array(&env, [b]), + &3600, + &false, + &0, + ); + client.close_plan(&owner); + env.ledger().set_timestamp(1_000_000 + 4000); + client.trigger_payout(&owner); + + let anchor_sym: soroban_sdk::Val = soroban_sdk::symbol_short!("anchor_pay").into(); + let mut has_anchor = false; + + for (contract, topics, _data) in env.events().all().iter() { + if contract != contract_id { + continue; + } + if topics.get(0).map(|v| v == anchor_sym).unwrap_or(false) { + has_anchor = true; + } + } + + assert!(!has_anchor, "should not emit anchor_pay for empty fiat_anchor_info"); +} + +#[test] +fn test_trigger_payout_mixed_fiat_and_non_fiat_beneficiaries() { + use soroban_sdk::testutils::Events; + + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(&env, &contract_id); + + let token_id = env.register_contract(None, mock_token::MockToken); + let token_client = mock_token::MockTokenClient::new(&env, &token_id); + + let owner = Address::generate(&env); + let alice = Address::generate(&env); // fiat + let bob = Address::generate(&env); // crypto-only + + token_client.mint(&owner, &1000); + + let alice_bene = Beneficiary { + address: alice.clone(), + allocation_bps: 6000, + fiat_anchor_info: String::from_str(&env, "KES_MPESA:0700000000"), + }; + let bob_bene = Beneficiary { + address: bob.clone(), + allocation_bps: 4000, + fiat_anchor_info: String::from_str(&env, ""), + }; + + env.ledger().set_timestamp(1_000_000); + client.create_plan( + &owner, + &token_id, + &1000, + &Vec::from_array(&env, [alice_bene, bob_bene]), + &3600, + &false, + &0, + ); + client.close_plan(&owner); + env.ledger().set_timestamp(1_000_000 + 4000); + client.trigger_payout(&owner); + + let anchor_sym: soroban_sdk::Val = soroban_sdk::symbol_short!("anchor_pay").into(); + let mut anchor_count = 0u32; + + for (contract, topics, _data) in env.events().all().iter() { + if contract != contract_id { + continue; + } + if topics.get(0).map(|v| v == anchor_sym).unwrap_or(false) { + anchor_count += 1; + } + } + + // Only one anchor event (for Alice), not two + assert_eq!(anchor_count, 1, "expected exactly one anchor_pay event"); + + // Balances still correct + assert_eq!(token_client.balance(&alice), 600); + assert_eq!(token_client.balance(&bob), 400); +} diff --git a/frontend/app/api/commitments/validate/route.ts b/frontend/app/api/commitments/validate/route.ts new file mode 100644 index 000000000..e2bfa0c32 --- /dev/null +++ b/frontend/app/api/commitments/validate/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; + +export interface CommitmentDraft { + title?: string; + net_amount?: number | string; + currency_preference?: string; + beneficiary_name?: string; + bank_account_number?: string; + bank_name?: string; + inactivity_days?: number | string; +} + +export interface FieldError { + field: string; + message: string; +} + +export interface ValidateResponse { + valid: boolean; + errors: FieldError[]; +} + +const SUPPORTED_CURRENCIES = ["XLM", "USDC", "EURC", "NGN", "KES", "BRL", "PHP", "EUR", "USD"]; + +export async function POST(req: NextRequest): Promise> { + let body: CommitmentDraft; + try { + body = await req.json(); + } catch { + return NextResponse.json({ valid: false, errors: [{ field: "_", message: "Invalid JSON body" }] }, { status: 400 }); + } + + const errors: FieldError[] = []; + + if (!body.title || String(body.title).trim().length < 3) { + errors.push({ field: "title", message: "Title must be at least 3 characters." }); + } + + const amount = Number(body.net_amount); + if (!body.net_amount || isNaN(amount) || amount <= 0) { + errors.push({ field: "net_amount", message: "Amount must be a positive number." }); + } + + if (!body.currency_preference || !SUPPORTED_CURRENCIES.includes(String(body.currency_preference).toUpperCase())) { + errors.push({ field: "currency_preference", message: `Currency must be one of: ${SUPPORTED_CURRENCIES.join(", ")}.` }); + } + + if (!body.beneficiary_name || String(body.beneficiary_name).trim().length < 2) { + errors.push({ field: "beneficiary_name", message: "Beneficiary name must be at least 2 characters." }); + } + + if (!body.bank_account_number || !/^\d{6,20}$/.test(String(body.bank_account_number).trim())) { + errors.push({ field: "bank_account_number", message: "Bank account number must be 6–20 digits." }); + } + + if (!body.bank_name || String(body.bank_name).trim().length < 2) { + errors.push({ field: "bank_name", message: "Bank name must be at least 2 characters." }); + } + + const days = Number(body.inactivity_days); + if (body.inactivity_days !== undefined && body.inactivity_days !== "") { + if (isNaN(days) || days < 30 || days > 3650) { + errors.push({ field: "inactivity_days", message: "Inactivity period must be between 30 and 3650 days." }); + } + } + + return NextResponse.json({ valid: errors.length === 0, errors }); +} diff --git a/frontend/components/CreateCommitmentStepConfigure.tsx b/frontend/components/CreateCommitmentStepConfigure.tsx new file mode 100644 index 000000000..8682d7639 --- /dev/null +++ b/frontend/components/CreateCommitmentStepConfigure.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { CommitmentDraft, FieldError } from "@/app/api/commitments/validate/route"; + +export interface ConfigureFormValues { + title: string; + net_amount: string; + currency_preference: string; + beneficiary_name: string; + bank_account_number: string; + bank_name: string; + inactivity_days: string; +} + +interface Props { + initialValues?: Partial; + onAdvance: (values: ConfigureFormValues) => void; +} + +const SUPPORTED_CURRENCIES = ["XLM", "USDC", "EURC", "NGN", "KES", "BRL", "PHP", "EUR", "USD"]; +const DEBOUNCE_MS = 500; + +const EMPTY: ConfigureFormValues = { + title: "", + net_amount: "", + currency_preference: "", + beneficiary_name: "", + bank_account_number: "", + bank_name: "", + inactivity_days: "", +}; + +export function CreateCommitmentStepConfigure({ initialValues, onAdvance }: Props) { + const [values, setValues] = useState({ ...EMPTY, ...initialValues }); + const [serverErrors, setServerErrors] = useState>({}); + const [validating, setValidating] = useState(false); + const [serverDown, setServerDown] = useState(false); + const abortRef = useRef(null); + const timerRef = useRef | null>(null); + + const validate = useCallback(async (draft: ConfigureFormValues) => { + if (abortRef.current) abortRef.current.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setValidating(true); + setServerDown(false); + + try { + const res = await fetch("/api/commitments/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...draft, + net_amount: draft.net_amount === "" ? undefined : Number(draft.net_amount), + inactivity_days: draft.inactivity_days === "" ? undefined : Number(draft.inactivity_days), + } satisfies CommitmentDraft), + signal: controller.signal, + }); + + const data = await res.json(); + const map: Record = {}; + for (const e of (data.errors ?? []) as FieldError[]) { + map[e.field] = e.message; + } + setServerErrors(map); + } catch (err) { + if ((err as Error).name !== "AbortError") { + setServerDown(true); + } + } finally { + if (!controller.signal.aborted) setValidating(false); + } + }, []); + + useEffect(() => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => validate(values), DEBOUNCE_MS); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [values, validate]); + + // cancel in-flight request on unmount + useEffect(() => () => { abortRef.current?.abort(); }, []); + + const handleChange = (field: keyof ConfigureFormValues) => + (e: React.ChangeEvent) => + setValues((v) => ({ ...v, [field]: e.target.value })); + + const isBlocked = validating || Object.keys(serverErrors).length > 0; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!isBlocked) onAdvance(values); + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {serverDown && ( +

+ Validation service unavailable — you may still proceed. +

+ )} + + +
+ ); +} + +// ─── Tiny accessible field wrapper ─────────────────────────────────────────── + +function Field({ + id, + label, + error, + children, +}: { + id: string; + label: string; + error?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {error && ( + + {error} + + )} +
+ ); +} diff --git a/frontend/docs/CREATE_DRAFT_VALIDATION.md b/frontend/docs/CREATE_DRAFT_VALIDATION.md new file mode 100644 index 000000000..e0650af2e --- /dev/null +++ b/frontend/docs/CREATE_DRAFT_VALIDATION.md @@ -0,0 +1,97 @@ +# CREATE_DRAFT_VALIDATION + +Documents the inline server-side draft validation wired into the Create Commitment wizard configure step. + +--- + +## Endpoint + +``` +POST /api/commitments/validate +``` + +### Request body + +All fields are optional in the body; the endpoint returns per-field errors for whatever is present and invalid. + +| Field | Type | Rules | +|---|---|---| +| `title` | `string` | Min 3 characters | +| `net_amount` | `number` | Positive finite number | +| `currency_preference` | `string` | One of `XLM USDC EURC NGN KES BRL PHP EUR USD` (case-insensitive) | +| `beneficiary_name` | `string` | Min 2 characters | +| `bank_account_number` | `string` | 6–20 digits, no spaces | +| `bank_name` | `string` | Min 2 characters | +| `inactivity_days` | `number` | 30–3650 inclusive (omit field to skip validation) | + +### Response + +```ts +{ + valid: boolean; // true only when errors is empty + errors: Array<{ + field: string; // matches the request field name + message: string; // human-readable message shown next to the input + }>; +} +``` + +**HTTP status** is always `200` for validation results. `400` is returned only for a malformed JSON body. + +--- + +## Component contract — `CreateCommitmentStepConfigure` + +```tsx + + onAdvance: (values: ConfigureFormValues) => void +/> +``` + +### Validation lifecycle + +1. User edits any field. +2. A 500 ms debounce timer starts; any new edit resets it. +3. After 500 ms, `POST /api/commitments/validate` is called with an `AbortController` signal. +4. If a new edit arrives before the response, the in-flight request is aborted. +5. On success, field errors are mapped to the corresponding inputs and the Continue button is disabled while errors exist. +6. If the endpoint is unreachable (network error), a non-blocking warning banner is shown and the Continue button remains enabled. +7. On unmount, the current in-flight request is aborted. + +### Accessibility + +Every field that has a server error gets: + +- `aria-invalid="true"` on the `` / `