Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,11 @@ Auth required. Newest first. Query: `?limit=` (default 50, clamped 1–200).

`status` is `pending`, `processing`, `completed`, or `failed`. Show `failure_reason` on failed rows — it carries the provider's own wording.

### `GET /withdrawals/{id}`
Auth required. Fetches a single withdrawal so a merchant can poll status without listing every withdrawal. Scoped to the authenticated merchant — another merchant's withdrawal id returns `404`.

`200` → the same withdrawal object shape as `GET /withdrawals`. `404` → `{ "error": "withdrawal not found" }`.

---

## Building the POS flow
Expand Down
21 changes: 21 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,27 @@ paths:
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }

/withdrawals/{id}:
get:
tags: [Money]
summary: Read a single withdrawal's status
description: >
Lets a merchant poll one withdrawal's status and failure_reason without
listing every withdrawal. Scoped to the authenticated merchant.
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
responses:
'200':
description: Withdrawal
content:
application/json:
schema: { $ref: '#/components/schemas/Withdrawal' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }

components:
securitySchemes:
bearerAuth:
Expand Down
20 changes: 18 additions & 2 deletions src/api/withdrawals.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use axum::extract::{Query, State};
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Deserialize;
use uuid::Uuid;

use crate::auth::extractor::AuthUser;
use crate::error::{bad_gateway, bad_request, bad_request_field, internal, ApiResult};
use crate::error::{bad_gateway, bad_request, bad_request_field, internal, not_found, ApiResult};
use crate::models::{CreateWithdrawalRequest, NewWithdrawal, Withdrawal};
use crate::services::withdrawals::{self, WithdrawalError};
use crate::validation::{is_valid_account_number, is_valid_bank_code};
Expand Down Expand Up @@ -53,6 +54,21 @@ pub async fn create(
Ok(Json(withdrawal))
}

pub async fn get(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Withdrawal>> {
let merchant_id = auth
.merchant_id
.ok_or_else(|| bad_request("no merchant associated with this account"))?;
let withdrawal = withdrawals::withdrawal_by_id(&state.db, id, merchant_id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("withdrawal not found"))?;
Ok(Json(withdrawal))
}

pub async fn list(
State(state): State<AppState>,
auth: AuthUser,
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub fn router(state: AppState) -> axum::Router {
.route("/transactions", axum::routing::get(api::transactions::list))
.route("/withdraw", axum::routing::post(api::withdrawals::create))
.route("/withdrawals", axum::routing::get(api::withdrawals::list))
.route("/withdrawals/{id}", axum::routing::get(api::withdrawals::get))
.route(
"/payment-requests",
axum::routing::post(api::payment_requests::create)
Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = router((*state).clone())
.layer(cors)
.layer(TraceLayer::new_for_http())
.layer(RequestBodyLimitLayer::new(1024 * 1024));
.layer(RequestBodyLimitLayer::new(64 * 1024));

let address: SocketAddr = config.bind_addr.parse()?;
tracing::info!(%address, "aframp started");
Expand Down
20 changes: 20 additions & 0 deletions src/services/withdrawals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,26 @@ pub async fn create_withdrawal(
}
}

/// Scoped to `merchant_id` so one merchant can't poll another's withdrawal
/// by guessing its id.
pub async fn withdrawal_by_id(
db: &PgPool,
id: Uuid,
merchant_id: Uuid,
) -> Result<Option<Withdrawal>, sqlx::Error> {
sqlx::query_as::<_, Withdrawal>(
"SELECT id, merchant_id, amount_stroops, asset, status, provider,
provider_reference, bank_code, account_number, failure_reason,
created_at, updated_at
FROM withdrawals
WHERE id = $1 AND merchant_id = $2",
)
.bind(id)
.bind(merchant_id)
.fetch_optional(db)
.await
}

pub async fn withdrawals_by_merchant(
db: &PgPool,
merchant_id: Uuid,
Expand Down