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 */} +
+ + +
+ + {/* 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'; +}