From ef649effaa3adb710d57a0f2000791dc9c466cde Mon Sep 17 00:00:00 2001 From: vicajohn Date: Mon, 29 Jun 2026 01:46:23 +0100 Subject: [PATCH 1/2] docs(cli): document admin_settlement_status subcommand --- cli/synapse-cli/README.md | 43 ++++++++ cli/synapse-cli/src/bin/mock-server.rs | 46 +++++++++ cli/synapse-cli/src/main.rs | 137 +++++++++++++++++++++++++ cli/synapse-cli/tests/cli.rs | 81 ++++++++++++++- 4 files changed, 303 insertions(+), 4 deletions(-) diff --git a/cli/synapse-cli/README.md b/cli/synapse-cli/README.md index 4329c83..5b99d04 100644 --- a/cli/synapse-cli/README.md +++ b/cli/synapse-cli/README.md @@ -10,6 +10,10 @@ The reconciliation tree is: - `synapse admin reconciliation report ` - `synapse admin reconciliation run --account [--period-hours ]` +The settlement tree is: + +- `synapse admin settlements update-status --status [--reason ] [--new-total ] [--actor ] [--json]` + The help text spells out required and optional flags for each subcommand. For example: ```powershell @@ -51,3 +55,42 @@ Summary: Amount mismatches: 1 Has discrepancies: yes ``` + +## Settlement Example + +In one terminal, start the mock API: + +```powershell +cargo run --manifest-path cli/synapse-cli/Cargo.toml --bin mock-server +``` + +Then update a settlement status against it and print the resulting settlement: + +```powershell +cargo run --manifest-path cli/synapse-cli/Cargo.toml -- ` + --base-url http://127.0.0.1:4010 ` + admin settlements update-status ` + 8f9b0f0c-9a89-4d1f-9d7d-0c7d7d0d9a11 ` + --status adjusted ` + --reason "Audit correction" ` + --new-total 125.0000000 +``` + +Sample output: + +```text +Settlement updated successfully + +Settlement ID: 8f9b0f0c-9a89-4d1f-9d7d-0c7d7d0d9a11 +Asset code: USDC +Status: adjusted +Total amount: 125.0000000 +Tx count: 8 +Period: 2026-06-26T00:00:00Z to 2026-06-27T00:00:00Z +Dispute reason: Audit correction +Original total amount: 130.0000000 +Reviewed by: admin +Reviewed at: 2026-06-27T09:15:00Z +Created at: 2026-06-27T09:00:00Z +Updated at: 2026-06-27T09:15:00Z +``` diff --git a/cli/synapse-cli/src/bin/mock-server.rs b/cli/synapse-cli/src/bin/mock-server.rs index cae09e7..75fe537 100644 --- a/cli/synapse-cli/src/bin/mock-server.rs +++ b/cli/synapse-cli/src/bin/mock-server.rs @@ -170,6 +170,52 @@ fn route(request_line: &str, scenario: &str) -> String { json_response(200, &body) } + ("PATCH", path) if path.starts_with("/admin/settlements/") && path.ends_with("/status") => { + let settlement_id = path + .trim_start_matches("/admin/settlements/") + .trim_end_matches("/status") + .trim_end_matches('/'); + + let body = if scenario == "edge" { + format!( + r#"{{ + "id": "{settlement_id}", + "asset_code": "USDC", + "total_amount": "125.0000000", + "tx_count": 8, + "period_start": "2026-06-26T00:00:00Z", + "period_end": "2026-06-27T00:00:00Z", + "status": "voided", + "created_at": "2026-06-27T09:00:00Z", + "updated_at": "2026-06-27T09:15:00Z", + "dispute_reason": "Manual review requested", + "original_total_amount": "130.0000000", + "reviewed_by": "admin", + "reviewed_at": "2026-06-27T09:15:00Z" +}}"# + ) + } else { + format!( + r#"{{ + "id": "{settlement_id}", + "asset_code": "USDC", + "total_amount": "125.0000000", + "tx_count": 8, + "period_start": "2026-06-26T00:00:00Z", + "period_end": "2026-06-27T00:00:00Z", + "status": "adjusted", + "created_at": "2026-06-27T09:00:00Z", + "updated_at": "2026-06-27T09:15:00Z", + "dispute_reason": "Audit correction", + "original_total_amount": "130.0000000", + "reviewed_by": "admin", + "reviewed_at": "2026-06-27T09:15:00Z" +}}"# + ) + }; + + json_response(200, &body) + } _ => json_response( 404, r#"{ diff --git a/cli/synapse-cli/src/main.rs b/cli/synapse-cli/src/main.rs index 2edef4b..9ab01c0 100644 --- a/cli/synapse-cli/src/main.rs +++ b/cli/synapse-cli/src/main.rs @@ -44,6 +44,13 @@ enum AdminCommands { long_about = "List, inspect, or run reconciliation reports through the admin API." )] Reconciliation(ReconciliationCommands), + + /// Settlement status management. + #[command( + about = "Settlement status management", + long_about = "Update settlement status through the admin API." + )] + Settlements(SettlementCommands), } #[derive(Subcommand, Debug)] @@ -99,6 +106,39 @@ enum ReconciliationCommands { }, } +#[derive(Subcommand, Debug)] +enum SettlementCommands { + #[command( + about = "Update a settlement's status", + long_about = "Update a settlement's status through the admin API and print the updated settlement.\n\nRequired arguments:\n UUID of the settlement to update.\nRequired flags:\n --status New status to apply (pending, completed, pending_review, disputed, adjusted, or voided).\nOptional flags:\n --reason Human-readable reason for the change.\n --new-total Replacement total amount; only meaningful when setting status to adjusted.\n --actor Actor recorded in the audit log (default: admin).\n --json Print the raw API response as JSON." + )] + UpdateStatus { + /// UUID of the settlement to update. + #[arg(value_name = "SETTLEMENT_ID")] + settlement_id: Uuid, + + /// New status to apply. + #[arg(long, value_name = "STATUS")] + status: String, + + /// Human-readable reason for the change. + #[arg(long, value_name = "REASON")] + reason: Option, + + /// Replacement total amount; only meaningful when setting status to adjusted. + #[arg(long = "new-total", value_name = "TOTAL")] + new_total: Option, + + /// Actor recorded in the audit log. + #[arg(long, value_name = "ACTOR", default_value = "admin")] + actor: String, + + /// Print the raw API response as JSON. + #[arg(long)] + json: bool, + }, +} + #[derive(Debug, Deserialize, Serialize)] struct ListReportsResponse { reports: Vec, @@ -184,6 +224,31 @@ struct RunRequest<'a> { period_hours: Option, } +#[derive(Debug, Deserialize, Serialize)] +struct SettlementResponse { + id: Uuid, + asset_code: String, + total_amount: String, + tx_count: i32, + period_start: String, + period_end: String, + status: String, + created_at: String, + updated_at: String, + dispute_reason: Option, + original_total_amount: Option, + reviewed_by: Option, + reviewed_at: Option, +} + +#[derive(Debug, Serialize)] +struct UpdateSettlementStatusRequest<'a> { + status: &'a str, + reason: Option<&'a str>, + new_total: Option<&'a str>, + actor: &'a str, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -195,6 +260,9 @@ async fn main() -> Result<()> { AdminCommands::Reconciliation(command) => { handle_reconciliation(&client, &base_url, command).await? } + AdminCommands::Settlements(command) => { + handle_settlement(&client, &base_url, command).await? + } }, } @@ -241,6 +309,37 @@ async fn handle_reconciliation( Ok(()) } +async fn handle_settlement( + client: &reqwest::Client, + base_url: &str, + command: SettlementCommands, +) -> Result<()> { + match command { + SettlementCommands::UpdateStatus { + settlement_id, + status, + reason, + new_total, + actor, + json, + } => { + let url = format!("{base_url}/admin/settlements/{settlement_id}/status"); + let response = send_json_request::( + client.patch(url).json(&UpdateSettlementStatusRequest { + status: &status, + reason: reason.as_deref(), + new_total: new_total.as_deref(), + actor: &actor, + }), + ) + .await?; + println!("{}", output::render(&response, json, format_settlement_table)?); + } + } + + Ok(()) +} + async fn send_json_request(request: reqwest::RequestBuilder) -> Result where T: for<'de> Deserialize<'de>, @@ -342,3 +441,41 @@ fn format_run_table(response: &RunResponse) -> String { ] .join("\n") } + +fn format_settlement_table(settlement: &SettlementResponse) -> String { + [ + "Settlement updated successfully".to_string(), + String::new(), + format!("Settlement ID: {}", settlement.id), + format!("Asset code: {}", settlement.asset_code), + format!("Status: {}", settlement.status), + format!("Total amount: {}", settlement.total_amount), + format!("Tx count: {}", settlement.tx_count), + format!("Period: {} to {}", settlement.period_start, settlement.period_end), + format!( + "Dispute reason: {}", + settlement + .dispute_reason + .as_deref() + .unwrap_or("not provided") + ), + format!( + "Original total amount: {}", + settlement + .original_total_amount + .as_deref() + .unwrap_or("not provided") + ), + format!( + "Reviewed by: {}", + settlement.reviewed_by.as_deref().unwrap_or("not provided") + ), + format!( + "Reviewed at: {}", + settlement.reviewed_at.as_deref().unwrap_or("not provided") + ), + format!("Created at: {}", settlement.created_at), + format!("Updated at: {}", settlement.updated_at), + ] + .join("\n") +} diff --git a/cli/synapse-cli/tests/cli.rs b/cli/synapse-cli/tests/cli.rs index 415c3b6..b8c7102 100644 --- a/cli/synapse-cli/tests/cli.rs +++ b/cli/synapse-cli/tests/cli.rs @@ -1,10 +1,11 @@ use assert_cmd::Command; use std::net::TcpListener; -use std::process::{Child, Command as StdCommand, Stdio}; +use std::process::{Child, Stdio}; use std::thread; use std::time::Duration; const SAMPLE_REPORT_ID: &str = "3f1d8c31-5f1d-4fb8-93e0-112233445566"; +const SAMPLE_SETTLEMENT_ID: &str = "8f9b0f0c-9a89-4d1f-9d7d-0c7d7d0d9a11"; #[test] fn reconciliation_commands_table_mode_happy_path() { @@ -119,6 +120,79 @@ fn reconciliation_commands_json_mode_edge_case() { assert!(stdout.contains("\"total_db_transactions\": 0")); } +#[test] +fn settlement_update_status_help_mentions_required_and_optional_flags() { + let mut cmd = synapse_command(); + cmd.args(["admin", "settlements", "update-status", "--help"]); + + let output = cmd.output().expect("help output"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("valid utf-8"); + + assert!(stdout.contains("Required arguments:")); + assert!(stdout.contains("")); + assert!(stdout.contains("Required flags:")); + assert!(stdout.contains("--status ")); + assert!(stdout.contains("Optional flags:")); + assert!(stdout.contains("--new-total ")); + assert!(stdout.contains("--actor ")); +} + +#[test] +fn settlement_update_status_table_and_json_modes() { + let server = MockServer::spawn("happy"); + let base_url = server.base_url(); + + let mut cmd = synapse_command(); + cmd.args([ + "--base-url", + &base_url, + "admin", + "settlements", + "update-status", + SAMPLE_SETTLEMENT_ID, + "--status", + "adjusted", + "--reason", + "Audit correction", + "--new-total", + "125.0000000", + ]); + + let output = cmd.output().expect("settlement output"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("valid utf-8"); + assert!(stdout.contains("Settlement updated successfully")); + assert!(stdout.contains("Settlement ID: 8f9b0f0c-9a89-4d1f-9d7d-0c7d7d0d9a11")); + assert!(stdout.contains("Status: adjusted")); + assert!(stdout.contains("Dispute reason: Audit correction")); + assert!(stdout.contains("Original total amount: 130.0000000")); + + let mut cmd = synapse_command(); + cmd.args([ + "--base-url", + &base_url, + "admin", + "settlements", + "update-status", + SAMPLE_SETTLEMENT_ID, + "--status", + "adjusted", + "--reason", + "Audit correction", + "--new-total", + "125.0000000", + "--json", + ]); + + let output = cmd.output().expect("settlement json output"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("valid utf-8"); + assert!(stdout.contains("\"status\": \"adjusted\"")); + assert!(stdout.contains("\"original_total_amount\": \"130.0000000\"")); + assert!(stdout.contains("\"reviewed_by\": \"admin\"")); +} + fn synapse_command() -> Command { Command::cargo_bin("synapse").expect("synapse binary exists") } @@ -131,9 +205,8 @@ struct MockServer { impl MockServer { fn spawn(scenario: &str) -> Self { let port = free_port(); - let binary = std::env::var_os("CARGO_BIN_EXE_mock-server") - .expect("mock-server binary path"); - let child = StdCommand::new(binary) + let child = Command::cargo_bin("mock-server") + .expect("mock-server binary exists") .env("MOCK_SERVER_ADDR", format!("127.0.0.1:{port}")) .env("MOCK_SERVER_SCENARIO", scenario) .stdin(Stdio::null()) From ee9664645748f780056f373b06969648980e2b30 Mon Sep 17 00:00:00 2001 From: vicajohn Date: Mon, 29 Jun 2026 04:41:28 +0100 Subject: [PATCH 2/2] docs(cli): polish update-status --help text - Add USAGE line for clarity - Split flags into REQUIRED and OPTIONAL sections --- cli/synapse-cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/synapse-cli/src/main.rs b/cli/synapse-cli/src/main.rs index 9ab01c0..63b0726 100644 --- a/cli/synapse-cli/src/main.rs +++ b/cli/synapse-cli/src/main.rs @@ -110,7 +110,7 @@ enum ReconciliationCommands { enum SettlementCommands { #[command( about = "Update a settlement's status", - long_about = "Update a settlement's status through the admin API and print the updated settlement.\n\nRequired arguments:\n UUID of the settlement to update.\nRequired flags:\n --status New status to apply (pending, completed, pending_review, disputed, adjusted, or voided).\nOptional flags:\n --reason Human-readable reason for the change.\n --new-total Replacement total amount; only meaningful when setting status to adjusted.\n --actor Actor recorded in the audit log (default: admin).\n --json Print the raw API response as JSON." + long_about = "Update a settlement's status through the admin API and print the updated settlement.\n\nUSAGE:\n synapse admin settlements update-status --status [OPTIONS]\n\nREQUIRED:\n UUID of the settlement to update.\n --status New status to apply (pending, completed, pending_review, disputed, adjusted, or voided).\n\nOPTIONAL:\n --reason Human-readable reason for the change.\n --new-total Replacement total amount; only meaningful when setting status to adjusted.\n --actor Actor recorded in the audit log (default: admin).\n --json Print the raw API response as JSON." )] UpdateStatus { /// UUID of the settlement to update.