//! Duly settlement contract. //! //! A client pays from any chain through NEAR Intents. The USDC arrives here by //! `ft_transfer_call` with the invoice id in `msg` (1Click `customRecipientMsg`), //! so the payment itself closes the invoice. In that same step the merchant's //! set-asides (tax, subcontractors) move into separate balances. //! //! Design rules: //! - The code has no owner role, pause or upgrade method: through this code nobody //! can move a balance except the account that holds it (and, within limits that //! account set, its appointed agent, which can only sweep to the account the //! holder named). An upgrade key is kept on the contract account during the //! hackathon and could replace this code, so "no admin" is not claimed. //! - Balances are internal. `ft_on_transfer` never makes an external call, so a //! payment cannot fail because some recipient is unregistered. //! - Every state-growing method is paid for by an attached deposit, so strangers //! cannot drain the contract's storage balance. //! - Funds move out only after the balance is debited; a failed transfer restores it. use near_sdk::json_types::{U128, U64}; use near_sdk::store::LookupMap; use near_sdk::{ env, near, require, AccountId, BorshStorageKey, Gas, NearToken, PanicOnDefault, Promise, PromiseError, }; /// Basis points in 100%. const BPS: u128 = 10_000; /// Set-aside rules per merchant. const MAX_SHARES: usize = 4; /// Payments recorded per invoice with their refund destination. Later ones still /// count towards the invoice and always appear in the event log. const MAX_PAYMENTS: usize = 3; const MAX_LABEL_BYTES: usize = 24; const MAX_REFUND_TO_BYTES: usize = 80; /// Smallest invoice: 0.01 USDC. const MIN_INVOICE: u128 = 10_000; /// Largest invoice: 10,000 USDC. The contract is unaudited; this is a deliberate ceiling. const MAX_INVOICE: u128 = 10_000_000_000; /// Longest window a discount offer can stay open: 30 days. const MAX_OFFER_MS: u64 = 30 * 24 * 60 * 60 * 1000; const REGISTER_DEPOSIT: NearToken = NearToken::from_millinear(10); const INVOICE_DEPOSIT: NearToken = NearToken::from_millinear(6); const PAYEE_ROW_DEPOSIT: NearToken = NearToken::from_millinear(2); const ONE_YOCTO: NearToken = NearToken::from_yoctonear(1); const GAS_FT_TRANSFER: Gas = Gas::from_tgas(15); const GAS_FT_TRANSFER_CALL: Gas = Gas::from_tgas(100); const GAS_CALLBACK: Gas = Gas::from_tgas(15); const GAS_OVERHEAD: Gas = Gas::from_tgas(10); #[derive(BorshStorageKey)] #[near] enum StorageKey { Merchants, Mids, Balances, Invoices, } /// One set-aside rule: `bps` of every settled amount goes to `to`. #[near(serializers = [borsh, json])] #[derive(Clone, Debug, PartialEq)] pub struct Share { pub to: AccountId, pub bps: u16, pub label: String, } /// Where money leaves the contract to. #[near(serializers = [borsh, json])] #[derive(Clone, Debug, PartialEq)] pub enum Dest { /// USDC to a NEAR account (it must be registered on the USDC contract). Near { account: AccountId }, /// USDC into a NEAR Intents deposit account from a 1Click quote /// (`depositType: INTENTS`), which then pays out on any chain. Intents { deposit_address: AccountId }, } #[near(serializers = [borsh])] pub struct Merchant { mid: u32, /// Last invoice number issued. Numbers run 1, 2, 3 with no gaps. seq: u32, shares: Vec, agent: Option, /// Largest discount the agent may offer on an overdue invoice. agent_max_discount_bps: u16, /// The only place an agent sweep can send money. sweep_to: Option, /// An agent sweep leaves at least this much in the balance. sweep_keep: u128, } #[near(serializers = [borsh])] pub struct Payment { amount: u128, at_ms: u64, block: u64, /// "::
" from the payer, used for refunds. refund_to: String, } #[near(serializers = [borsh])] pub struct Invoice { amount: u128, /// Everything received, including any excess. paid: u128, /// The part of `paid` applied to the invoice and split by set-asides. applied: u128, /// How much of `applied` went to set-asides. set_aside: u128, refunded: u128, issued_at_ms: u64, due_at_ms: u64, closed_at_ms: u64, doc_hash: [u8; 32], discount_bps: u16, discount_until_ms: u64, cancelled: bool, payments: Vec, } #[near(serializers = [json])] pub struct PaymentView { pub amount: U128, pub at_ms: U64, pub block: U64, pub refund_to: String, } #[near(serializers = [json])] pub struct InvoiceView { pub id: String, pub number: u32, pub merchant: AccountId, pub amount: U128, /// What the client owes right now, after any active discount. pub due_now: U128, pub paid: U128, pub applied: U128, pub excess: U128, pub set_aside: U128, pub refunded: U128, pub status: String, pub issued_at_ms: U64, pub due_at_ms: U64, pub closed_at_ms: U64, pub doc_hash: String, pub discount_bps: u16, pub discount_until_ms: U64, pub payments: Vec, } #[near(serializers = [json])] pub struct MerchantView { pub mid: u32, pub invoices: u32, pub balance: U128, pub shares: Vec, pub agent: Option, pub agent_max_discount_bps: u16, pub sweep_to: Option, pub sweep_keep: U128, } #[near(serializers = [json])] pub struct Totals { /// USDC this contract owes its balance holders. Compare with `ft_balance_of`. pub liabilities: U128, pub received: U128, pub merchants: u32, pub invoices: U64, } #[near(serializers = [json])] pub struct Config { pub usdc: AccountId, pub intents: AccountId, pub register_deposit: U128, pub invoice_deposit: U128, pub payee_row_deposit: U128, pub min_invoice: U128, pub max_invoice: U128, } #[near(contract_state)] #[derive(PanicOnDefault)] pub struct Duly { usdc: AccountId, intents: AccountId, merchants: LookupMap, mids: LookupMap, balances: LookupMap, invoices: LookupMap, last_mid: u32, invoice_count: u64, liabilities: u128, received: u128, } fn invoice_key(mid: u32, seq: u32) -> u64 { ((mid as u64) << 32) | seq as u64 } fn parse_id(id: &str) -> Option { let (mid, seq) = id.split_once('-')?; if mid.len() > 10 || seq.len() > 10 { return None; } Some(invoice_key(mid.parse().ok()?, seq.parse().ok()?)) } fn format_id(key: u64) -> String { let mut s = (key >> 32).to_string(); s.push('-'); s.push_str(&(key as u32).to_string()); s } fn hex_32(s: &str) -> Option<[u8; 32]> { let b = s.as_bytes(); if b.len() != 64 { return None; } let digit = |c: u8| match c { b'0'..=b'9' => Some(c - b'0'), b'a'..=b'f' => Some(c - b'a' + 10), _ => None, }; let mut out = [0u8; 32]; for i in 0..32 { out[i] = digit(b[2 * i])? << 4 | digit(b[2 * i + 1])?; } Some(out) } fn to_hex(b: &[u8]) -> String { const H: &[u8; 16] = b"0123456789abcdef"; let mut s = String::with_capacity(b.len() * 2); for x in b { s.push(H[(x >> 4) as usize] as char); s.push(H[(x & 15) as usize] as char); } s } /// Refund destinations come from the payer, so they are untrusted. Only a plain /// charset is stored; anything else is dropped rather than failing the payment. fn clean_refund_to(s: &str) -> String { let ok = s.len() <= MAX_REFUND_TO_BYTES && s.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b':' | b'.' | b'_' | b'-')); if ok { s.to_string() } else { String::new() } } /// NEP-297 event. `data` is a JSON object built by the callers below from account /// ids, numbers and validated labels only, so nothing in it needs escaping. fn emit(event: &str, data: String) { env::log_str(&format!( r#"EVENT_JSON:{{"standard":"duly","version":"1.0.0","event":"{event}","data":[{data}]}}"# )); } fn opt_json(a: &Option) -> String { a.as_ref().map_or("null".to_string(), |a| format!(r#""{a}""#)) } fn dest_json(d: &Dest) -> String { match d { Dest::Near { account } => format!(r#"{{"near":"{account}"}}"#), Dest::Intents { deposit_address } => format!(r#"{{"intents":"{deposit_address}"}}"#), } } impl Invoice { /// Total the client must pay at `now`, after an active discount. fn payable_total(&self, now: u64) -> u128 { if self.discount_bps > 0 && now <= self.discount_until_ms { self.amount - self.amount * self.discount_bps as u128 / BPS } else { self.amount } } fn due_now(&self, now: u64) -> u128 { if self.cancelled || self.closed_at_ms > 0 { 0 } else { self.payable_total(now).saturating_sub(self.applied) } } fn status(&self, now: u64) -> &'static str { if self.paid > 0 && self.refunded >= self.paid { "refunded" } else if self.cancelled { "cancelled" } else if self.closed_at_ms > 0 { if self.refunded > 0 { "partially_refunded" } else { "paid" } } else if self.paid > 0 { "partial" } else if now > self.due_at_ms { "overdue" } else { "open" } } } #[near] impl Duly { #[init] pub fn new(usdc: AccountId, intents: AccountId) -> Self { Self { usdc, intents, merchants: LookupMap::new(StorageKey::Merchants), mids: LookupMap::new(StorageKey::Mids), balances: LookupMap::new(StorageKey::Balances), invoices: LookupMap::new(StorageKey::Invoices), last_mid: 0, invoice_count: 0, liabilities: 0, received: 0, } } // ---------------------------------------------------------------- merchant /// Open a merchant record. The deposit pays for its storage. #[payable] pub fn register(&mut self) -> u32 { require!(env::attached_deposit() >= REGISTER_DEPOSIT, "Attach the register deposit"); let who = env::predecessor_account_id(); require!(!self.merchants.contains_key(&who), "Already registered"); self.last_mid += 1; let mid = self.last_mid; self.mids.insert(mid, who.clone()); self.balances.entry(who.clone()).or_insert(0); self.merchants.insert( who.clone(), Merchant { mid, seq: 0, shares: vec![], agent: None, agent_max_discount_bps: 0, sweep_to: None, sweep_keep: 0, }, ); emit("merchant_registered", format!(r#"{{"merchant":"{who}","mid":{mid}}}"#)); mid } /// Replace the set-aside rules. Applies to every payment from now on. /// Each payee that has no balance row yet costs one PAYEE_ROW_DEPOSIT. #[payable] pub fn set_shares(&mut self, shares: Vec) { let who = env::predecessor_account_id(); require!(self.merchants.contains_key(&who), "Not a merchant"); require!(shares.len() <= MAX_SHARES, "Too many set-asides"); let mut total: u32 = 0; for (i, s) in shares.iter().enumerate() { require!(s.bps > 0, "A set-aside cannot be zero"); require!(s.label.len() <= MAX_LABEL_BYTES, "Label too long"); require!( s.label.bytes().all(|c| c.is_ascii_alphanumeric() || c == b' ' || c == b'-'), "Label may use letters, numbers, spaces and hyphens" ); require!(s.to != who, "A set-aside cannot point at yourself"); require!(shares[..i].iter().all(|o| o.to != s.to), "Duplicate set-aside"); total += s.bps as u32; } require!(total as u128 <= BPS, "Set-asides add up to more than 100%"); let new_rows = shares.iter().filter(|s| !self.balances.contains_key(&s.to)).count() as u128; require!( env::attached_deposit().as_yoctonear() >= new_rows * PAYEE_ROW_DEPOSIT.as_yoctonear(), "Attach the deposit for new payees" ); for s in &shares { self.balances.entry(s.to.clone()).or_insert(0); } let lines: Vec = shares .iter() .map(|s| format!(r#"{{"to":"{}","bps":{},"label":"{}"}}"#, s.to, s.bps, s.label)) .collect(); emit("shares_set", format!(r#"{{"merchant":"{who}","shares":[{}]}}"#, lines.join(","))); self.merchants.get_mut(&who).unwrap().shares = shares; } /// Issue an invoice. Only the amount, dates and a hash of the encrypted /// document go on-chain: client names and line items stay private. #[payable] pub fn create_invoice( &mut self, amount: U128, due_at_ms: U64, doc_hash: String, discount_bps: Option, discount_until_ms: Option, ) -> InvoiceView { require!(env::attached_deposit() >= INVOICE_DEPOSIT, "Attach the invoice deposit"); let who = env::predecessor_account_id(); let now = env::block_timestamp_ms(); require!(amount.0 >= MIN_INVOICE, "Invoice is below the minimum"); require!(amount.0 <= MAX_INVOICE, "Invoice is above the maximum"); require!(due_at_ms.0 >= now, "Due date is in the past"); let doc_hash = hex_32(&doc_hash).unwrap_or_else(|| env::panic_str("Bad document hash")); let discount_bps = discount_bps.unwrap_or(0); let discount_until_ms = discount_until_ms.map_or(0, |v| v.0); require!(discount_bps as u128 <= BPS / 2, "Discount above 50%"); require!( discount_bps == 0 || discount_until_ms <= now.saturating_add(MAX_OFFER_MS), "Discount window too long" ); let merchant = self.merchants.get_mut(&who).unwrap_or_else(|| env::panic_str("Not a merchant")); merchant.seq += 1; let key = invoice_key(merchant.mid, merchant.seq); let number = merchant.seq; self.invoice_count += 1; self.invoices.insert( key, Invoice { amount: amount.0, paid: 0, applied: 0, set_aside: 0, refunded: 0, issued_at_ms: now, due_at_ms: due_at_ms.0, closed_at_ms: 0, doc_hash, discount_bps, discount_until_ms, cancelled: false, payments: vec![], }, ); emit( "invoice_created", format!( r#"{{"id":"{}","number":{number},"merchant":"{who}","amount":"{}"}}"#, format_id(key), amount.0 ), ); self.view_of(key).unwrap() } /// Stop an invoice collecting money. Anyone can pay a little into any invoice, /// so only a fully paid invoice blocks this. Anything already received stays /// refundable, and later payments are held as excess for refund. pub fn cancel_invoice(&mut self, id: String) { let key = self.owned_key(&id); let inv = self.invoices.get_mut(&key).unwrap(); require!(inv.closed_at_ms == 0, "Invoice is already paid: refund it instead"); require!(!inv.cancelled, "Invoice already cancelled"); inv.cancelled = true; emit("invoice_cancelled", format!(r#"{{"id":"{}"}}"#, format_id(key))); } /// Offer a discount if the client pays by `until_ms`. /// The merchant may offer up to 50%. The appointed agent may only touch /// overdue invoices and only up to the cap the merchant set. pub fn offer_discount(&mut self, id: String, bps: u16, until_ms: U64) { let key = parse_id(&id).unwrap_or_else(|| env::panic_str("Bad invoice id")); let owner = self.owner_of(key); let merchant = self.merchants.get(&owner).unwrap(); let caller = env::predecessor_account_id(); let now = env::block_timestamp_ms(); let inv = self.invoices.get(&key).unwrap_or_else(|| env::panic_str("No such invoice")); require!(!inv.cancelled && inv.closed_at_ms == 0, "Invoice is closed"); require!(until_ms.0 > now, "Offer already expired"); require!(until_ms.0 <= now.saturating_add(MAX_OFFER_MS), "Offer window too long"); if caller == owner { require!(bps as u128 <= BPS / 2, "Discount above 50%"); } else { require!(merchant.agent.as_ref() == Some(&caller), "Not your invoice"); require!(now > inv.due_at_ms, "Agent can only act on overdue invoices"); require!(bps <= merchant.agent_max_discount_bps, "Above the limit you were given"); } let inv = self.invoices.get_mut(&key).unwrap(); inv.discount_bps = bps; inv.discount_until_ms = until_ms.0; // What has been paid may already cover the discounted total. if inv.applied > 0 && inv.applied >= inv.payable_total(now) { inv.closed_at_ms = now; } emit( "discount_offered", format!( r#"{{"id":"{}","bps":{bps},"until_ms":"{}","by":"{caller}"}}"#, format_id(key), until_ms.0 ), ); } /// Appoint or remove the agent and set its limits. One yocto is required so /// a function-call key, which cannot attach deposits, can never widen them. #[payable] pub fn set_agent( &mut self, agent: Option, max_discount_bps: u16, sweep_to: Option, sweep_keep: U128, ) { near_sdk::assert_one_yocto(); require!(max_discount_bps as u128 <= BPS / 2, "Discount above 50%"); let who = env::predecessor_account_id(); let m = self.merchants.get_mut(&who).unwrap_or_else(|| env::panic_str("Not a merchant")); m.agent = agent.clone(); m.agent_max_discount_bps = max_discount_bps; m.sweep_to = sweep_to.clone(); m.sweep_keep = sweep_keep.0; emit( "agent_set", format!( r#"{{"merchant":"{who}","agent":{},"max_discount_bps":{max_discount_bps},"sweep_to":{},"sweep_keep":"{}"}}"#, opt_json(&agent), opt_json(&sweep_to), sweep_keep.0 ), ); } // ------------------------------------------------------------- settlement /// NEP-141 receiver. `msg` is "" or "|". /// Returns the amount to send back: everything for an unknown invoice, nothing /// otherwise. A known invoice always keeps the money, even if it is cancelled /// or already paid, so a late or double payment can be refunded to its payer /// instead of bouncing through a bridge. No external calls happen here. pub fn ft_on_transfer(&mut self, sender_id: AccountId, amount: U128, msg: String) -> U128 { require!(env::predecessor_account_id() == self.usdc, "Only USDC is accepted"); let (id, refund_to) = msg.split_once('|').unwrap_or((msg.as_str(), "")); let Some(key) = parse_id(id).filter(|k| self.invoices.contains_key(k)) else { env::log_str("Unknown invoice: sending the money back"); return amount; }; let owner = self.owner_of(key); let now = env::block_timestamp_ms(); let block = env::block_height(); let mut refund_to = clean_refund_to(refund_to); if refund_to.is_empty() && sender_id != self.intents { refund_to = format!("near:USDC:{sender_id}"); } let inv = self.invoices.get_mut(&key).unwrap(); let due = inv.due_now(now); let applied = amount.0.min(due); let excess = amount.0 - applied; inv.paid += amount.0; inv.applied += applied; if inv.payments.len() < MAX_PAYMENTS && amount.0.saturating_mul(100) >= inv.amount { inv.payments.push(Payment { amount: amount.0, at_ms: now, block, refund_to }); } let closed_now = !inv.cancelled && inv.closed_at_ms == 0 && applied == due && due > 0; if closed_now { inv.closed_at_ms = now; } let status = inv.status(now); // Set-asides apply to the invoiced amount only, never to an excess that // may have to go back to the payer. Rounding remainders stay with the merchant. let shares = self.merchants.get(&owner).unwrap().shares.clone(); let mut net = applied; let mut lines = Vec::with_capacity(shares.len()); for s in &shares { let part = applied * s.bps as u128 / BPS; if part > 0 { net -= part; self.credit(&s.to, part); lines.push(format!(r#"{{"to":"{}","label":"{}","amount":"{part}"}}"#, s.to, s.label)); } } self.credit(&owner, net + excess); self.received += amount.0; self.invoices.get_mut(&key).unwrap().set_aside += applied - net; emit( "invoice_payment", format!( r#"{{"id":"{}","merchant":"{owner}","amount":"{}","applied":"{applied}","excess":"{excess}","net":"{net}","set_asides":[{}],"status":"{status}","closed":{closed_now},"block":{block}}}"#, format_id(key), amount.0, lines.join(",") ), ); U128(0) } // -------------------------------------------------------------- money out /// Move your balance out, to a NEAR account or through Intents to any chain. #[payable] pub fn withdraw(&mut self, amount: U128, to: Dest) -> Promise { near_sdk::assert_one_yocto(); let who = env::predecessor_account_id(); require!(amount.0 > 0, "Nothing to withdraw"); self.debit(&who, amount.0); emit("withdrawal", format!(r#"{{"from":"{who}","amount":"{}","to":{}}}"#, amount.0, dest_json(&to))); self.pay_out(who, amount.0, to, None, 0) } /// Move your balance to another balance in this contract. A tax jar uses this /// to release money back to the merchant. The target must already have a row. #[payable] pub fn send(&mut self, to: AccountId, amount: U128) { near_sdk::assert_one_yocto(); let who = env::predecessor_account_id(); require!(amount.0 > 0, "Nothing to send"); require!(self.balances.contains_key(&to), "That account has no balance here yet"); self.debit(&who, amount.0); self.credit(&to, amount.0); emit("internal_transfer", format!(r#"{{"from":"{who}","to":"{to}","amount":"{}"}}"#, amount.0)); } /// Refund a client out of the merchant's balance. #[payable] pub fn refund(&mut self, id: String, amount: U128, to: Dest) -> Promise { near_sdk::assert_one_yocto(); let key = self.owned_key(&id); let who = env::predecessor_account_id(); let inv = self.invoices.get_mut(&key).unwrap(); require!(amount.0 > 0, "Nothing to refund"); require!(amount.0 <= inv.paid - inv.refunded, "Refund above what was paid"); inv.refunded += amount.0; // Refunding a part payment reopens what is owed. Excess was never applied. let reopened = if inv.closed_at_ms == 0 { amount.0.min(inv.applied) } else { 0 }; inv.applied -= reopened; self.debit(&who, amount.0); emit( "invoice_refund", format!(r#"{{"id":"{}","amount":"{}","to":{}}}"#, format_id(key), amount.0, dest_json(&to)), ); self.pay_out(who, amount.0, to, Some(key), reopened) } /// Agent-only: move everything above the merchant's floor to the one account /// the merchant named. The agent cannot choose the destination or the floor. pub fn agent_sweep(&mut self, merchant: AccountId) -> Promise { let m = self.merchants.get(&merchant).unwrap_or_else(|| env::panic_str("Not a merchant")); require!( m.agent.as_ref() == Some(&env::predecessor_account_id()), "Not the appointed agent" ); let to = m.sweep_to.clone().unwrap_or_else(|| env::panic_str("No sweep account set")); let keep = m.sweep_keep; let amount = self.balance_of(merchant.clone()).0.saturating_sub(keep); require!(amount > 0, "Nothing above the floor"); self.debit(&merchant, amount); emit("sweep", format!(r#"{{"merchant":"{merchant}","amount":"{amount}","to":"{to}"}}"#)); self.pay_out(merchant, amount, Dest::Near { account: to }, None, 0) } #[private] pub fn on_transfer( &mut self, from: AccountId, amount: U128, key: Option, reopened: U128, #[callback_result] result: Result<(), PromiseError>, ) { if result.is_err() { self.restore(&from, amount.0, key.map(|k| k.0), reopened.0); } } #[private] pub fn on_transfer_call( &mut self, from: AccountId, amount: U128, key: Option, reopened: U128, #[callback_result] used: Result, ) { let used = used.map_or(0, |u| u.0).min(amount.0); if used < amount.0 { self.restore(&from, amount.0 - used, key.map(|k| k.0), reopened.0); } } // ------------------------------------------------------------------ views pub fn get_invoice(&self, id: String) -> Option { self.view_of(parse_id(&id)?) } /// Invoices of one merchant by number, oldest first from `from_number`. pub fn list_invoices( &self, merchant: AccountId, from_number: u32, limit: u32, ) -> Vec { let Some(m) = self.merchants.get(&merchant) else { return vec![] }; let start = from_number.max(1); (start..=m.seq) .take(limit.min(50) as usize) .filter_map(|n| self.view_of(invoice_key(m.mid, n))) .collect() } pub fn get_merchant(&self, account: AccountId) -> Option { let m = self.merchants.get(&account)?; Some(MerchantView { mid: m.mid, invoices: m.seq, balance: self.balance_of(account.clone()), shares: m.shares.clone(), agent: m.agent.clone(), agent_max_discount_bps: m.agent_max_discount_bps, sweep_to: m.sweep_to.clone(), sweep_keep: U128(m.sweep_keep), }) } pub fn balance_of(&self, account: AccountId) -> U128 { U128(self.balances.get(&account).copied().unwrap_or(0)) } pub fn get_totals(&self) -> Totals { Totals { liabilities: U128(self.liabilities), received: U128(self.received), merchants: self.last_mid, invoices: U64(self.invoice_count), } } pub fn get_config(&self) -> Config { Config { usdc: self.usdc.clone(), intents: self.intents.clone(), register_deposit: U128(REGISTER_DEPOSIT.as_yoctonear()), invoice_deposit: U128(INVOICE_DEPOSIT.as_yoctonear()), payee_row_deposit: U128(PAYEE_ROW_DEPOSIT.as_yoctonear()), min_invoice: U128(MIN_INVOICE), max_invoice: U128(MAX_INVOICE), } } } impl Duly { fn owner_of(&self, key: u64) -> AccountId { self.mids .get(&((key >> 32) as u32)) .cloned() .unwrap_or_else(|| env::panic_str("No such invoice")) } /// Parse an id and require the caller to own that invoice. fn owned_key(&self, id: &str) -> u64 { let key = parse_id(id).unwrap_or_else(|| env::panic_str("Bad invoice id")); require!(self.invoices.contains_key(&key), "No such invoice"); require!(self.owner_of(key) == env::predecessor_account_id(), "Not your invoice"); key } fn view_of(&self, key: u64) -> Option { let inv = self.invoices.get(&key)?; let now = env::block_timestamp_ms(); Some(InvoiceView { id: format_id(key), number: key as u32, merchant: self.owner_of(key), amount: U128(inv.amount), due_now: U128(inv.due_now(now)), paid: U128(inv.paid), applied: U128(inv.applied), excess: U128((inv.paid - inv.refunded).saturating_sub(inv.applied)), set_aside: U128(inv.set_aside), refunded: U128(inv.refunded), status: inv.status(now).to_string(), issued_at_ms: U64(inv.issued_at_ms), due_at_ms: U64(inv.due_at_ms), closed_at_ms: U64(inv.closed_at_ms), doc_hash: to_hex(&inv.doc_hash), discount_bps: inv.discount_bps, discount_until_ms: U64(inv.discount_until_ms), payments: inv .payments .iter() .map(|p| PaymentView { amount: U128(p.amount), at_ms: U64(p.at_ms), block: U64(p.block), refund_to: p.refund_to.clone(), }) .collect(), }) } /// Only called for accounts that already have a row, so a payment can never /// grow storage beyond what deposits paid for. fn credit(&mut self, who: &AccountId, amount: u128) { let row = self.balances.get_mut(who).unwrap_or_else(|| env::panic_str("No balance row")); *row += amount; self.liabilities += amount; } fn debit(&mut self, who: &AccountId, amount: u128) { let row = self.balances.get_mut(who).unwrap_or_else(|| env::panic_str("No balance")); require!(*row >= amount, "Not enough balance"); *row -= amount; self.liabilities -= amount; } fn restore(&mut self, who: &AccountId, amount: u128, key: Option, reopened: u128) { self.credit(who, amount); if let Some(inv) = key.and_then(|k| self.invoices.get_mut(&k)) { inv.refunded -= amount.min(inv.refunded); if inv.closed_at_ms == 0 { inv.applied += reopened.min(amount); let now = env::block_timestamp_ms(); if !inv.cancelled && inv.applied > 0 && inv.applied >= inv.payable_total(now) { inv.closed_at_ms = now; } } } emit("payout_failed", format!(r#"{{"from":"{who}","amount":"{amount}"}}"#)); } fn pay_out( &self, from: AccountId, amount: u128, to: Dest, key: Option, reopened: u128, ) -> Promise { let key_arg = key.map(U64); let (args, gas, method) = match &to { Dest::Near { account } => ( format!(r#"{{"receiver_id":"{account}","amount":"{amount}"}}"#), GAS_FT_TRANSFER, "ft_transfer", ), Dest::Intents { deposit_address } => ( format!( r#"{{"receiver_id":"{}","amount":"{amount}","msg":"{deposit_address}"}}"#, self.intents ), GAS_FT_TRANSFER_CALL, "ft_transfer_call", ), }; let needed = gas.as_gas() + GAS_CALLBACK.as_gas() + GAS_OVERHEAD.as_gas(); require!(env::prepaid_gas().as_gas() >= needed, "Attach more gas"); let call = Promise::new(self.usdc.clone()).function_call( method.to_string(), args.into_bytes(), ONE_YOCTO, gas, ); let this = Self::ext(env::current_account_id()).with_static_gas(GAS_CALLBACK); match to { Dest::Near { .. } => { call.then(this.on_transfer(from, U128(amount), key_arg, U128(reopened))) } Dest::Intents { .. } => { call.then(this.on_transfer_call(from, U128(amount), key_arg, U128(reopened))) } } } } #[cfg(test)] mod tests;