forge/lib/services/src/v_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

195 lines
6.2 KiB
Rust

//! Virtual locker service layer providing business logic for locker management operations.
//!
//! Implements the service layer of the virtual locker system, handling business logic,
//! validation, and orchestration.
use forge_models::{EquipmentCategory, VLocker};
use forge_repositories::{VLockerHotRepository, VLockerRepository};
/// Service layer implementation for virtual locker business logic and operations.
///
/// Orchestrates virtual locker management operations, handling business logic, validation,
/// and data transformation. Manages equipment storage across four categories: items,
/// weapons, magazines, and backpacks.
///
/// # Thread Safety
/// Thread-safe when used with a thread-safe repository.
pub struct VLockerService<R: VLockerRepository> {
/// The repository instance used for all data persistence operations.
///
/// This repository handles the actual storage and retrieval of locker data,
/// abstracting away the specific database implementation details.
repository: R,
}
pub struct VLockerHotStateService<R: VLockerRepository, H: VLockerHotRepository> {
service: VLockerService<R>,
repository: H,
}
impl<R: VLockerRepository> VLockerService<R> {
/// Creates a new locker service with the provided repository.
///
/// The repository must be initialized and ready for use.
pub fn new(repository: R) -> Self {
Self { repository }
}
/// Creates a new empty locker for a player.
///
/// Handles duplicate checking and persistence.
pub fn create_locker(&self, uid: &str) -> Result<VLocker, 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 = VLocker::new();
self.repository.create(uid, &locker)?;
Ok(locker)
}
/// Retrieves a player's virtual locker.
pub fn fetch_locker(&self, uid: &str) -> Result<VLocker, String> {
match self.repository.fetch(uid)? {
Some(locker) => Ok(locker),
None => Err(format!("No locker found for player '{}'", uid)),
}
}
pub fn update_locker(&self, uid: &str, locker: &VLocker) -> Result<VLocker, String> {
self.repository.update(uid, locker)?;
Ok(locker.clone())
}
/// Retrieves a specific field from a player's virtual locker.
///
/// Fields: "items", "weapons", "magazines", "backpacks"
pub fn get_locker(&self, uid: &str, field: &str) -> Result<Vec<String>, String> {
self.repository.get(uid, field)
}
/// Generic method to add items to any category.
pub fn add_locker(
&self,
uid: &str,
category: EquipmentCategory,
classnames: Vec<String>,
) -> Result<VLocker, String> {
let mut locker = match self.repository.fetch(uid)? {
Some(l) => l,
None => VLocker::new(),
};
locker.add(category, classnames);
self.repository.update(uid, &locker)?;
Ok(locker)
}
/// Generic method to remove an item from any category.
pub fn remove_locker(
&self,
uid: &str,
category: EquipmentCategory,
classname: &str,
) -> Result<VLocker, String> {
let mut locker = match self.repository.fetch(uid)? {
Some(l) => l,
None => return Err(format!("No locker found for player '{}'", uid)),
};
if locker.remove(category, classname).is_none() {
return Err(format!("Item '{}' not found in locker", classname));
}
self.repository.update(uid, &locker)?;
Ok(locker)
}
/// Permanently deletes a player's virtual locker.
///
/// Irreversible operation. Delegates to repository.
pub fn delete_locker(&self, uid: &str) -> Result<(), String> {
// Business rule: Check if locker exists
if !self.repository.exists(uid)? {
return Err(format!("No locker found for player '{}'", uid));
}
// Delete locker
self.repository.delete(uid)?;
Ok(())
}
/// Checks if a player has a virtual locker.
///
/// Lightweight check without data retrieval.
pub fn locker_exists(&self, uid: &str) -> Result<bool, String> {
self.repository.exists(uid)
}
}
impl<R: VLockerRepository, H: VLockerHotRepository> VLockerHotStateService<R, H> {
pub fn new(repository: R, hot_repository: H) -> Self {
Self {
service: VLockerService::new(repository),
repository: hot_repository,
}
}
pub fn init_locker(&self, uid: &str) -> Result<VLocker, String> {
if let Some(locker) = self.repository.get(uid)? {
return Ok(locker);
}
let locker = match self.service.fetch_locker(uid) {
Ok(locker) => locker,
Err(_) => self.service.create_locker(uid)?,
};
self.repository.save(&locker, uid)?;
Ok(locker)
}
pub fn fetch_locker(&self, uid: &str) -> Result<VLocker, String> {
self.init_locker(uid)
}
pub fn get_locker(&self, uid: &str, field: &str) -> Result<Vec<String>, String> {
let locker = self.init_locker(uid)?;
Ok(match field.to_lowercase().as_str() {
"items" => locker.items,
"weapons" => locker.weapons,
"magazines" => locker.magazines,
"backpacks" => locker.backpacks,
_ => Vec::new(),
})
}
pub fn override_locker(&self, uid: &str, locker: VLocker) -> Result<VLocker, String> {
self.repository.save(&locker, uid)?;
Ok(locker)
}
pub fn save_locker(&self, uid: &str) -> Result<VLocker, String> {
let locker = self
.repository
.get(uid)?
.ok_or_else(|| format!("No locker found for player '{}'", uid))?;
let saved = if self.service.locker_exists(uid)? {
self.service.update_locker(uid, &locker)?
} else {
self.service.create_locker(uid)?
};
self.repository.save(&saved, uid)?;
Ok(saved)
}
pub fn remove_locker(&self, uid: &str) -> Result<(), String> {
self.repository.delete(uid)
}
}