diff --git a/CHANGELOG.md b/CHANGELOG.md index b1bcb69..cc548aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to the Sharpy smart contract are documented here. ## [Unreleased] +- feat: invoice templates — reusable invoice configs — Adds InvoiceTemplate struct and create/get_template function - 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 diff --git a/README.md b/README.md index 2abc2b1..20dea1e 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-156%20passing-00D4AA) +![Tests](https://img.shields.io/badge/tests-159%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** +- **Invoice templates** — `create_template`/`get_template` reusable configs `InvoiceTemplate` - **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 }` diff --git a/contracts/sharpy/src/events.rs b/contracts/sharpy/src/events.rs index 165e5c3..3377553 100644 --- a/contracts/sharpy/src/events.rs +++ b/contracts/sharpy/src/events.rs @@ -286,3 +286,10 @@ 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 }); } + +#[contracttype] +#[derive(Clone)] +pub struct TemplateCreatedEvent { pub template_id: u64, pub creator: Address } +pub fn template_created(env: &Env, template_id: u64, creator: &Address) { + env.events().publish((symbol_short!("tmpl"),), TemplateCreatedEvent { template_id, creator: creator.clone() }); +} diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index bf0f1b3..baf08f5 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, RecurringPauseState, SplitRule, + InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, RecurringPauseState, InvoiceTemplate, 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 template_key(id: u64) -> (Symbol, u64) { (symbol_short!("tmpl"), id) } fn template_counter_key() -> Symbol { symbol_short!("tmpl_ctr") } 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) } @@ -1107,6 +1108,21 @@ impl SharpyContract { 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) } + + pub fn create_template(env: Env, creator: Address, name: String, recipients: Vec
, amounts: Vec) -> u64 { + creator.require_auth(); + assert!(!recipients.is_empty(), "recipients empty"); + assert_eq!(recipients.len(), amounts.len(), "length mismatch"); + let ctr: u64 = env.storage().persistent().get(&template_counter_key()).unwrap_or(0) + 1; + env.storage().persistent().set(&template_counter_key(), &ctr); + let tmpl = InvoiceTemplate { name: name.clone(), recipients: recipients.clone(), amounts: amounts.clone(), template_id: ctr }; + env.storage().persistent().set(&template_key(ctr), &tmpl); + events::template_created(&env, ctr, &creator); + ctr + } + pub fn get_template(env: Env, template_id: u64) -> Option { + env.storage().persistent().get(&template_key(template_id)) + } } /// 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 2586f2e..e3226a8 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -3730,3 +3730,25 @@ mod test_recurring_pause { } } +#[cfg(test)] +mod test_template { + use soroban_sdk::{testutils::Address as _, Address, Env, String, 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_create_get_template() { + let (env, client)=setup(); let creator=Address::generate(&env); let r=Address::generate(&env); + let id=client.create_template(&creator, &String::from_str(&env, "standard"), &Vec::from_array(&env, [r.clone()]), &Vec::from_array(&env, [100i128])); + let tmpl=client.get_template(&id).unwrap(); assert_eq!(tmpl.name, String::from_str(&env, "standard")); assert_eq!(tmpl.recipients.get(0).unwrap(), r); + } + #[test] fn test_template_counter_increments() { + let (env, client)=setup(); let creator=Address::generate(&env); let r=Address::generate(&env); + let id1=client.create_template(&creator, &String::from_str(&env, "a"), &Vec::from_array(&env, [r.clone()]), &Vec::from_array(&env, [10i128])); + let id2=client.create_template(&creator, &String::from_str(&env, "b"), &Vec::from_array(&env, [r]), &Vec::from_array(&env, [20i128])); + assert_eq!(id2, id1+1); + } + #[test] #[should_panic(expected="recipients empty")] fn test_template_empty_panics() { + let (env, client)=setup(); let creator=Address::generate(&env); + client.create_template(&creator, &String::from_str(&env, "empty"), &Vec::new(&env), &Vec::new(&env)); + } +} + diff --git a/contracts/sharpy/src/types.rs b/contracts/sharpy/src/types.rs index 9b09530..3b171d0 100644 --- a/contracts/sharpy/src/types.rs +++ b/contracts/sharpy/src/types.rs @@ -280,3 +280,13 @@ pub struct DiscountConfig { #[contracttype] #[derive(Clone, Debug)] pub struct RecurringPauseState { pub paused: bool, pub updated_at: u64 } + +/// Reusable invoice template stored on-chain. +#[contracttype] +#[derive(Clone, Debug)] +pub struct InvoiceTemplate { + pub name: soroban_sdk::String, + pub recipients: soroban_sdk::Vec
, + pub amounts: soroban_sdk::Vec, + pub template_id: u64, +}