diff --git a/src/__tests__/useSplitCalculator.test.ts b/src/__tests__/useSplitCalculator.test.ts
new file mode 100644
index 0000000..e2511d1
--- /dev/null
+++ b/src/__tests__/useSplitCalculator.test.ts
@@ -0,0 +1,89 @@
+import { resolveRounding } from '@/hooks/useSplitCalculator';
+
+describe('resolveRounding', () => {
+ const STROOP_SCALE = 1e7;
+
+ it('should resolve rounding for 3-recipient split', () => {
+ const percentages = [33.33, 33.33, 33.34];
+ const totalAmount = 100;
+
+ const result = resolveRounding(percentages, totalAmount);
+
+ expect(result.amounts).toHaveLength(3);
+ const sumStroops = result.amounts.reduce(
+ (s, a) => s + Math.round(a * STROOP_SCALE),
+ 0
+ );
+ const totalStroops = Math.round(totalAmount * STROOP_SCALE);
+ expect(sumStroops).toBe(totalStroops);
+ });
+
+ it('should resolve rounding for 7-recipient split', () => {
+ const percentages = [14.28, 14.28, 14.28, 14.28, 14.28, 14.29, 14.31];
+ const totalAmount = 1000;
+
+ const result = resolveRounding(percentages, totalAmount);
+
+ expect(result.amounts).toHaveLength(7);
+ const sumStroops = result.amounts.reduce(
+ (s, a) => s + Math.round(a * STROOP_SCALE),
+ 0
+ );
+ const totalStroops = Math.round(totalAmount * STROOP_SCALE);
+ expect(sumStroops).toBe(totalStroops);
+ });
+
+ it('should resolve rounding for 11-recipient split', () => {
+ const percentages = Array(11)
+ .fill(0)
+ .map((_, i) => (i === 10 ? 9.1 : 9.09));
+ const totalAmount = 5000;
+
+ const result = resolveRounding(percentages, totalAmount);
+
+ expect(result.amounts).toHaveLength(11);
+ const sumStroops = result.amounts.reduce(
+ (s, a) => s + Math.round(a * STROOP_SCALE),
+ 0
+ );
+ const totalStroops = Math.round(totalAmount * STROOP_SCALE);
+ expect(sumStroops).toBe(totalStroops);
+ });
+
+ it('should assign adjustment to first recipient', () => {
+ const percentages = [50, 50];
+ const totalAmount = 100;
+
+ const result = resolveRounding(percentages, totalAmount);
+
+ expect(result.recipientIndex).toBe(0);
+ });
+
+ it('should return zero adjustment when no rounding needed', () => {
+ const percentages = [25, 25, 25, 25];
+ const totalAmount = 100;
+
+ const result = resolveRounding(percentages, totalAmount);
+
+ const sumStroops = result.amounts.reduce(
+ (s, a) => s + Math.round(a * STROOP_SCALE),
+ 0
+ );
+ const totalStroops = Math.round(totalAmount * STROOP_SCALE);
+ expect(sumStroops).toBe(totalStroops);
+ });
+
+ it('should handle fractional stroop amounts', () => {
+ const percentages = [33.333, 33.333, 33.334];
+ const totalAmount = 123.456789;
+
+ const result = resolveRounding(percentages, totalAmount);
+
+ const sumStroops = result.amounts.reduce(
+ (s, a) => s + Math.round(a * STROOP_SCALE),
+ 0
+ );
+ const totalStroops = Math.round(totalAmount * STROOP_SCALE);
+ expect(sumStroops).toBe(totalStroops);
+ });
+});
diff --git a/src/app/api/validate-email/route.ts b/src/app/api/validate-email/route.ts
new file mode 100644
index 0000000..fdfa177
--- /dev/null
+++ b/src/app/api/validate-email/route.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import { promises as dns } from "dns";
+
+export async function POST(request: Request) {
+ try {
+ const { email } = await request.json();
+
+ if (!email || typeof email !== "string") {
+ return NextResponse.json(
+ { error: "Email is required" },
+ { status: 400 }
+ );
+ }
+
+ const parts = email.split("@");
+ if (parts.length !== 2) {
+ return NextResponse.json(
+ { valid: false, hasMX: false },
+ { status: 200 }
+ );
+ }
+
+ const domain = parts[1];
+
+ try {
+ const mxRecords = await dns.resolveMx(domain);
+ const hasMX = Array.isArray(mxRecords) && mxRecords.length > 0;
+ return NextResponse.json({ valid: true, hasMX }, { status: 200 });
+ } catch {
+ return NextResponse.json({ valid: true, hasMX: false }, { status: 200 });
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return NextResponse.json(
+ { error: `Validation failed: ${message}` },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/components/EmailField.tsx b/src/components/EmailField.tsx
new file mode 100644
index 0000000..5b3b629
--- /dev/null
+++ b/src/components/EmailField.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { useEmailValidation } from "@/hooks/useEmailValidation";
+
+interface EmailFieldProps {
+ email: string;
+ onEmailChange: (email: string) => void;
+ onBlur?: () => void;
+ placeholder?: string;
+}
+
+export default function EmailField({
+ email,
+ onEmailChange,
+ onBlur,
+ placeholder = "recipient@example.com",
+}: EmailFieldProps) {
+ const { isValidFormat, isCheckingMX, mxValid } = useEmailValidation(email);
+
+ return (
+
+
+ onEmailChange(e.target.value)}
+ onBlur={onBlur}
+ placeholder={placeholder}
+ className={`flex-1 bg-gray-800 border rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 ${
+ !email
+ ? "border-gray-700 focus:ring-indigo-500"
+ : !isValidFormat
+ ? "border-red-500 focus:ring-red-500"
+ : mxValid === false
+ ? "border-yellow-500 focus:ring-yellow-500"
+ : "border-green-500 focus:ring-green-500"
+ }`}
+ />
+ {email && isValidFormat && (
+ <>
+ {isCheckingMX && (
+ Checking...
+ )}
+ {!isCheckingMX && mxValid === true && (
+ ✓
+ )}
+ {!isCheckingMX && mxValid === false && (
+ ⚠
+ )}
+ >
+ )}
+
+
+ {email && !isValidFormat && (
+
Invalid email format
+ )}
+ {email && isValidFormat && mxValid === false && !isCheckingMX && (
+
Domain has no MX records (delivery may fail)
+ )}
+
+ );
+}
diff --git a/src/components/RecipientForm.tsx b/src/components/RecipientForm.tsx
index 03d2432..8d4d8d1 100644
--- a/src/components/RecipientForm.tsx
+++ b/src/components/RecipientForm.tsx
@@ -8,11 +8,14 @@ import { searchAddressHistory, searchAmountHistory } from "@/lib/invoiceHistory"
import { searchRecipients, touchRecipient, type RecipientEntry } from "@/lib/recipients";
import { truncateAddress } from "@stellar-split/sdk";
import CsvRecipientImport from "@/components/CsvRecipientImport";
+import { useEmailValidation } from "@/hooks/useEmailValidation";
+import EmailField from "@/components/EmailField";
export interface RecipientRow {
address: string;
amount: string;
label?: string;
+ email?: string;
}
interface Props {
@@ -67,9 +70,9 @@ export default function RecipientForm({
}).filter((r) => r.address);
};
- const updateRow = (index: number, address: string, label?: string) => {
+ const updateRow = (index: number, address: string, label?: string, email?: string) => {
const next = recipients.map((r, i) =>
- i === index ? { ...r, address, label: label !== undefined ? label : r.label } : r
+ i === index ? { ...r, address, label: label !== undefined ? label : r.label, email: email !== undefined ? email : r.email } : r
);
onChange(next);
};
@@ -168,71 +171,84 @@ export default function RecipientForm({
return (
{recipients.map((row, i) => (
-
-
-
-
-
updateRow(i, address, label)}
- placeholder="G... or name*domain.com address"
- ariaLabel={`Recipient ${i + 1} address`}
+
+
-
-
handleAmountChange(i, e.target.value)
- }
- onFocus={() => !equalSplit && handleAmountFocus(i)}
- readOnly={equalSplit}
- required
- aria-label={`Recipient ${i + 1} amount`}
- className={`w-full bg-gray-800 border rounded-lg px-3 py-2 min-h-11 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${
- equalSplit
- ? "border-gray-600 text-gray-400 cursor-not-allowed"
- : "border-gray-700"
- }`}
- />
- {activeField === "amount" && activeIndex === i && amountSuggestions.length > 0 && !equalSplit && (
-
- {amountSuggestions.map((amount) => (
- -
-
-
- ))}
-
+
+
updateRow(i, address, label, row.email)}
+ placeholder="G... or name*domain.com address"
+ ariaLabel={`Recipient ${i + 1} address`}
+ />
+
+
+
+
handleAmountChange(i, e.target.value)
+ }
+ onFocus={() => !equalSplit && handleAmountFocus(i)}
+ readOnly={equalSplit}
+ required
+ aria-label={`Recipient ${i + 1} amount`}
+ className={`w-full bg-gray-800 border rounded-lg px-3 py-2 min-h-11 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${
+ equalSplit
+ ? "border-gray-600 text-gray-400 cursor-not-allowed"
+ : "border-gray-700"
+ }`}
+ />
+ {activeField === "amount" && activeIndex === i && amountSuggestions.length > 0 && !equalSplit && (
+
+ {amountSuggestions.map((amount) => (
+ -
+
+
+ ))}
+
+ )}
+
+
+ {recipients.length > 1 && (
+
)}
- {recipients.length > 1 && (
-
- )}
+
+
+
+ updateRow(i, row.address, row.label, email)}
+ onBlur={() => {}}
+ />
+
+
))}
diff --git a/src/components/invoice/NotesEditor.tsx b/src/components/invoice/NotesEditor.tsx
new file mode 100644
index 0000000..ff96027
--- /dev/null
+++ b/src/components/invoice/NotesEditor.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+import { useEditor, EditorContent } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import Link from '@tiptap/extension-link';
+import DOMPurify from 'dompurify';
+import { useEffect, useCallback } from 'react';
+
+interface NotesEditorProps {
+ value: string;
+ onChange: (html: string) => void;
+ placeholder?: string;
+ disabled?: boolean;
+}
+
+export default function NotesEditor({
+ value,
+ onChange,
+ placeholder = 'Add notes...',
+ disabled = false,
+}: NotesEditorProps) {
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ heading: { levels: [1, 2, 3] },
+ }),
+ Link.configure({ openOnClick: false }),
+ ],
+ content: value || '',
+ onUpdate: ({ editor }) => {
+ const html = editor.getHTML();
+ const sanitized = DOMPurify.sanitize(html, {
+ ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li', 'a', 'h1', 'h2', 'h3'],
+ ALLOWED_ATTR: ['href', 'title'],
+ });
+ onChange(sanitized);
+ },
+ editable: !disabled,
+ });
+
+ useEffect(() => {
+ if (editor && value && editor.getHTML() !== value) {
+ editor.commands.setContent(value);
+ }
+ }, [editor, value]);
+
+ const toggleBold = useCallback(() => {
+ editor?.chain().focus().toggleBold().run();
+ }, [editor]);
+
+ const toggleItalic = useCallback(() => {
+ editor?.chain().focus().toggleItalic().run();
+ }, [editor]);
+
+ const toggleBulletList = useCallback(() => {
+ editor?.chain().focus().toggleBulletList().run();
+ }, [editor]);
+
+ const insertLink = useCallback(() => {
+ const url = prompt('Enter URL:');
+ if (url) {
+ editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
+ }
+ }, [editor]);
+
+ if (!editor) {
+ return null;
+ }
+
+ return (
+
+
{
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ editor.chain().focus().run();
+ }
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/invoice/RateLimitBanner.tsx b/src/components/invoice/RateLimitBanner.tsx
new file mode 100644
index 0000000..eebbf77
--- /dev/null
+++ b/src/components/invoice/RateLimitBanner.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+interface RateLimitBannerProps {
+ isVisible: boolean;
+ secondsRemaining: number;
+ retryAfterSeconds: number | null;
+}
+
+export default function RateLimitBanner({
+ isVisible,
+ secondsRemaining,
+ retryAfterSeconds,
+}: RateLimitBannerProps) {
+ if (!isVisible) return null;
+
+ if (retryAfterSeconds === null) {
+ return (
+
+
Please wait before trying again
+
+ );
+ }
+
+ return (
+
+
You can create another invoice in {secondsRemaining} s
+
+ );
+}
diff --git a/src/components/invoice/RecipientRow.tsx b/src/components/invoice/RecipientRow.tsx
new file mode 100644
index 0000000..4f6524f
--- /dev/null
+++ b/src/components/invoice/RecipientRow.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useEmailValidation } from "@/hooks/useEmailValidation";
+
+interface RecipientRowProps {
+ email: string;
+ onEmailChange: (email: string) => void;
+ onBlur?: () => void;
+}
+
+export default function RecipientRow({
+ email,
+ onEmailChange,
+ onBlur,
+}: RecipientRowProps) {
+ const { isValidFormat, isCheckingMX, mxValid } = useEmailValidation(email);
+
+ return (
+
+
+ onEmailChange(e.target.value)}
+ onBlur={onBlur}
+ placeholder="recipient@example.com"
+ className={`flex-1 bg-gray-800 border rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 ${
+ !email
+ ? "border-gray-700 focus:ring-indigo-500"
+ : !isValidFormat
+ ? "border-red-500 focus:ring-red-500"
+ : mxValid === false
+ ? "border-yellow-500 focus:ring-yellow-500"
+ : "border-green-500 focus:ring-green-500"
+ }`}
+ />
+ {email && isValidFormat && (
+ <>
+ {isCheckingMX && (
+ Checking...
+ )}
+ {!isCheckingMX && mxValid === true && (
+ ✓
+ )}
+ {!isCheckingMX && mxValid === false && (
+ ⚠
+ )}
+ >
+ )}
+
+
+ {email && !isValidFormat && (
+
Invalid email format
+ )}
+ {email && isValidFormat && mxValid === false && !isCheckingMX && (
+
Domain has no MX records (delivery may fail)
+ )}
+
+ );
+}
diff --git a/src/components/invoice/SplitSummaryCard.tsx b/src/components/invoice/SplitSummaryCard.tsx
new file mode 100644
index 0000000..2fa3124
--- /dev/null
+++ b/src/components/invoice/SplitSummaryCard.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+interface SplitSummaryCardProps {
+ totalAmount: number;
+ recipientCount: number;
+ roundingAdjustment?: number;
+ recipientIndex?: number;
+}
+
+export default function SplitSummaryCard({
+ totalAmount,
+ recipientCount,
+ roundingAdjustment = 0,
+ recipientIndex = 0,
+}: SplitSummaryCardProps) {
+ const hasRoundingAdjustment = roundingAdjustment !== 0 && roundingAdjustment !== undefined;
+
+ return (
+
+
+
+ Total amount:
+ {totalAmount} USDC
+
+
+ Recipients:
+ {recipientCount}
+
+
+ {hasRoundingAdjustment && (
+
+
+
⚠
+
+
+ Rounding adjustment applied
+
+
+ {recipientIndex === 0 ? 'First' : `Recipient ${recipientIndex + 1}`} received {Math.abs(roundingAdjustment)} additional stroops to resolve rounding discrepancy.
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/hooks/useEmailValidation.ts b/src/hooks/useEmailValidation.ts
new file mode 100644
index 0000000..7985f82
--- /dev/null
+++ b/src/hooks/useEmailValidation.ts
@@ -0,0 +1,91 @@
+'use client';
+
+import { useState, useCallback, useRef, useEffect } from 'react';
+
+interface ValidationResult {
+ valid: boolean;
+ hasMX: boolean;
+}
+
+interface CacheEntry {
+ result: ValidationResult;
+ timestamp: number;
+}
+
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
+const domainCache = new Map();
+
+export function useEmailValidation(email: string, debounceMs = 500) {
+ const [isValidFormat, setIsValidFormat] = useState(false);
+ const [isCheckingMX, setIsCheckingMX] = useState(false);
+ const [mxValid, setMxValid] = useState(null);
+ const debounceTimerRef = useRef(null);
+
+ const checkEmail = useCallback(async () => {
+ if (!email) {
+ setIsValidFormat(false);
+ setMxValid(null);
+ return;
+ }
+
+ const formatValid = EMAIL_PATTERN.test(email);
+ setIsValidFormat(formatValid);
+
+ if (!formatValid) {
+ setMxValid(null);
+ return;
+ }
+
+ const domain = email.split('@')[1];
+ const now = Date.now();
+ const cached = domainCache.get(domain);
+
+ if (cached && now - cached.timestamp < CACHE_TTL) {
+ setMxValid(cached.result.hasMX);
+ return;
+ }
+
+ setIsCheckingMX(true);
+ try {
+ const response = await fetch('/api/validate-email', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email }),
+ });
+
+ if (!response.ok) {
+ setMxValid(null);
+ return;
+ }
+
+ const result: ValidationResult = await response.json();
+ domainCache.set(domain, { result, timestamp: now });
+ setMxValid(result.hasMX);
+ } catch {
+ setMxValid(null);
+ } finally {
+ setIsCheckingMX(false);
+ }
+ }, [email]);
+
+ useEffect(() => {
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+
+ debounceTimerRef.current = setTimeout(checkEmail, debounceMs);
+
+ return () => {
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+ };
+ }, [email, debounceMs, checkEmail]);
+
+ return {
+ isValidFormat,
+ isCheckingMX,
+ mxValid,
+ };
+}
diff --git a/src/hooks/useSplitCalculator.ts b/src/hooks/useSplitCalculator.ts
index 027d679..9c22445 100644
--- a/src/hooks/useSplitCalculator.ts
+++ b/src/hooks/useSplitCalculator.ts
@@ -46,6 +46,12 @@ export interface SplitCalculatorResult {
recipientCount: number;
}
+export interface RoundingResolution {
+ amounts: number[];
+ roundingAdjustment: number;
+ recipientIndex: number;
+}
+
const STROOP_SCALE = 1e7;
function roundToStroops(value: number): number {
diff --git a/src/lib/htmlSanitizer.ts b/src/lib/htmlSanitizer.ts
new file mode 100644
index 0000000..7a80110
--- /dev/null
+++ b/src/lib/htmlSanitizer.ts
@@ -0,0 +1,23 @@
+import DOMPurify from 'dompurify';
+
+const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li', 'a', 'h1', 'h2', 'h3'];
+const ALLOWED_ATTR = ['href', 'title'];
+
+export function sanitizeHtml(dirty: string): string {
+ if (!dirty || typeof dirty !== 'string') {
+ return '';
+ }
+
+ return DOMPurify.sanitize(dirty, {
+ ALLOWED_TAGS,
+ ALLOWED_ATTR,
+ });
+}
+
+export function createSafeHtmlProps(html: string) {
+ return {
+ dangerouslySetInnerHTML: {
+ __html: sanitizeHtml(html),
+ },
+ };
+}
diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts
index bb7e506..78fbfa4 100644
--- a/src/lib/stellar.ts
+++ b/src/lib/stellar.ts
@@ -305,3 +305,10 @@ export async function validateFederationAddress(address: string): Promise<{
return { isFunded: false, requiresMemo: false };
}
}
+
+export function validateRecipientAmountSum(amounts: number[], totalAmount: number): boolean {
+ const STROOP_SCALE = 1e7;
+ const totalStroops = Math.round(totalAmount * STROOP_SCALE);
+ const sumStroops = amounts.reduce((s, a) => s + Math.round(a * STROOP_SCALE), 0);
+ return totalStroops === sumStroops;
+}