1+ /**
2+ * GET /api/remittance/quote
3+ *
4+ * Query Parameters:
5+ * ┌─────────────┬──────────┬──────────────────────────────────────────────────────┐
6+ * │ Param │ Required │ Description │
7+ * ├─────────────┼──────────┼──────────────────────────────────────────────────────┤
8+ * │ amount │ Yes │ Amount to send (positive number, up to 2 decimals) │
9+ * │ currency │ Yes │ Source currency ISO code (e.g. "USD") │
10+ * │ toCurrency │ Yes │ Destination currency ISO code (e.g. "PHP") │
11+ * └─────────────┴──────────┴──────────────────────────────────────────────────────┘
12+ *
13+ * Success Response Shape { sendAmount, receiveAmount, fee, rate, expiry }:
14+ * ┌───────────────┬─────────┬───────────────────────────────────────────────────────┐
15+ * │ Field │ Type │ Description │
16+ * ├───────────────┼─────────┼───────────────────────────────────────────────────────┤
17+ * │ sendAmount │ number │ Amount the sender pays (equals `amount` param) │
18+ * │ receiveAmount │ number │ Amount recipient gets after fee + exchange conversion │
19+ * │ fee │ number │ Fee charged in source currency │
20+ * │ rate │ number │ Exchange rate: 1 unit of `currency` → `toCurrency` │
21+ * │ expiry │ string │ ISO-8601 datetime — quote valid until this timestamp │
22+ * └───────────────┴─────────┴───────────────────────────────────────────────────────┘
23+ *
24+ * Integration Strategy:
25+ * If ANCHOR_API_BASE_URL is set → call anchor's GET /quote (SEP-38 compatible)
26+ *
27+ * Caching: quotes are cached in-memory for QUOTE_TTL_SECONDS (default 60 s).
28+ * Cache key = `${amount}:${currency}:${toCurrency}` (lower-cased).
29+ */
30+
31+ import { NextResponse } from "next/server" ;
32+ import { z } from "zod" ;
33+ import { validatedRoute } from "@/lib/auth/middleware" ;
34+
35+
36+ /**
37+ * Coerce query-string strings into the right types.
38+ * `amount` arrives as a string from the URL — coerce to number first.
39+ */
40+ const quoteSchema = z . object ( {
41+ amount : z . coerce
42+ . number ( )
43+ . gt ( 0 , "amount must be greater than 0" ) ,
44+ currency : z
45+ . string ( )
46+ . length ( 3 , "currency must be a 3-letter ISO code" )
47+ . toUpperCase ( ) ,
48+
49+ toCurrency : z
50+ . string ( )
51+ . length ( 3 , "toCurrency must be a 3-letter ISO code" )
52+ . toUpperCase ( ) ,
53+ } ) ;
54+
55+ type QuoteInput = z . infer < typeof quoteSchema > ;
56+ type Response = {
57+ sendAmount : number ;
58+ receiveAmount : number ;
59+ fee : number ;
60+ rate : number ;
61+ expiry : string ;
62+ source : "anchor" | "stellar" ;
63+ }
64+
65+ type QuoteResponse = Response | { message : string }
66+
67+ // ---------------------------------------------------------------------------
68+ // Simple in-memory cache (survives across requests in the same server process)
69+ // ---------------------------------------------------------------------------
70+
71+ const QUOTE_TTL_MS = ( Number ( process . env . QUOTE_TTL_SECONDS ) || 60 ) * 1_000 ;
72+
73+ interface CacheEntry {
74+ data : QuoteResponse ;
75+ expiresAt : number ; // epoch ms
76+ }
77+
78+ const quoteCache = new Map < string , CacheEntry > ( ) ;
79+
80+ function getCached ( key : string ) : QuoteResponse | null {
81+ const entry = quoteCache . get ( key ) ;
82+ if ( ! entry ) return null ;
83+ if ( Date . now ( ) > entry . expiresAt ) {
84+ quoteCache . delete ( key ) ;
85+ return null ;
86+ }
87+ return entry . data ;
88+ }
89+
90+ function setCache ( key : string , data : QuoteResponse ) : void {
91+ quoteCache . set ( key , { data, expiresAt : Date . now ( ) + QUOTE_TTL_MS } ) ;
92+ }
93+
94+ // ---------------------------------------------------------------------------
95+ // Integration helpers
96+ // ---------------------------------------------------------------------------
97+
98+ /** Call an SEP-38-compatible anchor /quote endpoint. */
99+ async function fetchAnchorQuote ( input : QuoteInput ) : Promise < QuoteResponse > {
100+ const base = process . env . ANCHOR_API_BASE_URL ! . replace ( / \/ $ / , "" ) ;
101+ const url = new URL ( `${ base } /quote` ) ;
102+ url . searchParams . set ( "sell_asset" , `iso4217:${ input . currency } ` ) ;
103+ url . searchParams . set ( "sell_amount" , String ( input . amount ) ) ;
104+ url . searchParams . set ( "buy_asset" , `iso4217:${ input . toCurrency } ` ) ;
105+ url . searchParams . set ( "type" , `firm` ) ; // type can be indicative or firm, see https://developers.stellar.org/docs/platforms/anchor-platform/api-reference/callbacks/get-rates
106+
107+ const res = await fetch ( url . toString ( ) , {
108+ headers : { "Content-Type" : "application/json" } ,
109+ // next.js fetch — don't cache at the fetch layer; we cache ourselves
110+ cache : "no-store" ,
111+ } ) ;
112+
113+ if ( ! res . ok ) {
114+ const body = await res . text ( ) . catch ( ( ) => "" ) ;
115+ throw new Error ( `Anchor quote failed (${ res . status } ): ${ body } ` ) ;
116+ }
117+
118+ /**
119+ * SEP-38 response shape (simplified):
120+ * { id, expires_at, price, sell_asset, sell_amount, buy_asset, buy_amount, fee: { total, asset } }
121+ */
122+ const json = await res . json ( ) ;
123+
124+ const rate = Number ( json . price ) ; // buy units per 1 sell unit
125+ const fee = Number ( json . fee ?. total ?? 0 ) ;
126+ const sendAmount = Number ( json . sell_amount ?? input . amount ) ;
127+ const receiveAmount = Number ( json . buy_amount ?? ( sendAmount - fee ) * rate ) ;
128+
129+ return {
130+ sendAmount,
131+ receiveAmount : round2 ( receiveAmount ) ,
132+ fee : round2 ( fee ) ,
133+ rate,
134+ expiry : json . expires_at ?? ttlIso ( ) ,
135+ source : "anchor" ,
136+ } ;
137+ }
138+
139+
140+
141+
142+ async function resolveQuote ( input : QuoteInput ) : Promise < QuoteResponse > {
143+ if ( process . env . ANCHOR_API_BASE_URL ) {
144+ return fetchAnchorQuote ( input ) ;
145+ }
146+
147+ return { message : "Unable to resolve quote" }
148+ }
149+
150+
151+
152+ export const GET = validatedRoute (
153+ quoteSchema ,
154+ "query" ,
155+ async ( req , data : QuoteInput ) => {
156+ const cacheKey = `${ data . amount } :${ data . currency } :${ data . toCurrency } ` . toLowerCase ( ) ;
157+
158+ // Return cached quote if still fresh
159+ const cached = getCached ( cacheKey ) ;
160+ if ( cached ) {
161+ return NextResponse . json ( cached , {
162+ headers : { "X-Cache" : "HIT" , "Cache-Control" : `max-age=${ QUOTE_TTL_MS / 1000 } ` } ,
163+ } ) ;
164+ }
165+
166+ try {
167+ const quote = await resolveQuote ( data ) ;
168+ setCache ( cacheKey , quote ) ;
169+
170+ return NextResponse . json ( quote , {
171+ headers : { "X-Cache" : "MISS" , "Cache-Control" : `max-age=${ QUOTE_TTL_MS / 1000 } ` } ,
172+ } ) ;
173+ } catch ( err ) {
174+ const message = err instanceof Error ? err . message : "Failed to fetch quote" ;
175+ console . error ( "[quote] upstream error:" , err ) ;
176+ return NextResponse . json ( { error : message } , { status : 502 } ) ;
177+ }
178+ }
179+ ) ;
180+
181+
182+ function round2 ( n : number ) : number {
183+ return Math . round ( n * 100 ) / 100 ;
184+ }
185+
186+ /** Returns an ISO-8601 string QUOTE_TTL_MS from now. */
187+ function ttlIso ( ) : string {
188+ return new Date ( Date . now ( ) + QUOTE_TTL_MS ) . toISOString ( ) ;
189+ }
0 commit comments