forge/lib/services/src/locker.rs
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.5 KiB
Rust

//! Locker service layer providing business logic for item locker management.
//!
//! Handles validation, storage, and retrieval of player item lockers.
use forge_models::locker::{Item, Locker};
use forge_repositories::{LockerHotRepository, LockerRepository};
use std::collections::HashMap;
/// Service layer implementation for locker business logic and operations.
pub struct LockerService<R: LockerRepository> {
repository: R,
}
pub struct LockerHotStateService<R: LockerRepository, H: LockerHotRepository> {
service: LockerService<R>,
repository: H,
}
impl<R: LockerRepository> LockerService<R> {
/// Creates a new locker service with the provided repository.
pub fn new(repository: R) -> Self {
Self { repository }
}
/// Creates a new empty locker for a player
pub fn create_locker(&self, uid: String) -> Result<Locker, String> {
// Business rule: Check if locker already exists
if self.repository.exists(&uid)? {
return Err(format!("Locker for '{}' already exists", uid));
}
// Create empty locker (no items)
let locker = Locker::new().map_err(|e| format!("Validation failed: {}", e))?;
self.repository.create(&uid, &locker)?;
Ok(locker)
}
/// Replaces the entire locker with new data (Bulk Sync).
pub fn update_locker(
&self,
key: String,
items: HashMap<String, Item>,
) -> Result<Locker, String> {
let locker = Locker { items };
// Business rule: Check if locker has reached maximum capacity (25 items)
if locker.items.len() > 25 {
return Err("Locker exceeds maximum capacity of 25 items.".to_string());
}
self.repository.update(&key, &locker)?;
Ok(locker)
}
/// Adds a new item to a player's locker
pub fn add_item(&self, uid: String, item: Item) -> Result<Locker, String> {
// Get existing locker or create new one
let mut locker = match self.repository.get(&uid)? {
Some(l) => l,
None => Locker::new().map_err(|e| format!("Failed to create locker: {}", e))?,
};
// Business rule: Check if locker has reached maximum capacity (25 items)
// Only check if we are adding a NEW item (not updating existing)
if !locker.items.contains_key(&item.classname) && locker.items.len() >= 25 {
return Err("Locker is full. Maximum of 25 items allowed.".to_string());
}
// Add new item to locker (or overwrite existing)
locker
.add_item(item)
.map_err(|e| format!("Failed to add item: {}", e))?;
// Update locker with new item
self.repository.update(&uid, &locker)?;
Ok(locker)
}
/// Retrieves a player's locker
pub fn get_locker(&self, uid: String) -> Result<Locker, String> {
match self.repository.get(&uid)? {
Some(locker) => Ok(locker),
None => Err(format!("No locker found for player '{}'", uid)),
}
}
/// Patches an existing item in the locker
pub fn patch_item(
&self,
uid: String,
classname: String,
amount: Option<u32>,
) -> Result<Locker, String> {
// Get existing locker
let mut locker = match self.repository.get(&uid)? {
Some(l) => l,
None => return Err(format!("No locker found for player '{}'", uid)),
};
// Find the item to update by classname
let existing_item = locker
.get_item_mut(&classname)
.ok_or_else(|| format!("Item with classname '{}' not found in locker", classname))?;
if let Some(a) = amount {
if a == 0 {
return Err("Amount cannot be zero".to_string());
}
existing_item.amount = a;
}
// Update locker with modified item
self.repository.update(&uid, &locker)?;
Ok(locker)
}
/// Removes an item from the locker
pub fn remove_item(&self, uid: String, classname: String) -> Result<Locker, String> {
// Get existing locker
let mut locker = match self.repository.get(&uid)? {
Some(l) => l,
None => return Err(format!("No locker found for player '{}'", uid)),
};
// Remove the item by classname
locker
.remove_item(&classname)
.ok_or_else(|| format!("Item with classname '{}' not found in locker", classname))?;
// Update locker after removing item
self.repository.update(&uid, &locker)?;
Ok(locker)
}
/// Deletes a player's locker (all items)
pub fn delete_locker(&self, uid: String) -> Result<(), String> {
self.repository.delete(&uid)
}
/// Checks if a player has a locker (even if empty)
pub fn locker_exists(&self, uid: String) -> Result<bool, String> {
self.repository.exists(&uid)
}
}
impl<R: LockerRepository, H: LockerHotRepository> LockerHotStateService<R, H> {
pub fn new(repository: R, hot_repository: H) -> Self {
Self {
service: LockerService::new(repository),
repository: hot_repository,
}
}
pub fn init_locker(&self, uid: String) -> Result<Locker, String> {
if let Some(locker) = self.repository.get(&uid)? {
return Ok(locker);
}
let locker = match self.service.get_locker(uid.clone()) {
Ok(locker) => locker,
Err(_) => self.service.create_locker(uid.clone())?,
};
self.repository.save(&locker, &uid)?;
Ok(locker)
}
pub fn get_locker(&self, uid: String) -> Result<Locker, String> {
self.init_locker(uid)
}
pub fn override_locker(
&self,
uid: String,
items: HashMap<String, Item>,
) -> Result<Locker, String> {
let locker = Locker { items };
if locker.items.len() > 25 {
return Err("Locker exceeds maximum capacity of 25 items.".to_string());
}
self.repository.save(&locker, &uid)?;
Ok(locker)
}
pub fn save_locker(&self, uid: String) -> Result<Locker, String> {
let locker = self
.repository
.get(&uid)?
.ok_or_else(|| format!("No locker found for player '{}'", uid))?;
let saved = self
.service
.update_locker(uid.clone(), locker.items.clone())?;
self.repository.save(&saved, &uid)?;
Ok(saved)
}
pub fn remove_locker(&self, uid: String) -> Result<(), String> {
self.repository.delete(&uid)
}
}