diff --git a/src/__tests__/nlQueryParser.test.ts b/src/__tests__/nlQueryParser.test.ts
new file mode 100644
index 0000000..b630990
--- /dev/null
+++ b/src/__tests__/nlQueryParser.test.ts
@@ -0,0 +1,165 @@
+import { parseNaturalLanguageQuery, applyParsedFilters } from '@/lib/nlQueryParser';
+import { describe, it, expect } from 'vitest';
+
+describe('nlQueryParser', () => {
+ describe('parseNaturalLanguageQuery', () => {
+ it('should parse "unpaid invoices over 200 XLM from last month"', () => {
+ const result = parseNaturalLanguageQuery('unpaid invoices over 200 XLM from last month');
+ expect(result.filters.status).toBe('pending');
+ expect(result.filters.amountGte).toBe(200);
+ expect(result.filters.dueDateFrom).toBeDefined();
+ expect(result.filters.dueDateTo).toBeDefined();
+ expect(result.tokens.length).toBeGreaterThan(0);
+ });
+
+ it('should parse amount ranges', () => {
+ const result = parseNaturalLanguageQuery('invoices between 100 and 500 XLM');
+ expect(result.filters.amountGte).toBe(100);
+ expect(result.filters.amountLte).toBe(500);
+ expect(result.tokens.some((t) => t.type === 'amount_range')).toBe(true);
+ });
+
+ it('should parse minimum amount', () => {
+ const result = parseNaturalLanguageQuery('over 500 XLM');
+ expect(result.filters.amountGte).toBe(500);
+ });
+
+ it('should parse maximum amount', () => {
+ const result = parseNaturalLanguageQuery('under 1000 XLM');
+ expect(result.filters.amountLte).toBe(1000);
+ });
+
+ it('should parse status keywords', () => {
+ expect(parseNaturalLanguageQuery('unpaid').filters.status).toBe('pending');
+ expect(parseNaturalLanguageQuery('paid').filters.status).toBe('paid');
+ expect(parseNaturalLanguageQuery('overdue').filters.status).toBe('overdue');
+ expect(parseNaturalLanguageQuery('draft').filters.status).toBe('draft');
+ });
+
+ it('should parse relative dates', () => {
+ const result = parseNaturalLanguageQuery('this week');
+ expect(result.filters.dueDateFrom).toBeDefined();
+ expect(result.filters.dueDateTo).toBeDefined();
+ });
+
+ it('should parse "past N days"', () => {
+ const result = parseNaturalLanguageQuery('past 7 days');
+ expect(result.filters.dueDateFrom).toBeDefined();
+ expect(result.filters.dueDateTo).toBeDefined();
+ });
+
+ it('should parse asset names', () => {
+ expect(parseNaturalLanguageQuery('XLM').filters.asset).toBe('XLM');
+ expect(parseNaturalLanguageQuery('USDC').filters.asset).toBe('USDC');
+ });
+
+ it('should handle mixed case', () => {
+ const result = parseNaturalLanguageQuery('UNPAID Invoices OVER 200 xlm');
+ expect(result.filters.status).toBe('pending');
+ expect(result.filters.amountGte).toBe(200);
+ });
+
+ it('should handle typos gracefully (with keyword fallback)', () => {
+ const result = parseNaturalLanguageQuery('invoces for customer A');
+ expect(result.filters.titleKeyword).toBeDefined();
+ });
+
+ it('should handle complex query with multiple constraints', () => {
+ const result = parseNaturalLanguageQuery(
+ 'unpaid invoices between 100 and 500 XLM in USDC status from last week'
+ );
+ expect(result.filters.status).toBe('pending');
+ expect(result.filters.amountGte).toBe(100);
+ expect(result.filters.amountLte).toBe(500);
+ expect(result.filters.dueDateFrom).toBeDefined();
+ });
+
+ it('should generate filter tokens', () => {
+ const result = parseNaturalLanguageQuery('paid invoices over 300 XLM this month');
+ expect(result.tokens.length).toBeGreaterThan(0);
+ expect(result.tokens.some((t) => t.type === 'status')).toBe(true);
+ expect(result.tokens.some((t) => t.type === 'amount_min')).toBe(true);
+ });
+
+ it('should handle empty query', () => {
+ const result = parseNaturalLanguageQuery('');
+ expect(result.filters).toEqual({});
+ expect(result.tokens.length).toBe(0);
+ });
+
+ it('should handle query with only whitespace', () => {
+ const result = parseNaturalLanguageQuery(' ');
+ expect(result.tokens.length).toBe(0);
+ });
+ });
+
+ describe('applyParsedFilters', () => {
+ const mockInvoices = [
+ {
+ id: '1',
+ title: 'Invoice 1',
+ amount: 150,
+ status: 'pending',
+ recipients: [{ address: 'customer1@example.com' }],
+ },
+ {
+ id: '2',
+ title: 'Invoice 2',
+ amount: 300,
+ status: 'paid',
+ recipients: [{ address: 'customer2@example.com' }],
+ },
+ {
+ id: '3',
+ title: 'Invoice 3',
+ amount: 600,
+ status: 'overdue',
+ recipients: [{ address: 'customer3@example.com' }],
+ },
+ ];
+
+ it('should filter by amount minimum', () => {
+ const result = applyParsedFilters(mockInvoices, { amountGte: 300 });
+ expect(result.length).toBe(2);
+ expect(result.map((i) => i.id)).toEqual(['2', '3']);
+ });
+
+ it('should filter by amount maximum', () => {
+ const result = applyParsedFilters(mockInvoices, { amountLte: 300 });
+ expect(result.length).toBe(2);
+ expect(result.map((i) => i.id)).toEqual(['1', '2']);
+ });
+
+ it('should filter by amount range', () => {
+ const result = applyParsedFilters(mockInvoices, {
+ amountGte: 200,
+ amountLte: 500,
+ });
+ expect(result.length).toBe(1);
+ expect(result[0].id).toBe('2');
+ });
+
+ it('should filter by status', () => {
+ const result = applyParsedFilters(mockInvoices, { status: 'paid' });
+ expect(result.length).toBe(1);
+ expect(result[0].id).toBe('2');
+ });
+
+ it('should filter by keyword', () => {
+ const result = applyParsedFilters(mockInvoices, {
+ titleKeyword: 'customer1',
+ });
+ expect(result.length).toBe(1);
+ expect(result[0].id).toBe('1');
+ });
+
+ it('should combine multiple filters', () => {
+ const result = applyParsedFilters(mockInvoices, {
+ status: 'paid',
+ amountGte: 250,
+ });
+ expect(result.length).toBe(1);
+ expect(result[0].id).toBe('2');
+ });
+ });
+});
diff --git a/src/app/api/invoices/share-links/route.ts b/src/app/api/invoices/share-links/route.ts
new file mode 100644
index 0000000..1c7ddd8
--- /dev/null
+++ b/src/app/api/invoices/share-links/route.ts
@@ -0,0 +1,124 @@
+import { NextRequest, NextResponse } from 'next/server';
+import {
+ createShareLink,
+ getShareLinksForInvoice,
+ revokeShareLink,
+ type ShareLinkPermission,
+} from '@/lib/shareLink';
+
+interface CreateShareLinkRequest {
+ invoiceId: string;
+ permissions?: ShareLinkPermission;
+ durationMs?: number;
+ maxUses?: boolean;
+}
+
+interface RevokeShareLinkRequest {
+ tokenHash: string;
+}
+
+/**
+ * POST - Generate a new share link
+ * Body: { invoiceId, permissions, durationMs, maxUses }
+ */
+export async function POST(request: NextRequest) {
+ try {
+ const body = (await request.json().catch(() => null)) as CreateShareLinkRequest | null;
+
+ if (!body?.invoiceId) {
+ return NextResponse.json({ error: 'invoiceId is required' }, { status: 400 });
+ }
+
+ const permissions: ShareLinkPermission = body.permissions || 'read';
+ const durationMs = body.durationMs || 3600000; // 1 hour default
+ const maxUses = body.maxUses ? 1 : undefined; // Single-use if specified
+
+ // Validate duration
+ if (durationMs < 300000 || durationMs > 2592000000) {
+ // 5 minutes to 30 days
+ return NextResponse.json(
+ { error: 'durationMs must be between 5 minutes and 30 days' },
+ { status: 400 }
+ );
+ }
+
+ const shareLink = createShareLink(body.invoiceId, permissions, durationMs, maxUses);
+
+ return NextResponse.json({
+ tokenHash: shareLink.tokenHash,
+ token: shareLink.token, // Return this only on creation
+ invoiceId: shareLink.invoiceId,
+ permissions: shareLink.permissions,
+ expiresAt: shareLink.expiresAt,
+ maxUses: shareLink.maxUses,
+ createdAt: shareLink.createdAt,
+ });
+ } catch (error) {
+ console.error('Error creating share link:', error);
+ return NextResponse.json(
+ { error: 'Failed to create share link' },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * GET - List share links for an invoice
+ * Query: invoiceId
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const invoiceId = request.nextUrl.searchParams.get('invoiceId');
+
+ if (!invoiceId) {
+ return NextResponse.json({ error: 'invoiceId is required' }, { status: 400 });
+ }
+
+ const links = getShareLinksForInvoice(invoiceId);
+
+ return NextResponse.json({
+ links: links.map((link) => ({
+ tokenHash: link.tokenHash,
+ permissions: link.permissions,
+ expiresAt: link.expiresAt,
+ maxUses: link.maxUses,
+ usesConsumed: link.usesConsumed,
+ createdAt: link.createdAt,
+ })),
+ });
+ } catch (error) {
+ console.error('Error fetching share links:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch share links' },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * DELETE - Revoke a share link
+ * Body: { tokenHash }
+ */
+export async function DELETE(request: NextRequest) {
+ try {
+ const body = (await request.json().catch(() => null)) as RevokeShareLinkRequest | null;
+
+ if (!body?.tokenHash) {
+ return NextResponse.json({ error: 'tokenHash is required' }, { status: 400 });
+ }
+
+ const revoked = revokeShareLink(body.tokenHash);
+
+ if (!revoked) {
+ return NextResponse.json({ error: 'Share link not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('Error revoking share link:', error);
+ return NextResponse.json(
+ { error: 'Failed to revoke share link' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/share/[token]/route.ts b/src/app/share/[token]/route.ts
new file mode 100644
index 0000000..a095d83
--- /dev/null
+++ b/src/app/share/[token]/route.ts
@@ -0,0 +1,115 @@
+import { NextRequest, NextResponse } from 'next/server';
+import {
+ validateShareLink,
+ incrementShareLinkUse,
+ hashToken,
+} from '@/lib/shareLink';
+
+/**
+ * GET - Validate and access a shared invoice
+ * Returns: Invoice data or redirect based on permission
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { token: string } }
+) {
+ try {
+ const token = params.token;
+
+ if (!token) {
+ return NextResponse.json(
+ { error: 'Invalid share link' },
+ { status: 404 }
+ );
+ }
+
+ const validation = validateShareLink(token);
+
+ if (!validation.valid) {
+ return NextResponse.json(
+ {
+ error: 'Share link not available',
+ reason: validation.reason,
+ },
+ { status: 404 }
+ );
+ }
+
+ const { link, permission } = validation;
+
+ // Increment usage counter
+ incrementShareLinkUse(hashToken(token));
+
+ // Create a session cookie or token for this session
+ // The response includes the invoice data and permission scope
+ const response = NextResponse.json({
+ invoiceId: link.invoiceId,
+ permission,
+ expiresAt: link.expiresAt,
+ accessGrantedAt: new Date(),
+ });
+
+ // Set a secure, HTTP-only cookie for this session
+ // This cookie grants temporary access to the invoice
+ response.cookies.set({
+ name: 'share_session',
+ value: JSON.stringify({
+ invoiceId: link.invoiceId,
+ permission,
+ expiresAt: link.expiresAt.toISOString(),
+ accessGrantedAt: new Date().toISOString(),
+ }),
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: 3600, // 1 hour
+ });
+
+ return response;
+ } catch (error) {
+ console.error('Error accessing shared invoice:', error);
+ return NextResponse.json(
+ { error: 'Failed to access share link' },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * HEAD - Check if share link is valid without incrementing uses
+ */
+export async function HEAD(
+ request: NextRequest,
+ { params }: { params: { token: string } }
+) {
+ try {
+ const token = params.token;
+
+ if (!token) {
+ return NextResponse.json(
+ { error: 'Invalid share link' },
+ { status: 404 }
+ );
+ }
+
+ const validation = validateShareLink(token);
+
+ if (!validation.valid) {
+ return NextResponse.json(
+ {
+ error: 'Share link not available',
+ reason: validation.reason,
+ },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({ valid: true });
+ } catch (error) {
+ console.error('Error validating shared invoice:', error);
+ return NextResponse.json(
+ { error: 'Failed to validate share link' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/components/invoice/CostEstimatorPanel.tsx b/src/components/invoice/CostEstimatorPanel.tsx
new file mode 100644
index 0000000..1e04528
--- /dev/null
+++ b/src/components/invoice/CostEstimatorPanel.tsx
@@ -0,0 +1,207 @@
+'use client';
+
+import { type CostBreakdown } from '@/hooks/useInvoiceCostEstimate';
+
+interface CostEstimatorPanelProps {
+ breakdown: CostBreakdown;
+ isBalanceSufficient: boolean | null;
+ creatorBalance: number | null;
+ invoiceAmountXlm: number;
+ loading?: boolean;
+ error?: string | null;
+ disabled?: boolean;
+}
+
+export default function CostEstimatorPanel({
+ breakdown,
+ isBalanceSufficient,
+ creatorBalance,
+ invoiceAmountXlm,
+ loading = false,
+ error = null,
+ disabled = false,
+}: CostEstimatorPanelProps) {
+ const totalRequired = invoiceAmountXlm + breakdown.totalXlm;
+
+ return (
+
+
Cost Estimate
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading && (
+
+ Loading cost estimate...
+
+ )}
+
+
+ {/* Invoice Amount */}
+
+ Invoice Amount
+ {invoiceAmountXlm.toFixed(2)} XLM
+
+
+ {/* Network Fee */}
+ {breakdown.networkFeeXlm > 0 && (
+
+
+
Network Fee
+
+
+
+ Stellar network transaction fee
+
+
+
+
+ {breakdown.networkFeeXlm.toFixed(6)} XLM
+
+
+ )}
+
+ {/* Platform Fee */}
+ {breakdown.platformFeeXlm > 0 && (
+
+
+
Platform Fee
+
+
+
+ StellarSplit service fee (1% minimum 0.1 XLM)
+
+
+
+
+ {breakdown.platformFeeXlm.toFixed(4)} XLM
+
+
+ )}
+
+ {/* Reserve Top-up */}
+ {breakdown.reserveTopUpXlm > 0 && (
+
+
+
Trustline Reserve
+
+
+
+ Reserve for new trustlines (0.5 XLM each)
+
+
+
+
+ {breakdown.reserveTopUpXlm.toFixed(4)} XLM
+
+
+ )}
+
+ {/* Add-on Costs */}
+ {breakdown.addOnCostsXlm > 0 && (
+
+ Add-on Features
+
+ {breakdown.addOnCostsXlm.toFixed(4)} XLM
+
+
+ )}
+
+ {/* Divider */}
+
+
+ {/* Total */}
+
+ Total Required
+
+ {totalRequired.toFixed(4)} XLM
+
+
+
+ {/* Balance Status */}
+ {creatorBalance !== null && (
+
+
+ Your Balance
+
+ {creatorBalance.toFixed(4)} XLM
+
+
+
+ {isBalanceSufficient === false && (
+
+ Insufficient balance. Need {(totalRequired - creatorBalance).toFixed(4)} XLM more.
+
+ )}
+
+ )}
+
+
+ {disabled && (
+
+ Submit button will be disabled until all costs can be covered.
+
+ )}
+
+ );
+}
diff --git a/src/components/invoice/PaymentRetryWizard.tsx b/src/components/invoice/PaymentRetryWizard.tsx
new file mode 100644
index 0000000..1423ac9
--- /dev/null
+++ b/src/components/invoice/PaymentRetryWizard.tsx
@@ -0,0 +1,255 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import FocusTrap from "@/components/FocusTrap";
+import { parseStellarError, type ParsedStellarError } from "@/lib/stellarErrorParser";
+
+interface PaymentRetryWizardProps {
+ open: boolean;
+ error: any;
+ invoiceId: string;
+ retryAttempt: number;
+ onRetry: (feeMultiplier?: number) => Promise;
+ onRefreshSequence: () => Promise;
+ onClose: () => void;
+}
+
+const MAX_RETRY_ATTEMPTS = 3;
+
+export default function PaymentRetryWizard({
+ open,
+ error,
+ invoiceId,
+ retryAttempt,
+ onRetry,
+ onRefreshSequence,
+ onClose,
+}: PaymentRetryWizardProps) {
+ const [step, setStep] = useState<"explanation" | "action" | "complete">("explanation");
+ const [loading, setLoading] = useState(false);
+ const [feeMultiplier, setFeeMultiplier] = useState(1.5);
+ const [actionError, setActionError] = useState(null);
+
+ const parsedError: ParsedStellarError = parseStellarError(
+ error?.message || error?.code || String(error),
+ error
+ );
+
+ const isMaxRetriesReached = retryAttempt >= MAX_RETRY_ATTEMPTS;
+
+ const handleBumpFeeRetry = useCallback(async () => {
+ setLoading(true);
+ setActionError(null);
+ try {
+ await onRetry(feeMultiplier);
+ setStep("complete");
+ } catch (err) {
+ setActionError(err instanceof Error ? err.message : String(err));
+ setLoading(false);
+ }
+ }, [feeMultiplier, onRetry]);
+
+ const handleRefreshSequence = useCallback(async () => {
+ setLoading(true);
+ setActionError(null);
+ try {
+ await onRefreshSequence();
+ setStep("complete");
+ } catch (err) {
+ setActionError(err instanceof Error ? err.message : String(err));
+ setLoading(false);
+ }
+ }, [onRefreshSequence]);
+
+ const handleRetry = useCallback(async () => {
+ setLoading(true);
+ setActionError(null);
+ try {
+ await onRetry();
+ setStep("complete");
+ } catch (err) {
+ setActionError(err instanceof Error ? err.message : String(err));
+ setLoading(false);
+ }
+ }, [onRetry]);
+
+ if (!open) return null;
+
+ return (
+
+
+
+
+ {step === "explanation" && (
+ <>
+
+ {parsedError.title}
+
+
{parsedError.explanation}
+
+ {isMaxRetriesReached && (
+
+ You have reached the maximum number of retry attempts ({MAX_RETRY_ATTEMPTS}).
+ Please contact support if the issue persists.
+
+ )}
+
+
+
+
+
+ >
+ )}
+
+ {step === "action" && (
+ <>
+
+ {parsedError.actionType === "bump-fee" && "Increase Transaction Fee"}
+ {parsedError.actionType === "refresh-seq" && "Refresh Sequence Number"}
+ {parsedError.actionType === "fund-destination" && "Fund Destination Account"}
+ {parsedError.actionType === "manual" && "Resolve Issue"}
+
+
+
{parsedError.suggestedAction}
+
+ {actionError && (
+
+ {actionError}
+
+ )}
+
+ {parsedError.actionType === "bump-fee" && (
+
+
+
setFeeMultiplier(parseFloat(e.target.value))}
+ className="w-full"
+ disabled={loading}
+ />
+
+ Higher multiplier = faster confirmation but higher cost
+
+
+ )}
+
+ {parsedError.actionType === "fund-destination" && (
+
+
+ On testnet: Use{" "}
+
+ Friendbot
+ {" "}
+ to fund the destination.
+
+
+ )}
+
+
+
+ {parsedError.actionType === "bump-fee" && (
+
+ )}
+ {parsedError.actionType === "refresh-seq" && (
+
+ )}
+ {(parsedError.actionType === "fund-destination" ||
+ parsedError.actionType === "manual") && (
+
+ )}
+
+ >
+ )}
+
+ {step === "complete" && (
+ <>
+
+
+
Retry Submitted
+
+ Your transaction has been resubmitted. It should process shortly.
+
+
+
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/invoice/ShareLinkModal.tsx b/src/components/invoice/ShareLinkModal.tsx
new file mode 100644
index 0000000..b93a8c8
--- /dev/null
+++ b/src/components/invoice/ShareLinkModal.tsx
@@ -0,0 +1,313 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import FocusTrap from '@/components/FocusTrap';
+import { formatShareLinkDuration } from '@/lib/shareLink';
+import type { ShareLinkPermission } from '@/lib/shareLink';
+
+interface ActiveShareLink {
+ tokenHash: string;
+ permissions: ShareLinkPermission;
+ expiresAt: string;
+ maxUses?: number;
+ usesConsumed: number;
+ createdAt: string;
+}
+
+interface ShareLinkModalProps {
+ open: boolean;
+ invoiceId: string;
+ onClose: () => void;
+}
+
+const DURATION_OPTIONS = [
+ { label: '1 hour', ms: 3600000 },
+ { label: '24 hours', ms: 86400000 },
+ { label: '7 days', ms: 604800000 },
+ { label: '30 days', ms: 2592000000 },
+] as const;
+
+export default function ShareLinkModal({
+ open,
+ invoiceId,
+ onClose,
+}: ShareLinkModalProps) {
+ const [selectedDuration, setSelectedDuration] = useState(3600000);
+ const [selectedPermission, setSelectedPermission] = useState('read');
+ const [singleUse, setSingleUse] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [generatedLink, setGeneratedLink] = useState(null);
+ const [activeLinks, setActiveLinks] = useState([]);
+ const [copied, setCopied] = useState(false);
+
+ // Fetch active links
+ const fetchActiveLinks = useCallback(async () => {
+ try {
+ const response = await fetch(`/api/invoices/share-links?invoiceId=${invoiceId}`);
+ if (response.ok) {
+ const data = await response.json();
+ setActiveLinks(data.links || []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch share links:', err);
+ }
+ }, [invoiceId]);
+
+ useEffect(() => {
+ if (open) {
+ fetchActiveLinks();
+ }
+ }, [open, fetchActiveLinks]);
+
+ const handleGenerateLink = async () => {
+ setLoading(true);
+ setError(null);
+ setGeneratedLink(null);
+
+ try {
+ const response = await fetch('/api/invoices/share-links', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ invoiceId,
+ permissions: selectedPermission,
+ durationMs: selectedDuration,
+ maxUses: singleUse,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Failed to generate share link');
+ }
+
+ const data = await response.json();
+ const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
+ const fullLink = `${baseUrl}/share/${data.token}`;
+ setGeneratedLink(fullLink);
+
+ // Refresh active links
+ await fetchActiveLinks();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to generate link');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCopyLink = () => {
+ if (generatedLink) {
+ navigator.clipboard.writeText(generatedLink).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ }
+ };
+
+ const handleRevokeLink = async (tokenHash: string) => {
+ try {
+ const response = await fetch('/api/invoices/share-links', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tokenHash }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to revoke link');
+ }
+
+ // Refresh active links
+ await fetchActiveLinks();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to revoke link');
+ }
+ };
+
+ if (!open) return null;
+
+ return (
+
+
+
+
+ {/* Header */}
+
+
+ Share Invoice
+
+
+
+
+ {/* Generate Link Section */}
+
+
Generate New Link
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Duration */}
+
+
+
+
+
+ {/* Permission */}
+
+
+
+ {(['read', 'comment', 'read-only'] as const).map((perm) => (
+
+ ))}
+
+
+
+ {/* Single Use */}
+
+
+ {/* Generated Link */}
+ {generatedLink && (
+
+
Link Generated!
+
+
+
+
+
+ )}
+
+ {/* Generate Button */}
+
+
+
+ {/* Active Links */}
+ {activeLinks.length > 0 && (
+
+
Active Links
+
+ {activeLinks.map((link) => {
+ const expiresAt = new Date(link.expiresAt);
+ const isExpiringSoon = expiresAt.getTime() - Date.now() < 3600000; // < 1 hour
+ return (
+
+
+
+
+ Permission: {link.permissions}
+
+
+ Expires: {expiresAt.toLocaleString()}
+
+ {link.maxUses && (
+
+ Uses: {link.usesConsumed}/{link.maxUses}
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Footer */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/useInvoiceCostEstimate.ts b/src/hooks/useInvoiceCostEstimate.ts
new file mode 100644
index 0000000..c907884
--- /dev/null
+++ b/src/hooks/useInvoiceCostEstimate.ts
@@ -0,0 +1,111 @@
+'use client';
+
+import { useCallback, useMemo, useState, useEffect } from 'react';
+import {
+ calculatePlatformFee,
+ calculateReserveTopUp,
+ getAddOnCosts,
+ type FeatureAddOn,
+} from '@/lib/feeConfig';
+
+export interface CostBreakdown {
+ networkFeeXlm: number;
+ reserveTopUpXlm: number;
+ platformFeeXlm: number;
+ addOnCostsXlm: number;
+ totalXlm: number;
+}
+
+interface UseInvoiceCostEstimateParams {
+ invoiceAmountXlm: number;
+ recipientAddresses: string[];
+ creatorAddress: string;
+ enabledAddOns?: FeatureAddOn[];
+ feeTierMultiplier?: number;
+}
+
+const BASE_NETWORK_FEE_XLM = 0.00001; // 100 stroops
+
+export function useInvoiceCostEstimate({
+ invoiceAmountXlm,
+ recipientAddresses,
+ creatorAddress,
+ enabledAddOns = [],
+ feeTierMultiplier = 1,
+}: UseInvoiceCostEstimateParams) {
+ const [existingTrustlines, setExistingTrustlines] = useState>(new Set());
+ const [creatorBalance, setCreatorBalance] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchAccountData = async () => {
+ if (!creatorAddress || !recipientAddresses.length) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const { Horizon } = await import('@stellar/stellar-sdk');
+ const horizonUrl = process.env.NEXT_PUBLIC_HORIZON_URL || 'https://horizon-testnet.stellar.org';
+ const server = new Horizon.Server(horizonUrl);
+
+ // Fetch creator account for balance and existing trustlines
+ const creatorAccount = await server.loadAccount(creatorAddress);
+ const balances = creatorAccount.balances;
+ const nativeBalance = balances.find((b: any) => b.asset_type === 'native');
+ setCreatorBalance(parseFloat(nativeBalance?.balance || '0'));
+
+ const trustlines = new Set();
+ balances.forEach((b: any) => {
+ if (b.asset_type !== 'native') {
+ trustlines.add(`${b.asset_code}:${b.asset_issuer}`);
+ }
+ });
+ setExistingTrustlines(trustlines);
+ } catch (err) {
+ console.error('Failed to fetch account data:', err);
+ setError(err instanceof Error ? err.message : 'Failed to load account data');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchAccountData();
+ }, [creatorAddress, recipientAddresses]);
+
+ const breakdown = useMemo((): CostBreakdown => {
+ const networkFeeXlm = BASE_NETWORK_FEE_XLM * feeTierMultiplier;
+ const platformFeeXlm = calculatePlatformFee(invoiceAmountXlm);
+
+ // Estimate new trustlines needed (simplified: assume each recipient might need one)
+ // In reality, this would depend on assets being transferred
+ const estimatedNewTrustlines = Math.max(0, recipientAddresses.length - 1);
+ const reserveTopUpXlm = calculateReserveTopUp(estimatedNewTrustlines);
+
+ const addOnCostsXlm = getAddOnCosts(enabledAddOns);
+
+ const totalXlm = networkFeeXlm + platformFeeXlm + reserveTopUpXlm + addOnCostsXlm;
+
+ return {
+ networkFeeXlm,
+ reserveTopUpXlm,
+ platformFeeXlm,
+ addOnCostsXlm,
+ totalXlm,
+ };
+ }, [invoiceAmountXlm, recipientAddresses.length, feeTierMultiplier, enabledAddOns]);
+
+ const isBalanceSufficient = useMemo(() => {
+ if (creatorBalance === null) return null;
+ return creatorBalance >= invoiceAmountXlm + breakdown.totalXlm;
+ }, [creatorBalance, invoiceAmountXlm, breakdown.totalXlm]);
+
+ return {
+ breakdown,
+ isBalanceSufficient,
+ creatorBalance,
+ loading,
+ error,
+ };
+}
diff --git a/src/lib/feeConfig.ts b/src/lib/feeConfig.ts
new file mode 100644
index 0000000..bb14220
--- /dev/null
+++ b/src/lib/feeConfig.ts
@@ -0,0 +1,52 @@
+/**
+ * Platform fee configuration for invoice creation and management.
+ * These values can be updated to adjust pricing over time.
+ */
+
+export const PLATFORM_FEE_CONFIG = {
+ invoiceCreation: {
+ percentage: 0.01, // 1% of invoice amount
+ minimumXlm: 0.1, // Minimum 0.1 XLM
+ },
+ trustlineReserve: {
+ costXlm: 0.5, // Each trustline costs 0.5 XLM in reserve
+ },
+ networkBaseFee: 100, // stroops (0.00001 XLM), Stellar base fee
+} as const;
+
+export const FEATURE_ADD_ON_COSTS = {
+ installmentPlan: {
+ costXlm: 0.5,
+ description: "Installment payment plan",
+ },
+ customBranding: {
+ costXlm: 1.0,
+ description: "Custom branding and styling",
+ },
+} as const;
+
+export type FeatureAddOn = keyof typeof FEATURE_ADD_ON_COSTS;
+
+/**
+ * Calculate minimum platform fee based on amount
+ */
+export function calculatePlatformFee(invoiceAmountXlm: number): number {
+ const percentage = invoiceAmountXlm * PLATFORM_FEE_CONFIG.invoiceCreation.percentage;
+ return Math.max(percentage, PLATFORM_FEE_CONFIG.invoiceCreation.minimumXlm);
+}
+
+/**
+ * Calculate reserve top-up based on number of new trustlines
+ */
+export function calculateReserveTopUp(newTrustlineCount: number): number {
+ return newTrustlineCount * PLATFORM_FEE_CONFIG.trustlineReserve.costXlm;
+}
+
+/**
+ * Get cost for enabled add-ons
+ */
+export function getAddOnCosts(enabledAddOns: FeatureAddOn[]): number {
+ return enabledAddOns.reduce((total, addon) => {
+ return total + FEATURE_ADD_ON_COSTS[addon].costXlm;
+ }, 0);
+}
diff --git a/src/lib/nlQueryParser.ts b/src/lib/nlQueryParser.ts
new file mode 100644
index 0000000..e553ce7
--- /dev/null
+++ b/src/lib/nlQueryParser.ts
@@ -0,0 +1,316 @@
+/**
+ * Natural language query parser for invoice search.
+ * Parses freeform queries into structured filter parameters.
+ */
+
+export interface ParsedFilter {
+ amountGte?: number;
+ amountLte?: number;
+ asset?: string;
+ status?: 'pending' | 'paid' | 'overdue' | 'draft';
+ dueDateFrom?: Date;
+ dueDateTo?: Date;
+ recipientKeyword?: string;
+ titleKeyword?: string;
+}
+
+export interface FilterToken {
+ type:
+ | 'amount_range'
+ | 'amount_min'
+ | 'amount_max'
+ | 'status'
+ | 'date_range'
+ | 'date_min'
+ | 'date_max'
+ | 'asset'
+ | 'keyword';
+ label: string;
+ value: string;
+}
+
+/**
+ * Parse relative date strings into dates
+ */
+function parseRelativeDate(
+ dateStr: string
+): { from: Date; to: Date } | null {
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const yesterday = new Date(today);
+ yesterday.setDate(yesterday.getDate() - 1);
+ const tomorrow = new Date(today);
+ tomorrow.setDate(tomorrow.getDate() + 1);
+
+ const normalized = dateStr.toLowerCase().trim();
+
+ // Yesterday
+ if (normalized === 'yesterday') {
+ const end = new Date(yesterday);
+ end.setHours(23, 59, 59, 999);
+ return { from: yesterday, to: end };
+ }
+
+ // Today
+ if (normalized === 'today') {
+ const end = new Date(today);
+ end.setHours(23, 59, 59, 999);
+ return { from: today, to: end };
+ }
+
+ // Tomorrow
+ if (normalized === 'tomorrow') {
+ const end = new Date(tomorrow);
+ end.setHours(23, 59, 59, 999);
+ return { from: tomorrow, to: end };
+ }
+
+ // This week (Monday to Sunday)
+ if (normalized === 'this week') {
+ const from = new Date(today);
+ const dayOfWeek = from.getDay();
+ from.setDate(from.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
+ const to = new Date(from);
+ to.setDate(to.getDate() + 6);
+ to.setHours(23, 59, 59, 999);
+ return { from, to };
+ }
+
+ // Last week
+ if (normalized === 'last week') {
+ const from = new Date(today);
+ const dayOfWeek = from.getDay();
+ from.setDate(from.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1) - 7);
+ const to = new Date(from);
+ to.setDate(to.getDate() + 6);
+ to.setHours(23, 59, 59, 999);
+ return { from, to };
+ }
+
+ // This month
+ if (normalized === 'this month') {
+ const from = new Date(today.getFullYear(), today.getMonth(), 1);
+ const to = new Date(today.getFullYear(), today.getMonth() + 1, 0);
+ to.setHours(23, 59, 59, 999);
+ return { from, to };
+ }
+
+ // Last month
+ if (normalized === 'last month') {
+ const from = new Date(today.getFullYear(), today.getMonth() - 1, 1);
+ const to = new Date(today.getFullYear(), today.getMonth(), 0);
+ to.setHours(23, 59, 59, 999);
+ return { from, to };
+ }
+
+ // Past N days
+ const pastDaysMatch = normalized.match(/past\s+(\d+)\s+days?/);
+ if (pastDaysMatch) {
+ const days = parseInt(pastDaysMatch[1], 10);
+ const from = new Date(today);
+ from.setDate(from.getDate() - days);
+ const to = new Date(today);
+ to.setHours(23, 59, 59, 999);
+ return { from, to };
+ }
+
+ return null;
+}
+
+/**
+ * Parse amount from string like "100", "100 XLM", "100xlm"
+ */
+function parseAmount(str: string): number | null {
+ const match = str.match(/(\d+(?:\.\d+)?)/);
+ if (!match) return null;
+ return parseFloat(match[1]);
+}
+
+/**
+ * Parse status keywords
+ */
+function parseStatus(keyword: string): ParsedFilter['status'] | null {
+ const normalized = keyword.toLowerCase().trim();
+ const statusMap: Record = {
+ unpaid: 'pending',
+ pending: 'pending',
+ paid: 'paid',
+ overdue: 'overdue',
+ draft: 'draft',
+ cancelled: 'paid', // Map cancelled to paid for filtering
+ };
+ return statusMap[normalized] || null;
+}
+
+/**
+ * Parse asset name
+ */
+function parseAsset(keyword: string): string | null {
+ const normalized = keyword.toUpperCase().trim();
+ const validAssets = ['XLM', 'USDC', 'EURC', 'ETH', 'USDT'];
+ return validAssets.includes(normalized) ? normalized : null;
+}
+
+/**
+ * Main parser function
+ */
+export function parseNaturalLanguageQuery(
+ query: string
+): { filters: ParsedFilter; tokens: FilterToken[] } {
+ const tokens: FilterToken[] = [];
+ const filters: ParsedFilter = {};
+ let remainingQuery = query;
+
+ // Amount range: "between 200 and 400"
+ const betweenMatch = remainingQuery.match(
+ /between\s+(\d+(?:\.\d+)?)\s+(?:and|to)\s+(\d+(?:\.\d+)?)\s*(?:xlm)?/i
+ );
+ if (betweenMatch) {
+ const min = parseFloat(betweenMatch[1]);
+ const max = parseFloat(betweenMatch[2]);
+ filters.amountGte = Math.min(min, max);
+ filters.amountLte = Math.max(min, max);
+ tokens.push({
+ type: 'amount_range',
+ label: `Amount between ${min} and ${max} XLM`,
+ value: `${min}-${max}`,
+ });
+ remainingQuery = remainingQuery.replace(betweenMatch[0], '');
+ }
+
+ // Amount minimum: "over X", "more than X", "at least X"
+ const amountMinMatch = remainingQuery.match(
+ /(?:over|more\s+than|at\s+least)\s+(\d+(?:\.\d+)?)\s*(?:xlm)?/i
+ );
+ if (amountMinMatch) {
+ const amount = parseFloat(amountMinMatch[1]);
+ filters.amountGte = amount;
+ tokens.push({
+ type: 'amount_min',
+ label: `Amount ≥ ${amount} XLM`,
+ value: String(amount),
+ });
+ remainingQuery = remainingQuery.replace(amountMinMatch[0], '');
+ }
+
+ // Amount maximum: "under X", "less than X", "below X"
+ const amountMaxMatch = remainingQuery.match(
+ /(?:under|less\s+than|below)\s+(\d+(?:\.\d+)?)\s*(?:xlm)?/i
+ );
+ if (amountMaxMatch) {
+ const amount = parseFloat(amountMaxMatch[1]);
+ filters.amountLte = amount;
+ tokens.push({
+ type: 'amount_max',
+ label: `Amount ≤ ${amount} XLM`,
+ value: String(amount),
+ });
+ remainingQuery = remainingQuery.replace(amountMaxMatch[0], '');
+ }
+
+ // Status keywords
+ const statusKeywords = ['unpaid', 'pending', 'paid', 'overdue', 'draft', 'cancelled'];
+ for (const keyword of statusKeywords) {
+ const regex = new RegExp(`\\b${keyword}\\b`, 'i');
+ if (regex.test(remainingQuery)) {
+ const status = parseStatus(keyword);
+ if (status) {
+ filters.status = status;
+ tokens.push({
+ type: 'status',
+ label: `Status: ${status}`,
+ value: status,
+ });
+ }
+ remainingQuery = remainingQuery.replace(regex, '');
+ break;
+ }
+ }
+
+ // Relative date ranges
+ const datePatterns = [
+ 'past \\d+ days?',
+ 'last month',
+ 'last week',
+ 'this month',
+ 'this week',
+ 'today',
+ 'yesterday',
+ 'tomorrow',
+ ];
+ for (const pattern of datePatterns) {
+ const regex = new RegExp(pattern, 'i');
+ const match = remainingQuery.match(regex);
+ if (match) {
+ const dateRange = parseRelativeDate(match[0]);
+ if (dateRange) {
+ filters.dueDateFrom = dateRange.from;
+ filters.dueDateTo = dateRange.to;
+ tokens.push({
+ type: 'date_range',
+ label: `Date: ${match[0]}`,
+ value: match[0],
+ });
+ }
+ remainingQuery = remainingQuery.replace(regex, '');
+ break;
+ }
+ }
+
+ // Asset name: "XLM", "USDC", etc.
+ const assetMatch = remainingQuery.match(/\b(xlm|usdc|eurc|eth|usdt)\b/i);
+ if (assetMatch) {
+ const asset = parseAsset(assetMatch[1]);
+ if (asset) {
+ filters.asset = asset;
+ tokens.push({
+ type: 'asset',
+ label: `Asset: ${asset}`,
+ value: asset,
+ });
+ }
+ remainingQuery = remainingQuery.replace(assetMatch[0], '');
+ }
+
+ // Remaining text as keyword fallback
+ const keyword = remainingQuery.trim();
+ if (keyword.length > 0) {
+ filters.titleKeyword = keyword;
+ tokens.push({
+ type: 'keyword',
+ label: `Keyword: ${keyword}`,
+ value: keyword,
+ });
+ }
+
+ return { filters, tokens };
+}
+
+/**
+ * Apply parsed filters to invoice array
+ */
+export function applyParsedFilters }>(
+ items: T[],
+ filters: ParsedFilter
+): T[] {
+ return items.filter((item) => {
+ if (filters.amountGte !== undefined && (item.amount || 0) < filters.amountGte) {
+ return false;
+ }
+ if (filters.amountLte !== undefined && (item.amount || 0) > filters.amountLte) {
+ return false;
+ }
+ if (filters.status && item.status !== filters.status) {
+ return false;
+ }
+ if (filters.titleKeyword) {
+ const title = (item.title || '').toLowerCase();
+ const recipientAddrs = (item.recipients || []).map((r) => r.address.toLowerCase()).join(' ');
+ const searchText = title + ' ' + recipientAddrs;
+ if (!searchText.includes(filters.titleKeyword.toLowerCase())) {
+ return false;
+ }
+ }
+ return true;
+ });
+}
diff --git a/src/lib/shareLink.ts b/src/lib/shareLink.ts
new file mode 100644
index 0000000..dadd5ca
--- /dev/null
+++ b/src/lib/shareLink.ts
@@ -0,0 +1,154 @@
+import crypto from 'crypto';
+
+export type ShareLinkPermission = 'read' | 'comment' | 'read-only';
+
+export interface ShareLink {
+ tokenHash: string;
+ invoiceId: string;
+ permissions: ShareLinkPermission;
+ expiresAt: Date;
+ maxUses?: number;
+ usesConsumed: number;
+ createdAt: Date;
+ revokedAt?: Date;
+ token?: string; // Only returned when creating
+}
+
+/**
+ * In-memory store for share links.
+ * In production, this should be replaced with a database.
+ */
+const shareLinkStore = new Map();
+
+/**
+ * Generate a cryptographically secure random token
+ */
+export function generateShareToken(): string {
+ return crypto.randomBytes(32).toString('hex');
+}
+
+/**
+ * Hash a token using SHA-256 (for storage)
+ */
+export function hashToken(token: string): string {
+ return crypto.createHash('sha256').update(token).digest('hex');
+}
+
+/**
+ * Timing-safe token comparison
+ */
+export function timingSafeCompareToken(provided: string, stored: string): boolean {
+ return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(stored));
+}
+
+/**
+ * Create a new share link
+ */
+export function createShareLink(
+ invoiceId: string,
+ permissions: ShareLinkPermission = 'read',
+ durationMs: number = 3600000, // 1 hour default
+ maxUses?: number
+): ShareLink {
+ const token = generateShareToken();
+ const tokenHash = hashToken(token);
+ const now = new Date();
+ const expiresAt = new Date(now.getTime() + durationMs);
+
+ const shareLink: ShareLink = {
+ tokenHash,
+ invoiceId,
+ permissions,
+ expiresAt,
+ maxUses,
+ usesConsumed: 0,
+ createdAt: now,
+ token, // Return the unhashed token once
+ };
+
+ shareLinkStore.set(tokenHash, shareLink);
+ return shareLink;
+}
+
+/**
+ * Validate and retrieve a share link by token
+ */
+export function validateShareLink(
+ token: string
+): { valid: false; reason: string } | { valid: true; link: ShareLink; permission: ShareLinkPermission } {
+ const tokenHash = hashToken(token);
+ const link = shareLinkStore.get(tokenHash);
+
+ if (!link) {
+ return { valid: false, reason: 'Link not found or revoked' };
+ }
+
+ if (link.revokedAt) {
+ return { valid: false, reason: 'Link has been revoked' };
+ }
+
+ if (new Date() > link.expiresAt) {
+ return { valid: false, reason: 'Link has expired' };
+ }
+
+ if (link.maxUses && link.usesConsumed >= link.maxUses) {
+ return { valid: false, reason: 'Link has reached maximum uses' };
+ }
+
+ return { valid: true, link, permission: link.permissions };
+}
+
+/**
+ * Increment uses for a share link
+ */
+export function incrementShareLinkUse(tokenHash: string): void {
+ const link = shareLinkStore.get(tokenHash);
+ if (link) {
+ link.usesConsumed += 1;
+ }
+}
+
+/**
+ * Revoke a share link
+ */
+export function revokeShareLink(tokenHash: string): boolean {
+ const link = shareLinkStore.get(tokenHash);
+ if (link) {
+ link.revokedAt = new Date();
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Get all active share links for an invoice
+ */
+export function getShareLinksForInvoice(invoiceId: string): ShareLink[] {
+ return Array.from(shareLinkStore.values()).filter((link) => {
+ return (
+ link.invoiceId === invoiceId &&
+ !link.revokedAt &&
+ new Date() <= link.expiresAt
+ );
+ });
+}
+
+/**
+ * Format duration for display
+ */
+export function formatShareLinkDuration(ms: number): string {
+ const hours = ms / 3600000;
+ const days = hours / 24;
+
+ if (hours < 1) {
+ const minutes = Math.round(ms / 60000);
+ return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
+ }
+ if (hours < 24) {
+ const h = Math.round(hours);
+ return `${h} hour${h !== 1 ? 's' : ''}`;
+ }
+
+ const d = Math.round(days);
+ return `${d} day${d !== 1 ? 's' : ''}`;
+}
diff --git a/src/lib/stellarErrorParser.ts b/src/lib/stellarErrorParser.ts
new file mode 100644
index 0000000..3615c60
--- /dev/null
+++ b/src/lib/stellarErrorParser.ts
@@ -0,0 +1,100 @@
+export type ErrorActionType = 'bump-fee' | 'refresh-seq' | 'fund-destination' | 'manual';
+
+export interface ParsedStellarError {
+ code: string;
+ title: string;
+ explanation: string;
+ suggestedAction: string;
+ actionType: ErrorActionType;
+}
+
+const ERROR_MAPPING: Record = {
+ tx_insufficient_fee: {
+ code: 'tx_insufficient_fee',
+ title: 'Insufficient Transaction Fee',
+ explanation: 'The transaction fee is too low for current network conditions. Network fees fluctuate based on demand.',
+ suggestedAction: 'Increase the transaction fee using the fee bump slider. Check the median network fee and increase yours accordingly.',
+ actionType: 'bump-fee',
+ },
+ tx_bad_seq: {
+ code: 'tx_bad_seq',
+ title: 'Invalid Sequence Number',
+ explanation: 'The transaction sequence number is outdated or invalid. This can happen if another transaction from this account was recently submitted.',
+ suggestedAction: 'Refresh the sequence number from the blockchain and rebuild the transaction.',
+ actionType: 'refresh-seq',
+ },
+ op_no_destination: {
+ code: 'op_no_destination',
+ title: 'Destination Account Not Found',
+ explanation: 'The recipient account does not exist on the Stellar network. New accounts must be funded first.',
+ suggestedAction: 'Fund the destination account with at least 1 XLM (or use Friendbot on testnet), then retry this payment.',
+ actionType: 'fund-destination',
+ },
+ op_underfunded: {
+ code: 'op_underfunded',
+ title: 'Insufficient Balance',
+ explanation: 'The source account does not have enough balance to cover the payment and transaction fees.',
+ suggestedAction: 'Add more funds to your account and try again.',
+ actionType: 'manual',
+ },
+ tx_failed: {
+ code: 'tx_failed',
+ title: 'Transaction Failed',
+ explanation: 'An operation in the transaction failed. This could be due to various reasons including trustline issues.',
+ suggestedAction: 'Check the detailed error message and verify all account requirements are met.',
+ actionType: 'manual',
+ },
+ op_no_trust: {
+ code: 'op_no_trust',
+ title: 'Missing Trustline',
+ explanation: 'The account does not have a trustline for the asset being transferred.',
+ suggestedAction: 'Establish a trustline for this asset first, then retry.',
+ actionType: 'manual',
+ },
+};
+
+export function parseStellarError(errorCode: string, rawError?: any): ParsedStellarError {
+ const normalizedCode = errorCode.toLowerCase().trim();
+
+ // Check direct mapping
+ if (ERROR_MAPPING[normalizedCode]) {
+ return ERROR_MAPPING[normalizedCode];
+ }
+
+ // Try to extract result_code from Horizon error response
+ const resultCode = rawError?.response?.data?.result_code || rawError?.result_code;
+ if (resultCode && ERROR_MAPPING[resultCode]) {
+ return ERROR_MAPPING[resultCode];
+ }
+
+ // Check for partial matches (e.g., "tx_insufficient_fee" in larger error string)
+ for (const [key, value] of Object.entries(ERROR_MAPPING)) {
+ if (normalizedCode.includes(key) || (typeof rawError === 'string' && rawError.includes(key))) {
+ return value;
+ }
+ }
+
+ // Fallback for unknown errors
+ return {
+ code: 'unknown_error',
+ title: 'Transaction Error',
+ explanation: `The transaction failed with error: ${errorCode}`,
+ suggestedAction: 'Review the error details and contact support if the issue persists.',
+ actionType: 'manual',
+ };
+}
+
+export function isBumpFeeError(error: any): boolean {
+ const parsed = parseStellarError(error?.message || error?.code || String(error), error);
+ return parsed.actionType === 'bump-fee';
+}
+
+export function isSequenceError(error: any): boolean {
+ const parsed = parseStellarError(error?.message || error?.code || String(error), error);
+ return parsed.actionType === 'refresh-seq';
+}
+
+export function isFundingError(error: any): boolean {
+ const parsed = parseStellarError(error?.message || error?.code || String(error), error);
+ return parsed.actionType === 'fund-destination';
+}