#![cfg(feature = "postgres")]
use chrono::{Datelike, NaiveDate, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use stateset_core::{
AccountSubType, AccountType, CreateAutoPostingConfig, CreateFixedAsset, CreateGlAccount,
CreateGlPeriod, DepreciationEntryStatus, DepreciationMethod, FixedAssetCategory,
FixedAssetFilter, FixedAssetStatus, JournalEntryFilter,
};
use stateset_embedded::AsyncCommerce;
fn postgres_url() -> Option<String> {
std::env::var("POSTGRES_URL").ok().or_else(|| std::env::var("DATABASE_URL").ok())
}
const fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).expect("valid date")
}
fn new_asset_input(name: &str) -> CreateFixedAsset {
CreateFixedAsset {
asset_number: None,
name: name.into(),
description: Some("pg integration asset".into()),
category: FixedAssetCategory::Equipment,
acquisition_date: date(2026, 1, 1),
acquisition_cost: dec!(1200),
salvage_value: dec!(0),
useful_life_months: 12,
depreciation_method: DepreciationMethod::StraightLine,
in_service_date: None,
location_id: None,
asset_account_id: None,
accumulated_depreciation_account_id: None,
depreciation_expense_account_id: None,
currency: None,
}
}
#[tokio::test]
async fn postgres_fixed_asset_lifecycle_and_schedule_persistence() {
let Some(url) = postgres_url() else {
eprintln!("POSTGRES_URL or DATABASE_URL not set; skipping fixed asset lifecycle test");
return;
};
let commerce = AsyncCommerce::connect(&url).await.expect("connect + migrate");
let assets = commerce.fixed_assets();
let asset = assets.create(new_asset_input("PG Lathe")).await.expect("create asset");
assert_eq!(asset.status, FixedAssetStatus::Draft);
assert!(!asset.asset_number.is_empty(), "asset number must be generated");
assert_eq!(asset.accumulated_depreciation, Decimal::ZERO);
assert!(assets.post_depreciation(asset.id, 1).await.is_err());
let asset =
assets.place_in_service(asset.id, date(2026, 2, 1)).await.expect("place in service");
assert_eq!(asset.status, FixedAssetStatus::InService);
assert_eq!(asset.in_service_date, Some(date(2026, 2, 1)));
let schedule = assets.generate_schedule(asset.id).await.expect("generate schedule");
assert_eq!(schedule.entries.len(), 12);
assert_eq!(schedule.total_depreciation, dec!(1200));
assert!(schedule.entries.iter().all(|e| e.status == DepreciationEntryStatus::Scheduled));
assert_eq!(schedule.entries[0].amount, dec!(100));
let persisted =
assets.get_schedule(asset.id).await.expect("get schedule").expect("schedule exists");
assert_eq!(persisted.entries.len(), 12);
assert_eq!(persisted.total_depreciation, dec!(1200));
assert_eq!(persisted.entries[11].accumulated, dec!(1200));
assert_eq!(persisted.entries[11].book_value, dec!(0));
let asset = assets.post_depreciation(asset.id, 3).await.expect("post 3 periods");
assert_eq!(asset.accumulated_depreciation, dec!(300));
assert_eq!(asset.status, FixedAssetStatus::InService);
let persisted =
assets.get_schedule(asset.id).await.expect("get schedule").expect("schedule exists");
let posted =
persisted.entries.iter().filter(|e| e.status == DepreciationEntryStatus::Posted).count();
assert_eq!(posted, 3, "first three entries must persist as posted");
let asset = assets.post_depreciation(asset.id, 9).await.expect("post remaining periods");
assert_eq!(asset.accumulated_depreciation, dec!(1200));
assert_eq!(asset.status, FixedAssetStatus::FullyDepreciated);
assert!(assets.post_depreciation(asset.id, 1).await.is_err(), "no entries left to post");
let listed = assets
.list(FixedAssetFilter {
status: Some(FixedAssetStatus::FullyDepreciated),
..Default::default()
})
.await
.expect("list assets");
assert!(listed.iter().any(|a| a.id == asset.id));
}
#[tokio::test]
async fn postgres_fixed_asset_depreciation_auto_posts_journal_entry() {
let Some(url) = postgres_url() else {
eprintln!("POSTGRES_URL or DATABASE_URL not set; skipping depreciation auto-post test");
return;
};
let commerce = AsyncCommerce::connect(&url).await.expect("connect + migrate");
let gl = commerce.general_ledger();
let assets = commerce.fixed_assets();
gl.initialize_chart_of_accounts().await.expect("init chart of accounts");
let acct = |number: &'static str| {
let gl = ≷
async move {
gl.get_account_by_number(number)
.await
.expect("query account")
.unwrap_or_else(|| panic!("account {number} exists"))
.id
}
};
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
let expense = gl
.create_account(CreateGlAccount {
account_number: format!("6D-{suffix}"),
name: format!("Depreciation Expense {suffix}"),
description: None,
account_type: AccountType::Expense,
account_sub_type: Some(AccountSubType::DepreciationExpense),
parent_account_id: None,
is_header: None,
is_posting: Some(true),
currency: None,
})
.await
.expect("create depreciation expense account");
let accumulated = gl
.create_account(CreateGlAccount {
account_number: format!("1D-{suffix}"),
name: format!("Accumulated Depreciation {suffix}"),
description: None,
account_type: AccountType::Asset,
account_sub_type: Some(AccountSubType::AccumulatedDepreciation),
parent_account_id: None,
is_header: None,
is_posting: Some(true),
currency: None,
})
.await
.expect("create accumulated depreciation account");
let today = Utc::now().date_naive();
let start = date(today.year(), today.month(), 1);
let end = if today.month() == 12 {
date(today.year() + 1, 1, 1)
} else {
date(today.year(), today.month() + 1, 1)
}
.pred_opt()
.expect("previous day");
if let Ok(period) = gl
.create_period(CreateGlPeriod {
period_name: format!("{}-{:02}", today.year(), today.month()),
fiscal_year: today.year(),
period_number: today.month() as i32,
start_date: start,
end_date: end,
})
.await
{
gl.open_period(period.id).await.expect("open period");
}
gl.set_auto_posting_config(CreateAutoPostingConfig {
config_name: "default".into(),
cash_account_id: acct("1010").await,
accounts_receivable_account_id: acct("1100").await,
inventory_account_id: acct("1200").await,
accounts_payable_account_id: acct("2010").await,
unearned_revenue_account_id: None,
sales_revenue_account_id: acct("4010").await,
shipping_revenue_account_id: None,
cogs_account_id: acct("5010").await,
bad_debt_expense_account_id: None,
fx_gain_loss_account_id: None,
auto_post_depreciation: true,
auto_post_revenue_recognition: false,
})
.await
.expect("set auto-posting config");
let mut input = new_asset_input("PG Auto-Post Press");
input.depreciation_expense_account_id = Some(expense.id);
input.accumulated_depreciation_account_id = Some(accumulated.id);
let asset = assets.create(input).await.expect("create asset");
assets.place_in_service(asset.id, date(2026, 2, 1)).await.expect("place in service");
assets.generate_schedule(asset.id).await.expect("generate schedule");
let asset = assets.post_depreciation(asset.id, 2).await.expect("post depreciation");
assert_eq!(asset.accumulated_depreciation, dec!(200));
let entries = gl
.list_journal_entries(JournalEntryFilter {
source_document_type: Some("fixed_asset_depreciation".into()),
source_document_id: Some(asset.id),
..Default::default()
})
.await
.expect("list journal entries");
assert_eq!(entries.len(), 1, "exactly one depreciation entry for this posting");
let entry = &entries[0];
assert_eq!(entry.total_debits, dec!(200));
assert_eq!(entry.total_credits, dec!(200));
let lines = gl.get_journal_entry_lines(entry.id).await.expect("entry lines");
assert_eq!(lines.len(), 2);
assert!(
lines.iter().any(|l| l.account_id == expense.id && l.debit_amount == dec!(200)),
"debit hits the asset's depreciation expense account"
);
assert!(
lines.iter().any(|l| l.account_id == accumulated.id && l.credit_amount == dec!(200)),
"credit hits the asset's accumulated depreciation account"
);
}
#[tokio::test]
async fn postgres_fixed_asset_dispose_records_gain_loss() {
let Some(url) = postgres_url() else {
eprintln!("POSTGRES_URL or DATABASE_URL not set; skipping fixed asset dispose test");
return;
};
let commerce = AsyncCommerce::connect(&url).await.expect("connect + migrate");
let assets = commerce.fixed_assets();
let asset = assets.create(new_asset_input("PG Forklift")).await.expect("create asset");
assets.place_in_service(asset.id, date(2026, 2, 1)).await.expect("place in service");
assets.generate_schedule(asset.id).await.expect("generate schedule");
let asset = assets.post_depreciation(asset.id, 3).await.expect("post 3 periods");
assert_eq!(asset.accumulated_depreciation, dec!(300));
assert_eq!(asset.book_value(), dec!(900));
assert!(
assets.dispose(asset.id, date(2026, 6, 1), dec!(-1), None).await.is_err(),
"negative proceeds must be rejected"
);
let disposed = assets
.dispose(asset.id, date(2026, 6, 1), dec!(1000), Some("sold at auction".into()))
.await
.expect("dispose asset");
assert_eq!(disposed.status, FixedAssetStatus::Disposed);
let disposal = disposed.disposal.expect("disposal details recorded");
assert_eq!(disposal.disposal_date, date(2026, 6, 1));
assert_eq!(disposal.proceeds, dec!(1000));
assert_eq!(disposal.book_value_at_disposal, dec!(900));
assert_eq!(disposal.gain_loss, dec!(100));
assert_eq!(disposal.notes.as_deref(), Some("sold at auction"));
assert!(assets.post_depreciation(disposed.id, 1).await.is_err());
assert!(assets.dispose(disposed.id, date(2026, 7, 1), dec!(1), None).await.is_err());
let fetched = assets.get(disposed.id).await.expect("get").expect("asset exists");
assert_eq!(fetched.status, FixedAssetStatus::Disposed);
assert_eq!(fetched.disposal.expect("disposal persisted").gain_loss, dec!(100));
}