From e572481f0e59338dbc4c97aac8ef3da2a05d94d1 Mon Sep 17 00:00:00 2001 From: DammyAji Date: Sun, 28 Jun 2026 02:02:15 +0100 Subject: [PATCH 1/3] feat(backend): add PDF inheritance audit report endpoint (#825) Add GET /api/plans/:id/report endpoint that generates and returns a downloadable PDF inheritance audit report. Changes: - backend/Cargo.toml: add printpdf = '0.7' dependency - backend/src/pdf_report.rs: new module; build_pdf_bytes(ReportData) uses printpdf BuiltinFont (no external font files), renders plan overview, owner, activity log, and beneficiary table - backend/src/api.rs: add get_plan_report handler; loads plan + beneficiaries from DB, computes live accrued yield, offloads PDF construction to tokio::task::spawn_blocking to avoid blocking the async executor, returns bytes with Content-Type: application/pdf and Content-Disposition: attachment headers - backend/src/lib.rs: register pdf_report module Security: endpoint is public (plan UUID acts as capability token); no sensitive data beyond what the plan owner already stored is exposed. Async safety: printpdf is synchronous; PDF generation is wrapped in spawn_blocking so it runs on a dedicated thread pool and never blocks the Tokio runtime. Closes #825 --- backend/Cargo.toml | 1 + backend/src/api.rs | 134 ++++++++++++++++++++- backend/src/lib.rs | 1 + backend/src/pdf_report.rs | 245 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 backend/src/pdf_report.rs diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9e8158061..6eccf9213 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -28,3 +28,4 @@ jsonwebtoken = "9.0" base64 = "0.21" stellar-strkey = "0.0.8" ed25519-dalek = { version = "2.1", features = ["pkcs8", "rand_core"] } +printpdf = "0.7" diff --git a/backend/src/api.rs b/backend/src/api.rs index eab385ed2..779b27180 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -1,8 +1,9 @@ use axum::{ - extract::{Query, State}, - http::StatusCode, + body::Body, + extract::{Path, Query, State}, + http::{HeaderValue, StatusCode}, middleware::from_fn, - response::IntoResponse, + response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; @@ -16,6 +17,7 @@ use uuid::Uuid; use crate::auth::signature_auth_middleware; use crate::kyc_webhook::kyc_webhook_handler; +use crate::pdf_report::{self, ReportData}; use crate::stellar_anchor::AnchorRegistry; use crate::ws::{ws_handler, KycUpdateEvent}; use crate::yield_calculator; @@ -121,6 +123,7 @@ pub fn create_router(state: Arc) -> Router { // Public or admin routes let public_routes = Router::new() .route("/api/plans", get(get_plans)) + .route("/api/plans/:id/report", get(get_plan_report)) .route("/api/anchor/payout-status", get(get_anchor_payouts)) .route("/api/kyc/webhook", post(kyc_webhook_handler)) .route("/ws/kyc", get(ws_handler)); @@ -695,8 +698,7 @@ async fn trigger_payout( "Payout trigger logic not implemented", ) } -// -// Handler: Get Anchor Payouts +/// Handler: Get Anchor Payouts // Queries the payouts table filtered by beneficiary_address with pagination. async fn get_anchor_payouts( State(state): State>, @@ -773,3 +775,125 @@ async fn get_anchor_payouts( ) .into_response() } + +/// Handler: GET /api/plans/:id/report +/// +/// Generates and returns a downloadable PDF inheritance audit report for the +/// given plan. PDF construction is offloaded to a blocking thread via +/// `tokio::task::spawn_blocking` so it never stalls the async runtime. +/// +/// The endpoint is public (no signature auth required) – the plan UUID in the +/// URL acts as a capability token; plans are looked up by their primary key. +async fn get_plan_report( + State(state): State>, + Path(plan_id): Path, +) -> Response { + // 1. Load the plan. + let plan = match sqlx::query_as::<_, PlanRow>( + r#" + SELECT id, owner_address, token_address, amount, grace_period, + grace_period_seconds, earn_yield, last_ping, is_active, + status, yield_rate_bps, accrued_yield, created_at + FROM plans + WHERE id = $1 + "#, + ) + .bind(plan_id) + .fetch_optional(&state.db_pool) + .await + { + Ok(Some(p)) => p, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Plan not found" })), + ) + .into_response(); + } + Err(e) => { + error!(error = %e, %plan_id, "Failed to fetch plan for report"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Database error" })), + ) + .into_response(); + } + }; + + // 2. Load beneficiaries. + let beneficiaries = match sqlx::query_as::<_, BeneficiaryRow>( + r#" + SELECT id, plan_id, wallet_address, allocation_bps, fiat_anchor_info + FROM beneficiaries + WHERE plan_id = $1 + ORDER BY allocation_bps DESC + "#, + ) + .bind(plan_id) + .fetch_all(&state.db_pool) + .await + { + Ok(rows) => rows, + Err(e) => { + error!(error = %e, %plan_id, "Failed to fetch beneficiaries for report"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Database error" })), + ) + .into_response(); + } + }; + + // 3. Compute live accrued yield (stored value + time elapsed since last ping). + let accrued_yield = compute_accrued_yield(&plan.amount, plan.yield_rate_bps, plan.last_ping) + + plan + .accrued_yield + .to_string() + .parse::() + .unwrap_or(0.0); + + let report_data = ReportData { + plan, + beneficiaries, + accrued_yield, + }; + + // 4. Build PDF bytes on a blocking thread – avoids blocking the async executor. + let pdf_bytes = match tokio::task::spawn_blocking(move || pdf_report::build_pdf_bytes(report_data)).await + { + Ok(Ok(bytes)) => bytes, + Ok(Err(e)) => { + error!(error = %e, "PDF generation failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Failed to generate PDF" })), + ) + .into_response(); + } + Err(e) => { + error!(error = %e, "PDF generation task panicked"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "PDF generation task failed" })), + ) + .into_response(); + } + }; + + // 5. Return the PDF with appropriate download headers. + let filename = format!("inheritance-audit-{plan_id}.pdf"); + let content_disposition = format!("attachment; filename=\"{filename}\""); + + let mut response = Response::new(Body::from(pdf_bytes)); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + axum::http::header::CONTENT_TYPE, + HeaderValue::from_static("application/pdf"), + ); + response.headers_mut().insert( + axum::http::header::CONTENT_DISPOSITION, + HeaderValue::from_str(&content_disposition) + .unwrap_or_else(|_| HeaderValue::from_static("attachment; filename=\"report.pdf\"")), + ); + response +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index a2c0fca19..aa563e9e3 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; pub mod db; pub mod inactivity_watchdog; pub mod kyc_webhook; +pub mod pdf_report; pub mod stellar_anchor; pub mod telemetry; pub mod ws; diff --git a/backend/src/pdf_report.rs b/backend/src/pdf_report.rs new file mode 100644 index 000000000..d94650074 --- /dev/null +++ b/backend/src/pdf_report.rs @@ -0,0 +1,245 @@ +//! PDF Inheritance Audit Report generator (Issue #825). +//! +//! Call [`build_pdf_bytes`] inside `tokio::task::spawn_blocking` – it is +//! entirely synchronous and must not be called directly on the async runtime. + +use crate::api::{BeneficiaryRow, PlanRow}; +use printpdf::{BuiltinFont, Mm, PdfDocument}; +use std::io::BufWriter; + +/// Data bundle for one PDF report. +pub struct ReportData { + pub plan: PlanRow, + pub beneficiaries: Vec, + /// Live accrued yield (stored + elapsed since last ping). + pub accrued_yield: f64, +} + +/// Build and return raw PDF bytes for the given report data. +/// +/// **Synchronous** – run inside `tokio::task::spawn_blocking`. +pub fn build_pdf_bytes(data: ReportData) -> Result, printpdf::Error> { + let (doc, page1, layer1) = + PdfDocument::new("Inheritance Audit Report", Mm(210.0), Mm(297.0), "Main"); + + let layer = doc.get_page(page1).get_layer(layer1); + let bold = doc.add_builtin_font(BuiltinFont::HelveticaBold)?; + let regular = doc.add_builtin_font(BuiltinFont::Helvetica)?; + + let lm = Mm(15.0_f32); // left margin + let rc = Mm(110.0_f32); // right / value column + let lh = Mm(7.0_f32); // line height + let mut y = Mm(280.0_f32); + + // ── Title ───────────────────────────────────────────────────────────── + layer.use_text( + "InheritX - Inheritance Audit Report", + 18.0_f32, + lm, + y, + &bold, + ); + y -= lh * 2.0_f32; + + // ── Plan Overview ───────────────────────────────────────────────────── + layer.use_text("Plan Overview", 13.0_f32, lm, y, &bold); + y -= lh; + + let rows: Vec<(&str, String)> = vec![ + ("Plan ID:", data.plan.id.to_string()), + ("Status:", data.plan.status.clone()), + ("Token:", data.plan.token_address.clone()), + ("Principal:", data.plan.amount.to_string()), + ( + "Yield Enabled:", + if data.plan.earn_yield { + "Yes".to_string() + } else { + "No".to_string() + }, + ), + ("Yield Rate (bps):", data.plan.yield_rate_bps.to_string()), + ("Accrued Yield:", format!("{:.6}", data.accrued_yield)), + ( + "Grace Period (s):", + data.plan.grace_period_seconds.to_string(), + ), + ( + "Active:", + if data.plan.is_active { + "Yes".to_string() + } else { + "No".to_string() + }, + ), + ( + "Created At:", + data.plan + .created_at + .format("%Y-%m-%d %H:%M UTC") + .to_string(), + ), + ]; + + for (label, value) in rows { + layer.use_text(*label, 10.0_f32, lm, y, ®ular); + layer.use_text(value.as_str(), 10.0_f32, rc, y, ®ular); + y -= lh; + } + y -= lh; + + // ── Owner ───────────────────────────────────────────────────────────── + layer.use_text("Plan Owner", 13.0_f32, lm, y, &bold); + y -= lh; + layer.use_text("Wallet Address:", 10.0_f32, lm, y, ®ular); + layer.use_text( + data.plan.owner_address.as_str(), + 10.0_f32, + rc, + y, + ®ular, + ); + y -= lh * 2.0_f32; + + // ── Activity Log ────────────────────────────────────────────────────── + layer.use_text("Activity Log", 13.0_f32, lm, y, &bold); + y -= lh; + + let last_ping_str = if data.plan.last_ping == 0 { + "Never pinged".to_string() + } else { + chrono::DateTime::from_timestamp(data.plan.last_ping, 0) + .map(|dt: chrono::DateTime| dt.format("%Y-%m-%d %H:%M UTC").to_string()) + .unwrap_or_else(|| data.plan.last_ping.to_string()) + }; + layer.use_text("Last Proof-of-Life:", 10.0_f32, lm, y, ®ular); + layer.use_text(last_ping_str.as_str(), 10.0_f32, rc, y, ®ular); + y -= lh; + + let deadline_str = if data.plan.last_ping > 0 { + let epoch = data.plan.last_ping + data.plan.grace_period_seconds; + chrono::DateTime::from_timestamp(epoch, 0) + .map(|dt: chrono::DateTime| dt.format("%Y-%m-%d %H:%M UTC").to_string()) + .unwrap_or_else(|| epoch.to_string()) + } else { + "N/A".to_string() + }; + layer.use_text("Inactivity Deadline:", 10.0_f32, lm, y, ®ular); + layer.use_text(deadline_str.as_str(), 10.0_f32, rc, y, ®ular); + y -= lh * 2.0_f32; + + // ── Beneficiaries ───────────────────────────────────────────────────── + layer.use_text("Beneficiaries", 13.0_f32, lm, y, &bold); + y -= lh; + + layer.use_text("Wallet Address", 9.0_f32, lm, y, &bold); + layer.use_text("Alloc (bps)", 9.0_f32, Mm(110.0_f32), y, &bold); + layer.use_text("Alloc (%)", 9.0_f32, Mm(145.0_f32), y, &bold); + layer.use_text("Fiat Anchor", 9.0_f32, Mm(170.0_f32), y, &bold); + y -= lh; + + for b in &data.beneficiaries { + let addr = if b.wallet_address.len() > 28 { + format!("{}...", &b.wallet_address[..28]) + } else { + b.wallet_address.clone() + }; + let anchor = if b.fiat_anchor_info.is_empty() { + "-".to_string() + } else if b.fiat_anchor_info.len() > 18 { + format!("{}...", &b.fiat_anchor_info[..18]) + } else { + b.fiat_anchor_info.clone() + }; + let pct = format!("{:.2}%", b.allocation_bps as f64 / 100.0); + + layer.use_text(addr.as_str(), 9.0_f32, lm, y, ®ular); + layer.use_text( + &b.allocation_bps.to_string(), + 9.0_f32, + Mm(110.0_f32), + y, + ®ular, + ); + layer.use_text(pct.as_str(), 9.0_f32, Mm(145.0_f32), y, ®ular); + layer.use_text(anchor.as_str(), 9.0_f32, Mm(170.0_f32), y, ®ular); + y -= lh; + } + + y -= lh; + layer.use_text( + "Generated automatically by InheritX.", + 7.0_f32, + lm, + y, + ®ular, + ); + + let mut buf = BufWriter::new(Vec::new()); + doc.save(&mut buf)?; + buf.into_inner().map_err(|e| { + printpdf::Error::IoError(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal::Decimal; + use uuid::Uuid; + + fn sample_data() -> ReportData { + ReportData { + plan: PlanRow { + id: Uuid::new_v4(), + owner_address: "GABC1234OWNER".to_string(), + token_address: "USDC".to_string(), + amount: Decimal::new(100_000, 2), + grace_period: 30, + grace_period_seconds: 2_592_000, + earn_yield: true, + last_ping: 1_700_000_000, + is_active: true, + status: "ACTIVE".to_string(), + yield_rate_bps: 500, + accrued_yield: Decimal::new(5_000, 3), + created_at: chrono::Utc::now(), + }, + beneficiaries: vec![ + BeneficiaryRow { + id: Uuid::new_v4(), + plan_id: Uuid::new_v4(), + wallet_address: "GBENEF1WALLET".to_string(), + allocation_bps: 6000, + fiat_anchor_info: "NGN/bank".to_string(), + }, + BeneficiaryRow { + id: Uuid::new_v4(), + plan_id: Uuid::new_v4(), + wallet_address: "GBENEF2WALLET".to_string(), + allocation_bps: 4000, + fiat_anchor_info: String::new(), + }, + ], + accrued_yield: 5.0, + } + } + + #[test] + fn test_build_pdf_returns_valid_bytes() { + let bytes = build_pdf_bytes(sample_data()).expect("PDF generation failed"); + assert!(bytes.starts_with(b"%PDF"), "output is not a valid PDF"); + assert!(bytes.len() > 1024, "PDF suspiciously small"); + } + + #[test] + fn test_build_pdf_no_beneficiaries() { + let mut data = sample_data(); + data.beneficiaries.clear(); + let bytes = build_pdf_bytes(data).expect("PDF generation failed"); + assert!(bytes.starts_with(b"%PDF")); + } +} From af204e184c60446a2c6d0d3372e3d8fd1791deb9 Mon Sep 17 00:00:00 2001 From: DammyAji Date: Sun, 28 Jun 2026 02:02:15 +0100 Subject: [PATCH 2/3] feat(backend): add PDF inheritance audit report endpoint (#825) Add GET /api/plans/:id/report endpoint that generates and returns a downloadable PDF inheritance audit report. Changes: - backend/Cargo.toml: add printpdf = '0.7' dependency - backend/src/pdf_report.rs: new module; build_pdf_bytes(ReportData) uses printpdf BuiltinFont (no external font files), renders plan overview, owner, activity log, and beneficiary table - backend/src/api.rs: add get_plan_report handler; loads plan + beneficiaries from DB, computes live accrued yield, offloads PDF construction to tokio::task::spawn_blocking to avoid blocking the async executor, returns bytes with Content-Type: application/pdf and Content-Disposition: attachment headers - backend/src/lib.rs: register pdf_report module Security: endpoint is public (plan UUID acts as capability token); no sensitive data beyond what the plan owner already stored is exposed. Async safety: printpdf is synchronous; PDF generation is wrapped in spawn_blocking so it runs on a dedicated thread pool and never blocks the Tokio runtime. Closes #825 --- backend/Cargo.toml | 1 + backend/src/api.rs | 135 +++++++++++++++++++++++- backend/src/lib.rs | 1 + backend/src/pdf_report.rs | 216 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 backend/src/pdf_report.rs diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9e8158061..6eccf9213 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -28,3 +28,4 @@ jsonwebtoken = "9.0" base64 = "0.21" stellar-strkey = "0.0.8" ed25519-dalek = { version = "2.1", features = ["pkcs8", "rand_core"] } +printpdf = "0.7" diff --git a/backend/src/api.rs b/backend/src/api.rs index eab385ed2..d6486bc87 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -1,8 +1,9 @@ use axum::{ - extract::{Query, State}, - http::StatusCode, + body::Body, + extract::{Path, Query, State}, + http::{HeaderValue, StatusCode}, middleware::from_fn, - response::IntoResponse, + response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; @@ -16,6 +17,7 @@ use uuid::Uuid; use crate::auth::signature_auth_middleware; use crate::kyc_webhook::kyc_webhook_handler; +use crate::pdf_report::{self, ReportData}; use crate::stellar_anchor::AnchorRegistry; use crate::ws::{ws_handler, KycUpdateEvent}; use crate::yield_calculator; @@ -121,6 +123,7 @@ pub fn create_router(state: Arc) -> Router { // Public or admin routes let public_routes = Router::new() .route("/api/plans", get(get_plans)) + .route("/api/plans/:id/report", get(get_plan_report)) .route("/api/anchor/payout-status", get(get_anchor_payouts)) .route("/api/kyc/webhook", post(kyc_webhook_handler)) .route("/ws/kyc", get(ws_handler)); @@ -695,8 +698,7 @@ async fn trigger_payout( "Payout trigger logic not implemented", ) } -// -// Handler: Get Anchor Payouts +/// Handler: Get Anchor Payouts // Queries the payouts table filtered by beneficiary_address with pagination. async fn get_anchor_payouts( State(state): State>, @@ -773,3 +775,126 @@ async fn get_anchor_payouts( ) .into_response() } + +/// Handler: GET /api/plans/:id/report +/// +/// Generates and returns a downloadable PDF inheritance audit report for the +/// given plan. PDF construction is offloaded to a blocking thread via +/// `tokio::task::spawn_blocking` so it never stalls the async runtime. +/// +/// The endpoint is public (no signature auth required) – the plan UUID in the +/// URL acts as a capability token; plans are looked up by their primary key. +async fn get_plan_report( + State(state): State>, + Path(plan_id): Path, +) -> Response { + // 1. Load the plan. + let plan = match sqlx::query_as::<_, PlanRow>( + r#" + SELECT id, owner_address, token_address, amount, grace_period, + grace_period_seconds, earn_yield, last_ping, is_active, + status, yield_rate_bps, accrued_yield, created_at + FROM plans + WHERE id = $1 + "#, + ) + .bind(plan_id) + .fetch_optional(&state.db_pool) + .await + { + Ok(Some(p)) => p, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Plan not found" })), + ) + .into_response(); + } + Err(e) => { + error!(error = %e, %plan_id, "Failed to fetch plan for report"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Database error" })), + ) + .into_response(); + } + }; + + // 2. Load beneficiaries. + let beneficiaries = match sqlx::query_as::<_, BeneficiaryRow>( + r#" + SELECT id, plan_id, wallet_address, allocation_bps, fiat_anchor_info + FROM beneficiaries + WHERE plan_id = $1 + ORDER BY allocation_bps DESC + "#, + ) + .bind(plan_id) + .fetch_all(&state.db_pool) + .await + { + Ok(rows) => rows, + Err(e) => { + error!(error = %e, %plan_id, "Failed to fetch beneficiaries for report"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Database error" })), + ) + .into_response(); + } + }; + + // 3. Compute live accrued yield (stored value + time elapsed since last ping). + let stored_yield = plan + .accrued_yield + .to_string() + .parse::() + .unwrap_or(0.0); + let accrued_yield = + compute_accrued_yield(&plan.amount, plan.yield_rate_bps, plan.last_ping) + stored_yield; + + let report_data = ReportData { + plan, + beneficiaries, + accrued_yield, + }; + + // 4. Build PDF bytes on a blocking thread – avoids blocking the async executor. + let pdf_bytes = match tokio::task::spawn_blocking(move || pdf_report::build_pdf_bytes(report_data)).await + { + Ok(Ok(bytes)) => bytes, + Ok(Err(e)) => { + error!(error = %e, "PDF generation failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Failed to generate PDF" })), + ) + .into_response(); + } + Err(e) => { + error!(error = %e, "PDF generation task panicked"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "PDF generation task failed" })), + ) + .into_response(); + } + }; + + // 5. Return the PDF with appropriate download headers. + let filename = format!("inheritance-audit-{plan_id}.pdf"); + let content_disposition = format!("attachment; filename=\"{filename}\""); + + let mut response = Response::new(Body::from(pdf_bytes)); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + axum::http::header::CONTENT_TYPE, + HeaderValue::from_static("application/pdf"), + ); + response.headers_mut().insert( + axum::http::header::CONTENT_DISPOSITION, + HeaderValue::from_str(&content_disposition) + .unwrap_or_else(|_| HeaderValue::from_static("attachment; filename=\"report.pdf\"")), + ); + response +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index a2c0fca19..aa563e9e3 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; pub mod db; pub mod inactivity_watchdog; pub mod kyc_webhook; +pub mod pdf_report; pub mod stellar_anchor; pub mod telemetry; pub mod ws; diff --git a/backend/src/pdf_report.rs b/backend/src/pdf_report.rs new file mode 100644 index 000000000..1b76201db --- /dev/null +++ b/backend/src/pdf_report.rs @@ -0,0 +1,216 @@ +//! PDF Inheritance Audit Report generator (Issue #825). +//! +//! Call [`build_pdf_bytes`] inside `tokio::task::spawn_blocking` – it is +//! entirely synchronous and must not be called directly on the async runtime. + +use crate::api::{BeneficiaryRow, PlanRow}; +use chrono::TimeZone as _; +use printpdf::{BuiltinFont, Mm, PdfDocument}; +use std::io::BufWriter; + +/// Data bundle for one PDF report. +pub struct ReportData { + pub plan: PlanRow, + pub beneficiaries: Vec, + /// Live accrued yield (stored + elapsed since last ping). + pub accrued_yield: f64, +} + +fn fmt_epoch(epoch: i64) -> String { + chrono::Utc + .timestamp_opt(epoch, 0) + .single() + .map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string()) + .unwrap_or_else(|| epoch.to_string()) +} + +/// Build and return raw PDF bytes for the given report data. +/// +/// **Synchronous** – run inside `tokio::task::spawn_blocking`. +pub fn build_pdf_bytes(data: ReportData) -> Result, printpdf::Error> { + let (doc, page1, layer1) = + PdfDocument::new("Inheritance Audit Report", Mm(210.0), Mm(297.0), "Main"); + + let layer = doc.get_page(page1).get_layer(layer1); + let bold = doc.add_builtin_font(BuiltinFont::HelveticaBold)?; + let regular = doc.add_builtin_font(BuiltinFont::Helvetica)?; + + let lm = Mm(15.0_f32); + let rc = Mm(110.0_f32); + let lh = Mm(7.0_f32); + let mut y = Mm(280.0_f32); + + // ── Title ───────────────────────────────────────────────────────────── + layer.use_text("InheritX - Inheritance Audit Report", 18.0_f32, lm, y, &bold); + y -= lh * 2.0_f32; + + // ── Plan Overview ───────────────────────────────────────────────────── + layer.use_text("Plan Overview", 13.0_f32, lm, y, &bold); + y -= lh; + + // Pre-compute strings so references into them are valid for the slice. + let plan_id = data.plan.id.to_string(); + let amount_str = data.plan.amount.to_string(); + let yield_rate_str = data.plan.yield_rate_bps.to_string(); + let accrued_str = format!("{:.6}", data.accrued_yield); + let grace_str = data.plan.grace_period_seconds.to_string(); + let created_str = data.plan.created_at.format("%Y-%m-%d %H:%M UTC").to_string(); + + let overview: &[(&str, &str)] = &[ + ("Plan ID:", &plan_id), + ("Status:", &data.plan.status), + ("Token:", &data.plan.token_address), + ("Principal:", &amount_str), + ("Yield Enabled:", if data.plan.earn_yield { "Yes" } else { "No" }), + ("Yield Rate (bps):", &yield_rate_str), + ("Accrued Yield:", &accrued_str), + ("Grace Period (s):", &grace_str), + ("Active:", if data.plan.is_active { "Yes" } else { "No" }), + ("Created At:", &created_str), + ]; + + for (label, value) in overview { + layer.use_text(*label, 10.0_f32, lm, y, ®ular); + layer.use_text(*value, 10.0_f32, rc, y, ®ular); + y -= lh; + } + y -= lh; + + // ── Owner ───────────────────────────────────────────────────────────── + layer.use_text("Plan Owner", 13.0_f32, lm, y, &bold); + y -= lh; + layer.use_text("Wallet Address:", 10.0_f32, lm, y, ®ular); + layer.use_text(data.plan.owner_address.as_str(), 10.0_f32, rc, y, ®ular); + y -= lh * 2.0_f32; + + // ── Activity Log ────────────────────────────────────────────────────── + layer.use_text("Activity Log", 13.0_f32, lm, y, &bold); + y -= lh; + + let last_ping_str = if data.plan.last_ping == 0 { + "Never pinged".to_string() + } else { + fmt_epoch(data.plan.last_ping) + }; + layer.use_text("Last Proof-of-Life:", 10.0_f32, lm, y, ®ular); + layer.use_text(last_ping_str.as_str(), 10.0_f32, rc, y, ®ular); + y -= lh; + + let deadline_str = if data.plan.last_ping > 0 { + fmt_epoch(data.plan.last_ping + data.plan.grace_period_seconds) + } else { + "N/A".to_string() + }; + layer.use_text("Inactivity Deadline:", 10.0_f32, lm, y, ®ular); + layer.use_text(deadline_str.as_str(), 10.0_f32, rc, y, ®ular); + y -= lh * 2.0_f32; + + // ── Beneficiaries ───────────────────────────────────────────────────── + layer.use_text("Beneficiaries", 13.0_f32, lm, y, &bold); + y -= lh; + + layer.use_text("Wallet Address", 9.0_f32, lm, y, &bold); + layer.use_text("Alloc (bps)", 9.0_f32, Mm(110.0_f32), y, &bold); + layer.use_text("Alloc (%)", 9.0_f32, Mm(145.0_f32), y, &bold); + layer.use_text("Fiat Anchor", 9.0_f32, Mm(170.0_f32), y, &bold); + y -= lh; + + for b in &data.beneficiaries { + let addr = if b.wallet_address.len() > 28 { + format!("{}...", &b.wallet_address[..28]) + } else { + b.wallet_address.clone() + }; + let anchor = if b.fiat_anchor_info.is_empty() { + "-".to_string() + } else if b.fiat_anchor_info.len() > 18 { + format!("{}...", &b.fiat_anchor_info[..18]) + } else { + b.fiat_anchor_info.clone() + }; + let pct = format!("{:.2}%", b.allocation_bps as f64 / 100.0); + let bps = b.allocation_bps.to_string(); + + layer.use_text(addr.as_str(), 9.0_f32, lm, y, ®ular); + layer.use_text(bps.as_str(), 9.0_f32, Mm(110.0_f32), y, ®ular); + layer.use_text(pct.as_str(), 9.0_f32, Mm(145.0_f32), y, ®ular); + layer.use_text(anchor.as_str(), 9.0_f32, Mm(170.0_f32), y, ®ular); + y -= lh; + } + + layer.use_text( + "Generated automatically by InheritX.", + 7.0_f32, + lm, + y, + ®ular, + ); + + let mut buf = BufWriter::new(Vec::new()); + doc.save(&mut buf)?; + buf.into_inner().map_err(|e| { + printpdf::Error::IoError(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal::Decimal; + use uuid::Uuid; + + fn sample_data() -> ReportData { + ReportData { + plan: PlanRow { + id: Uuid::new_v4(), + owner_address: "GABC1234OWNER".to_string(), + token_address: "USDC".to_string(), + amount: Decimal::new(100_000, 2), + grace_period: 30, + grace_period_seconds: 2_592_000, + earn_yield: true, + last_ping: 1_700_000_000, + is_active: true, + status: "ACTIVE".to_string(), + yield_rate_bps: 500, + accrued_yield: Decimal::new(5_000, 3), + created_at: chrono::Utc::now(), + }, + beneficiaries: vec![ + BeneficiaryRow { + id: Uuid::new_v4(), + plan_id: Uuid::new_v4(), + wallet_address: "GBENEF1WALLET".to_string(), + allocation_bps: 6000, + fiat_anchor_info: "NGN/bank".to_string(), + }, + BeneficiaryRow { + id: Uuid::new_v4(), + plan_id: Uuid::new_v4(), + wallet_address: "GBENEF2WALLET".to_string(), + allocation_bps: 4000, + fiat_anchor_info: String::new(), + }, + ], + accrued_yield: 5.0, + } + } + + #[test] + fn test_build_pdf_returns_valid_bytes() { + let bytes = build_pdf_bytes(sample_data()).expect("PDF generation failed"); + assert!(bytes.starts_with(b"%PDF"), "output is not a valid PDF"); + assert!(bytes.len() > 1024, "PDF suspiciously small"); + } + + #[test] + fn test_build_pdf_no_beneficiaries() { + let mut data = sample_data(); + data.beneficiaries.clear(); + let bytes = build_pdf_bytes(data).expect("PDF generation failed"); + assert!(bytes.starts_with(b"%PDF")); + } +} From c69a9291e31a5340ebfd4655a705f1af318ea8a1 Mon Sep 17 00:00:00 2001 From: DammyAji Date: Mon, 29 Jun 2026 05:16:51 +0100 Subject: [PATCH 3/3] Fix CI: resolve merge conflicts and missing symbols for PDF audit report - Resolve unresolved merge conflict markers in backend/src/pdf_report.rs - Remove stray ' master' text from backend/src/api.rs - Add missing get_plan_report handler function signature - Restore chrono import and proper formatting in api.rs - Restore redis dependency in Cargo.toml (still used by cache/config) - Remove unused ApiError/ApiResponse struct from api.rs Fixes cargo fmt 'unclosed delimiter' error reported in CI. --- backend/Cargo.toml | 1 + backend/src/api.rs | 19 +++++--- backend/src/pdf_report.rs | 91 --------------------------------------- 3 files changed, 13 insertions(+), 98 deletions(-) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 6eccf9213..820bdf4c2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -28,4 +28,5 @@ jsonwebtoken = "9.0" base64 = "0.21" stellar-strkey = "0.0.8" ed25519-dalek = { version = "2.1", features = ["pkcs8", "rand_core"] } +redis = { version = "0.27", features = ["tokio-comp"] } printpdf = "0.7" diff --git a/backend/src/api.rs b/backend/src/api.rs index 3b10a1bc6..59c742768 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -6,7 +6,9 @@ use axum::{ response::{IntoResponse, Response}, routing::{get, post}, Json, Router, -};use rust_decimal::Decimal; +}; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; @@ -103,10 +105,6 @@ pub struct PayoutStatusResponse { } #[derive(Serialize)] -struct ApiError { - error: String, -} - pub fn create_router(state: Arc) -> Router { let cors = CorsLayer::new() .allow_origin(Any) @@ -835,6 +833,13 @@ async fn ping_plan( &beneficiary_addresses, ) .await; + +// Handler: Get Plan PDF Report +// Generates a downloadable PDF audit report for a specific plan. +async fn get_plan_report( + State(state): State>, + Path(plan_id): Path, +) -> impl IntoResponse { // 1. Load the plan. let plan = match sqlx::query_as::<_, PlanRow>( r#" @@ -944,7 +949,8 @@ async fn ping_plan( .unwrap_or_else(|_| HeaderValue::from_static("attachment; filename=\"report.pdf\"")), ); response -======= +} + // --- KYC Endpoints --- #[derive(Debug, Serialize, Deserialize)] @@ -1063,5 +1069,4 @@ async fn get_kyc_requirements() -> impl IntoResponse { }; (StatusCode::OK, Json(response)) - master } diff --git a/backend/src/pdf_report.rs b/backend/src/pdf_report.rs index 1bb7dcef6..95e3fb4c2 100644 --- a/backend/src/pdf_report.rs +++ b/backend/src/pdf_report.rs @@ -4,10 +4,7 @@ //! entirely synchronous and must not be called directly on the async runtime. use crate::api::{BeneficiaryRow, PlanRow}; -<<<<<<< HEAD use chrono::TimeZone as _; -======= ->>>>>>> e572481f0e59338dbc4c97aac8ef3da2a05d94d1 use printpdf::{BuiltinFont, Mm, PdfDocument}; use std::io::BufWriter; @@ -19,7 +16,6 @@ pub struct ReportData { pub accrued_yield: f64, } -<<<<<<< HEAD fn fmt_epoch(epoch: i64) -> String { chrono::Utc .timestamp_opt(epoch, 0) @@ -28,8 +24,6 @@ fn fmt_epoch(epoch: i64) -> String { .unwrap_or_else(|| epoch.to_string()) } -======= ->>>>>>> e572481f0e59338dbc4c97aac8ef3da2a05d94d1 /// Build and return raw PDF bytes for the given report data. /// /// **Synchronous** – run inside `tokio::task::spawn_blocking`. @@ -41,20 +35,11 @@ pub fn build_pdf_bytes(data: ReportData) -> Result, printpdf::Error> { let bold = doc.add_builtin_font(BuiltinFont::HelveticaBold)?; let regular = doc.add_builtin_font(BuiltinFont::Helvetica)?; -<<<<<<< HEAD let lm = Mm(15.0_f32); let rc = Mm(110.0_f32); let lh = Mm(7.0_f32); let mut y = Mm(280.0_f32); - // ── Title ───────────────────────────────────────────────────────────── - layer.use_text("InheritX - Inheritance Audit Report", 18.0_f32, lm, y, &bold); -======= - let lm = Mm(15.0_f32); // left margin - let rc = Mm(110.0_f32); // right / value column - let lh = Mm(7.0_f32); // line height - let mut y = Mm(280.0_f32); - // ── Title ───────────────────────────────────────────────────────────── layer.use_text( "InheritX - Inheritance Audit Report", @@ -63,15 +48,12 @@ pub fn build_pdf_bytes(data: ReportData) -> Result, printpdf::Error> { y, &bold, ); ->>>>>>> e572481f0e59338dbc4c97aac8ef3da2a05d94d1 y -= lh * 2.0_f32; // ── Plan Overview ───────────────────────────────────────────────────── layer.use_text("Plan Overview", 13.0_f32, lm, y, &bold); y -= lh; -<<<<<<< HEAD - // Pre-compute strings so references into them are valid for the slice. let plan_id = data.plan.id.to_string(); let amount_str = data.plan.amount.to_string(); let yield_rate_str = data.plan.yield_rate_bps.to_string(); @@ -95,47 +77,6 @@ pub fn build_pdf_bytes(data: ReportData) -> Result, printpdf::Error> { for (label, value) in overview { layer.use_text(*label, 10.0_f32, lm, y, ®ular); layer.use_text(*value, 10.0_f32, rc, y, ®ular); -======= - let rows: Vec<(&str, String)> = vec![ - ("Plan ID:", data.plan.id.to_string()), - ("Status:", data.plan.status.clone()), - ("Token:", data.plan.token_address.clone()), - ("Principal:", data.plan.amount.to_string()), - ( - "Yield Enabled:", - if data.plan.earn_yield { - "Yes".to_string() - } else { - "No".to_string() - }, - ), - ("Yield Rate (bps):", data.plan.yield_rate_bps.to_string()), - ("Accrued Yield:", format!("{:.6}", data.accrued_yield)), - ( - "Grace Period (s):", - data.plan.grace_period_seconds.to_string(), - ), - ( - "Active:", - if data.plan.is_active { - "Yes".to_string() - } else { - "No".to_string() - }, - ), - ( - "Created At:", - data.plan - .created_at - .format("%Y-%m-%d %H:%M UTC") - .to_string(), - ), - ]; - - for (label, value) in rows { - layer.use_text(*label, 10.0_f32, lm, y, ®ular); - layer.use_text(value.as_str(), 10.0_f32, rc, y, ®ular); ->>>>>>> e572481f0e59338dbc4c97aac8ef3da2a05d94d1 y -= lh; } y -= lh; @@ -144,9 +85,6 @@ pub fn build_pdf_bytes(data: ReportData) -> Result, printpdf::Error> { layer.use_text("Plan Owner", 13.0_f32, lm, y, &bold); y -= lh; layer.use_text("Wallet Address:", 10.0_f32, lm, y, ®ular); -<<<<<<< HEAD - layer.use_text(data.plan.owner_address.as_str(), 10.0_f32, rc, y, ®ular); -======= layer.use_text( data.plan.owner_address.as_str(), 10.0_f32, @@ -154,7 +92,6 @@ pub fn build_pdf_bytes(data: ReportData) -> Result, printpdf::Error> { y, ®ular, ); ->>>>>>> e572481f0e59338dbc4c97aac8ef3da2a05d94d1 y -= lh * 2.0_f32; // ── Activity Log ────────────────────────────────────────────────────── @@ -164,27 +101,14 @@ pub fn build_pdf_bytes(data: ReportData) -> Result, printpdf::Error> { let last_ping_str = if data.plan.last_ping == 0 { "Never pinged".to_string() } else { -<<<<<<< HEAD fmt_epoch(data.plan.last_ping) -======= - chrono::DateTime::from_timestamp(data.plan.last_ping, 0) - .map(|dt: chrono::DateTime| dt.format("%Y-%m-%d %H:%M UTC").to_string()) - .unwrap_or_else(|| data.plan.last_ping.to_string()) ->>>>>>> e572481f0e59338dbc4c97aac8ef3da2a05d94d1 }; layer.use_text("Last Proof-of-Life:", 10.0_f32, lm, y, ®ular); layer.use_text(last_ping_str.as_str(), 10.0_f32, rc, y, ®ular); y -= lh; let deadline_str = if data.plan.last_ping > 0 { -<<<<<<< HEAD fmt_epoch(data.plan.last_ping + data.plan.grace_period_seconds) -======= - let epoch = data.plan.last_ping + data.plan.grace_period_seconds; - chrono::DateTime::from_timestamp(epoch, 0) - .map(|dt: chrono::DateTime| dt.format("%Y-%m-%d %H:%M UTC").to_string()) - .unwrap_or_else(|| epoch.to_string()) ->>>>>>> e572481f0e59338dbc4c97aac8ef3da2a05d94d1 } else { "N/A".to_string() }; @@ -216,31 +140,16 @@ pub fn build_pdf_bytes(data: ReportData) -> Result, printpdf::Error> { b.fiat_anchor_info.clone() }; let pct = format!("{:.2}%", b.allocation_bps as f64 / 100.0); -<<<<<<< HEAD let bps = b.allocation_bps.to_string(); layer.use_text(addr.as_str(), 9.0_f32, lm, y, ®ular); layer.use_text(bps.as_str(), 9.0_f32, Mm(110.0_f32), y, ®ular); -======= - - layer.use_text(addr.as_str(), 9.0_f32, lm, y, ®ular); - layer.use_text( - &b.allocation_bps.to_string(), - 9.0_f32, - Mm(110.0_f32), - y, - ®ular, - ); ->>>>>>> e572481f0e59338dbc4c97aac8ef3da2a05d94d1 layer.use_text(pct.as_str(), 9.0_f32, Mm(145.0_f32), y, ®ular); layer.use_text(anchor.as_str(), 9.0_f32, Mm(170.0_f32), y, ®ular); y -= lh; } -<<<<<<< HEAD -======= y -= lh; ->>>>>>> e572481f0e59338dbc4c97aac8ef3da2a05d94d1 layer.use_text( "Generated automatically by InheritX.", 7.0_f32,