Jacob Schmidt ff7ff0c4e5 Implement org credit line debt and bank repayment flow (#2)
## Summary

This finishes the org credit line workflow so it behaves like reserved treasury-backed credit instead of a simple member allowance.

## What changed

- reserve org funds immediately when a credit line is assigned
- track credit lines with:
  - approved amount
  - available amount
  - outstanding principal
  - interest rate
  - amount due
- consume reserved credit during store checkout without charging org funds a second time
- add credit line repayment through the bank app
- sync richer credit line state into org and bank payloads/UI
- keep legacy `amount` compatibility mapped to available credit for older consumers

## User-facing behavior

- assigning a credit line now reduces available org funds immediately
- spending on `credit_line` reduces available credit and creates debt with interest
- the bank app now shows outstanding credit debt and allows repayment from personal bank funds
- the org treasury view now shows reserved credit and outstanding due totals

## Validation

- `cargo fmt`
- `npm run build:webui`
- `cargo test -p forge-services --quiet`
- `cargo test -p forge-server --quiet`

## Follow-up checks

- validate in-game that assigning a credit line reduces org funds immediately
- validate store checkout with `credit_line` updates available credit and debt correctly
- validate bank repayment decreases player bank balance, increases org funds, and reduces amount due

Co-authored-by: Jacob Schmidt <innovativestudios@outlook.com>
Reviewed-on: #2
2026-04-02 16:50:38 -05:00

205 lines
6.9 KiB
Rust

use forge_models::{TaskOwnershipContext, TaskRecord};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
pub trait TaskRepository: Send + Sync {
fn reset(&self) -> Result<(), String>;
fn list_catalog(&self) -> Result<HashMap<String, TaskRecord>, String>;
fn get_catalog_entry(&self, id: &str) -> Result<Option<TaskRecord>, String>;
fn save_catalog_entry(&self, id: String, entry: TaskRecord) -> Result<(), String>;
fn delete_catalog_entry(&self, id: &str) -> Result<(), String>;
fn get_ownership(&self, id: &str) -> Result<Option<TaskOwnershipContext>, String>;
fn save_ownership(&self, id: String, ownership: TaskOwnershipContext) -> Result<(), String>;
fn delete_ownership(&self, id: &str) -> Result<(), String>;
fn list_active_statuses(&self) -> Result<HashMap<String, String>, String>;
fn get_active_status(&self, id: &str) -> Result<Option<String>, String>;
fn set_active_status(&self, id: String, status: String) -> Result<(), String>;
fn delete_active_status(&self, id: &str) -> Result<(), String>;
fn get_completed_status(&self, id: &str) -> Result<Option<String>, String>;
fn set_completed_status(&self, id: String, status: String) -> Result<(), String>;
fn delete_completed_status(&self, id: &str) -> Result<(), String>;
fn increment_defuse_count(&self, id: &str) -> Result<u64, String>;
fn get_defuse_count(&self, id: &str) -> Result<u64, String>;
fn clear_defuse_count(&self, id: &str) -> Result<(), String>;
}
#[derive(Debug, Default)]
struct TaskState {
catalog: HashMap<String, TaskRecord>,
ownership: HashMap<String, TaskOwnershipContext>,
active_statuses: HashMap<String, String>,
completed_statuses: HashMap<String, String>,
defuse_counts: HashMap<String, u64>,
}
#[derive(Clone, Debug, Default)]
pub struct InMemoryTaskRepository {
state: Arc<RwLock<TaskState>>,
}
impl InMemoryTaskRepository {
pub fn new() -> Self {
Self::default()
}
}
impl TaskRepository for InMemoryTaskRepository {
fn reset(&self) -> Result<(), String> {
let mut state = self
.state
.write()
.map_err(|_| "Task state lock poisoned.".to_string())?;
state.catalog.clear();
state.ownership.clear();
state.active_statuses.clear();
state.completed_statuses.clear();
state.defuse_counts.clear();
Ok(())
}
fn list_catalog(&self) -> Result<HashMap<String, TaskRecord>, String> {
self.state
.read()
.map(|state| state.catalog.clone())
.map_err(|_| "Task catalog state lock poisoned.".to_string())
}
fn get_catalog_entry(&self, id: &str) -> Result<Option<TaskRecord>, String> {
self.state
.read()
.map(|state| state.catalog.get(id).cloned())
.map_err(|_| "Task catalog state lock poisoned.".to_string())
}
fn save_catalog_entry(&self, id: String, entry: TaskRecord) -> Result<(), String> {
self.state
.write()
.map_err(|_| "Task catalog state lock poisoned.".to_string())?
.catalog
.insert(id, entry);
Ok(())
}
fn delete_catalog_entry(&self, id: &str) -> Result<(), String> {
self.state
.write()
.map_err(|_| "Task catalog state lock poisoned.".to_string())?
.catalog
.remove(id);
Ok(())
}
fn get_ownership(&self, id: &str) -> Result<Option<TaskOwnershipContext>, String> {
self.state
.read()
.map(|state| state.ownership.get(id).cloned())
.map_err(|_| "Task ownership state lock poisoned.".to_string())
}
fn save_ownership(&self, id: String, ownership: TaskOwnershipContext) -> Result<(), String> {
self.state
.write()
.map_err(|_| "Task ownership state lock poisoned.".to_string())?
.ownership
.insert(id, ownership);
Ok(())
}
fn delete_ownership(&self, id: &str) -> Result<(), String> {
self.state
.write()
.map_err(|_| "Task ownership state lock poisoned.".to_string())?
.ownership
.remove(id);
Ok(())
}
fn list_active_statuses(&self) -> Result<HashMap<String, String>, String> {
self.state
.read()
.map(|state| state.active_statuses.clone())
.map_err(|_| "Task status state lock poisoned.".to_string())
}
fn get_active_status(&self, id: &str) -> Result<Option<String>, String> {
self.state
.read()
.map(|state| state.active_statuses.get(id).cloned())
.map_err(|_| "Task status state lock poisoned.".to_string())
}
fn set_active_status(&self, id: String, status: String) -> Result<(), String> {
self.state
.write()
.map_err(|_| "Task status state lock poisoned.".to_string())?
.active_statuses
.insert(id, status);
Ok(())
}
fn delete_active_status(&self, id: &str) -> Result<(), String> {
self.state
.write()
.map_err(|_| "Task status state lock poisoned.".to_string())?
.active_statuses
.remove(id);
Ok(())
}
fn get_completed_status(&self, id: &str) -> Result<Option<String>, String> {
self.state
.read()
.map(|state| state.completed_statuses.get(id).cloned())
.map_err(|_| "Task completed status state lock poisoned.".to_string())
}
fn set_completed_status(&self, id: String, status: String) -> Result<(), String> {
self.state
.write()
.map_err(|_| "Task completed status state lock poisoned.".to_string())?
.completed_statuses
.insert(id, status);
Ok(())
}
fn delete_completed_status(&self, id: &str) -> Result<(), String> {
self.state
.write()
.map_err(|_| "Task completed status state lock poisoned.".to_string())?
.completed_statuses
.remove(id);
Ok(())
}
fn increment_defuse_count(&self, id: &str) -> Result<u64, String> {
let mut state = self
.state
.write()
.map_err(|_| "Task defuse state lock poisoned.".to_string())?;
let next_count = 1 + state.defuse_counts.get(id).copied().unwrap_or_default();
state.defuse_counts.insert(id.to_string(), next_count);
Ok(next_count)
}
fn get_defuse_count(&self, id: &str) -> Result<u64, String> {
self.state
.read()
.map(|state| state.defuse_counts.get(id).copied().unwrap_or_default())
.map_err(|_| "Task defuse state lock poisoned.".to_string())
}
fn clear_defuse_count(&self, id: &str) -> Result<(), String> {
self.state
.write()
.map_err(|_| "Task defuse state lock poisoned.".to_string())?
.defuse_counts
.remove(id);
Ok(())
}
}