@@ -2,7 +2,7 @@ use crate::services::allbridge::{
22 AllbridgeClient , AllbridgeQuoteRequest , BridgeStatusKind , BridgeTransferStatus ,
33} ;
44use axum:: {
5- extract:: { Path , State } ,
5+ extract:: { Multipart , Path , State } ,
66 http:: StatusCode ,
77 response:: IntoResponse ,
88 Json ,
@@ -344,3 +344,332 @@ pub async fn run_status_poller(state: BridgeState) {
344344 }
345345 }
346346}
347+
348+ // ── #553 Batch Payout Upload ──────────────────────────────────────────────────
349+
350+ /// A single disbursement record accepted in both JSON-array and CSV upload modes.
351+ #[ derive( Debug , Deserialize , Serialize , Clone ) ]
352+ pub struct PayoutRecord {
353+ /// Destination Stellar address or registered username.
354+ pub destination : String ,
355+ /// Amount in stroops (1 XLM = 10_000_000 stroops) or as a decimal string.
356+ pub amount : String ,
357+ /// Optional human-readable note attached to the payment.
358+ #[ serde( default ) ]
359+ pub memo : Option < String > ,
360+ }
361+
362+ #[ derive( Debug , Deserialize ) ]
363+ pub struct BatchJsonPayload {
364+ pub payouts : Vec < PayoutRecord > ,
365+ }
366+
367+ #[ derive( Serialize ) ]
368+ pub struct BatchUploadResponse {
369+ pub accepted : usize ,
370+ pub rejected : usize ,
371+ pub errors : Vec < String > ,
372+ pub batch_id : Option < String > ,
373+ }
374+
375+ /// Validate a single payout record, returning an error description if invalid.
376+ fn validate_record ( idx : usize , record : & PayoutRecord ) -> Option < String > {
377+ if record. destination . trim ( ) . is_empty ( ) {
378+ return Some ( format ! ( "row {}: destination is required" , idx + 1 ) ) ;
379+ }
380+ let amount_str = record. amount . trim ( ) . replace ( ',' , "" ) ;
381+ match amount_str. parse :: < f64 > ( ) {
382+ Ok ( v) if v <= 0.0 => Some ( format ! ( "row {}: amount must be positive" , idx + 1 ) ) ,
383+ Err ( _) => Some ( format ! (
384+ "row {}: amount '{}' is not a valid number" ,
385+ idx + 1 ,
386+ record. amount
387+ ) ) ,
388+ Ok ( _) => None ,
389+ }
390+ }
391+
392+ /// Parse a CSV byte slice into a list of payout records.
393+ /// Expected CSV columns (header row required): destination, amount, memo (optional).
394+ fn parse_csv ( data : & [ u8 ] ) -> Result < Vec < PayoutRecord > , String > {
395+ let text = std:: str:: from_utf8 ( data) . map_err ( |_| "CSV is not valid UTF-8" . to_string ( ) ) ?;
396+ let mut lines = text. lines ( ) ;
397+
398+ // Parse header row
399+ let header_line = lines
400+ . next ( )
401+ . ok_or_else ( || "CSV file is empty" . to_string ( ) ) ?;
402+ let headers: Vec < & str > = header_line. split ( ',' ) . map ( |h| h. trim ( ) ) . collect ( ) ;
403+
404+ let dest_col = headers
405+ . iter ( )
406+ . position ( |h| h. to_lowercase ( ) == "destination" )
407+ . ok_or_else ( || "CSV missing required column: destination" . to_string ( ) ) ?;
408+ let amount_col = headers
409+ . iter ( )
410+ . position ( |h| h. to_lowercase ( ) == "amount" )
411+ . ok_or_else ( || "CSV missing required column: amount" . to_string ( ) ) ?;
412+ let memo_col = headers
413+ . iter ( )
414+ . position ( |h| h. to_lowercase ( ) == "memo" ) ;
415+
416+ let mut records = Vec :: new ( ) ;
417+ for line in lines {
418+ let line = line. trim ( ) ;
419+ if line. is_empty ( ) {
420+ continue ;
421+ }
422+ let cols: Vec < & str > = line. splitn ( headers. len ( ) , ',' ) . collect ( ) ;
423+ let destination = cols. get ( dest_col) . copied ( ) . unwrap_or ( "" ) . trim ( ) . to_string ( ) ;
424+ let amount = cols. get ( amount_col) . copied ( ) . unwrap_or ( "" ) . trim ( ) . to_string ( ) ;
425+ let memo = memo_col
426+ . and_then ( |i| cols. get ( i) . copied ( ) )
427+ . map ( |s| s. trim ( ) . to_string ( ) )
428+ . filter ( |s| !s. is_empty ( ) ) ;
429+ records. push ( PayoutRecord {
430+ destination,
431+ amount,
432+ memo,
433+ } ) ;
434+ }
435+
436+ Ok ( records)
437+ }
438+
439+ /// Validate all records and split them into accepted/rejected sets.
440+ fn split_valid ( records : Vec < PayoutRecord > ) -> ( Vec < PayoutRecord > , Vec < String > ) {
441+ let mut accepted = Vec :: new ( ) ;
442+ let mut errors = Vec :: new ( ) ;
443+ for ( i, record) in records. into_iter ( ) . enumerate ( ) {
444+ if let Some ( err) = validate_record ( i, & record) {
445+ errors. push ( err) ;
446+ } else {
447+ accepted. push ( record) ;
448+ }
449+ }
450+ ( accepted, errors)
451+ }
452+
453+ /// Persist accepted payouts as a new batch and return the generated batch ID.
454+ async fn persist_batch (
455+ pool : & sqlx:: PgPool ,
456+ records : & [ PayoutRecord ] ,
457+ ) -> Result < String , sqlx:: Error > {
458+ let total_amount: f64 = records
459+ . iter ( )
460+ . map ( |r| r. amount . trim ( ) . replace ( ',' , "" ) . parse :: < f64 > ( ) . unwrap_or ( 0.0 ) )
461+ . sum ( ) ;
462+ let total_amount_i64 = ( total_amount * 1_000_000.0 ) . round ( ) as i64 ;
463+
464+ let batch_row = sqlx:: query (
465+ r#"
466+ INSERT INTO payout_batches
467+ (currency, total_recipients, total_amount, status)
468+ VALUES ('XLM', $1, $2, 'PENDING')
469+ RETURNING id
470+ "# ,
471+ )
472+ . bind ( records. len ( ) as i32 )
473+ . bind ( total_amount_i64)
474+ . fetch_one ( pool)
475+ . await ?;
476+
477+ let batch_id: uuid:: Uuid = batch_row. get ( "id" ) ;
478+
479+ for record in records {
480+ let amount_i64 =
481+ ( record. amount . trim ( ) . replace ( ',' , "" ) . parse :: < f64 > ( ) . unwrap_or ( 0.0 ) * 1_000_000.0 )
482+ . round ( ) as i64 ;
483+ sqlx:: query (
484+ r#"
485+ INSERT INTO batch_recipients
486+ (batch_id, destination_address, amount, status)
487+ VALUES ($1, $2, $3, 'PENDING')
488+ "# ,
489+ )
490+ . bind ( & batch_id)
491+ . bind ( & record. destination )
492+ . bind ( amount_i64)
493+ . execute ( pool)
494+ . await ?;
495+ }
496+
497+ Ok ( batch_id. to_string ( ) )
498+ }
499+
500+ /// POST `/api/payouts/batch-upload`
501+ ///
502+ /// Accepts disbursement parameters via:
503+ /// - **JSON body**: `{ "payouts": [{ "destination": "...", "amount": "...", "memo": "..." }] }`
504+ /// - **Multipart form**: field named `file` containing a CSV with columns `destination,amount,memo`
505+ ///
506+ /// Validates every record. Rejects the entire batch if the format is wrong. If individual
507+ /// records are invalid, they are reported in the `errors` array while valid ones proceed.
508+ /// Returns 422 if the payload is empty after validation.
509+ pub async fn batch_upload (
510+ State ( state) : State < BridgeState > ,
511+ content_type : axum:: http:: HeaderMap ,
512+ body : axum:: body:: Bytes ,
513+ ) -> impl IntoResponse {
514+ // Determine payload format from Content-Type header.
515+ let ct = content_type
516+ . get ( axum:: http:: header:: CONTENT_TYPE )
517+ . and_then ( |v| v. to_str ( ) . ok ( ) )
518+ . unwrap_or ( "" ) ;
519+
520+ let raw_records: Result < Vec < PayoutRecord > , String > = if ct. contains ( "multipart/form-data" ) {
521+ // Re-build a Multipart from raw bytes is non-trivial without the extractor.
522+ // Instead, we handle this via the dedicated multipart handler below.
523+ // This branch should not be reached when using `batch_upload_multipart`.
524+ Err ( "Use the multipart endpoint for CSV uploads" . to_string ( ) )
525+ } else {
526+ // Assume JSON body
527+ match serde_json:: from_slice :: < BatchJsonPayload > ( & body) {
528+ Ok ( payload) => {
529+ if payload. payouts . is_empty ( ) {
530+ Err ( "payouts array must not be empty" . to_string ( ) )
531+ } else {
532+ Ok ( payload. payouts )
533+ }
534+ }
535+ Err ( e) => Err ( format ! ( "Invalid JSON payload: {}" , e) ) ,
536+ }
537+ } ;
538+
539+ let records = match raw_records {
540+ Ok ( r) => r,
541+ Err ( msg) => {
542+ return (
543+ StatusCode :: BAD_REQUEST ,
544+ Json ( serde_json:: json!( { "error" : msg } ) ) ,
545+ )
546+ . into_response ( ) ;
547+ }
548+ } ;
549+
550+ let ( accepted, errors) = split_valid ( records) ;
551+
552+ if accepted. is_empty ( ) {
553+ return (
554+ StatusCode :: UNPROCESSABLE_ENTITY ,
555+ Json ( BatchUploadResponse {
556+ accepted : 0 ,
557+ rejected : errors. len ( ) ,
558+ errors,
559+ batch_id : None ,
560+ } ) ,
561+ )
562+ . into_response ( ) ;
563+ }
564+
565+ match persist_batch ( & state. pool , & accepted) . await {
566+ Ok ( batch_id) => Json ( BatchUploadResponse {
567+ accepted : accepted. len ( ) ,
568+ rejected : errors. len ( ) ,
569+ errors,
570+ batch_id : Some ( batch_id) ,
571+ } )
572+ . into_response ( ) ,
573+ Err ( e) => {
574+ tracing:: error!( "Failed to persist batch upload: {:?}" , e) ;
575+ (
576+ StatusCode :: INTERNAL_SERVER_ERROR ,
577+ Json ( serde_json:: json!( { "error" : "Failed to store batch" } ) ) ,
578+ )
579+ . into_response ( )
580+ }
581+ }
582+ }
583+
584+ /// POST `/api/payouts/batch-upload/csv`
585+ ///
586+ /// Multipart form upload variant accepting a CSV file in a field named `file`.
587+ /// Columns required: `destination`, `amount`. Column `memo` is optional.
588+ pub async fn batch_upload_csv (
589+ State ( state) : State < BridgeState > ,
590+ mut multipart : Multipart ,
591+ ) -> impl IntoResponse {
592+ let mut csv_bytes: Option < Vec < u8 > > = None ;
593+
594+ while let Ok ( Some ( field) ) = multipart. next_field ( ) . await {
595+ let name = field. name ( ) . unwrap_or ( "" ) . to_string ( ) ;
596+ if name == "file" {
597+ match field. bytes ( ) . await {
598+ Ok ( bytes) => {
599+ csv_bytes = Some ( bytes. to_vec ( ) ) ;
600+ break ;
601+ }
602+ Err ( e) => {
603+ return (
604+ StatusCode :: BAD_REQUEST ,
605+ Json ( serde_json:: json!( { "error" : format!( "Failed to read uploaded file: {}" , e) } ) ) ,
606+ )
607+ . into_response ( ) ;
608+ }
609+ }
610+ }
611+ }
612+
613+ let csv_data = match csv_bytes {
614+ Some ( b) if !b. is_empty ( ) => b,
615+ _ => {
616+ return (
617+ StatusCode :: BAD_REQUEST ,
618+ Json ( serde_json:: json!( { "error" : "Multipart field 'file' is required and must not be empty" } ) ) ,
619+ )
620+ . into_response ( ) ;
621+ }
622+ } ;
623+
624+ let records = match parse_csv ( & csv_data) {
625+ Ok ( r) => r,
626+ Err ( msg) => {
627+ return (
628+ StatusCode :: BAD_REQUEST ,
629+ Json ( serde_json:: json!( { "error" : msg } ) ) ,
630+ )
631+ . into_response ( ) ;
632+ }
633+ } ;
634+
635+ if records. is_empty ( ) {
636+ return (
637+ StatusCode :: UNPROCESSABLE_ENTITY ,
638+ Json ( serde_json:: json!( { "error" : "CSV contains no data rows" } ) ) ,
639+ )
640+ . into_response ( ) ;
641+ }
642+
643+ let ( accepted, errors) = split_valid ( records) ;
644+
645+ if accepted. is_empty ( ) {
646+ return (
647+ StatusCode :: UNPROCESSABLE_ENTITY ,
648+ Json ( BatchUploadResponse {
649+ accepted : 0 ,
650+ rejected : errors. len ( ) ,
651+ errors,
652+ batch_id : None ,
653+ } ) ,
654+ )
655+ . into_response ( ) ;
656+ }
657+
658+ match persist_batch ( & state. pool , & accepted) . await {
659+ Ok ( batch_id) => Json ( BatchUploadResponse {
660+ accepted : accepted. len ( ) ,
661+ rejected : errors. len ( ) ,
662+ errors,
663+ batch_id : Some ( batch_id) ,
664+ } )
665+ . into_response ( ) ,
666+ Err ( e) => {
667+ tracing:: error!( "Failed to persist CSV batch upload: {:?}" , e) ;
668+ (
669+ StatusCode :: INTERNAL_SERVER_ERROR ,
670+ Json ( serde_json:: json!( { "error" : "Failed to store batch" } ) ) ,
671+ )
672+ . into_response ( )
673+ }
674+ }
675+ }
0 commit comments