Payroll accounting, class tracking, CPA handoff package (48 MCP tools)
This commit is contained in:
1 parent
72f7143aa9
commit
0d91d5839f
1 file changed
+362
-5
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
use crate::policy::ReceiptPolicy;
|
use crate::policy::ReceiptPolicy;
|
||||||
use crate::types::*;
|
use crate::types::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::{Datelike, NaiveDate};
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
||||||
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
|
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
|
||||||
@@ -129,6 +129,22 @@ fn resolve_account(store: &Store, account: &str) -> Result<AccountId, String> {
|
|||||||
.ok_or_else(|| format!("unknown account {account:?}: use an id or code from books.chart"))
|
.ok_or_else(|| format!("unknown account {account:?}: use an id or code from books.chart"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a class by id or name (case-insensitive).
|
||||||
|
fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
|
||||||
|
if let Ok(id) = class.parse::<u32>()
|
||||||
|
&& store.ledger().get_class(id).is_some()
|
||||||
|
{
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
store
|
||||||
|
.ledger()
|
||||||
|
.find_class_by_name(class)
|
||||||
|
.map(|c| c.id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!("unknown class {class:?}: use an id or name from books.list_classes")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn money(n: Decimal) -> String {
|
fn money(n: Decimal) -> String {
|
||||||
// Decimal's Display never emits floats or "-0" for exact values.
|
// Decimal's Display never emits floats or "-0" for exact values.
|
||||||
n.to_string()
|
n.to_string()
|
||||||
@@ -225,11 +241,28 @@ fn movement_through(ledger: &Ledger, account: AccountId, through: NaiveDate) ->
|
|||||||
|
|
||||||
/// Net movement of one account over entries in `[from, to]`, normal side.
|
/// Net movement of one account over entries in `[from, to]`, normal side.
|
||||||
fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDate) -> Decimal {
|
fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDate) -> Decimal {
|
||||||
|
movement_in_class(ledger, account, from, to, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signed movement of an account in its normal side over a date range,
|
||||||
|
/// optionally filtered to one class. `None` class matches all lines
|
||||||
|
/// (including untagged ones); `Some(id)` matches only lines tagged with it.
|
||||||
|
fn movement_in_class(
|
||||||
|
ledger: &Ledger,
|
||||||
|
account: AccountId,
|
||||||
|
from: NaiveDate,
|
||||||
|
to: NaiveDate,
|
||||||
|
class_id: Option<u32>,
|
||||||
|
) -> Decimal {
|
||||||
let movement: Decimal = ledger
|
let movement: Decimal = ledger
|
||||||
.posted_entries()
|
.posted_entries()
|
||||||
.filter(|e| e.date() >= from && e.date() <= to)
|
.filter(|e| e.date() >= from && e.date() <= to)
|
||||||
.flat_map(|e| e.lines())
|
.flat_map(|e| e.lines())
|
||||||
.filter(|l| l.account == account)
|
.filter(|l| l.account == account)
|
||||||
|
.filter(|l| match class_id {
|
||||||
|
None => true,
|
||||||
|
Some(cid) => l.class_id == Some(cid),
|
||||||
|
})
|
||||||
.map(|l| match l.side {
|
.map(|l| match l.side {
|
||||||
Side::Debit => l.amount.amount,
|
Side::Debit => l.amount.amount,
|
||||||
Side::Credit => -l.amount.amount,
|
Side::Credit => -l.amount.amount,
|
||||||
@@ -330,7 +363,7 @@ impl BooksServer {
|
|||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.profit_and_loss",
|
name = "books.profit_and_loss",
|
||||||
description = "Profit and loss for a period: revenue, expenses, net income. Dates are YYYY-MM-DD; omit for all time.",
|
description = "Profit and loss for a period: revenue, expenses, net income. Dates are YYYY-MM-DD; omit for all time. Pass class (name or id) to filter to one division.",
|
||||||
annotations(read_only_hint = true)
|
annotations(read_only_hint = true)
|
||||||
)]
|
)]
|
||||||
async fn profit_and_loss(
|
async fn profit_and_loss(
|
||||||
@@ -351,6 +384,10 @@ impl BooksServer {
|
|||||||
if end < start {
|
if end < start {
|
||||||
return Err("end is before start".to_string());
|
return Err("end is before start".to_string());
|
||||||
}
|
}
|
||||||
|
let class_id = match p.class {
|
||||||
|
Some(c) => Some(resolve_class(&store, &c)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
let mut revenue = Decimal::ZERO;
|
let mut revenue = Decimal::ZERO;
|
||||||
let mut expenses = Decimal::ZERO;
|
let mut expenses = Decimal::ZERO;
|
||||||
for a in ledger.chart().all() {
|
for a in ledger.chart().all() {
|
||||||
@@ -358,8 +395,12 @@ impl BooksServer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match a.account_type {
|
match a.account_type {
|
||||||
AccountType::Revenue => revenue += movement_in(ledger, a.id, start, end),
|
AccountType::Revenue => {
|
||||||
AccountType::Expense => expenses += movement_in(ledger, a.id, start, end),
|
revenue += movement_in_class(ledger, a.id, start, end, class_id)
|
||||||
|
}
|
||||||
|
AccountType::Expense => {
|
||||||
|
expenses += movement_in_class(ledger, a.id, start, end, class_id)
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,7 +553,7 @@ impl BooksServer {
|
|||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.add_line",
|
name = "books.add_line",
|
||||||
description = "Add one line to a draft entry. Amount is a decimal string, never a float. The entry must balance before posting. For foreign-currency lines, pass foreign_currency, foreign_amount, and fx_rate (foreign -> base); the base amount is computed as foreign_amount * fx_rate."
|
description = "Add one line to a draft entry. Amount is a decimal string, never a float. The entry must balance before posting. For foreign-currency lines, pass foreign_currency, foreign_amount, and fx_rate (foreign -> base); the base amount is computed as foreign_amount * fx_rate. Pass class (name or id) to tag the line for divisional tracking."
|
||||||
)]
|
)]
|
||||||
async fn add_line(
|
async fn add_line(
|
||||||
&self,
|
&self,
|
||||||
@@ -547,6 +588,21 @@ impl BooksServer {
|
|||||||
.add_line(p.entry_id, account, side, Money::new(amount, currency))
|
.add_line(p.entry_id, account, side, Money::new(amount, currency))
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
// Class tag, if requested. The line just added is the last one.
|
||||||
|
if let Some(class_ref) = p.class {
|
||||||
|
let class_id = resolve_class(&store, &class_ref)?;
|
||||||
|
let line_index = store
|
||||||
|
.ledger()
|
||||||
|
.get_entry(p.entry_id)
|
||||||
|
.ok_or_else(|| format!("unknown entry {}", p.entry_id))?
|
||||||
|
.lines()
|
||||||
|
.len()
|
||||||
|
.checked_sub(1)
|
||||||
|
.ok_or_else(|| "entry has no lines".to_string())?;
|
||||||
|
store
|
||||||
|
.set_line_class(p.entry_id, line_index, Some(class_id))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
Ok(Json(AddLineOut { ok: true }))
|
Ok(Json(AddLineOut { ok: true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2087,6 +2143,306 @@ impl BooksServer {
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(Json(SetFxRateOut { ok: true }))
|
Ok(Json(SetFxRateOut { ok: true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Payroll (accounting only — the caller supplies every number; no tax tables)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.run_payroll",
|
||||||
|
description = "Run payroll for a pay date. Each line carries one employee's gross pay and every withholding/deduction as decimal strings — the engine cuts one balanced entry (Dr Salary & Wage Expense, Dr Payroll Tax Expense for the employer share, Cr the withholding payables, Cr cash for net pay). Auto-creates the 61xx/21xx accounts. All amounts are decimal strings, never floats."
|
||||||
|
)]
|
||||||
|
async fn run_payroll(
|
||||||
|
&self,
|
||||||
|
params: Parameters<RunPayrollParams>,
|
||||||
|
) -> Result<Json<RunPayrollOut>, String> {
|
||||||
|
let p = params.0;
|
||||||
|
let mut store = self.lock()?;
|
||||||
|
let cash = resolve_account(&store, &p.cash_account)?;
|
||||||
|
let lines: Vec<ledger_store::PayrollLineInput> = p
|
||||||
|
.lines
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| {
|
||||||
|
Ok(ledger_core::PayrollLine {
|
||||||
|
employee_name: l.employee_name,
|
||||||
|
gross_pay: parse_amount(&l.gross_pay)?,
|
||||||
|
federal_withholding: parse_amount(&l.federal_withholding)?,
|
||||||
|
state_withholding: parse_amount(&l.state_withholding)?,
|
||||||
|
ss_employee: parse_amount(&l.ss_employee)?,
|
||||||
|
medicare_employee: parse_amount(&l.medicare_employee)?,
|
||||||
|
ss_employer: parse_amount(&l.ss_employer)?,
|
||||||
|
medicare_employer: parse_amount(&l.medicare_employer)?,
|
||||||
|
other_deductions: parse_amount(&l.other_deductions)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<_, String>>()?;
|
||||||
|
let run_id = store
|
||||||
|
.run_payroll(parse_date(&p.date)?, lines, cash)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let run = store
|
||||||
|
.ledger()
|
||||||
|
.get_payroll_run(run_id)
|
||||||
|
.ok_or_else(|| format!("payroll run {run_id} not found after creation"))?;
|
||||||
|
let total_gross: Decimal = run.lines.iter().map(|l| l.gross_pay).sum();
|
||||||
|
let total_net: Decimal = run.lines.iter().map(|l| l.net_pay()).sum();
|
||||||
|
Ok(Json(RunPayrollOut {
|
||||||
|
run_id,
|
||||||
|
entry_id: run.entry_id,
|
||||||
|
total_gross: money(total_gross),
|
||||||
|
total_net: money(total_net),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.remit_payroll_taxes",
|
||||||
|
description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry."
|
||||||
|
)]
|
||||||
|
async fn remit_payroll_taxes(
|
||||||
|
&self,
|
||||||
|
params: Parameters<RemitPayrollTaxesParams>,
|
||||||
|
) -> Result<Json<RemitPayrollTaxesOut>, String> {
|
||||||
|
let p = params.0;
|
||||||
|
let mut store = self.lock()?;
|
||||||
|
let cash = resolve_account(&store, &p.cash_account)?;
|
||||||
|
// Total before remitting, for the response.
|
||||||
|
let mut total = Decimal::ZERO;
|
||||||
|
for code in ["2110", "2120", "2130", "2140", "2150"] {
|
||||||
|
if let Some(a) = store.ledger().chart().find_by_code(code) {
|
||||||
|
total += store
|
||||||
|
.ledger()
|
||||||
|
.account_balance(a.id)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let entry_id = store
|
||||||
|
.remit_payroll_taxes(parse_date(&p.date)?, cash)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(Json(RemitPayrollTaxesOut {
|
||||||
|
entry_id,
|
||||||
|
total: money(total),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.list_payroll_runs",
|
||||||
|
description = "List payroll runs in id order: date, employee count, totals, and the posted entry id.",
|
||||||
|
annotations(read_only_hint = true)
|
||||||
|
)]
|
||||||
|
async fn list_payroll_runs(&self) -> Result<Json<ListPayrollRunsOut>, String> {
|
||||||
|
let store = self.lock()?;
|
||||||
|
let runs = store
|
||||||
|
.list_payroll_runs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let total_gross: Decimal = r.lines.iter().map(|l| l.gross_pay).sum();
|
||||||
|
PayrollRunOut {
|
||||||
|
id: r.id,
|
||||||
|
date: r.date.to_string(),
|
||||||
|
employee_count: r.lines.len(),
|
||||||
|
total_gross: money(total_gross),
|
||||||
|
entry_id: r.entry_id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(ListPayrollRunsOut { runs }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Classes (divisional tracking)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.add_class",
|
||||||
|
description = "Add a tracking class for divisional P&L (e.g. an app, website, or channel). Tag journal lines with it via books.add_line's class param, then report per division."
|
||||||
|
)]
|
||||||
|
async fn add_class(
|
||||||
|
&self,
|
||||||
|
params: Parameters<AddClassParams>,
|
||||||
|
) -> Result<Json<AddClassOut>, String> {
|
||||||
|
let p = params.0;
|
||||||
|
let mut store = self.lock()?;
|
||||||
|
let id = store.add_class(&p.name).map_err(|e| e.to_string())?;
|
||||||
|
Ok(Json(AddClassOut { id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.list_classes",
|
||||||
|
description = "List tracking classes in id order.",
|
||||||
|
annotations(read_only_hint = true)
|
||||||
|
)]
|
||||||
|
async fn list_classes(&self) -> Result<Json<ListClassesOut>, String> {
|
||||||
|
let store = self.lock()?;
|
||||||
|
let classes = store
|
||||||
|
.list_classes()
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| ClassOut {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(ListClassesOut { classes }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.profit_and_loss_by_class",
|
||||||
|
description = "P&L broken down by tracking class for a period, plus an unclassified row for untagged lines. Dates are YYYY-MM-DD; omit for all time.",
|
||||||
|
annotations(read_only_hint = true)
|
||||||
|
)]
|
||||||
|
async fn profit_and_loss_by_class(
|
||||||
|
&self,
|
||||||
|
params: Parameters<ProfitAndLossParams>,
|
||||||
|
) -> Result<Json<ProfitAndLossByClassOut>, String> {
|
||||||
|
let p = params.0;
|
||||||
|
let store = self.lock()?;
|
||||||
|
let ledger = store.ledger();
|
||||||
|
let start = match p.start {
|
||||||
|
Some(s) => parse_date(&s)?,
|
||||||
|
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
|
||||||
|
};
|
||||||
|
let end = match p.end {
|
||||||
|
Some(s) => parse_date(&s)?,
|
||||||
|
None => chrono::Utc::now().date_naive(),
|
||||||
|
};
|
||||||
|
if end < start {
|
||||||
|
return Err("end is before start".to_string());
|
||||||
|
}
|
||||||
|
// Per-class P&L over the date range: accumulate line by line.
|
||||||
|
use std::collections::HashMap;
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Acc {
|
||||||
|
revenue: Decimal,
|
||||||
|
expenses: Decimal,
|
||||||
|
}
|
||||||
|
let mut by_class: HashMap<Option<u32>, Acc> = HashMap::new();
|
||||||
|
for entry in ledger.posted_entries() {
|
||||||
|
if entry.date() < start || entry.date() > end {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for line in entry.lines() {
|
||||||
|
let account = match ledger.chart().get(line.account) {
|
||||||
|
Some(a) if !a.placeholder => a,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let signed = match line.side {
|
||||||
|
Side::Debit => line.amount.amount,
|
||||||
|
Side::Credit => -line.amount.amount,
|
||||||
|
};
|
||||||
|
// Normal-side positive.
|
||||||
|
let normal = match account.account_type.normal_side() {
|
||||||
|
Side::Debit => signed,
|
||||||
|
Side::Credit => -signed,
|
||||||
|
};
|
||||||
|
let acc = by_class.entry(line.class_id).or_default();
|
||||||
|
match account.account_type {
|
||||||
|
AccountType::Revenue => acc.revenue += normal,
|
||||||
|
AccountType::Expense => acc.expenses += normal,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort: named classes by name, unclassified last.
|
||||||
|
let mut rows: Vec<(Option<String>, Acc)> = by_class
|
||||||
|
.into_iter()
|
||||||
|
.map(|(cid, acc)| {
|
||||||
|
let name = cid
|
||||||
|
.and_then(|id| ledger.get_class(id))
|
||||||
|
.map(|c| c.name.clone());
|
||||||
|
(name, acc)
|
||||||
|
})
|
||||||
|
// Drop rows with no P&L movement.
|
||||||
|
.filter(|(_, acc)| !acc.revenue.is_zero() || !acc.expenses.is_zero())
|
||||||
|
.collect();
|
||||||
|
rows.sort_by(|a, b| match (&a.0, &b.0) {
|
||||||
|
(Some(x), Some(y)) => x.cmp(y),
|
||||||
|
(Some(_), None) => std::cmp::Ordering::Less,
|
||||||
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||||
|
(None, None) => std::cmp::Ordering::Equal,
|
||||||
|
});
|
||||||
|
let classes = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(class, acc)| ClassPnlOut {
|
||||||
|
class,
|
||||||
|
revenue: money(acc.revenue),
|
||||||
|
expenses: money(acc.expenses),
|
||||||
|
net_income: money(acc.revenue - acc.expenses),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(ProfitAndLossByClassOut {
|
||||||
|
start: start.to_string(),
|
||||||
|
end: end.to_string(),
|
||||||
|
classes,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// CPA handoff package
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.export_cpa_package",
|
||||||
|
description = "Build the CPA handoff package as a zip (base64): trial-balance.xlsx, general-ledger.xlsx (every posted line), profit-and-loss.pdf, balance-sheet.pdf. The as_of date defaults to today; the P&L covers the fiscal year to date.",
|
||||||
|
annotations(read_only_hint = true)
|
||||||
|
)]
|
||||||
|
async fn export_cpa_package(
|
||||||
|
&self,
|
||||||
|
params: Parameters<ExportCpaPackageParams>,
|
||||||
|
) -> Result<Json<ExportCpaPackageOut>, String> {
|
||||||
|
use ledger_import::{XlsxReport, report_to_xlsx};
|
||||||
|
let p = params.0;
|
||||||
|
let as_of = match p.as_of {
|
||||||
|
Some(s) => parse_date(&s)?,
|
||||||
|
None => chrono::Utc::now().date_naive(),
|
||||||
|
};
|
||||||
|
let store = self.lock()?;
|
||||||
|
let ledger = store.ledger();
|
||||||
|
|
||||||
|
// Fiscal year to date for the P&L: Jan 1 of the as_of year.
|
||||||
|
let fy_start = NaiveDate::from_ymd_opt(as_of.year(), 1, 1).unwrap();
|
||||||
|
|
||||||
|
// 1. Trial balance XLSX.
|
||||||
|
let tb = ledger_core::reports::trial_balance(ledger);
|
||||||
|
let tb_xlsx = report_to_xlsx(&XlsxReport::TrialBalance(&tb)).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 2. General ledger XLSX (every posted line).
|
||||||
|
let gl_xlsx =
|
||||||
|
report_to_xlsx(&XlsxReport::GeneralLedger(ledger)).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 3. P&L PDF (fiscal year to date).
|
||||||
|
let pnl_pdf =
|
||||||
|
ledger_import::profit_and_loss_pdf(ledger, &fy_start.to_string(), &as_of.to_string())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 4. Balance sheet PDF.
|
||||||
|
let bs_pdf = ledger_import::balance_sheet_pdf(ledger, &as_of.to_string())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
// Zip it.
|
||||||
|
let mut zip_buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
|
||||||
|
let opts = zip::write::SimpleFileOptions::default();
|
||||||
|
for (name, bytes) in [
|
||||||
|
("trial-balance.xlsx", tb_xlsx.as_slice()),
|
||||||
|
("general-ledger.xlsx", gl_xlsx.as_slice()),
|
||||||
|
("profit-and-loss.pdf", pnl_pdf.as_slice()),
|
||||||
|
("balance-sheet.pdf", bs_pdf.as_slice()),
|
||||||
|
] {
|
||||||
|
zip.start_file(name, opts).map_err(|e| e.to_string())?;
|
||||||
|
use std::io::Write;
|
||||||
|
zip.write_all(bytes).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
zip.finish().map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(ExportCpaPackageOut {
|
||||||
|
file_name: format!("cpa-package-{as_of}.zip"),
|
||||||
|
content_base64: base64::Engine::encode(
|
||||||
|
&base64::engine::general_purpose::STANDARD,
|
||||||
|
&zip_buf,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -2415,6 +2771,7 @@ impl BooksServer {
|
|||||||
.profit_and_loss(Parameters(ProfitAndLossParams {
|
.profit_and_loss(Parameters(ProfitAndLossParams {
|
||||||
start: None,
|
start: None,
|
||||||
end: None,
|
end: None,
|
||||||
|
class: None,
|
||||||
}))
|
}))
|
||||||
.await?;
|
.await?;
|
||||||
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
|
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
|
||||||
|
|||||||
Reference in new issue
Block a user