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

237 lines
7.7 KiB
Rust

use forge_models::CadRecord;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
const CAD_ACTIVITY_LIMIT: usize = 200;
pub trait CadRepository: Send + Sync {
fn append_activity(&self, entry: Value) -> Result<(), String>;
fn recent_activity(&self, limit: usize) -> Result<Vec<Value>, String>;
fn snapshot_activity(&self) -> Result<Vec<Value>, String>;
fn list_assignments(&self) -> Result<HashMap<String, CadRecord>, String>;
fn get_assignment(&self, id: &str) -> Result<Option<CadRecord>, String>;
fn save_assignment(&self, id: String, entry: CadRecord) -> Result<(), String>;
fn delete_assignment(&self, id: &str) -> Result<(), String>;
fn list_orders(&self) -> Result<HashMap<String, CadRecord>, String>;
fn get_order(&self, id: &str) -> Result<Option<CadRecord>, String>;
fn save_order(&self, id: String, entry: CadRecord) -> Result<(), String>;
fn delete_order(&self, id: &str) -> Result<(), String>;
fn list_requests(&self) -> Result<HashMap<String, CadRecord>, String>;
fn get_request(&self, id: &str) -> Result<Option<CadRecord>, String>;
fn save_request(&self, id: String, entry: CadRecord) -> Result<(), String>;
fn delete_request(&self, id: &str) -> Result<(), String>;
fn list_profiles(&self) -> Result<HashMap<String, CadRecord>, String>;
fn get_profile(&self, id: &str) -> Result<Option<CadRecord>, String>;
fn save_profile(&self, id: String, entry: CadRecord) -> Result<(), String>;
fn delete_profile(&self, id: &str) -> Result<(), String>;
fn next_order_id(&self) -> Result<String, String>;
fn next_request_id(&self) -> Result<String, String>;
}
#[derive(Debug, Default)]
struct CadState {
activity: Vec<Value>,
assignments: HashMap<String, CadRecord>,
orders: HashMap<String, CadRecord>,
requests: HashMap<String, CadRecord>,
profiles: HashMap<String, CadRecord>,
order_sequence: u64,
request_sequence: u64,
}
#[derive(Clone, Debug, Default)]
pub struct InMemoryCadRepository {
state: Arc<RwLock<CadState>>,
}
impl InMemoryCadRepository {
pub fn new() -> Self {
Self::default()
}
}
impl CadRepository for InMemoryCadRepository {
fn append_activity(&self, entry: Value) -> Result<(), String> {
let mut state = self
.state
.write()
.map_err(|_| "CAD activity state lock poisoned.".to_string())?;
state.activity.push(entry);
if state.activity.len() > CAD_ACTIVITY_LIMIT {
let overflow = state.activity.len() - CAD_ACTIVITY_LIMIT;
state.activity.drain(0..overflow);
}
Ok(())
}
fn recent_activity(&self, limit: usize) -> Result<Vec<Value>, String> {
let state = self
.state
.read()
.map_err(|_| "CAD activity state lock poisoned.".to_string())?;
let start = state.activity.len().saturating_sub(limit);
Ok(state.activity[start..].to_vec())
}
fn snapshot_activity(&self) -> Result<Vec<Value>, String> {
self.state
.read()
.map(|state| state.activity.clone())
.map_err(|_| "CAD activity state lock poisoned.".to_string())
}
fn list_assignments(&self) -> Result<HashMap<String, CadRecord>, String> {
self.state
.read()
.map(|state| state.assignments.clone())
.map_err(|_| "CAD assignments state lock poisoned.".to_string())
}
fn get_assignment(&self, id: &str) -> Result<Option<CadRecord>, String> {
self.state
.read()
.map(|state| state.assignments.get(id).cloned())
.map_err(|_| "CAD assignments state lock poisoned.".to_string())
}
fn save_assignment(&self, id: String, entry: CadRecord) -> Result<(), String> {
self.state
.write()
.map_err(|_| "CAD assignments state lock poisoned.".to_string())?
.assignments
.insert(id, entry);
Ok(())
}
fn delete_assignment(&self, id: &str) -> Result<(), String> {
self.state
.write()
.map_err(|_| "CAD assignments state lock poisoned.".to_string())?
.assignments
.remove(id);
Ok(())
}
fn list_orders(&self) -> Result<HashMap<String, CadRecord>, String> {
self.state
.read()
.map(|state| state.orders.clone())
.map_err(|_| "CAD orders state lock poisoned.".to_string())
}
fn get_order(&self, id: &str) -> Result<Option<CadRecord>, String> {
self.state
.read()
.map(|state| state.orders.get(id).cloned())
.map_err(|_| "CAD orders state lock poisoned.".to_string())
}
fn save_order(&self, id: String, entry: CadRecord) -> Result<(), String> {
self.state
.write()
.map_err(|_| "CAD orders state lock poisoned.".to_string())?
.orders
.insert(id, entry);
Ok(())
}
fn delete_order(&self, id: &str) -> Result<(), String> {
self.state
.write()
.map_err(|_| "CAD orders state lock poisoned.".to_string())?
.orders
.remove(id);
Ok(())
}
fn list_requests(&self) -> Result<HashMap<String, CadRecord>, String> {
self.state
.read()
.map(|state| state.requests.clone())
.map_err(|_| "CAD requests state lock poisoned.".to_string())
}
fn get_request(&self, id: &str) -> Result<Option<CadRecord>, String> {
self.state
.read()
.map(|state| state.requests.get(id).cloned())
.map_err(|_| "CAD requests state lock poisoned.".to_string())
}
fn save_request(&self, id: String, entry: CadRecord) -> Result<(), String> {
self.state
.write()
.map_err(|_| "CAD requests state lock poisoned.".to_string())?
.requests
.insert(id, entry);
Ok(())
}
fn delete_request(&self, id: &str) -> Result<(), String> {
self.state
.write()
.map_err(|_| "CAD requests state lock poisoned.".to_string())?
.requests
.remove(id);
Ok(())
}
fn list_profiles(&self) -> Result<HashMap<String, CadRecord>, String> {
self.state
.read()
.map(|state| state.profiles.clone())
.map_err(|_| "CAD profiles state lock poisoned.".to_string())
}
fn get_profile(&self, id: &str) -> Result<Option<CadRecord>, String> {
self.state
.read()
.map(|state| state.profiles.get(id).cloned())
.map_err(|_| "CAD profiles state lock poisoned.".to_string())
}
fn save_profile(&self, id: String, entry: CadRecord) -> Result<(), String> {
self.state
.write()
.map_err(|_| "CAD profiles state lock poisoned.".to_string())?
.profiles
.insert(id, entry);
Ok(())
}
fn delete_profile(&self, id: &str) -> Result<(), String> {
self.state
.write()
.map_err(|_| "CAD profiles state lock poisoned.".to_string())?
.profiles
.remove(id);
Ok(())
}
fn next_order_id(&self) -> Result<String, String> {
let mut state = self
.state
.write()
.map_err(|_| "CAD order sequence lock poisoned.".to_string())?;
state.order_sequence += 1;
Ok(format!("cad-order:{}", state.order_sequence))
}
fn next_request_id(&self) -> Result<String, String> {
let mut state = self
.state
.write()
.map_err(|_| "CAD request sequence lock poisoned.".to_string())?;
state.request_sequence += 1;
Ok(format!("cad-request:{}", state.request_sequence))
}
}