use super::*; use near_sdk::test_utils::{get_logs, VMContextBuilder}; use near_sdk::{testing_env, PromiseResult, RuntimeFeesConfig}; const DOC: &str = "aa00000000000000000000000000000000000000000000000000000000000001"; const DAY: u64 = 86_400_000; const T0: u64 = 1_800_000_000_000; // ms fn acc(s: &str) -> AccountId { s.parse().unwrap() } fn usdc() -> AccountId { acc("usdc.near") } fn intents() -> AccountId { acc("intents.near") } fn merchant() -> AccountId { acc("studio.near") } fn tax() -> AccountId { acc("tax.near") } fn designer() -> AccountId { acc("designer.near") } fn ctx(who: AccountId, deposit: NearToken, now_ms: u64) -> VMContextBuilder { let mut b = VMContextBuilder::new(); b.current_account_id(acc("dulypay.near")) .signer_account_id(acc("relayer.near")) .predecessor_account_id(who) .attached_deposit(deposit) .prepaid_gas(Gas::from_tgas(300)) .block_timestamp(now_ms * 1_000_000) .block_height(1000); b } fn call_as(who: AccountId, deposit: NearToken, now_ms: u64) { testing_env!(ctx(who, deposit, now_ms).build()); } fn yocto1() -> NearToken { NearToken::from_yoctonear(1) } fn zero() -> NearToken { NearToken::from_yoctonear(0) } fn setup() -> Duly { call_as(merchant(), REGISTER_DEPOSIT, T0); let mut c = Duly::new(usdc(), intents()); assert_eq!(c.register(), 1); c } fn invoice(c: &mut Duly, amount: u128) -> String { call_as(merchant(), INVOICE_DEPOSIT, T0); c.create_invoice(U128(amount), U64(T0 + 30 * DAY), DOC.into(), None, None).id } fn pay(c: &mut Duly, msg: &str, amount: u128) -> U128 { pay_at(c, msg, amount, T0 + DAY) } fn pay_at(c: &mut Duly, msg: &str, amount: u128, now: u64) -> U128 { call_as(usdc(), zero(), now); c.ft_on_transfer(intents(), U128(amount), msg.to_string()) } fn with_tax_and_designer(c: &mut Duly) { call_as(merchant(), PAYEE_ROW_DEPOSIT.saturating_mul(2), T0); c.set_shares(vec![ Share { to: tax(), bps: 2000, label: "Tax".into() }, Share { to: designer(), bps: 1000, label: "Designer".into() }, ]); } fn status(c: &Duly, id: &str) -> String { c.get_invoice(id.into()).unwrap().status } fn bal(c: &Duly, a: AccountId) -> u128 { c.balance_of(a).0 } // ---------------------------------------------------------------- id handling #[test] fn ids_round_trip_and_reject_garbage() { let k = invoice_key(7, 42); assert_eq!(format_id(k), "7-42"); assert_eq!(parse_id("7-42"), Some(k)); for bad in ["", "7", "-", "7-", "-42", "a-b", "7-42-1", "99999999999-1", "1-99999999999"] { assert_eq!(parse_id(bad), None, "{bad}"); } } #[test] fn hex_is_strict() { assert!(hex_32(DOC).is_some()); assert!(hex_32(&DOC[1..]).is_none()); assert!(hex_32(&DOC.to_uppercase()).is_none()); assert_eq!(to_hex(&hex_32(DOC).unwrap()), DOC); } // --------------------------------------------------------------- registration #[test] #[should_panic(expected = "Attach the register deposit")] fn register_needs_deposit() { call_as(merchant(), zero(), T0); Duly::new(usdc(), intents()).register(); } #[test] #[should_panic(expected = "Already registered")] fn register_twice_fails() { let mut c = setup(); call_as(merchant(), REGISTER_DEPOSIT, T0); c.register(); } #[test] fn register_deposit_covers_full_merchant_record() { let mut c = setup(); let before = env::storage_usage(); // worst case: longest account ids, four shares, longest labels call_as(acc(&"a".repeat(64)), REGISTER_DEPOSIT, T0); c.register(); let long = |i: u8| -> AccountId { acc(&format!("{}", (b'b' + i) as char).repeat(64)) }; let shares: Vec = (0..4) .map(|i| Share { to: long(i), bps: 100, label: "x".repeat(MAX_LABEL_BYTES) }) .collect(); call_as(acc(&"a".repeat(64)), PAYEE_ROW_DEPOSIT.saturating_mul(4), T0); c.set_shares(shares); call_as(acc(&"a".repeat(64)), yocto1(), T0); c.set_agent(Some(long(4)), 100, Some(long(5)), U128(1)); c.merchants.flush(); c.mids.flush(); c.balances.flush(); let used = env::storage_usage() - before; let paid = (REGISTER_DEPOSIT.as_yoctonear() + 4 * PAYEE_ROW_DEPOSIT.as_yoctonear()) / 10u128.pow(19); assert!((used as u128) <= paid, "used {used} bytes, paid for {paid}"); } #[test] fn invoice_deposit_covers_invoice_with_all_payment_records() { let mut c = setup(); let id = invoice(&mut c, 3_000_000); c.invoices.flush(); let before = env::storage_usage(); let longest = "x".repeat(MAX_REFUND_TO_BYTES); for _ in 0..MAX_PAYMENTS { pay(&mut c, &format!("{id}|{longest}"), 1_000_000); } c.invoices.flush(); let grew = env::storage_usage() - before; // 40 bytes of trie overhead per key plus the key and the empty row let row = 40 + 64 + 8 + 200; let paid = INVOICE_DEPOSIT.as_yoctonear() / 10u128.pow(19); assert!(row + grew as u128 <= paid, "row {row} + grew {grew} > paid {paid}"); } // ------------------------------------------------------------------- invoices #[test] fn numbers_are_sequential_per_merchant() { let mut c = setup(); call_as(acc("other.near"), REGISTER_DEPOSIT, T0); c.register(); assert_eq!(invoice(&mut c, 1_000_000), "1-1"); assert_eq!(invoice(&mut c, 1_000_000), "1-2"); call_as(acc("other.near"), INVOICE_DEPOSIT, T0); let v = c.create_invoice(U128(MIN_INVOICE), U64(T0 + 1), DOC.into(), None, None); assert_eq!((v.id.as_str(), v.number), ("2-1", 1)); assert_eq!(invoice(&mut c, 1_000_000), "1-3"); } #[test] #[should_panic(expected = "Not a merchant")] fn invoice_needs_merchant() { let mut c = setup(); call_as(acc("stranger.near"), INVOICE_DEPOSIT, T0); c.create_invoice(U128(MIN_INVOICE), U64(T0 + 1), DOC.into(), None, None); } #[test] #[should_panic(expected = "Attach the invoice deposit")] fn invoice_needs_deposit() { let mut c = setup(); call_as(merchant(), zero(), T0); c.create_invoice(U128(MIN_INVOICE), U64(T0 + 1), DOC.into(), None, None); } #[test] #[should_panic(expected = "below the minimum")] fn invoice_minimum() { let mut c = setup(); call_as(merchant(), INVOICE_DEPOSIT, T0); c.create_invoice(U128(MIN_INVOICE - 1), U64(T0 + 1), DOC.into(), None, None); } #[test] #[should_panic(expected = "above the maximum")] fn invoice_maximum() { let mut c = setup(); call_as(merchant(), INVOICE_DEPOSIT, T0); c.create_invoice(U128(MAX_INVOICE + 1), U64(T0 + 1), DOC.into(), None, None); } #[test] #[should_panic(expected = "Due date is in the past")] fn invoice_due_date_in_past() { let mut c = setup(); call_as(merchant(), INVOICE_DEPOSIT, T0); c.create_invoice(U128(MIN_INVOICE), U64(T0 - 1), DOC.into(), None, None); } #[test] #[should_panic(expected = "Bad document hash")] fn invoice_bad_hash() { let mut c = setup(); call_as(merchant(), INVOICE_DEPOSIT, T0); c.create_invoice(U128(MIN_INVOICE), U64(T0 + 1), "abc".into(), None, None); } #[test] fn document_fingerprint_is_fixed_when_the_invoice_is_issued() { // The fingerprint (SHA-256 of the encrypted line items) is written once by // create_invoice. Paying, refunding and discounting never touch it, and no method // takes a new one, so the pay page can always compare the document against it. let mut c = setup(); let id = invoice(&mut c, 100_000_000); assert_eq!(c.get_invoice(id.clone()).unwrap().doc_hash, DOC); call_as(merchant(), zero(), T0); c.offer_discount(id.clone(), 500, U64(T0 + DAY)); pay(&mut c, &id, 40_000_000); pay(&mut c, &id, 60_000_000); call_as(merchant(), yocto1(), T0 + 2 * DAY); let _ = c.refund(id.clone(), U128(1_000_000), Dest::Near { account: acc("client.near") }); assert_eq!(c.get_invoice(id).unwrap().doc_hash, DOC); } #[test] #[should_panic(expected = "Bad document hash")] fn document_fingerprint_must_be_64_lowercase_hex() { let mut c = setup(); call_as(merchant(), INVOICE_DEPOSIT, T0); c.create_invoice(U128(MIN_INVOICE), U64(T0 + 1), DOC.to_uppercase(), None, None); } #[test] fn list_pages_through_invoices() { let mut c = setup(); for _ in 0..5 { invoice(&mut c, 1_000_000); } let page = c.list_invoices(merchant(), 2, 2); assert_eq!(page.iter().map(|v| v.number).collect::>(), vec![2, 3]); assert_eq!(c.list_invoices(merchant(), 0, 100).len(), 5); assert!(c.list_invoices(acc("nobody.near"), 1, 10).is_empty()); } // ----------------------------------------------------------------- settlement #[test] fn exact_payment_closes_invoice_in_the_same_call() { let mut c = setup(); let id = invoice(&mut c, 100_000_000); assert_eq!(pay(&mut c, &id, 100_000_000).0, 0); let v = c.get_invoice(id.clone()).unwrap(); assert_eq!(v.status, "paid"); assert_eq!(v.due_now.0, 0); assert_eq!(v.closed_at_ms.0, T0 + DAY); assert_eq!(bal(&c, merchant()), 100_000_000); assert_eq!(c.get_totals().liabilities.0, 100_000_000); let log = get_logs().join(""); assert!(log.contains("EVENT_JSON")); assert!(log.contains("\"closed\":true")); } #[test] fn set_asides_happen_in_the_settlement_step() { let mut c = setup(); with_tax_and_designer(&mut c); let id = invoice(&mut c, 100_000_000); pay(&mut c, &id, 100_000_000); assert_eq!(bal(&c, tax()), 20_000_000); assert_eq!(bal(&c, designer()), 10_000_000); assert_eq!(bal(&c, merchant()), 70_000_000); assert_eq!(c.get_totals().liabilities.0, 100_000_000); let log = get_logs().join(""); assert!(log.contains("\"label\":\"Tax\"")); assert!(log.contains("\"net\":\"70000000\"")); } #[test] fn rounding_dust_stays_with_merchant_and_nothing_is_lost() { let mut c = setup(); call_as(merchant(), PAYEE_ROW_DEPOSIT.saturating_mul(2), T0); c.set_shares(vec![ Share { to: tax(), bps: 3333, label: "Tax".into() }, Share { to: designer(), bps: 3333, label: "D".into() }, ]); let id = invoice(&mut c, 10_001); pay(&mut c, &id, 10_001); assert_eq!(bal(&c, tax()), 3_333); assert_eq!(bal(&c, designer()), 3_333); assert_eq!(bal(&c, merchant()), 3_335); } #[test] fn partial_payments_add_up_then_close() { let mut c = setup(); with_tax_and_designer(&mut c); let id = invoice(&mut c, 100_000_000); pay(&mut c, &id, 40_000_000); let v = c.get_invoice(id.clone()).unwrap(); assert_eq!((v.status.as_str(), v.due_now.0), ("partial", 60_000_000)); pay(&mut c, &id, 60_000_000); let v = c.get_invoice(id.clone()).unwrap(); assert_eq!((v.status.as_str(), v.due_now.0), ("paid", 0)); assert_eq!(bal(&c, tax()), 20_000_000); assert_eq!(bal(&c, merchant()), 70_000_000); assert_eq!(v.payments.len(), 2); } #[test] fn overpayment_is_kept_apart_from_set_asides() { let mut c = setup(); with_tax_and_designer(&mut c); let id = invoice(&mut c, 100_000_000); pay(&mut c, &id, 130_000_000); let v = c.get_invoice(id).unwrap(); assert_eq!((v.status.as_str(), v.excess.0), ("paid", 30_000_000)); assert_eq!(bal(&c, tax()), 20_000_000); assert_eq!(bal(&c, designer()), 10_000_000); assert_eq!(bal(&c, merchant()), 100_000_000); } #[test] fn double_payment_is_held_as_excess_not_split_twice() { let mut c = setup(); with_tax_and_designer(&mut c); let id = invoice(&mut c, 100_000_000); pay(&mut c, &id, 100_000_000); pay(&mut c, &id, 100_000_000); let v = c.get_invoice(id).unwrap(); assert_eq!((v.status.as_str(), v.excess.0), ("paid", 100_000_000)); assert_eq!(bal(&c, tax()), 20_000_000); assert_eq!(bal(&c, merchant()), 170_000_000); } #[test] fn unknown_invoice_goes_straight_back() { let mut c = setup(); for msg in ["", "garbage", "9-9", "1-1", "1-1|x", "{\"a\":1}", "-", "99999999999-1"] { assert_eq!(pay(&mut c, msg, 5_000_000).0, 5_000_000, "{msg}"); } assert_eq!(c.get_totals().liabilities.0, 0); } #[test] #[should_panic(expected = "Only USDC is accepted")] fn other_tokens_are_refused() { let mut c = setup(); let id = invoice(&mut c, 1_000_000); call_as(acc("fake-usdc.near"), zero(), T0); c.ft_on_transfer(intents(), U128(1_000_000), id); } #[test] #[should_panic(expected = "Only USDC is accepted")] fn a_direct_call_cannot_fake_a_payment() { let mut c = setup(); let id = invoice(&mut c, 1_000_000); call_as(merchant(), zero(), T0); c.ft_on_transfer(merchant(), U128(1_000_000), id); } #[test] fn payment_to_cancelled_invoice_is_kept_for_refund() { let mut c = setup(); with_tax_and_designer(&mut c); let id = invoice(&mut c, 1_000_000); call_as(merchant(), zero(), T0); c.cancel_invoice(id.clone()); assert_eq!(pay(&mut c, &id, 1_000_000).0, 0); let v = c.get_invoice(id).unwrap(); assert_eq!((v.status.as_str(), v.excess.0), ("cancelled", 1_000_000)); assert_eq!(bal(&c, tax()), 0); assert_eq!(bal(&c, merchant()), 1_000_000); } #[test] fn refund_destination_is_recorded_and_sanitised() { let mut c = setup(); let id = invoice(&mut c, 3_000_000); pay(&mut c, &format!("{id}|base:USDC:0xAbC123"), 1_000_000); pay(&mut c, &format!("{id}|x\"}},{{\"evil\":\"