Skip to content

Commit 59ce9c5

Browse files
authored
Merge pull request #191 from PortalTechnologiesInc/wheatley/issue-151-amount-wrapper-minimal
feat(amount): add lightweight Amount wrapper for msats/fiat cents
2 parents 7eeee7a + 4f706e1 commit 59ce9c5

4 files changed

Lines changed: 66 additions & 24 deletions

File tree

crates/portal-rest/src/handlers.rs

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ use portal::nostr_relay_pool::RelayOptions;
1818
use portal::protocol::calendar::Calendar;
1919
use portal::protocol::jwt::CustomClaims;
2020
use portal::protocol::model::payment::{
21-
CashuDirectContent, CashuRequestContent, Currency, ExchangeRate, InvoiceRequestContent,
22-
PaymentStatus, RecurringPaymentRequestContent, SinglePaymentRequestContent,
21+
Amount, CashuDirectContent, CashuRequestContent, Currency, ExchangeRate,
22+
InvoiceRequestContent, PaymentStatus, RecurringPaymentRequestContent,
23+
SinglePaymentRequestContent,
2324
};
2425
use portal::protocol::model::Timestamp;
2526
use portal::utils::fetch_nip05_profile as portal_fetch_nip05;
@@ -72,22 +73,22 @@ fn parse_subkeys(subkeys: &[String]) -> Result<Vec<PublicKey>, String> {
7273
/// Resolve amount and exchange rate: for Millisats returns (amount, None);
7374
/// for Fiat fetches market data and returns (amount_msat, Some(ExchangeRate)).
7475
async fn resolve_amount_and_exchange_rate(
75-
amount: u64,
76+
amount: Amount,
7677
currency: &Currency,
7778
market_api: Arc<portal_rates::MarketAPI>,
78-
) -> Result<(u64, Option<ExchangeRate>), portal_rates::RatesError> {
79+
) -> Result<(Amount, Option<ExchangeRate>), portal_rates::RatesError> {
7980
match currency {
8081
Currency::Millisats => Ok((amount, None)),
8182
Currency::Fiat(currency_code) => {
8283
let market_data = market_api.fetch_market_data(currency_code).await?;
83-
let fiat_amount = amount as f64 / 100.0;
84+
let fiat_amount = amount.as_fiat_major();
8485
let msat = (market_data.calculate_millisats(fiat_amount) as i64).max(0) as u64;
8586
let exchange_rate = ExchangeRate {
8687
rate: market_data.rate,
8788
source: market_data.source,
8889
time: Timestamp::now(),
8990
};
90-
Ok((msat, Some(exchange_rate)))
91+
Ok((Amount::new(msat), Some(exchange_rate)))
9192
}
9293
}
9394
}
@@ -316,7 +317,7 @@ pub async fn request_recurring_payment(
316317
let subkeys = parse_subkeys(&req.subkeys).map_err(|e| bad_request(format!("Invalid subkeys: {e}")))?;
317318

318319
let (_, current_exchange_rate) = resolve_amount_and_exchange_rate(
319-
req.payment_request.amount,
320+
Amount::new(req.payment_request.amount),
320321
&req.payment_request.currency,
321322
state.market_api.clone(),
322323
)
@@ -325,7 +326,7 @@ pub async fn request_recurring_payment(
325326

326327
let payment_request = RecurringPaymentRequestContent {
327328
description: req.payment_request.description,
328-
amount: req.payment_request.amount,
329+
amount: Amount::new(req.payment_request.amount),
329330
currency: req.payment_request.currency,
330331
auth_token: req.payment_request.auth_token,
331332
recurrence: req.payment_request.recurrence,
@@ -383,22 +384,22 @@ pub async fn request_single_payment(
383384

384385
let amount = req.payment_request.amount;
385386
let (msat_amount, current_exchange_rate) = resolve_amount_and_exchange_rate(
386-
amount,
387+
Amount::new(amount),
387388
&req.payment_request.currency,
388389
state.market_api.clone(),
389390
)
390391
.await
391392
.map_err(|e| internal_error(format!("Failed to fetch market data: {e}")))?;
392393

393394
let invoice = wallet
394-
.make_invoice(msat_amount, Some(req.payment_request.description.clone()))
395+
.make_invoice(msat_amount.as_millisats(), Some(req.payment_request.description.clone()))
395396
.await
396397
.map_err(|e| internal_error(format!("Failed to make invoice: {e}")))?;
397398

398399
let request_id = req.payment_request.request_id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
399400
let expires_at = Timestamp::now_plus_seconds(300);
400401
let payment_request = SinglePaymentRequestContent {
401-
amount,
402+
amount: Amount::new(amount),
402403
currency: req.payment_request.currency,
403404
expires_at,
404405
invoice: invoice.clone(),
@@ -575,7 +576,7 @@ pub async fn request_invoice(
575576

576577
// Resolve amount/exchange rate synchronously — errors returned as 400
577578
let (expected_amount_msat, current_exchange_rate) = resolve_amount_and_exchange_rate(
578-
req.content.amount,
579+
Amount::new(req.content.amount),
579580
&req.content.currency,
580581
state.market_api.clone(),
581582
)
@@ -584,7 +585,7 @@ pub async fn request_invoice(
584585

585586
let sdk_content = InvoiceRequestContent {
586587
request_id,
587-
amount: req.content.amount,
588+
amount: Amount::new(req.content.amount),
588589
currency: req.content.currency.clone(),
589590
current_exchange_rate,
590591
expires_at: req.content.expires_at,
@@ -630,15 +631,15 @@ pub async fn request_invoice(
630631
}
631632
};
632633

633-
let amount_diff =
634-
(invoice_amount_msat as i128 - expected_amount_msat as i128).abs();
634+
let expected_msat = expected_amount_msat.as_millisats();
635+
let amount_diff = (invoice_amount_msat as i128 - expected_msat as i128).abs();
635636
if amount_diff > 1 {
636637
events
637638
.push(
638639
&sid,
639640
NotificationData::Error {
640641
reason: format!(
641-
"Invoice amount mismatch: got {invoice_amount_msat} msat, expected {expected_amount_msat} msat (diff: {amount_diff} msat)"
642+
"Invoice amount mismatch: got {invoice_amount_msat} msat, expected {expected_msat} msat (diff: {amount_diff} msat)"
642643
),
643644
},
644645
)

crates/portal/src/conversation/app/payments.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@ impl PaymentRequestContent {
133133

134134
pub fn amount(&self) -> u64 {
135135
match self {
136-
Self::Single(content) => content.amount,
137-
Self::Recurring(content) => content.amount,
136+
Self::Single(content) => content.amount.as_u64(),
137+
Self::Recurring(content) => content.amount.as_u64(),
138138
}
139139
}
140140
}

crates/portal/src/conversation/sdk/payments.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ impl RecurringPaymentRequestSenderConversation {
3232
subkey_proof: Option<SubkeyProof>,
3333
payment_request: RecurringPaymentRequestContent,
3434
) -> Result<Self, String> {
35-
if payment_request.amount == 0 {
35+
if payment_request.amount.as_u64() == 0 {
3636
return Err("Recurring payment amount must be greater than zero".to_string());
3737
}
3838

@@ -125,7 +125,7 @@ impl SinglePaymentRequestSenderConversation {
125125
subkey_proof: Option<SubkeyProof>,
126126
payment_request: SinglePaymentRequestContent,
127127
) -> Result<Self, String> {
128-
if payment_request.amount == 0 {
128+
if payment_request.amount.as_u64() == 0 {
129129
return Err("Payment amount must be greater than zero".to_string());
130130
}
131131

crates/portal/src/protocol/model.rs

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,51 @@ pub mod payment {
245245

246246
use super::*;
247247

248+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
249+
#[serde(transparent)]
250+
#[cfg_attr(feature = "bindings", derive(uniffi::Record))]
251+
pub struct Amount {
252+
pub value: u64,
253+
}
254+
255+
impl Amount {
256+
pub const fn new(value: u64) -> Self {
257+
Self { value }
258+
}
259+
260+
pub const fn as_u64(self) -> u64 {
261+
self.value
262+
}
263+
264+
pub const fn as_millisats(self) -> u64 {
265+
self.value
266+
}
267+
268+
pub const fn as_fiat_cents(self) -> u64 {
269+
self.value
270+
}
271+
272+
pub fn as_fiat_major(self) -> f64 {
273+
self.value as f64 / 100.0
274+
}
275+
}
276+
277+
impl From<u64> for Amount {
278+
fn from(value: u64) -> Self {
279+
Self::new(value)
280+
}
281+
}
282+
283+
impl From<Amount> for u64 {
284+
fn from(value: Amount) -> Self {
285+
value.value
286+
}
287+
}
288+
248289
#[derive(Debug, Clone, Serialize, Deserialize)]
249290
#[cfg_attr(feature = "bindings", derive(uniffi::Record))]
250291
pub struct SinglePaymentRequestContent {
251-
pub amount: u64,
292+
pub amount: Amount,
252293
pub currency: Currency,
253294
pub current_exchange_rate: Option<ExchangeRate>,
254295
pub invoice: String,
@@ -297,7 +338,7 @@ pub mod payment {
297338
#[derive(Debug, Clone, Serialize, Deserialize)]
298339
#[cfg_attr(feature = "bindings", derive(uniffi::Record))]
299340
pub struct RecurringPaymentRequestContent {
300-
pub amount: u64,
341+
pub amount: Amount,
301342
pub currency: Currency,
302343
pub recurrence: RecurrenceInfo,
303344
pub current_exchange_rate: Option<ExchangeRate>,
@@ -338,7 +379,7 @@ pub mod payment {
338379
pub enum RecurringPaymentStatus {
339380
Confirmed {
340381
subscription_id: String,
341-
authorized_amount: u64,
382+
authorized_amount: Amount,
342383
authorized_currency: Currency,
343384
authorized_recurrence: RecurrenceInfo,
344385
},
@@ -373,7 +414,7 @@ pub mod payment {
373414
#[cfg_attr(feature = "bindings", derive(uniffi::Record))]
374415
pub struct InvoiceRequestContent {
375416
pub request_id: String,
376-
pub amount: u64,
417+
pub amount: Amount,
377418
pub currency: Currency,
378419
pub current_exchange_rate: Option<ExchangeRate>,
379420
pub expires_at: Timestamp,

0 commit comments

Comments
 (0)