diff --git a/CHANGELOG.md b/CHANGELOG.md index 59445a1..b1bcb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to the Sharpy smart contract are documented here. ## [Unreleased] +- feat: recurring pause — pause/resume recurring chain — Adds pause_recurring / resume_recurring and is_recurring_pau - feat: discount config — set/get_discount — feat/discount-config - feat: invoice metadata — set/get_invoice_metadata — Adds InvoiceMetadata (key-value map) via set/get with creato - feat: deadline extension — extend_deadline for creators — Adds extend_deadline(caller, id, new_deadline) allowing crea diff --git a/README.md b/README.md index ca81908..2abc2b1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Soroban](https://img.shields.io/badge/Soroban-Protocol%2027-6C63FF?logo=stellar) ![Rust](https://img.shields.io/badge/Rust-stable-orange?logo=rust) -![Tests](https://img.shields.io/badge/tests-153%20passing-00D4AA) +![Tests](https://img.shields.io/badge/tests-156%20passing-00D4AA) ![License](https://img.shields.io/badge/license-MIT-green) ![Version](https://img.shields.io/badge/version-0.2.0-6C63FF) [![Demo](https://img.shields.io/badge/Demo-Watch%20on%20Loom-00D4AA?logo=loom)](https://www.loom.com/share/09aa4a78e0c944dcab866a7036fde24d) @@ -85,6 +85,7 @@ graph TD - **Freeze control** — `freeze_invoice()`/`unfreeze_invoice()` admin blocks/re-enables `pay` (frozen field) - **Invoice notes** — `set_invoice_notes()`/`get_invoice_notes()` free-text `InvoiceNotes { text, updated_at }` - **Invoice tags** +- **Recurring pause** — `pause_recurring`/`resume_recurring`/`is_recurring_paused` - **Discount config** — `set/get_discount` `DiscountConfig { discount_bps, updated_at }` - **Invoice metadata** — `set/get_invoice_metadata` `InvoiceMetadata { entries, updated_at }` - **Deadline extension** — `extend_deadline(caller, id, new_deadline)` creator can push deadline forward diff --git a/contracts/sharpy/src/events.rs b/contracts/sharpy/src/events.rs index 2b46d9c..165e5c3 100644 --- a/contracts/sharpy/src/events.rs +++ b/contracts/sharpy/src/events.rs @@ -279,3 +279,10 @@ pub struct DiscountUpdatedEvent { pub invoice_id: u64, pub discount_bps: u32 } pub fn discount_updated(env: &Env, invoice_id: u64, discount_bps: u32) { env.events().publish((symbol_short!("disc"),), DiscountUpdatedEvent { invoice_id, discount_bps }); } + +#[contracttype] +#[derive(Clone)] +pub struct RecurringPausedEvent { pub invoice_id: u64, pub paused: bool } +pub fn recurring_paused(env: &Env, invoice_id: u64, paused: bool) { + env.events().publish((symbol_short!("rpause"),), RecurringPausedEvent { invoice_id, paused }); +} diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 86629d9..bf0f1b3 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -22,7 +22,7 @@ mod test; use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Bytes, Env, Map, String, Symbol, Vec}; use types::{ AuditEntry, CreateInvoiceParams, DisputeState, Invoice, InvoiceNotes, InvoiceOptions, - InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, SplitRule, + InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, RecurringPauseState, SplitRule, SubscriptionParams, }; @@ -42,6 +42,7 @@ fn account_balance_key(account: &Address, token: &Address) -> (Symbol, Address, } fn invoice_notes_key(id: u64) -> (Symbol, u64) { (symbol_short!("notes"), id) } fn invoice_tags_key(id: u64) -> (Symbol, u64) { (symbol_short!("itags"), id) } +fn recurring_pause_key(id: u64) -> (Symbol, u64) { (symbol_short!("rpause"), id) } fn discount_key(id: u64) -> (Symbol, u64) { (symbol_short!("disc"), id) } fn invoice_metadata_key(id: u64) -> (Symbol, u64) { (symbol_short!("imeta"), id) } fn invoice_memo_ext_key(id: u64) -> (Symbol, u64) { (symbol_short!("imemo"), id) } @@ -1080,6 +1081,32 @@ impl SharpyContract { pub fn get_discount(env: Env, invoice_id: u64) -> Option { env.storage().persistent().get(&discount_key(invoice_id)) } + + pub fn pause_recurring(env: Env, caller: Address, invoice_id: u64) { + caller.require_auth(); + let invoice = load_invoice(&env, invoice_id); + assert!(invoice.creator == caller, "only creator can pause recurring"); + let params: SubscriptionParams = env.storage().persistent().get(&recurring_params_key(invoice_id)).expect("not recurring"); + let _ = params; + let state = RecurringPauseState { paused: true, updated_at: env.ledger().timestamp() }; + env.storage().persistent().set(&recurring_pause_key(invoice_id), &state); + append_audit(&env, invoice_id, symbol_short!("rpause"), &caller); + events::recurring_paused(&env, invoice_id, true); + } + pub fn resume_recurring(env: Env, caller: Address, invoice_id: u64) { + caller.require_auth(); + let invoice = load_invoice(&env, invoice_id); + assert!(invoice.creator == caller, "only creator can resume recurring"); + let state: RecurringPauseState = env.storage().persistent().get(&recurring_pause_key(invoice_id)).expect("not paused"); + assert!(state.paused, "not paused"); + let new_state = RecurringPauseState { paused: false, updated_at: env.ledger().timestamp() }; + env.storage().persistent().set(&recurring_pause_key(invoice_id), &new_state); + append_audit(&env, invoice_id, symbol_short!("resume"), &caller); + events::recurring_paused(&env, invoice_id, false); + } + pub fn is_recurring_paused(env: Env, invoice_id: u64) -> bool { + env.storage().persistent().get::<(Symbol,u64), RecurringPauseState>(&recurring_pause_key(invoice_id)).map(|s| s.paused).unwrap_or(false) + } } /// Validates that a token address is not the zero address. diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 927aa0b..2586f2e 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -3705,3 +3705,28 @@ mod test_discount { client.set_discount(&s, &id, &500u32); } } + +#[cfg(test)] +mod test_recurring_pause { + use soroban_sdk::{testutils::Address as _, Address, Env, Vec}; + use crate::SharpyContractClient; + fn setup() -> (Env, SharpyContractClient<'static>) { let env=Env::default(); env.mock_all_auths(); let cid=env.register(crate::SharpyContract, ()); let c=SharpyContractClient::new(&env,&cid); let a=Address::generate(&env); let t=Address::generate(&env); c.initialize(&a,&t); (env,c) } + #[test] fn test_pause_resume() { + let (env, client)=setup(); let creator=Address::generate(&env); let r=Address::generate(&env); let tok=Address::generate(&env); let dl=env.ledger().timestamp()+86400; + let id=client.create_recurring(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [100i128]), &Vec::from_array(&env, [tok]), &dl, &86400u64, &3u32); + assert!(!client.is_recurring_paused(&id)); + client.pause_recurring(&creator, &id); assert!(client.is_recurring_paused(&id)); + client.resume_recurring(&creator, &id); assert!(!client.is_recurring_paused(&id)); + } + #[test] #[should_panic(expected="only creator can pause recurring")] fn test_pause_non_creator() { + let (env, client)=setup(); let creator=Address::generate(&env); let s=Address::generate(&env); let r=Address::generate(&env); let tok=Address::generate(&env); let dl=env.ledger().timestamp()+86400; + let id=client.create_recurring(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [100i128]), &Vec::from_array(&env, [tok]), &dl, &86400u64, &3u32); + client.pause_recurring(&s, &id); + } + #[test] #[should_panic(expected="not recurring")] fn test_pause_non_recurring_panics() { + let (env, client)=setup(); let creator=Address::generate(&env); let r=Address::generate(&env); let tok=Address::generate(&env); let dl=env.ledger().timestamp()+86400; + let id=client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [100i128]), &Vec::from_array(&env, [tok]), &dl, &crate::types::InvoiceOptions{escrow_enabled:false, escrow_release_delay:None, split_rules:Vec::new(&env), auto_resolve_rules:Vec::new(&env), arbitrator:None}); + client.pause_recurring(&creator, &id); + } +} + diff --git a/contracts/sharpy/src/types.rs b/contracts/sharpy/src/types.rs index 2e87ec3..9b09530 100644 --- a/contracts/sharpy/src/types.rs +++ b/contracts/sharpy/src/types.rs @@ -275,3 +275,8 @@ pub struct DiscountConfig { /// Timestamp set. pub updated_at: u64, } + +/// Pause state for recurring invoices. +#[contracttype] +#[derive(Clone, Debug)] +pub struct RecurringPauseState { pub paused: bool, pub updated_at: u64 }