diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 666849c6e..820bdf4c2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -29,3 +29,4 @@ 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 28a848303..59c742768 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -1,9 +1,9 @@ use axum::{ - extract::{Query, State}, - http::StatusCode, - http::{header::HeaderName, HeaderValue}, + body::Body, + extract::{Path, Query, State}, + http::{header::HeaderName, HeaderValue, StatusCode}, middleware::from_fn, - response::IntoResponse, + response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; @@ -18,6 +18,7 @@ use uuid::Uuid; use crate::auth::signature_auth_middleware; use crate::cache::PlanCache; 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; @@ -104,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) @@ -124,6 +121,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("/api/kyc/status", get(get_kyc_status)) @@ -836,374 +834,121 @@ async fn ping_plan( ) .await; - // 5. Return updated plan status and virtual balance - let virtual_balance = plan.amount + new_accrued_yield; - ( - StatusCode::OK, - Json(PingResponse { - owner: plan.owner_address, - status: plan.status, - virtual_balance, - }), - ) - .into_response() -} -// Handler: Trigger Payout -// Contributors: Implement calculating final payout with yield, parsing fiat payout details, -// submitting fiat payouts to AnchorRegistry, and marking the plan inactive -async fn trigger_payout( +// Handler: Get Plan PDF Report +// Generates a downloadable PDF audit report for a specific plan. +async fn get_plan_report( State(state): State>, - Json(payload): Json, + Path(plan_id): Path, ) -> impl IntoResponse { - // 1. Begin database transaction - let mut tx = match state.db_pool.begin().await { - Ok(tx) => tx, - Err(e) => { - error!(error = %e, "Failed to begin database transaction"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": format!("Failed to begin database transaction: {}", e) })), - ).into_response(); - } - }; - - // 2. Fetch the active plan for the owner + // 1. Load the plan. let plan = match sqlx::query_as::<_, PlanRow>( - "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 owner_address = $1 AND is_active = true FOR UPDATE", + 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(&payload.owner) - .fetch_optional(&mut *tx) + .bind(plan_id) + .fetch_optional(&state.db_pool) .await { Ok(Some(p)) => p, Ok(None) => { return ( StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "No active plan found for this owner" })), - ).into_response(); + Json(serde_json::json!({ "error": "Plan not found" })), + ) + .into_response(); } Err(e) => { - error!(owner = %payload.owner, error = %e, "Database error fetching plan"); + error!(error = %e, %plan_id, "Failed to fetch plan for report"); return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": format!("Database error: {}", e) })), - ).into_response(); + Json(serde_json::json!({ "error": "Database error" })), + ) + .into_response(); } }; - // 3. Verify if the grace period has elapsed - let now = chrono::Utc::now().timestamp(); - let deadline = plan.last_ping + plan.grace_period_seconds; - if now < deadline { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": "Grace period has not elapsed" })), - ) - .into_response(); - } - - // 4. Compute final locked amount + yield - let accrued_yield_f64 = compute_projected_accrued_yield(&plan); - let accrued_yield_dec = match Decimal::from_f64_retain(accrued_yield_f64) { - Some(d) => d.normalize(), - None => Decimal::ZERO, - }; - let total_payout_dec = plan.amount + accrued_yield_dec; - - // 5. Load beneficiaries for the plan - let beneficiaries_rows = match sqlx::query_as::<_, BeneficiaryRow>( + // 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(&mut *tx) + .bind(plan_id) + .fetch_all(&state.db_pool) .await { Ok(rows) => rows, Err(e) => { - error!(plan_id = %plan.id, error = %e, "Failed to load beneficiaries"); + error!(error = %e, %plan_id, "Failed to fetch beneficiaries for report"); return ( StatusCode::INTERNAL_SERVER_ERROR, - Json( - serde_json::json!({ "error": format!("Failed to load beneficiaries: {}", e) }), - ), + Json(serde_json::json!({ "error": "Database error" })), ) .into_response(); } }; - let n = beneficiaries_rows.len(); - if n == 0 { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": "Plan has no beneficiaries" })), - ) - .into_response(); - } - - // 6. Iterate over beneficiaries and insert payout records - let mut remaining = total_payout_dec; - let mut payout_rows = Vec::with_capacity(n); - - for (i, b) in beneficiaries_rows.iter().enumerate() { - let share = if i == n - 1 { - remaining - } else { - let amount = - (total_payout_dec * Decimal::from(b.allocation_bps)) / Decimal::from(10000); - let amount = amount.floor(); - remaining -= amount; - amount - }; - - if share <= Decimal::ZERO { - continue; - } - - let is_fiat = !b.fiat_anchor_info.trim().is_empty(); - let payout_type_str = if is_fiat { "fiat" } else { "crypto" }; - let payout_status_str = "processing"; - - let payout_row = match sqlx::query_as::<_, PayoutRow>( - r#" - INSERT INTO payouts (plan_id, beneficiary_address, amount, payout_type, status) - VALUES ($1, $2, $3, $4::payout_type, $5::payout_status) - RETURNING id, plan_id, beneficiary_address, amount::text, payout_type::text, status::text, created_at - "#, - ) - .bind(plan.id) - .bind(&b.wallet_address) - .bind(share) - .bind(payout_type_str) - .bind(payout_status_str) - .fetch_one(&mut *tx) - .await { - Ok(row) => row, - Err(e) => { - error!(plan_id = %plan.id, beneficiary = %b.wallet_address, error = %e, "Failed to insert payout record"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": format!("Failed to insert payout record: {}", e) })), - ).into_response(); - } - }; - - // Initiate payout distribution - if is_fiat { - let (beneficiary_name, fiat_currency, bank_name, account_number) = - parse_fiat_anchor_info(&b.fiat_anchor_info, &b.wallet_address); - let token_amount_f64 = share.to_string().parse::().unwrap_or(0.0); - let req = crate::stellar_anchor::AnchorPayoutRequest { - beneficiary_address: b.wallet_address.clone(), - beneficiary_name, - token: plan.token_address.clone(), - token_amount: token_amount_f64, - fiat_currency, - bank_name, - account_number, - }; - state.anchor.create_payout(req); - } else { - tracing::info!( - plan_id = %plan.id, - beneficiary = %b.wallet_address, - amount = %share, - "Initiated on-chain crypto distribution" - ); - } - - payout_rows.push(payout_row); - } - - // 7. Mark the plan as inactive - if let Err(e) = sqlx::query( - "UPDATE plans SET is_active = false, status = 'PAID_OUT', accrued_yield = $1, last_ping = $2 WHERE id = $3" - ) - .bind(accrued_yield_dec) - .bind(now) - .bind(plan.id) - .execute(&mut *tx) - .await { - error!(plan_id = %plan.id, error = %e, "Failed to mark plan as inactive"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": format!("Failed to mark plan as inactive: {}", e) })), - ).into_response(); - } - - // 8. Commit transaction - if let Err(e) = tx.commit().await { - error!(error = %e, "Failed to commit database transaction"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": format!("Failed to commit database transaction: {}", e) })), - ).into_response(); - } - - // 9. Invalidate cache - let beneficiary_addresses: Vec = beneficiaries_rows - .iter() - .map(|b| b.wallet_address.clone()) - .collect(); - invalidate_plan_cache( - &state.plan_cache, - &plan.owner_address, - &beneficiary_addresses, - ) - .await; - - (StatusCode::OK, Json(payout_rows)).into_response() -} - -fn parse_fiat_anchor_info(info: &str, wallet_address: &str) -> (String, String, String, String) { - #[derive(Deserialize)] - struct LocalAnchorInfo { - name: Option, - currency: Option, - bank: Option, - account: Option, - } - - if let Ok(parsed) = serde_json::from_str::(info) { - return ( - parsed.name.unwrap_or_else(|| "Beneficiary".to_string()), - parsed.currency.unwrap_or_else(|| "USD".to_string()), - parsed - .bank - .unwrap_or_else(|| "Stellar Anchor Bank".to_string()), - parsed.account.unwrap_or_else(|| { - format!("ACC-{}", &wallet_address[..8.min(wallet_address.len())]) - }), - ); - } - - let parts: Vec<&str> = if info.contains(';') { - info.split(';').map(|s| s.trim()).collect() - } else if info.contains(',') { - info.split(',').map(|s| s.trim()).collect() - } else { - vec![info] - }; - - if parts.len() >= 3 { - return ( - "Beneficiary".to_string(), - parts[2].to_string(), - parts[0].to_string(), - parts[1].to_string(), - ); - } - - let info_upper = info.to_uppercase(); - let fiat_currency = if info_upper.contains("NGN") { - "NGN" - } else if info_upper.contains("KES") { - "KES" - } else if info_upper.contains("BRL") { - "BRL" - } else if info_upper.contains("PHP") { - "PHP" - } else if info_upper.contains("EUR") { - "EUR" - } else { - "USD" - }; - - let bank_name = if info.trim().is_empty() { - "Stellar Anchor Bank".to_string() - } else { - info.to_string() + // 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, }; - let account_number = format!("ACC-{}", &wallet_address[..8.min(wallet_address.len())]); - - ( - "Beneficiary".to_string(), - fiat_currency.to_string(), - bank_name, - account_number, - ) -} -// -// Handler: Get Anchor Payouts -// Queries the payouts table filtered by beneficiary_address with pagination. -async fn get_anchor_payouts( - State(state): State>, - Query(query): Query, -) -> impl IntoResponse { - let page = query.page.unwrap_or(1).max(1); - let page_size = query.page_size.unwrap_or(20).clamp(1, 100); - let offset = (page - 1) * page_size; - let address = query.beneficiary_address.as_deref(); - - let total: i64 = match sqlx::query_scalar( - r#"SELECT COUNT(*) FROM payouts WHERE ($1::text IS NULL OR beneficiary_address = $1)"#, - ) - .bind(address) - .fetch_one(&state.db_pool) - .await + // 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(count) => count, - Err(e) => { - error!(error = %e, "Failed to count payouts"); + Ok(Ok(bytes)) => bytes, + Ok(Err(e)) => { + error!(error = %e, "PDF generation failed"); return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError { - error: "Database query failed".to_string(), - }), + Json(serde_json::json!({ "error": "Failed to generate PDF" })), ) .into_response(); } - }; - - let rows: Vec = match sqlx::query_as::<_, PayoutRow>( - r#" - SELECT - id, - plan_id, - beneficiary_address, - amount::text AS amount, - payout_type::text AS payout_type, - status::text AS status, - created_at - FROM payouts - WHERE ($1::text IS NULL OR beneficiary_address = $1) - ORDER BY created_at DESC - LIMIT $2 OFFSET $3 - "#, - ) - .bind(address) - .bind(page_size) - .bind(offset) - .fetch_all(&state.db_pool) - .await - { - Ok(rows) => rows, Err(e) => { - error!(error = %e, "Failed to query payouts"); + error!(error = %e, "PDF generation task panicked"); return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError { - error: "Database query failed".to_string(), - }), + Json(serde_json::json!({ "error": "PDF generation task failed" })), ) .into_response(); } }; - ( - StatusCode::OK, - Json(PayoutStatusResponse { - data: rows, - page, - page_size, - total, - }), - ) - .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 } // --- KYC Endpoints --- @@ -1214,7 +959,6 @@ pub struct KYCStatusResponse { pub kyc_status: String, pub submitted_at: Option>, pub approved_at: Option>, - pub rejected_at: Option>, pub rejection_reason: Option, pub provider_reference: Option, } @@ -1284,28 +1028,8 @@ async fn submit_kyc(Json(_payload): Json) -> impl IntoResponse rejection_reason: None, provider_reference: Some("ref-001".to_string()), }; - (StatusCode::OK, Json(response)) -} - -// Upload KYC document -async fn upload_kyc_document() -> impl IntoResponse { - // In a real implementation, this would: - // 1. Receive multipart form data with file and document_type - // 2. Validate file (size, type) - // 3. Upload to cloud storage (S3, etc.) - // 4. Store metadata in database - // 5. Return document_id and URL - - let response = KYCDocumentResponse { - document_id: Uuid::new_v4().to_string(), - url: "https://example.com/documents/doc-001".to_string(), - }; - - (StatusCode::OK, Json(response)) -} - -// Check if KYC is required + // Check if KYC is required async fn is_kyc_required() -> impl IntoResponse { #[derive(Debug, Serialize)] struct RequiredResponse { diff --git a/backend/src/lib.rs b/backend/src/lib.rs index c75cf7567..3e615032b 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -5,6 +5,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..95e3fb4c2 --- /dev/null +++ b/backend/src/pdf_report.rs @@ -0,0 +1,228 @@ +//! 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; + + 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; + } + + 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")); + } +}