payments.rs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. use std::str::FromStr;
  2. use axum::body::Body;
  3. use axum::extract::{Query, State};
  4. use axum::http::StatusCode;
  5. use axum::response::{Html, Response};
  6. use axum::Form;
  7. use cdk_common::util::hex;
  8. use ldk_node::lightning::offers::offer::Offer;
  9. use ldk_node::lightning_invoice::Bolt11Invoice;
  10. use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus};
  11. use maud::html;
  12. use serde::Deserialize;
  13. use crate::web::handlers::utils::{deserialize_optional_u64, get_paginated_payments_streaming};
  14. use crate::web::handlers::AppState;
  15. use crate::web::templates::{
  16. error_message, format_msats_as_btc, format_sats_as_btc, info_card, is_node_running,
  17. layout_with_status, payment_list_item, success_message,
  18. };
  19. #[derive(Deserialize)]
  20. pub struct PaymentsQuery {
  21. filter: Option<String>,
  22. page: Option<u32>,
  23. per_page: Option<u32>,
  24. }
  25. #[derive(Debug, Deserialize)]
  26. pub struct PayBolt11Form {
  27. invoice: String,
  28. #[serde(deserialize_with = "deserialize_optional_u64")]
  29. amount_btc: Option<u64>,
  30. }
  31. #[derive(Deserialize)]
  32. pub struct PayBolt12Form {
  33. offer: String,
  34. #[serde(deserialize_with = "deserialize_optional_u64")]
  35. amount_btc: Option<u64>,
  36. }
  37. pub async fn payments_page(
  38. State(state): State<AppState>,
  39. query: Query<PaymentsQuery>,
  40. ) -> Result<Html<String>, StatusCode> {
  41. let filter = query.filter.as_deref().unwrap_or("all");
  42. let page = query.page.unwrap_or(1).max(1);
  43. let per_page = query.per_page.unwrap_or(25).clamp(10, 100); // Limit between 10-100 items per page
  44. // Use efficient pagination function
  45. let (current_page_payments, total_count) = get_paginated_payments_streaming(
  46. &state.node.inner,
  47. filter,
  48. ((page - 1) * per_page) as usize,
  49. per_page as usize,
  50. );
  51. // Calculate pagination
  52. let total_pages = ((total_count as f64) / (per_page as f64)).ceil() as u32;
  53. let start_index = ((page - 1) * per_page) as usize;
  54. let end_index = (start_index + per_page as usize).min(total_count);
  55. // Helper function to build URL with pagination params
  56. let build_url = |new_page: u32, new_filter: &str, new_per_page: u32| -> String {
  57. let mut params = vec![];
  58. if new_filter != "all" {
  59. params.push(format!("filter={}", new_filter));
  60. }
  61. if new_page != 1 {
  62. params.push(format!("page={}", new_page));
  63. }
  64. if new_per_page != 25 {
  65. params.push(format!("per_page={}", new_per_page));
  66. }
  67. if params.is_empty() {
  68. "/payments".to_string()
  69. } else {
  70. format!("/payments?{}", params.join("&"))
  71. }
  72. };
  73. let content = html! {
  74. h2 style="text-align: center; margin-bottom: 3rem;" { "Payments" }
  75. div class="card" {
  76. div class="payment-list-header" {
  77. div {
  78. h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { "Payment History" }
  79. @if total_count > 0 {
  80. p style="margin: 0.25rem 0 0 0; color: #666; font-size: 0.9rem;" {
  81. "Showing " (start_index + 1) " to " (end_index) " of " (total_count) " payments"
  82. }
  83. }
  84. }
  85. div class="payment-filter-tabs" {
  86. a href=(build_url(1, "all", per_page)) class=(if filter == "all" { "payment-filter-tab active" } else { "payment-filter-tab" }) { "All" }
  87. a href=(build_url(1, "incoming", per_page)) class=(if filter == "incoming" { "payment-filter-tab active" } else { "payment-filter-tab" }) { "Incoming" }
  88. a href=(build_url(1, "outgoing", per_page)) class=(if filter == "outgoing" { "payment-filter-tab active" } else { "payment-filter-tab" }) { "Outgoing" }
  89. }
  90. }
  91. // Payment list (no metrics here)
  92. @if current_page_payments.is_empty() {
  93. @if total_count == 0 {
  94. p { "No payments found." }
  95. } @else {
  96. p { "No payments found on this page. "
  97. a href=(build_url(1, filter, per_page)) { "Go to first page" }
  98. }
  99. }
  100. } @else {
  101. @for payment in &current_page_payments {
  102. @let direction_str = match payment.direction {
  103. PaymentDirection::Inbound => "Inbound",
  104. PaymentDirection::Outbound => "Outbound",
  105. };
  106. @let (payment_hash, description, payment_type, preimage) = match &payment.kind {
  107. PaymentKind::Bolt11 { hash, preimage, .. } => {
  108. (Some(hash.to_string()), None::<String>, "BOLT11", preimage.map(|p| p.to_string()))
  109. },
  110. PaymentKind::Bolt12Offer { hash, offer_id, preimage, .. } => {
  111. // For BOLT12, we can use either the payment hash or offer ID
  112. let identifier = hash.map(|h| h.to_string()).unwrap_or_else(|| offer_id.to_string());
  113. (Some(identifier), None::<String>, "BOLT12", preimage.map(|p| p.to_string()))
  114. },
  115. PaymentKind::Bolt12Refund { hash, preimage, .. } => {
  116. (hash.map(|h| h.to_string()), None::<String>, "BOLT12", preimage.map(|p| p.to_string()))
  117. },
  118. PaymentKind::Spontaneous { hash, preimage, .. } => {
  119. (Some(hash.to_string()), None::<String>, "Spontaneous", preimage.map(|p| p.to_string()))
  120. },
  121. PaymentKind::Onchain { txid, .. } => {
  122. (Some(txid.to_string()), None::<String>, "On-chain", None)
  123. },
  124. PaymentKind::Bolt11Jit { hash, .. } => {
  125. (Some(hash.to_string()), None::<String>, "BOLT11 JIT", None)
  126. },
  127. };
  128. @let status_str = {
  129. // Helper function to determine invoice status
  130. fn get_invoice_status(status: PaymentStatus, direction: PaymentDirection, payment_type: &str) -> &'static str {
  131. match status {
  132. PaymentStatus::Succeeded => "Succeeded",
  133. PaymentStatus::Failed => "Failed",
  134. PaymentStatus::Pending => {
  135. // For inbound BOLT11 payments, show "Unpaid" instead of "Pending"
  136. if direction == PaymentDirection::Inbound && payment_type == "BOLT11" {
  137. "Unpaid"
  138. } else {
  139. "Pending"
  140. }
  141. }
  142. }
  143. }
  144. get_invoice_status(payment.status, payment.direction, payment_type)
  145. };
  146. @let amount_str = {
  147. match (payment.amount_msat, payment.fee_paid_msat) {
  148. (Some(amount), Some(fee)) => format_msats_as_btc(amount + fee),
  149. (Some(amount), None) => format_msats_as_btc(amount),
  150. _ => "Unknown".to_string()
  151. }
  152. };
  153. (payment_list_item(
  154. &payment.id.to_string(),
  155. direction_str,
  156. status_str,
  157. &amount_str,
  158. payment_hash.as_deref(),
  159. description.as_deref(),
  160. Some(payment.latest_update_timestamp), // Use the actual timestamp
  161. payment_type,
  162. preimage.as_deref(),
  163. ))
  164. }
  165. }
  166. // Pagination controls (bottom)
  167. @if total_pages > 1 {
  168. div class="pagination-controls" style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #eee;" {
  169. div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;" {
  170. // Previous page
  171. @if page > 1 {
  172. a href=(build_url(page - 1, filter, per_page)) class="pagination-btn" { "← Previous" }
  173. } @else {
  174. span class="pagination-btn disabled" { "← Previous" }
  175. }
  176. // Page numbers
  177. @let start_page = (page.saturating_sub(2)).max(1);
  178. @let end_page = (page + 2).min(total_pages);
  179. @if start_page > 1 {
  180. a href=(build_url(1, filter, per_page)) class="pagination-number" { "1" }
  181. @if start_page > 2 {
  182. span class="pagination-ellipsis" { "..." }
  183. }
  184. }
  185. @for p in start_page..=end_page {
  186. @if p == page {
  187. span class="pagination-number active" { (p) }
  188. } @else {
  189. a href=(build_url(p, filter, per_page)) class="pagination-number" { (p) }
  190. }
  191. }
  192. @if end_page < total_pages {
  193. @if end_page < total_pages - 1 {
  194. span class="pagination-ellipsis" { "..." }
  195. }
  196. a href=(build_url(total_pages, filter, per_page)) class="pagination-number" { (total_pages) }
  197. }
  198. // Next page
  199. @if page < total_pages {
  200. a href=(build_url(page + 1, filter, per_page)) class="pagination-btn" { "Next →" }
  201. } @else {
  202. span class="pagination-btn disabled" { "Next →" }
  203. }
  204. }
  205. }
  206. }
  207. // Compact per-page selector integrated with pagination
  208. @if total_count > 0 {
  209. div class="per-page-selector" {
  210. label for="per-page" { "Show:" }
  211. select id="per-page" onchange="changePage()" {
  212. option value="10" selected[per_page == 10] { "10" }
  213. option value="25" selected[per_page == 25] { "25" }
  214. option value="50" selected[per_page == 50] { "50" }
  215. option value="100" selected[per_page == 100] { "100" }
  216. }
  217. span { "per page" }
  218. }
  219. }
  220. }
  221. // JavaScript for per-page selector
  222. script {
  223. "function changePage() {
  224. const perPageSelect = document.getElementById('per-page');
  225. const newPerPage = perPageSelect.value;
  226. const currentUrl = new URL(window.location);
  227. currentUrl.searchParams.set('per_page', newPerPage);
  228. currentUrl.searchParams.set('page', '1'); // Reset to first page when changing per_page
  229. window.location.href = currentUrl.toString();
  230. }"
  231. }
  232. };
  233. let is_running = is_node_running(&state.node.inner);
  234. Ok(Html(
  235. layout_with_status("Payment History", content, is_running).into_string(),
  236. ))
  237. }
  238. pub async fn send_payments_page(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
  239. let content = html! {
  240. h2 style="text-align: center; margin-bottom: 3rem;" { "Send Payment" }
  241. div class="card" {
  242. // Tab navigation
  243. div class="payment-tabs" style="display: flex; gap: 0.5rem; margin-bottom: 1.5rem; border-bottom: 1px solid hsl(var(--border)); padding-bottom: 0;" {
  244. button type="button" class="payment-tab active" onclick="switchTab('bolt11')" data-tab="bolt11" {
  245. "BOLT11 Invoice"
  246. }
  247. button type="button" class="payment-tab" onclick="switchTab('bolt12')" data-tab="bolt12" {
  248. "BOLT12 Offer"
  249. }
  250. }
  251. // BOLT11 tab content
  252. div id="bolt11-content" class="tab-content active" {
  253. form method="post" action="/payments/bolt11" {
  254. div class="form-group" {
  255. label for="invoice" { "BOLT11 Invoice" }
  256. textarea id="invoice" name="invoice" required placeholder="lnbc..." rows="4" {}
  257. }
  258. div class="form-group" {
  259. label for="amount_btc_bolt11" { "Amount Override (optional)" }
  260. input type="number" id="amount_btc_bolt11" name="amount_btc" placeholder="Leave empty to use invoice amount" step="1" {}
  261. p style="font-size: 0.8125rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem;" {
  262. "Only specify an amount if you want to override the invoice amount"
  263. }
  264. }
  265. div class="form-actions" {
  266. a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
  267. button type="submit" class="button-primary" { "Pay Invoice" }
  268. }
  269. }
  270. }
  271. // BOLT12 tab content
  272. div id="bolt12-content" class="tab-content" {
  273. form method="post" action="/payments/bolt12" {
  274. div class="form-group" {
  275. label for="offer" { "BOLT12 Offer" }
  276. textarea id="offer" name="offer" required placeholder="lno..." rows="4" {}
  277. }
  278. div class="form-group" {
  279. label for="amount_btc_bolt12" { "Amount" }
  280. input type="number" id="amount_btc_bolt12" name="amount_btc" placeholder="Amount in satoshis" step="1" {}
  281. p style="font-size: 0.8125rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem;" {
  282. "Required for variable amount offers, ignored for fixed amount offers"
  283. }
  284. }
  285. div class="form-actions" {
  286. a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
  287. button type="submit" class="button-primary" { "Pay Offer" }
  288. }
  289. }
  290. }
  291. }
  292. // Tab switching script
  293. script type="text/javascript" {
  294. (maud::PreEscaped(r#"
  295. function switchTab(tabName) {
  296. console.log('Switching to tab:', tabName);
  297. // Hide all tab contents
  298. const contents = document.querySelectorAll('.tab-content');
  299. contents.forEach(content => content.classList.remove('active'));
  300. // Remove active class from all tabs
  301. const tabs = document.querySelectorAll('.payment-tab');
  302. tabs.forEach(tab => tab.classList.remove('active'));
  303. // Show selected tab content
  304. const tabContent = document.getElementById(tabName + '-content');
  305. if (tabContent) {
  306. tabContent.classList.add('active');
  307. console.log('Activated tab content:', tabName);
  308. }
  309. // Add active class to selected tab
  310. const tabButton = document.querySelector('[data-tab="' + tabName + '"]');
  311. if (tabButton) {
  312. tabButton.classList.add('active');
  313. console.log('Activated tab button:', tabName);
  314. }
  315. }
  316. "#))
  317. }
  318. };
  319. let is_running = is_node_running(&state.node.inner);
  320. Ok(Html(
  321. layout_with_status("Send Payments", content, is_running).into_string(),
  322. ))
  323. }
  324. pub async fn post_pay_bolt11(
  325. State(state): State<AppState>,
  326. Form(form): Form<PayBolt11Form>,
  327. ) -> Result<Response, StatusCode> {
  328. let invoice = match Bolt11Invoice::from_str(form.invoice.trim()) {
  329. Ok(inv) => inv,
  330. Err(e) => {
  331. tracing::warn!("Web interface: Invalid BOLT11 invoice provided: {}", e);
  332. let content = html! {
  333. (error_message(&format!("Invalid BOLT11 invoice: {e}")))
  334. div class="card" {
  335. a href="/payments" { button { "← Try Again" } }
  336. }
  337. };
  338. return Ok(Response::builder()
  339. .status(StatusCode::BAD_REQUEST)
  340. .header("content-type", "text/html")
  341. .body(Body::from(
  342. layout_with_status("Payment Error", content, true).into_string(),
  343. ))
  344. .unwrap());
  345. }
  346. };
  347. tracing::info!(
  348. "Web interface: Attempting to pay BOLT11 invoice payment_hash={}, amount_override={:?}",
  349. invoice.payment_hash(),
  350. form.amount_btc
  351. );
  352. let payment_id = if let Some(amount_btc) = form.amount_btc {
  353. // Convert Bitcoin to millisatoshis
  354. let amount_msats = amount_btc * 1000;
  355. state
  356. .node
  357. .inner
  358. .bolt11_payment()
  359. .send_using_amount(&invoice, amount_msats, None)
  360. } else {
  361. state.node.inner.bolt11_payment().send(&invoice, None)
  362. };
  363. let payment_id = match payment_id {
  364. Ok(id) => {
  365. tracing::info!(
  366. "Web interface: BOLT11 payment initiated with payment_id={}",
  367. hex::encode(id.0)
  368. );
  369. id
  370. }
  371. Err(e) => {
  372. tracing::error!("Web interface: Failed to initiate BOLT11 payment: {}", e);
  373. let content = html! {
  374. (error_message(&format!("Failed to initiate payment: {e}")))
  375. div class="card" {
  376. a href="/payments" { button { "← Try Again" } }
  377. }
  378. };
  379. return Ok(Response::builder()
  380. .status(StatusCode::INTERNAL_SERVER_ERROR)
  381. .header("content-type", "text/html")
  382. .body(Body::from(
  383. layout_with_status("Payment Error", content, true).into_string(),
  384. ))
  385. .unwrap());
  386. }
  387. };
  388. // Wait for payment to complete (max 10 seconds)
  389. let start = std::time::Instant::now();
  390. let timeout = std::time::Duration::from_secs(10);
  391. let payment_result = loop {
  392. if let Some(details) = state.node.inner.payment(&payment_id) {
  393. match details.status {
  394. PaymentStatus::Succeeded => {
  395. tracing::info!(
  396. "Web interface: BOLT11 payment succeeded for payment_hash={}",
  397. invoice.payment_hash()
  398. );
  399. break Ok(details);
  400. }
  401. PaymentStatus::Failed => {
  402. tracing::error!(
  403. "Web interface: BOLT11 payment failed for payment_hash={}",
  404. invoice.payment_hash()
  405. );
  406. break Err("Payment failed".to_string());
  407. }
  408. PaymentStatus::Pending => {
  409. if start.elapsed() > timeout {
  410. tracing::warn!(
  411. "Web interface: BOLT11 payment timeout for payment_hash={}",
  412. invoice.payment_hash()
  413. );
  414. break Err("Payment is still pending after timeout".to_string());
  415. }
  416. tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  417. continue;
  418. }
  419. }
  420. } else {
  421. break Err("Payment not found".to_string());
  422. }
  423. };
  424. let content = match payment_result {
  425. Ok(details) => {
  426. let (preimage, fee_msats) = match details.kind {
  427. PaymentKind::Bolt11 {
  428. hash: _,
  429. preimage,
  430. secret: _,
  431. } => (
  432. preimage.map(|p| p.to_string()).unwrap_or_default(),
  433. details.fee_paid_msat.unwrap_or(0),
  434. ),
  435. _ => (String::new(), 0),
  436. };
  437. html! {
  438. (success_message("Payment succeeded!"))
  439. (info_card(
  440. "Payment Details",
  441. vec![
  442. ("Payment Hash", invoice.payment_hash().to_string()),
  443. ("Payment Preimage", preimage),
  444. ("Fee Paid", format_msats_as_btc(fee_msats)),
  445. ("Amount", form.amount_btc.map(|_a| format_sats_as_btc(details.amount_msat.unwrap_or(1000) / 1000)).unwrap_or_default()),
  446. ]
  447. ))
  448. div class="card" {
  449. a href="/payments" { button { "← Make Another Payment" } }
  450. }
  451. }
  452. }
  453. Err(error) => {
  454. html! {
  455. (error_message(&format!("Payment failed: {error}")))
  456. div class="card" {
  457. a href="/payments" { button { "← Try Again" } }
  458. }
  459. }
  460. }
  461. };
  462. Ok(Response::builder()
  463. .header("content-type", "text/html")
  464. .body(Body::from(
  465. layout_with_status("Payment Result", content, true).into_string(),
  466. ))
  467. .unwrap())
  468. }
  469. pub async fn post_pay_bolt12(
  470. State(state): State<AppState>,
  471. Form(form): Form<PayBolt12Form>,
  472. ) -> Result<Response, StatusCode> {
  473. let offer = match Offer::from_str(form.offer.trim()) {
  474. Ok(offer) => offer,
  475. Err(e) => {
  476. tracing::warn!("Web interface: Invalid BOLT12 offer provided: {:?}", e);
  477. let content = html! {
  478. (error_message(&format!("Invalid BOLT12 offer: {e:?}")))
  479. div class="card" {
  480. a href="/payments" { button { "← Try Again" } }
  481. }
  482. };
  483. return Ok(Response::builder()
  484. .status(StatusCode::BAD_REQUEST)
  485. .header("content-type", "text/html")
  486. .body(Body::from(
  487. layout_with_status("Payment Error", content, true).into_string(),
  488. ))
  489. .unwrap());
  490. }
  491. };
  492. tracing::info!(
  493. "Web interface: Attempting to pay BOLT12 offer offer_id={}, amount_override={:?}",
  494. offer.id(),
  495. form.amount_btc
  496. );
  497. // Determine payment method based on offer type and user input
  498. let payment_id = match offer.amount() {
  499. Some(_) => {
  500. // Fixed amount offer - use send() method, ignore user input amount
  501. state.node.inner.bolt12_payment().send(&offer, None, None)
  502. }
  503. None => {
  504. // Variable amount offer - requires user to specify amount via send_using_amount()
  505. let amount_btc = match form.amount_btc {
  506. Some(amount) => amount,
  507. None => {
  508. tracing::warn!("Web interface: Amount required for variable amount BOLT12 offer but not provided");
  509. let content = html! {
  510. (error_message("Amount is required for variable amount offers. This offer does not have a fixed amount, so you must specify how much you want to pay."))
  511. div class="card" {
  512. a href="/payments" { button { "← Try Again" } }
  513. }
  514. };
  515. return Ok(Response::builder()
  516. .status(StatusCode::BAD_REQUEST)
  517. .header("content-type", "text/html")
  518. .body(Body::from(
  519. layout_with_status("Payment Error", content, true).into_string(),
  520. ))
  521. .unwrap());
  522. }
  523. };
  524. let amount_msats = amount_btc * 1_000;
  525. state
  526. .node
  527. .inner
  528. .bolt12_payment()
  529. .send_using_amount(&offer, amount_msats, None, None)
  530. }
  531. };
  532. let payment_id = match payment_id {
  533. Ok(id) => {
  534. tracing::info!(
  535. "Web interface: BOLT12 payment initiated with payment_id={}",
  536. hex::encode(id.0)
  537. );
  538. id
  539. }
  540. Err(e) => {
  541. tracing::error!("Web interface: Failed to initiate BOLT12 payment: {}", e);
  542. let content = html! {
  543. (error_message(&format!("Failed to initiate payment: {e}")))
  544. div class="card" {
  545. a href="/payments" { button { "← Try Again" } }
  546. }
  547. };
  548. return Ok(Response::builder()
  549. .status(StatusCode::INTERNAL_SERVER_ERROR)
  550. .header("content-type", "text/html")
  551. .body(Body::from(
  552. layout_with_status("Payment Error", content, true).into_string(),
  553. ))
  554. .unwrap());
  555. }
  556. };
  557. // Wait for payment to complete (max 10 seconds)
  558. let start = std::time::Instant::now();
  559. let timeout = std::time::Duration::from_secs(10);
  560. let payment_result = loop {
  561. if let Some(details) = state.node.inner.payment(&payment_id) {
  562. match details.status {
  563. PaymentStatus::Succeeded => {
  564. tracing::info!(
  565. "Web interface: BOLT12 payment succeeded for offer_id={}",
  566. offer.id()
  567. );
  568. break Ok(details);
  569. }
  570. PaymentStatus::Failed => {
  571. tracing::error!(
  572. "Web interface: BOLT12 payment failed for offer_id={}",
  573. offer.id()
  574. );
  575. break Err("Payment failed".to_string());
  576. }
  577. PaymentStatus::Pending => {
  578. if start.elapsed() > timeout {
  579. tracing::warn!(
  580. "Web interface: BOLT12 payment timeout for offer_id={}",
  581. offer.id()
  582. );
  583. break Err("Payment is still pending after timeout".to_string());
  584. }
  585. tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  586. continue;
  587. }
  588. }
  589. } else {
  590. break Err("Payment not found".to_string());
  591. }
  592. };
  593. let content = match payment_result {
  594. Ok(details) => {
  595. let (payment_hash, preimage, fee_msats) = match details.kind {
  596. PaymentKind::Bolt12Offer {
  597. hash,
  598. preimage,
  599. secret: _,
  600. offer_id: _,
  601. payer_note: _,
  602. quantity: _,
  603. } => (
  604. hash.map(|h| h.to_string()).unwrap_or_default(),
  605. preimage.map(|p| p.to_string()).unwrap_or_default(),
  606. details.fee_paid_msat.unwrap_or(0),
  607. ),
  608. _ => (String::new(), String::new(), 0),
  609. };
  610. html! {
  611. (success_message("Payment succeeded!"))
  612. (info_card(
  613. "Payment Details",
  614. vec![
  615. ("Payment Hash", payment_hash),
  616. ("Payment Preimage", preimage),
  617. ("Fee Paid", format_msats_as_btc(fee_msats)),
  618. ("Amount Paid", form.amount_btc.map(format_sats_as_btc).unwrap_or_else(|| {
  619. // If no amount was specified in the form, show the actual amount from the payment details
  620. details.amount_msat.map(format_msats_as_btc).unwrap_or_else(|| "Unknown".to_string())
  621. })),
  622. ]
  623. ))
  624. div class="card" {
  625. a href="/payments" { button { "← Make Another Payment" } }
  626. }
  627. }
  628. }
  629. Err(error) => {
  630. html! {
  631. (error_message(&format!("Payment failed: {error}")))
  632. div class="card" {
  633. a href="/payments" { button { "← Try Again" } }
  634. }
  635. }
  636. }
  637. };
  638. Ok(Response::builder()
  639. .header("content-type", "text/html")
  640. .body(Body::from(
  641. layout_with_status("Payment Result", content, true).into_string(),
  642. ))
  643. .unwrap())
  644. }