Skip to content

Commit 95f7df9

Browse files
committed
feat(backend): add batch upload endpoint accepting CSV or JSON of mass payouts
Closes #553 - POST /api/payouts/batch-upload accepts JSON payload {"payouts":[...]} - POST /api/payouts/batch-upload/csv accepts multipart/form-data CSV file - Validates destination and amount per record; rejects malformed totals - Inserts valid records into payout_batches and batch_recipients tables - Returns accepted/rejected counts and batch_id on success - Enabled axum multipart feature for CSV file upload support
1 parent af6e582 commit 95f7df9

3 files changed

Lines changed: 342 additions & 2 deletions

File tree

backend/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version = "0.1.0"
44
edition = "2021"
55

66
[dependencies]
7-
axum = "0.7.4"
7+
axum = { version = "0.7.4", features = ["multipart"] }
88
tokio = { version = "1.36.0", features = ["full"] }
99
serde = { version = "1.0.197", features = ["derive"] }
1010
serde_json = "1.0.114"

backend/src/api/bridge.rs

Lines changed: 330 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use crate::services::allbridge::{
22
AllbridgeClient, AllbridgeQuoteRequest, BridgeStatusKind, BridgeTransferStatus,
33
};
44
use 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+
}

backend/src/api/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,17 @@ pub fn bridge_routes(state: bridge::BridgeState) -> Router {
122122
.with_state(state)
123123
}
124124

125+
/// #553 — Batch payout upload routes (JSON body + CSV multipart).
126+
///
127+
/// - POST `/api/payouts/batch-upload` → JSON `{ "payouts": [...] }`
128+
/// - POST `/api/payouts/batch-upload/csv` → multipart/form-data with `file` field
129+
pub fn batch_upload_routes(state: bridge::BridgeState) -> Router {
130+
Router::new()
131+
.route("/batch-upload", post(bridge::batch_upload))
132+
.route("/batch-upload/csv", post(bridge::batch_upload_csv))
133+
.with_state(state)
134+
}
135+
125136
/// Yield routes without a Redis cache; reads fall through to Postgres.
126137
pub fn yield_routes(pool: sqlx::PgPool) -> Router {
127138
yield_routes_with_state(r#yield::YieldState::new(pool, None))

0 commit comments

Comments
 (0)