diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bd9447..7c58651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to the Sharpy smart contract are documented here. ## [Unreleased] +- feat: archival — archive and restore invoices — Adds archive_invoice and is_archived query, creator-only, 3 - feat: approval flow — multi-approver workflow — feat/approval-flow - 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 diff --git a/README.md b/README.md index 2726723..e5d63db 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-162%20passing-00D4AA) +![Tests](https://img.shields.io/badge/tests-165%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** +- **Archival** — `archive_invoice`/`unarchive_invoice`/`is_archived` terminal invoice archiving - **Approval flow** — `set_approval_config`/`approve_invoice`/`get_approval_state` multi-sig prep - **Invoice templates** — `create_template`/`get_template` reusable configs `InvoiceTemplate` - **Recurring pause** — `pause_recurring`/`resume_recurring`/`is_recurring_paused` diff --git a/contracts/sharpy/src/events.rs b/contracts/sharpy/src/events.rs index 504c93c..0d4450c 100644 --- a/contracts/sharpy/src/events.rs +++ b/contracts/sharpy/src/events.rs @@ -300,3 +300,10 @@ pub struct InvoiceApprovedEvent { pub invoice_id: u64, pub approver: Address } pub fn invoice_approved(env: &Env, invoice_id: u64, approver: &Address) { env.events().publish((symbol_short!("appr"),), InvoiceApprovedEvent { invoice_id, approver: approver.clone() }); } + +#[contracttype] +#[derive(Clone)] +pub struct InvoiceArchivedEvent { pub invoice_id: u64, pub archiver: Address } +pub fn invoice_archived(env: &Env, invoice_id: u64, archiver: &Address) { + env.events().publish((symbol_short!("arch"),), InvoiceArchivedEvent { invoice_id, archiver: archiver.clone() }); +} diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 083c7b9..2fd761a 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, InvoiceTemplate, ApprovalState, SplitRule, + InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, RecurringPauseState, InvoiceTemplate, ApprovalState, ArchivalState, 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 archival_key(id: u64) -> (Symbol, u64) { (symbol_short!("arch"), id) } fn approval_key(id: u64) -> (Symbol, u64) { (symbol_short!("appr"), 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) } @@ -1145,6 +1146,29 @@ impl SharpyContract { pub fn get_approval_state(env: Env, invoice_id: u64) -> Option { env.storage().persistent().get(&approval_key(invoice_id)) } + + pub fn archive_invoice(env: Env, caller: Address, invoice_id: u64) { + caller.require_auth(); + let invoice = load_invoice(&env, invoice_id); + assert!(invoice.creator == caller, "only creator can archive"); + assert!(invoice.status != InvoiceStatus::Pending, "only terminal invoices can be archived"); + let state = ArchivalState { archived: true, at: env.ledger().timestamp() }; + env.storage().persistent().set(&archival_key(invoice_id), &state); + append_audit(&env, invoice_id, symbol_short!("arch"), &caller); + events::invoice_archived(&env, invoice_id, &caller); + } + pub fn is_archived(env: Env, invoice_id: u64) -> bool { + env.storage().persistent().get::<(Symbol,u64), ArchivalState>(&archival_key(invoice_id)).map(|s| s.archived).unwrap_or(false) + } + pub fn unarchive_invoice(env: Env, caller: Address, invoice_id: u64) { + caller.require_auth(); + let invoice = load_invoice(&env, invoice_id); + assert!(invoice.creator == caller, "only creator can unarchive"); + let state: ArchivalState = env.storage().persistent().get(&archival_key(invoice_id)).expect("not archived"); + assert!(state.archived, "not archived"); + env.storage().persistent().remove(&archival_key(invoice_id)); + append_audit(&env, invoice_id, symbol_short!("unarch"), &caller); + } } /// 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 d1de91e..7244c14 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -3779,3 +3779,37 @@ mod test_approval { } } +#[cfg(test)] +mod test_archival { + use soroban_sdk::{testutils::Address as _, token, 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) } + fn no_rules(env: &Env) -> crate::types::InvoiceOptions { crate::types::InvoiceOptions{escrow_enabled:false, escrow_release_delay:None, split_rules:Vec::new(env), auto_resolve_rules:Vec::new(env), arbitrator:None} } + #[test] fn test_archive_and_unarchive() { + let (env, client)=setup(); let admin=Address::generate(&env); let tok=env.register_stellar_asset_contract(admin.clone()); let sac=token::StellarAssetClient::new(&env,&tok); + let creator=Address::generate(&env); let r=Address::generate(&env); let payer=Address::generate(&env); + sac.mint(&payer, &5000i128); + let dl=env.ledger().timestamp()+86400; + let id=client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [1000i128]), &Vec::from_array(&env, [tok.clone()]), &dl, &no_rules(&env)); + client.pay(&payer, &id, &1000i128); + assert!(!client.is_archived(&id)); + client.archive_invoice(&creator, &id); assert!(client.is_archived(&id)); + client.unarchive_invoice(&creator, &id); assert!(!client.is_archived(&id)); + } + #[test] #[should_panic(expected="only terminal invoices can be archived")] fn test_archive_pending_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, &no_rules(&env)); + client.archive_invoice(&creator, &id); + } + #[test] #[should_panic(expected="only creator can archive")] fn test_archive_non_creator() { + let (env, client)=setup(); let admin=Address::generate(&env); let tok=env.register_stellar_asset_contract(admin.clone()); + let sac=token::StellarAssetClient::new(&env,&tok); + let creator=Address::generate(&env); let stranger=Address::generate(&env); let r=Address::generate(&env); let payer=Address::generate(&env); + sac.mint(&payer, &5000i128); + let dl=env.ledger().timestamp()+86400; + let id=client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [1000i128]), &Vec::from_array(&env, [tok.clone()]), &dl, &no_rules(&env)); + client.pay(&payer, &id, &1000i128); + client.archive_invoice(&stranger, &id); + } +} + diff --git a/contracts/sharpy/src/types.rs b/contracts/sharpy/src/types.rs index 473cf7c..b98eaaa 100644 --- a/contracts/sharpy/src/types.rs +++ b/contracts/sharpy/src/types.rs @@ -298,3 +298,8 @@ pub struct ApprovalState { pub approvers: soroban_sdk::Vec
, pub required: u32, } + +/// Archival marker for invoices. +#[contracttype] +#[derive(Clone, Debug)] +pub struct ArchivalState { pub archived: bool, pub at: u64 }