diff --git a/__tests__/contract-sync/listener.test.ts b/__tests__/contract-sync/listener.test.ts index 9855fb7..44a09bf 100644 --- a/__tests__/contract-sync/listener.test.ts +++ b/__tests__/contract-sync/listener.test.ts @@ -177,6 +177,38 @@ describe('SorobanEventListener', () => { canonicalListener.stop() }) + it('parses deadline extension events with the milestone id and new deadline', async () => { + vi.useRealTimers() + const canonicalListener = new SorobanEventListener({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + contractAddresses: ['CA1234'], + initialLedger: 500, + }) + const canonicalCallback = vi.fn() + canonicalListener.setCallback(canonicalCallback) + + mockGetEvents.mockResolvedValue({ + events: [{ + topic: ['DeadlineExtended', 'CA-CONTRACT', '7', 'GARB'], + value: [100, 200, true], + ledger: 501, + txHash: 'tx-deadline-extension', + }], + }) + mockGetLatestLedger.mockResolvedValue({ sequence: 505 }) + + await (canonicalListener as any).poll() + + expect(canonicalCallback).toHaveBeenCalledWith(expect.objectContaining({ + event: 'deadline_extended', + milestoneId: 7, + deadline: 200, + wasExpired: true, + })) + canonicalListener.stop() + }) + it('awaits the callback for each event before advancing the checkpoint', async () => { // Uses real timers and calls the private poll() directly so the // ordering can be observed deterministically without racing fake-timer diff --git a/__tests__/contract-sync/mapper.test.ts b/__tests__/contract-sync/mapper.test.ts index e6a8ec2..f0f0845 100644 --- a/__tests__/contract-sync/mapper.test.ts +++ b/__tests__/contract-sync/mapper.test.ts @@ -108,6 +108,38 @@ describe('mapEventToAction', () => { expect(action.milestoneId).toBe(1) }) + it('maps deadline_set to a milestone due date', () => { + const action = mapEventToAction('deadline_set', makePayload({ + event: 'deadline_set', + milestoneId: 3, + deadline: 2_000_000_000, + })) + expect(action.kind).toBe('update_milestone') + expect(action.milestoneUpdate?.dueDate).toBe('2033-05-18T03:33:20.000Z') + expect(action.milestoneId).toBe(3) + }) + + it('maps deadline_expired to the expired milestone state', () => { + const action = mapEventToAction('deadline_expired', makePayload({ + event: 'deadline_expired', + milestoneId: 3, + })) + expect(action.milestoneUpdate?.status).toBe('expired') + expect(action.milestoneId).toBe(3) + }) + + it('maps deadline_extended to an extended milestone and its new due date', () => { + const action = mapEventToAction('deadline_extended', makePayload({ + event: 'deadline_extended', + milestoneId: 3, + deadline: 2_000_000_000, + wasExpired: true, + })) + expect(action.milestoneUpdate?.status).toBe('extended') + expect(action.milestoneUpdate?.dueDate).toBe('2033-05-18T03:33:20.000Z') + expect(action.milestoneUpdate?.isOverdue).toBe(false) + }) + it('handles unknown event as noop', () => { const payload = makePayload({ event: 'unknown' as any }) const action = mapEventToAction('unknown' as any, payload) diff --git a/__tests__/deadline-monitor/types.test.ts b/__tests__/deadline-monitor/types.test.ts index a3e1926..445fb1e 100644 --- a/__tests__/deadline-monitor/types.test.ts +++ b/__tests__/deadline-monitor/types.test.ts @@ -3,7 +3,15 @@ import { DEADLINE_EXEMPT_STATUSES, getReminderWindowMs } from '@/lib/deadline-mo describe('deadline-monitor types', () => { it('exempts approved, paid, and rejected milestones from deadline tracking', () => { - expect(DEADLINE_EXEMPT_STATUSES).toEqual(['approved', 'paid', 'rejected']) + expect(DEADLINE_EXEMPT_STATUSES).toEqual([ + 'approved', + 'paid', + 'rejected', + 'disputed', + 'refunded', + 'expired', + 'auto_expired', + ]) }) it('uses a 24 hour reminder window', () => { diff --git a/contracts/contracts/escrow/src/lib.rs b/contracts/contracts/escrow/src/lib.rs index 27eb5c9..d82054e 100644 --- a/contracts/contracts/escrow/src/lib.rs +++ b/contracts/contracts/escrow/src/lib.rs @@ -16,6 +16,8 @@ pub enum MilestoneStatus { Disputed = 7, Resolved = 8, AutoExpired = 9, + Expired = 10, + Extended = 11, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -43,6 +45,8 @@ pub enum DataKey { Milestone(u32), MilestoneIds, EscrowBalance, + PendingDeadline(u32), + ExpiredFrom(u32), } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -73,6 +77,14 @@ pub enum Error { DeadlineExceeded = 11, /// ERR_ALREADY_EXPIRED AlreadyExpired = 12, + /// ERR_INVALID_DEADLINE + InvalidDeadline = 13, + /// ERR_EXTENSION_NOT_FOUND + ExtensionNotFound = 14, + /// ERR_DUPLICATE_MILESTONE_ID + DuplicateMilestoneId = 15, + /// ERR_INSUFFICIENT_BALANCE + InsufficientBalance = 16, } /// Standardized event schemas. Topics are indexed by Soroban RPC consumers; @@ -200,6 +212,82 @@ pub struct MilestoneExpired { pub amount: i128, } +#[contractevent] +pub struct DeadlineSet { + #[topic] + pub contract_id: Address, + #[topic] + pub milestone_id: u32, + #[topic] + pub actor: Address, + pub deadline: u64, +} + +#[contractevent] +pub struct DeadlineExpired { + #[topic] + pub contract_id: Address, + #[topic] + pub milestone_id: u32, + #[topic] + pub actor: Address, + pub deadline: u64, +} + +#[contractevent] +pub struct DeadlineExtended { + #[topic] + pub contract_id: Address, + #[topic] + pub milestone_id: u32, + #[topic] + pub actor: Address, + pub previous_deadline: u64, + pub new_deadline: u64, + pub was_expired: bool, +} + +fn mark_milestone_expired( + env: &Env, + milestone_id: u32, + milestone: &mut Milestone, + actor: &Address, +) -> Result<(), Error> { + if milestone.deadline == 0 || env.ledger().timestamp() <= milestone.deadline { + return Err(Error::DeadlineExceeded); + } + if !matches!( + milestone.status, + MilestoneStatus::Funded + | MilestoneStatus::InProgress + | MilestoneStatus::Submitted + | MilestoneStatus::Approved + | MilestoneStatus::Extended + ) { + return Err(Error::InvalidMilestoneStatus); + } + + env.storage() + .instance() + .set(&DataKey::ExpiredFrom(milestone_id), &milestone.status); + milestone.status = MilestoneStatus::Expired; + milestone.client_approved = false; + milestone.freelancer_approved = false; + env.storage() + .instance() + .set(&DataKey::Milestone(milestone_id), milestone); + + DeadlineExpired { + contract_id: env.current_contract_address(), + milestone_id, + actor: actor.clone(), + deadline: milestone.deadline, + } + .publish(env); + + Ok(()) +} + #[contract] pub struct EscrowContract; @@ -230,6 +318,15 @@ impl EscrowContract { if milestone.amount <= 0 { return Err(Error::ZeroAmount); } + if milestone.deadline == 0 || milestone.deadline <= env.ledger().timestamp() { + return Err(Error::InvalidDeadline); + } + for j in 0..ids.len() { + if ids.get(j).unwrap() == milestone.id { + return Err(Error::DuplicateMilestoneId); + } + } + milestone.status = MilestoneStatus::Created; milestone.client_approved = false; milestone.freelancer_approved = false; env.storage().instance().set(&DataKey::Milestone(milestone.id), &milestone); @@ -247,7 +344,7 @@ impl EscrowContract { EscrowCreated { contract_id: env.current_contract_address(), - actor: admin, + actor: admin.clone(), client, freelancer, arbiter, @@ -256,6 +353,22 @@ impl EscrowContract { } .publish(&env); + for i in 0..ids.len() { + let id = ids.get(i).unwrap(); + let milestone: Milestone = env + .storage() + .instance() + .get(&DataKey::Milestone(id)) + .ok_or(Error::MilestoneNotFound)?; + DeadlineSet { + contract_id: env.current_contract_address(), + milestone_id: id, + actor: admin.clone(), + deadline: milestone.deadline, + } + .publish(&env); + } + Ok(()) } @@ -274,6 +387,9 @@ impl EscrowContract { for i in 0..ids.len() { let id = ids.get(i).unwrap(); let milestone: Milestone = env.storage().instance().get(&DataKey::Milestone(id)).ok_or(Error::MilestoneNotFound)?; + if milestone.deadline > 0 && env.ledger().timestamp() > milestone.deadline { + return Err(Error::DeadlineExceeded); + } total_amount += milestone.amount; } @@ -313,9 +429,14 @@ impl EscrowContract { let mut milestone: Milestone = env.storage().instance().get(&DataKey::Milestone(milestone_id)).ok_or(Error::MilestoneNotFound)?; - if milestone.status != MilestoneStatus::Funded { + if milestone.status != MilestoneStatus::Funded + && milestone.status != MilestoneStatus::Extended + { return Err(Error::InvalidMilestoneStatus); } + if milestone.deadline > 0 && env.ledger().timestamp() > milestone.deadline { + return Err(Error::DeadlineExceeded); + } milestone.status = MilestoneStatus::InProgress; env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); @@ -367,6 +488,9 @@ impl EscrowContract { if milestone.status != MilestoneStatus::Submitted { return Err(Error::InvalidMilestoneStatus); } + if milestone.deadline > 0 && env.ledger().timestamp() > milestone.deadline { + return Err(Error::DeadlineExceeded); + } if milestone.client_approved { return Err(Error::AlreadyApproved); } @@ -394,6 +518,9 @@ impl EscrowContract { if milestone.status != MilestoneStatus::Approved { return Err(Error::InvalidMilestoneStatus); } + if milestone.deadline > 0 && env.ledger().timestamp() > milestone.deadline { + return Err(Error::DeadlineExceeded); + } if milestone.freelancer_approved { return Err(Error::AlreadyApproved); } @@ -426,6 +553,9 @@ impl EscrowContract { if milestone.status == MilestoneStatus::Released { return Err(Error::InvalidMilestoneStatus); } + if milestone.deadline > 0 && env.ledger().timestamp() > milestone.deadline { + return Err(Error::DeadlineExceeded); + } // While a dispute is open the escrowed funds are frozen: `release` must be // blocked until the arbitrator resolves the dispute. @@ -438,13 +568,15 @@ impl EscrowContract { } let transfer_amount = milestone.amount; - milestone.status = MilestoneStatus::Released; - env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); - let mut balance: i128 = env.storage().instance().get(&DataKey::EscrowBalance).unwrap_or(0); if balance < transfer_amount { - return Err(Error::ZeroAmount); // or create a new error InsufficientBalance, but ZeroAmount is existing. Wait, let's just do it. + return Err(Error::InsufficientBalance); } + milestone.status = MilestoneStatus::Released; + env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); + env.storage() + .instance() + .remove(&DataKey::PendingDeadline(milestone_id)); balance -= transfer_amount; env.storage().instance().set(&DataKey::EscrowBalance, &balance); @@ -468,27 +600,45 @@ impl EscrowContract { caller.require_auth(); let freelancer: Address = env.storage().instance().get(&DataKey::Freelancer).ok_or(Error::NotInitialized)?; - if caller != freelancer { + let client: Address = env.storage().instance().get(&DataKey::Client).ok_or(Error::NotInitialized)?; + if caller != freelancer && caller != client { return Err(Error::Unauthorized); } let mut milestone: Milestone = env.storage().instance().get(&DataKey::Milestone(milestone_id)).ok_or(Error::MilestoneNotFound)?; - if milestone.status != MilestoneStatus::Funded && milestone.status != MilestoneStatus::Submitted { - return Err(Error::InvalidMilestoneStatus); + match milestone.status { + MilestoneStatus::Expired => { + if caller != client { + return Err(Error::Unauthorized); + } + if milestone.deadline == 0 + || env.ledger().timestamp() <= milestone.deadline + { + return Err(Error::InvalidMilestoneStatus); + } + } + MilestoneStatus::Funded | MilestoneStatus::Submitted => { + if caller != freelancer { + return Err(Error::Unauthorized); + } + } + _ => return Err(Error::InvalidMilestoneStatus), } let transfer_amount = milestone.amount; - milestone.status = MilestoneStatus::Refunded; - env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); - let mut balance: i128 = env.storage().instance().get(&DataKey::EscrowBalance).unwrap_or(0); - if balance >= transfer_amount { - balance -= transfer_amount; - env.storage().instance().set(&DataKey::EscrowBalance, &balance); + if balance < transfer_amount { + return Err(Error::InsufficientBalance); } + balance -= transfer_amount; + env.storage().instance().set(&DataKey::EscrowBalance, &balance); + milestone.status = MilestoneStatus::Refunded; + env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); + env.storage() + .instance() + .remove(&DataKey::PendingDeadline(milestone_id)); - let client: Address = env.storage().instance().get(&DataKey::Client).ok_or(Error::NotInitialized)?; let token_address: Address = env.storage().instance().get(&DataKey::Token).ok_or(Error::NotInitialized)?; let token_client = token::Client::new(&env, &token_address); token_client.transfer(&env.current_contract_address(), &client, &transfer_amount); @@ -522,15 +672,42 @@ impl EscrowContract { // A dispute can only be raised after the milestone has been funded and // submitted (Submitted/Approved) - it must not bypass the normal lifecycle. - if milestone.status != MilestoneStatus::Submitted - && milestone.status != MilestoneStatus::Approved + if (milestone.status == MilestoneStatus::Submitted + || milestone.status == MilestoneStatus::Approved) + && milestone.deadline > 0 + && milestone.deadline > 0 + && env.ledger().timestamp() > milestone.deadline { + mark_milestone_expired(&env, milestone_id, &mut milestone, &caller)?; + } + + let can_dispute = match milestone.status { + MilestoneStatus::Submitted | MilestoneStatus::Approved => true, + MilestoneStatus::Expired => env + .storage() + .instance() + .get::(&DataKey::ExpiredFrom(milestone_id)) + .map(|status| { + status == MilestoneStatus::Submitted + || status == MilestoneStatus::Approved + || status == MilestoneStatus::Extended + }) + .unwrap_or(false), + _ => false, + }; + if !can_dispute { return Err(Error::InvalidMilestoneStatus); } milestone.status = MilestoneStatus::Disputed; milestone.client_approved = false; milestone.freelancer_approved = false; + env.storage() + .instance() + .remove(&DataKey::ExpiredFrom(milestone_id)); + env.storage() + .instance() + .remove(&DataKey::PendingDeadline(milestone_id)); env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); DisputeRaised { @@ -554,14 +731,17 @@ impl EscrowContract { } let transfer_amount = milestone.amount; - milestone.status = MilestoneStatus::Resolved; - env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); - let mut balance: i128 = env.storage().instance().get(&DataKey::EscrowBalance).unwrap_or(0); - if balance >= transfer_amount { - balance -= transfer_amount; - env.storage().instance().set(&DataKey::EscrowBalance, &balance); + if balance < transfer_amount { + return Err(Error::InsufficientBalance); } + balance -= transfer_amount; + env.storage().instance().set(&DataKey::EscrowBalance, &balance); + milestone.status = MilestoneStatus::Resolved; + env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); + env.storage() + .instance() + .remove(&DataKey::PendingDeadline(milestone_id)); let recipient: Address = if release_to_freelancer { env.storage().instance().get(&DataKey::Freelancer).ok_or(Error::NotInitialized)? @@ -590,42 +770,132 @@ impl EscrowContract { caller.require_auth(); let mut milestone: Milestone = env.storage().instance().get(&DataKey::Milestone(milestone_id)).ok_or(Error::MilestoneNotFound)?; - if milestone.deadline == 0 { - return Err(Error::InvalidMilestoneStatus); + if milestone.status == MilestoneStatus::Expired + || milestone.status == MilestoneStatus::AutoExpired + { + return Err(Error::AlreadyExpired); } - if env.ledger().timestamp() <= milestone.deadline { + mark_milestone_expired(&env, milestone_id, &mut milestone, &caller) + } + + pub fn request_deadline_extension( + env: Env, + milestone_id: u32, + new_deadline: u64, + caller: Address, + ) -> Result<(), Error> { + caller.require_auth(); + let client: Address = env.storage().instance().get(&DataKey::Client).ok_or(Error::NotInitialized)?; + let arbiter: Address = env.storage().instance().get(&DataKey::Arbiter).ok_or(Error::NotInitialized)?; + if caller != client && caller != arbiter { + return Err(Error::Unauthorized); + } + + let mut milestone: Milestone = env.storage().instance().get(&DataKey::Milestone(milestone_id)).ok_or(Error::MilestoneNotFound)?; + if milestone.deadline > 0 + && env.ledger().timestamp() > milestone.deadline + && matches!( + milestone.status, + MilestoneStatus::Funded + | MilestoneStatus::InProgress + | MilestoneStatus::Submitted + | MilestoneStatus::Approved + | MilestoneStatus::Extended + ) + { + mark_milestone_expired(&env, milestone_id, &mut milestone, &caller)?; + } + if !matches!( + milestone.status, + MilestoneStatus::Funded + | MilestoneStatus::InProgress + | MilestoneStatus::Submitted + | MilestoneStatus::Approved + | MilestoneStatus::Expired + | MilestoneStatus::Extended + ) { return Err(Error::InvalidMilestoneStatus); } - if milestone.status == MilestoneStatus::Released - || milestone.status == MilestoneStatus::Refunded - || milestone.status == MilestoneStatus::Disputed - || milestone.status == MilestoneStatus::AutoExpired + let current_deadline = milestone.deadline; + let pending_deadline: Option = env + .storage() + .instance() + .get(&DataKey::PendingDeadline(milestone_id)); + if new_deadline <= current_deadline + || new_deadline <= env.ledger().timestamp() + || pending_deadline.map(|pending| new_deadline <= pending).unwrap_or(false) { - return Err(Error::AlreadyExpired); + return Err(Error::InvalidDeadline); } - let transfer_amount = milestone.amount; - milestone.status = MilestoneStatus::AutoExpired; - env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); + env.storage() + .instance() + .set(&DataKey::PendingDeadline(milestone_id), &new_deadline); + Ok(()) + } - let mut balance: i128 = env.storage().instance().get(&DataKey::EscrowBalance).unwrap_or(0); - if balance >= transfer_amount { - balance -= transfer_amount; - env.storage().instance().set(&DataKey::EscrowBalance, &balance); + pub fn approve_deadline_extension(env: Env, milestone_id: u32) -> Result<(), Error> { + let arbiter: Address = env.storage().instance().get(&DataKey::Arbiter).ok_or(Error::NotInitialized)?; + arbiter.require_auth(); + + let new_deadline: u64 = env + .storage() + .instance() + .get(&DataKey::PendingDeadline(milestone_id)) + .ok_or(Error::ExtensionNotFound)?; + let mut milestone: Milestone = env.storage().instance().get(&DataKey::Milestone(milestone_id)).ok_or(Error::MilestoneNotFound)?; + if milestone.deadline > 0 + && env.ledger().timestamp() > milestone.deadline + && matches!( + milestone.status, + MilestoneStatus::Funded + | MilestoneStatus::InProgress + | MilestoneStatus::Submitted + | MilestoneStatus::Approved + | MilestoneStatus::Extended + ) + { + mark_milestone_expired(&env, milestone_id, &mut milestone, &arbiter)?; + } + if new_deadline <= milestone.deadline || new_deadline <= env.ledger().timestamp() { + return Err(Error::InvalidDeadline); + } + if !matches!( + milestone.status, + MilestoneStatus::Funded + | MilestoneStatus::InProgress + | MilestoneStatus::Submitted + | MilestoneStatus::Approved + | MilestoneStatus::Expired + | MilestoneStatus::Extended + ) { + return Err(Error::InvalidMilestoneStatus); } - let client: Address = env.storage().instance().get(&DataKey::Client).ok_or(Error::NotInitialized)?; - let token_address: Address = env.storage().instance().get(&DataKey::Token).ok_or(Error::NotInitialized)?; - let token_client = token::Client::new(&env, &token_address); - token_client.transfer(&env.current_contract_address(), &client, &transfer_amount); + let previous_deadline = milestone.deadline; + let was_expired = milestone.status == MilestoneStatus::Expired; + if was_expired { + milestone.status = MilestoneStatus::Extended; + milestone.client_approved = false; + milestone.freelancer_approved = false; + } + env.storage() + .instance() + .remove(&DataKey::ExpiredFrom(milestone_id)); + milestone.deadline = new_deadline; + env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); + env.storage() + .instance() + .remove(&DataKey::PendingDeadline(milestone_id)); - MilestoneExpired { + DeadlineExtended { contract_id: env.current_contract_address(), milestone_id, - actor: caller, - recipient: client, - amount: transfer_amount, + actor: arbiter, + previous_deadline, + new_deadline, + was_expired, } .publish(&env); @@ -634,10 +904,7 @@ impl EscrowContract { pub fn is_milestone_expired(env: Env, milestone_id: u32) -> Result { let milestone: Milestone = env.storage().instance().get(&DataKey::Milestone(milestone_id)).ok_or(Error::MilestoneNotFound)?; - if milestone.deadline == 0 { - return Ok(false); - } - Ok(env.ledger().timestamp() > milestone.deadline) + Ok(milestone.deadline > 0 && env.ledger().timestamp() > milestone.deadline) } pub fn get_milestone_deadline(env: Env, milestone_id: u32) -> Result { diff --git a/contracts/contracts/escrow/src/test.rs b/contracts/contracts/escrow/src/test.rs index 12a68d0..70edc9d 100644 --- a/contracts/contracts/escrow/src/test.rs +++ b/contracts/contracts/escrow/src/test.rs @@ -2,7 +2,7 @@ use super::*; use soroban_sdk::{ - testutils::{Address as _, Events as _}, + testutils::{Address as _, Events as _, Ledger as _}, vec, Address, Env, String, }; @@ -54,7 +54,7 @@ fn setup_test() -> TestSetup { fn milestone(env: &Env, id: u32, amount: i128) -> Milestone { Milestone { id, - deadline: 0, + deadline: 10_000, amount, status: MilestoneStatus::Created, description: String::from_str(env, "Security milestone"), @@ -64,7 +64,15 @@ fn milestone(env: &Env, id: u32, amount: i128) -> Milestone { } fn initialize_single_milestone(setup: &TestSetup, amount: i128) { - let milestones = vec![&setup.env, milestone(&setup.env, 1, amount)]; + initialize_single_milestone_with_deadline(setup, amount, 10_000); +} + +fn initialize_single_milestone_with_deadline(setup: &TestSetup, amount: i128, deadline: u64) { + let milestone = Milestone { + deadline, + ..milestone(&setup.env, 1, amount) + }; + let milestones = vec![&setup.env, milestone]; setup.escrow_client.initialize( &setup.admin, &setup.client, @@ -91,7 +99,7 @@ fn test_happy_path() { let milestone_1 = Milestone { id: 1, - deadline: 0, + deadline: 10_000, amount: 100, status: MilestoneStatus::Created, description: String::from_str(&env, "Milestone 1"), @@ -100,7 +108,7 @@ fn test_happy_path() { }; let milestone_2 = Milestone { id: 2, - deadline: 0, + deadline: 10_000, amount: 200, status: MilestoneStatus::Created, description: String::from_str(&env, "Milestone 2"), @@ -164,7 +172,7 @@ fn test_voluntary_refund() { let milestone = Milestone { id: 1, - deadline: 0, + deadline: 10_000, amount: 250, status: MilestoneStatus::Created, description: String::from_str(&env, "Project Work"), @@ -197,7 +205,7 @@ fn test_dispute_and_resolve_to_freelancer() { let milestone = Milestone { id: 1, - deadline: 0, + deadline: 10_000, amount: 400, status: MilestoneStatus::Created, description: String::from_str(&env, "High Value Milestone"), @@ -234,7 +242,7 @@ fn test_dispute_and_resolve_to_client() { let milestone = Milestone { id: 1, - deadline: 0, + deadline: 10_000, amount: 400, status: MilestoneStatus::Created, description: String::from_str(&env, "High Value Milestone"), @@ -271,7 +279,7 @@ fn test_double_initialization_fails() { let milestone = Milestone { id: 1, - deadline: 0, + deadline: 10_000, amount: 100, status: MilestoneStatus::Created, description: String::from_str(&env, "Milestone"), @@ -294,7 +302,7 @@ fn test_zero_amount_fails() { let milestone = Milestone { id: 1, - deadline: 0, + deadline: 10_000, amount: 0, // Zero amount status: MilestoneStatus::Created, description: String::from_str(&env, "Invalid Milestone"), @@ -315,7 +323,7 @@ fn test_unauthorized_release_fails() { let milestone = Milestone { id: 1, - deadline: 0, + deadline: 10_000, amount: 100, status: MilestoneStatus::Created, description: String::from_str(&env, "Milestone"), @@ -342,7 +350,7 @@ fn test_version() { let milestone = Milestone { id: 1, - deadline: 0, + deadline: 10_000, amount: 100, status: MilestoneStatus::Created, description: String::from_str(&env, "Milestone"), @@ -365,7 +373,7 @@ fn test_release_without_approval_fails() { let milestone = Milestone { id: 1, - deadline: 0, + deadline: 10_000, amount: 100, status: MilestoneStatus::Created, description: String::from_str(&env, "Milestone"), @@ -391,7 +399,7 @@ fn test_double_client_approval_fails() { let milestone = Milestone { id: 1, - deadline: 0, + deadline: 10_000, amount: 100, status: MilestoneStatus::Created, description: String::from_str(&env, "Milestone"), @@ -418,7 +426,7 @@ fn test_double_freelancer_confirmation_fails() { let milestone = Milestone { id: 1, - deadline: 0, + deadline: 10_000, amount: 100, status: MilestoneStatus::Created, description: String::from_str(&env, "Milestone"), @@ -445,7 +453,7 @@ fn test_dispute_clears_approvals() { let milestone = Milestone { id: 1, - deadline: 0, + deadline: 10_000, amount: 400, status: MilestoneStatus::Created, description: String::from_str(&env, "High Value Milestone"), @@ -568,9 +576,8 @@ fn test_successful_security_events_are_emitted() { .filter_by_contract(&setup.escrow_client.address) .events() .len(), - 1 + 2 ); - setup.escrow_client.fund(); assert_eq!( env.events() @@ -762,3 +769,228 @@ fn test_dispute_events_are_emitted() { 1 ); } + +#[test] +fn test_deadline_is_set_and_expiry_locks_funds_until_client_refund() { + let setup = setup_test(); + initialize_single_milestone_with_deadline(&setup, 150, 100); + let token_client = token::Client::new(&setup.env, &setup.token_address); + assert_eq!( + setup + .env + .events() + .all() + .filter_by_contract(&setup.escrow_client.address) + .events() + .len(), + 2 + ); + assert_eq!(setup.escrow_client.get_milestone_deadline(&1), 100); + + setup.escrow_client.fund(); + setup.env.ledger().set_timestamp(101); + setup.escrow_client.auto_expire(&setup.client, &1); + + assert_eq!( + setup.escrow_client.get_milestones().get(0).unwrap().status, + MilestoneStatus::Expired + ); + assert_eq!(setup.escrow_client.get_escrow_balance(), 150); + assert_eq!(token_client.balance(&setup.client), 850); + assert_eq!(token_client.balance(&setup.escrow_client.address), 150); + + setup.escrow_client.refund(&1, &setup.client); + assert_eq!( + setup.escrow_client.get_milestones().get(0).unwrap().status, + MilestoneStatus::Refunded + ); + assert_eq!(setup.escrow_client.get_escrow_balance(), 0); + assert_eq!(token_client.balance(&setup.client), 1000); +} + +#[test] +fn test_expired_milestone_can_be_extended_only_with_arbiter_approval() { + let setup = setup_test(); + initialize_single_milestone_with_deadline(&setup, 150, 100); + setup.escrow_client.fund(); + setup.env.ledger().set_timestamp(101); + setup.escrow_client.auto_expire(&setup.client, &1); + + setup + .escrow_client + .request_deadline_extension(&1, &200, &setup.client); + setup.escrow_client.approve_deadline_extension(&1); + + assert_eq!(setup.escrow_client.get_milestone_deadline(&1), 200); + assert_eq!( + setup.escrow_client.get_milestones().get(0).unwrap().status, + MilestoneStatus::Extended + ); + + setup.escrow_client.start_milestone(&1); + setup.escrow_client.submit_milestone(&1); + assert_eq!( + setup.escrow_client.get_milestones().get(0).unwrap().status, + MilestoneStatus::Submitted + ); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #7)")] +fn test_unauthorized_user_cannot_request_deadline_extension() { + let setup = setup_test(); + initialize_single_milestone_with_deadline(&setup, 150, 100); + + let stranger = Address::generate(&setup.env); + setup + .escrow_client + .request_deadline_extension(&1, &200, &stranger); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #13)")] +fn test_deadline_extension_cannot_shorten_deadline() { + let setup = setup_test(); + initialize_single_milestone_with_deadline(&setup, 150, 100); + setup.escrow_client.fund(); + + setup + .escrow_client + .request_deadline_extension(&1, &99, &setup.client); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #11)")] +fn test_approval_is_rejected_after_deadline() { + let setup = setup_test(); + initialize_single_milestone_with_deadline(&setup, 150, 100); + setup.escrow_client.fund(); + setup.escrow_client.start_milestone(&1); + setup.escrow_client.submit_milestone(&1); + setup.env.ledger().set_timestamp(101); + + setup.escrow_client.approve(&1); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #11)")] +fn test_release_is_rejected_after_deadline() { + let setup = setup_test(); + initialize_single_milestone_with_deadline(&setup, 150, 100); + setup.escrow_client.fund(); + setup.escrow_client.start_milestone(&1); + setup.escrow_client.submit_milestone(&1); + setup.escrow_client.approve(&1); + setup.env.ledger().set_timestamp(101); + + setup.escrow_client.release(&1, &setup.client); +} + +#[test] +fn test_dispute_can_be_raised_after_deadline() { + let setup = setup_test(); + initialize_single_milestone_with_deadline(&setup, 150, 100); + setup.escrow_client.fund(); + setup.escrow_client.start_milestone(&1); + setup.escrow_client.submit_milestone(&1); + setup.env.ledger().set_timestamp(101); + + setup.escrow_client.dispute(&1, &setup.client); + + assert_eq!( + setup.escrow_client.get_milestones().get(0).unwrap().status, + MilestoneStatus::Disputed + ); + assert_eq!(setup.escrow_client.get_escrow_balance(), 150); +} + +#[test] +fn test_simultaneously_expired_milestones_are_independently_refundable() { + let setup = setup_test(); + let milestones = vec![ + &setup.env, + Milestone { + id: 1, + deadline: 100, + amount: 100, + status: MilestoneStatus::Created, + description: String::from_str(&setup.env, "First"), + client_approved: false, + freelancer_approved: false, + }, + Milestone { + id: 2, + deadline: 100, + amount: 200, + status: MilestoneStatus::Created, + description: String::from_str(&setup.env, "Second"), + client_approved: false, + freelancer_approved: false, + }, + ]; + setup.escrow_client.initialize( + &setup.admin, + &setup.client, + &setup.freelancer, + &setup.arbiter, + &setup.token_address, + &milestones, + ); + setup.escrow_client.fund(); + setup.env.ledger().set_timestamp(101); + + setup.escrow_client.auto_expire(&setup.client, &1); + setup.escrow_client.auto_expire(&setup.client, &2); + assert_eq!(setup.escrow_client.get_escrow_balance(), 300); + + setup.escrow_client.refund(&1, &setup.client); + setup.escrow_client.refund(&2, &setup.client); + assert_eq!(setup.escrow_client.get_escrow_balance(), 0); + assert_eq!( + setup.escrow_client.get_milestones().get(0).unwrap().status, + MilestoneStatus::Refunded + ); + assert_eq!( + setup.escrow_client.get_milestones().get(1).unwrap().status, + MilestoneStatus::Refunded + ); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #13)")] +fn test_initialization_rejects_missing_deadline() { + let setup = setup_test(); + let milestones = vec![&setup.env, milestone(&setup.env, 1, 100)]; + let invalid = Milestone { + deadline: 0, + ..milestones.get(0).unwrap() + }; + setup.escrow_client.initialize( + &setup.admin, + &setup.client, + &setup.freelancer, + &setup.arbiter, + &setup.token_address, + &vec![&setup.env, invalid], + ); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #15)")] +fn test_duplicate_milestone_ids_are_rejected() { + let setup = setup_test(); + let milestones = vec![ + &setup.env, + milestone(&setup.env, 1, 100), + milestone(&setup.env, 1, 200), + ]; + + setup.escrow_client.initialize( + &setup.admin, + &setup.client, + &setup.freelancer, + &setup.arbiter, + &setup.token_address, + &milestones, + ); +} diff --git a/docs/soroban-contract-events.md b/docs/soroban-contract-events.md index 060aa00..364cce1 100644 --- a/docs/soroban-contract-events.md +++ b/docs/soroban-contract-events.md @@ -20,6 +20,10 @@ Contract methods that can fail return `Result<_, Error>`. Soroban exposes the en | Escrow | 10 | `AlreadyApproved` | `ERR_ALREADY_APPROVED` | | Escrow | 11 | `DeadlineExceeded` | `ERR_DEADLINE_EXCEEDED` | | Escrow | 12 | `AlreadyExpired` | `ERR_ALREADY_EXPIRED` | +| Escrow | 13 | `InvalidDeadline` | `ERR_INVALID_DEADLINE` | +| Escrow | 14 | `ExtensionNotFound` | `ERR_EXTENSION_NOT_FOUND` | +| Escrow | 15 | `DuplicateMilestoneId` | `ERR_DUPLICATE_MILESTONE_ID` | +| Escrow | 16 | `InsufficientBalance` | `ERR_INSUFFICIENT_BALANCE` | | Dispute | 1 | `AlreadyInitialized` | `ERR_ALREADY_INITIALIZED` | | Dispute | 2 | `NotInitialized` | `ERR_NOT_INITIALIZED` | | Dispute | 3 | `DisputeNotFound` | `ERR_DISPUTE_NOT_FOUND` | @@ -52,6 +56,18 @@ All standardized events use the PascalCase type names below in contract source a | `RefundIssued` / `refund_issued` | `contract_id`, `milestone_id`, `actor` | `recipient`, `amount` | | `DisputeResolved` / `dispute_resolved` | `contract_id`, `milestone_id`, `actor` | `recipient`, `amount`, `release_to_freelancer` | | `MilestoneExpired` / `milestone_expired` | `contract_id`, `milestone_id`, `actor` | `recipient`, `amount` | +| `DeadlineSet` / `deadline_set` | `contract_id`, `milestone_id`, `actor` | `deadline` | +| `DeadlineExpired` / `deadline_expired` | `contract_id`, `milestone_id`, `actor` | `deadline` | +| `DeadlineExtended` / `deadline_extended` | `contract_id`, `milestone_id`, `actor` | `previous_deadline`, `new_deadline`, `was_expired` | + +Milestones require a future Unix timestamp deadline at initialization. After the +deadline, the permissionless `auto_expire` call marks a funded, unfinished +milestone as `Expired` and leaves its funds in escrow. The client may call +`refund` to return those funds; alternatively, the client or arbitrator may +request a later deadline and only the arbitrator can approve it. Approval +transitions `Expired` to `Extended`. A submitted/approved milestone may still +enter dispute after expiry, in which case its funds remain locked until the +arbitrator resolves it. Expiry never transfers funds automatically. ### Dispute events diff --git a/docs/sync-flow.md b/docs/sync-flow.md index e16b9f1..2a56364 100644 --- a/docs/sync-flow.md +++ b/docs/sync-flow.md @@ -51,6 +51,9 @@ Defines all Soroban contract events that the system listens for: | `dispute` | Milestone disputed | milestone_id | | `resolve` | Dispute resolved by arbiter | milestone_id, release_to_freelancer | | `expire` | Milestone auto-expired | milestone_id, transfer_amount | +| `deadline_set` | Deadline recorded at initialization | milestone_id, deadline | +| `deadline_expired` | Milestone expires; funds remain locked | milestone_id, deadline | +| `deadline_extended` | Arbitrator approves extension | milestone_id, new_deadline, was_expired | ### 2. Event Mapper (`lib/contract-sync/mapper.ts`) @@ -68,6 +71,9 @@ Maps each on-chain event to the corresponding database status change: | `dispute` | status=disputed | status=disputed | | `resolve` | status=completed | status=paid | | `expire` | - | status=auto_expired | +| `deadline_set` | - | due_date updated | +| `deadline_expired` | - | status=expired | +| `deadline_extended` | - | status=extended, due_date updated | ### 3. Sync Queue (`lib/contract-sync/queue.ts`) @@ -105,7 +111,7 @@ contract_sync_log ( id UUID PRIMARY KEY, contract_id UUID REFERENCES contracts, milestone_id UUID REFERENCES milestones, - event_type contract_sync_event_type, -- enum of all 10 events + event_type contract_sync_event_type, -- canonical and legacy event names tx_hash TEXT, ledger_sequence BIGINT, status sync_status, -- pending/processing/success/failed/dead_letter @@ -205,7 +211,7 @@ npm test -- --reporter=verbose ``` Unit tests cover: -- Event mapping correctness for all 10 event types +- Event mapping correctness for all canonical and legacy event types - Queue enqueue/dequeue/retry/dead letter logic, including `onRetry`/`onDeadLetter` hooks - Backoff delay calculation - Soroban event parsing diff --git a/lib/contract-sync/listener.ts b/lib/contract-sync/listener.ts index c45cb1e..fb2981b 100644 --- a/lib/contract-sync/listener.ts +++ b/lib/contract-sync/listener.ts @@ -162,10 +162,12 @@ export class SorobanEventListener { const milestoneId = this.extractMilestoneId(eventName, topic, data, legacy) const disputeId = this.extractDisputeId(eventName, topic, data, legacy) const amount = this.extractEventField(eventName, data, 'amount', legacy) + const deadline = this.extractDeadline(eventName, data) const actor = this.extractActor(eventName, topic, data, legacy) const recipient = this.extractEventField(eventName, data, 'recipient', legacy) const support = this.extractBooleanField(data, 'support') const releaseToFreelancer = this.extractBooleanField(data, 'release_to_freelancer') + const wasExpired = this.extractBooleanField(data, 'was_expired') return { event: eventName as SorobanContractEvent, @@ -177,6 +179,8 @@ export class SorobanEventListener { milestoneId, disputeId, amount, + deadline, + wasExpired, actor, recipient, support, @@ -209,12 +213,27 @@ export class SorobanEventListener { data: any[], legacy: boolean ): number | undefined { - if (['milestone_submitted', 'milestone_approved', 'milestone_confirmed', 'payment_released', 'dispute_raised', 'refund_issued', 'dispute_resolved', 'milestone_expired', 'dispute_created', 'vote_cast', 'stake_claimed'].includes(eventName)) { + if (['milestone_submitted', 'milestone_approved', 'milestone_confirmed', 'payment_released', 'dispute_raised', 'refund_issued', 'dispute_resolved', 'milestone_expired', 'deadline_set', 'deadline_expired', 'deadline_extended', 'dispute_created', 'vote_cast', 'stake_claimed'].includes(eventName)) { return this.extractNumber(topic[legacy ? 1 : 2]) ?? this.extractNumber(data[0]) } return undefined } + private extractDeadline(eventName: string, data: any[]): number | undefined { + const value = data[0] + if (value && typeof value === 'object' && !Array.isArray(value)) { + const deadline = value.deadline ?? value.new_deadline ?? value.newDeadline + return this.extractNumber(deadline) + } + if (eventName === 'deadline_extended') { + return this.extractNumber(data[1]) + } + if (eventName === 'milestone_submitted') { + return this.extractNumber(data[1]) + } + return this.extractNumber(data[0]) + } + private extractDisputeId( eventName: string, topic: any[], @@ -252,9 +271,15 @@ export class SorobanEventListener { private extractBooleanField(data: any[], field: string): boolean | undefined { const value = data[0] - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined - const result = value[field] ?? value[this.toCamelCase(field)] - return typeof result === 'boolean' ? result : undefined + if (value && typeof value === 'object' && !Array.isArray(value)) { + const result = value[field] ?? value[this.toCamelCase(field)] + if (typeof result === 'boolean') return result + } + if (field === 'was_expired') { + const positionalValue = data[2] + return typeof positionalValue === 'boolean' ? positionalValue : undefined + } + return undefined } private extractNumber(value: any): number | undefined { diff --git a/lib/contract-sync/mapper.ts b/lib/contract-sync/mapper.ts index b4a9d23..f492bf5 100644 --- a/lib/contract-sync/mapper.ts +++ b/lib/contract-sync/mapper.ts @@ -20,7 +20,11 @@ export interface ContractStatusUpdate { } export interface MilestoneStatusUpdate { - status: string + status?: string + dueDate?: string + isOverdue?: boolean + overdueAt?: string | null + reminderSentAt?: string | null submittedAt?: string approvedAt?: string paidAt?: string @@ -56,6 +60,17 @@ export function mapEventToAction(event: SorobanContractEvent, data: SorobanEvent disputeInfo: null, } + case 'deadline_set': + return { + kind: 'update_milestone', + contractUpdate: null, + milestoneUpdate: { + ...(data.deadline != null ? { dueDate: new Date(data.deadline * 1000).toISOString() } : {}), + }, + milestoneId: data.milestoneId ?? null, + disputeInfo: null, + } + case 'escrow_funded': return { kind: 'update_contract', @@ -186,6 +201,33 @@ export function mapEventToAction(event: SorobanContractEvent, data: SorobanEvent disputeInfo: null, } + case 'deadline_expired': + return { + kind: 'update_milestone', + contractUpdate: null, + milestoneUpdate: { + status: 'expired', + rejectionReason: 'Milestone deadline exceeded', + }, + milestoneId: data.milestoneId ?? null, + disputeInfo: null, + } + + case 'deadline_extended': + return { + kind: 'update_milestone', + contractUpdate: null, + milestoneUpdate: { + ...(data.wasExpired ? { status: 'extended' } : {}), + ...(data.deadline != null ? { dueDate: new Date(data.deadline * 1000).toISOString() } : {}), + isOverdue: false, + overdueAt: null, + reminderSentAt: null, + }, + milestoneId: data.milestoneId ?? null, + disputeInfo: null, + } + default: return { kind: 'noop', diff --git a/lib/contract-sync/service.ts b/lib/contract-sync/service.ts index 4ea8a94..3427d45 100644 --- a/lib/contract-sync/service.ts +++ b/lib/contract-sync/service.ts @@ -2,6 +2,7 @@ import { sql } from '@/lib/db' import { SorobanEventListener } from './listener' import { SyncQueue } from './queue' import { mapEventToAction } from './mapper' +import type { MilestoneStatusUpdate } from './mapper' import { buildSyncDedupeKey } from './types' import type { SorobanEventPayload, SyncStatus, SorobanContractEvent } from './types' @@ -166,7 +167,7 @@ export class ContractSyncService { if (action.milestoneUpdate && action.milestoneId != null) { const milestoneDbId = await this.resolveMilestoneDbId(contractId, action.milestoneId) if (milestoneDbId) { - await this.applyMilestoneUpdate(milestoneDbId, action.milestoneUpdate as unknown as Record) + await this.applyMilestoneUpdate(milestoneDbId, action.milestoneUpdate) } } } @@ -223,7 +224,7 @@ export class ContractSyncService { private async applyMilestoneUpdate( milestoneId: string, - update: Record + update: MilestoneStatusUpdate ): Promise { const sets: string[] = ['updated_at = NOW()'] @@ -232,6 +233,8 @@ export class ContractSyncService { const dbField = this.fieldToDbColumnMilestone(field) if (value === null) { sets.push(`${dbField} = NULL`) + } else if (typeof value === 'boolean') { + sets.push(`${dbField} = ${value ? 'TRUE' : 'FALSE'}`) } else { const escaped = value.replace(/'/g, "''") sets.push(`${dbField} = '${escaped}'`) @@ -334,6 +337,10 @@ export class ContractSyncService { submittedAt: 'submitted_at', approvedAt: 'approved_at', paidAt: 'paid_at', + dueDate: 'due_date', + isOverdue: 'is_overdue', + overdueAt: 'overdue_at', + reminderSentAt: 'reminder_sent_at', releaseTxHash: 'release_tx_hash', rejectionReason: 'rejection_reason', } diff --git a/lib/contract-sync/types.ts b/lib/contract-sync/types.ts index 1c73c28..e437031 100644 --- a/lib/contract-sync/types.ts +++ b/lib/contract-sync/types.ts @@ -17,6 +17,9 @@ export type CanonicalSorobanContractEvent = | 'refund_issued' | 'dispute_resolved' | 'milestone_expired' + | 'deadline_set' + | 'deadline_expired' + | 'deadline_extended' | 'dispute_created' | 'vote_cast' | 'stake_claimed' @@ -47,6 +50,9 @@ export const CANONICAL_SOROBAN_EVENTS: readonly CanonicalSorobanContractEvent[] 'refund_issued', 'dispute_resolved', 'milestone_expired', + 'deadline_set', + 'deadline_expired', + 'deadline_extended', 'dispute_created', 'vote_cast', 'stake_claimed', @@ -75,6 +81,9 @@ const EVENT_ALIASES: Record = { refundissued: 'refund_issued', disputeresolved: 'dispute_resolved', milestoneexpired: 'milestone_expired', + deadlineset: 'deadline_set', + deadlineexpired: 'deadline_expired', + deadlineextended: 'deadline_extended', disputecreated: 'dispute_created', votecast: 'vote_cast', stakeclaimed: 'stake_claimed', @@ -98,6 +107,8 @@ export interface SorobanEventPayload { milestoneId?: number disputeId?: number amount?: string + deadline?: number + wasExpired?: boolean actor?: string recipient?: string support?: boolean diff --git a/lib/db/migrations/009_contract_event_standardization.sql b/lib/db/migrations/009_contract_event_standardization.sql index 18d9710..867d340 100644 --- a/lib/db/migrations/009_contract_event_standardization.sql +++ b/lib/db/migrations/009_contract_event_standardization.sql @@ -13,6 +13,9 @@ BEGIN ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'refund_issued'; ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'dispute_resolved'; ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'milestone_expired'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'deadline_set'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'deadline_expired'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'deadline_extended'; ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'dispute_created'; ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'vote_cast'; ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'stake_claimed'; diff --git a/lib/db/migrations/011_escrow_deadline_lifecycle.sql b/lib/db/migrations/011_escrow_deadline_lifecycle.sql new file mode 100644 index 0000000..0236920 --- /dev/null +++ b/lib/db/migrations/011_escrow_deadline_lifecycle.sql @@ -0,0 +1,18 @@ +-- Persist the on-chain deadline lifecycle and its audit events. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'milestone_status') THEN + ALTER TYPE milestone_status ADD VALUE IF NOT EXISTS 'disputed'; + ALTER TYPE milestone_status ADD VALUE IF NOT EXISTS 'refunded'; + ALTER TYPE milestone_status ADD VALUE IF NOT EXISTS 'auto_expired'; + ALTER TYPE milestone_status ADD VALUE IF NOT EXISTS 'expired'; + ALTER TYPE milestone_status ADD VALUE IF NOT EXISTS 'extended'; + END IF; + + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'contract_sync_event_type') THEN + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'deadline_set'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'deadline_expired'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'deadline_extended'; + END IF; +END; +$$; diff --git a/lib/deadline-monitor/types.ts b/lib/deadline-monitor/types.ts index 415834d..01b43b8 100644 --- a/lib/deadline-monitor/types.ts +++ b/lib/deadline-monitor/types.ts @@ -6,7 +6,15 @@ export interface DeadlineCheckResult { } /** Milestone statuses that no longer need deadline tracking once reached. */ -export const DEADLINE_EXEMPT_STATUSES = ['approved', 'paid', 'rejected'] as const +export const DEADLINE_EXEMPT_STATUSES = [ + 'approved', + 'paid', + 'rejected', + 'disputed', + 'refunded', + 'expired', + 'auto_expired', +] as const /** How far ahead of the due date to send a single "deadline approaching" reminder. */ export function getReminderWindowMs(): number { diff --git a/lib/escrow/types.ts b/lib/escrow/types.ts index c7cbbbc..4f12d8b 100644 --- a/lib/escrow/types.ts +++ b/lib/escrow/types.ts @@ -27,6 +27,10 @@ export type EscrowStatus = export type MilestoneStatus = | 'auto_expired' + | 'expired' + | 'extended' + | 'disputed' + | 'refunded' | 'pending' | 'in_progress' | 'submitted' diff --git a/lib/validations/index.ts b/lib/validations/index.ts index 53deec9..b7a4a35 100644 --- a/lib/validations/index.ts +++ b/lib/validations/index.ts @@ -98,8 +98,8 @@ export const ListProjectsSchema = z.object({ // ─── Milestone ───────────────────────────────────────────────────────────── -const MILESTONE_STATUSES = ['pending', 'in_progress', 'submitted', 'approved', 'paid', 'disputed'] as const -const IMMUTABLE_MILESTONE_STATUSES = ['submitted', 'approved', 'paid'] as const +const MILESTONE_STATUSES = ['pending', 'in_progress', 'submitted', 'approved', 'paid', 'disputed', 'expired', 'extended', 'auto_expired', 'refunded'] as const +const IMMUTABLE_MILESTONE_STATUSES = ['submitted', 'approved', 'paid', 'disputed', 'expired', 'extended', 'auto_expired', 'refunded'] as const export const MilestoneStatusSchema = z.enum(MILESTONE_STATUSES)