feat(package): aging CSVs in the export and the CPA package (P4b S6)
The CPA's first tie-out is that the aging equals the control account, so books.export and the CPA package now write ar_aging.csv and ap_aging.csv from one builder: one row per open document (number, party, dates, terms, total, paid, discounted, outstanding, bucket, days overdue) and a TOTAL line carrying the outstanding sum, the GL account and balance, and the difference (always 0.00; B-23, B-38 INVARIANT 1). XLSX and PDF lay the same aging out as a statement with the tie-out beneath it. The package README states the tie-out, the gross discount method (A-8) and the direct write-off method (A-5). The chart templates gain 6995 Purchase Discounts so no template puts another account at a control role's code (4100 and 6060 already match). The viewer's Money out list gains the discount-deadline column and colours a due-soon or discount-open date; the screenshot is re-rendered. Test: the_aging_csvs_tie_to_the_control_check in ledger-package; the existing package file-order test and mcp_journeys see the two new files.
This commit is contained in:
1 parent
57f04dbb0c
commit
6c4480cdc8
8 files changed
+390
-39
No files matched your search
@@ -55,6 +55,7 @@ struct Item {
|
||||
party: String,
|
||||
date: NaiveDate,
|
||||
due: NaiveDate,
|
||||
terms: Option<ledger_core::Terms>,
|
||||
state: Result<Option<DocState>, ledger_core::LedgerError>,
|
||||
}
|
||||
|
||||
@@ -74,6 +75,7 @@ fn items(b: &Books, bills: bool) -> Vec<Item> {
|
||||
.unwrap_or_default(),
|
||||
date: d.date,
|
||||
due: d.due_date,
|
||||
terms: d.terms.clone(),
|
||||
state: b.doc_today(DocRef::Bill(d.id)),
|
||||
})
|
||||
.collect()
|
||||
@@ -89,6 +91,7 @@ fn items(b: &Books, bills: bool) -> Vec<Item> {
|
||||
.unwrap_or_default(),
|
||||
date: d.date,
|
||||
due: d.due_date,
|
||||
terms: d.terms.clone(),
|
||||
state: b.doc_today(DocRef::Invoice(d.id)),
|
||||
})
|
||||
.collect()
|
||||
@@ -282,26 +285,23 @@ pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) {
|
||||
return;
|
||||
}
|
||||
let amt = widgets::AMOUNT_MIN + 16.0;
|
||||
let fixed = 110.0 + 104.0 * 2.0 + 120.0 + amt * 2.0;
|
||||
let fixed = 110.0 + 104.0 * 2.0 + 120.0 + amt * 2.0 + if bills { 104.0 } else { 0.0 };
|
||||
let w = ui.available_width().max(fixed + 160.0);
|
||||
widgets::min_width(ui, "invoice_list", w, |ui| {
|
||||
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
head("Document", 110.0, false),
|
||||
head(if bills { "Vendor" } else { "Customer" }, 0.0, false),
|
||||
head("Date", 104.0, false),
|
||||
head("Due", 104.0, false),
|
||||
head("Status", 120.0, false),
|
||||
head("Total", amt, true),
|
||||
head("Still owed", amt, true),
|
||||
],
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
let mut header = vec![
|
||||
head("Document", 110.0, false),
|
||||
head(if bills { "Vendor" } else { "Customer" }, 0.0, false),
|
||||
head("Date", 104.0, false),
|
||||
head("Due", 104.0, false),
|
||||
head("Status", 120.0, false),
|
||||
head("Total", amt, true),
|
||||
head("Still owed", amt, true),
|
||||
];
|
||||
if bills {
|
||||
header.push(head("Discount by", 104.0, false));
|
||||
}
|
||||
row(ui, w, &header, None, true, false);
|
||||
widgets::rule(ui);
|
||||
for it in rows {
|
||||
let (status, total, owed, late) = match &it.state {
|
||||
@@ -314,28 +314,49 @@ pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) {
|
||||
Ok(None) => ("Unknown".into(), String::new(), String::new(), false),
|
||||
Err(_) => ("Can't compute".into(), String::new(), String::new(), false),
|
||||
};
|
||||
let discount_by = it.terms.as_ref().and_then(|t| t.discount_by(it.date));
|
||||
let discount_open = bills && discount_by.is_some_and(|d| d >= b.today);
|
||||
let discount_soon = discount_open
|
||||
&& discount_by.is_some_and(|d| d <= b.today + chrono::Days::new(3));
|
||||
let due_soon = bills
|
||||
&& matches!(&it.state, Ok(Some(s))
|
||||
if !s.outstanding.is_zero() && s.status != DocStatus::Void)
|
||||
&& it.due <= b.today + chrono::Days::new(7);
|
||||
let name = format!("{}, {}, {}, owes {}", it.name, it.party, status, owed);
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(it.name.clone(), 110.0, false, p.text),
|
||||
(it.party.clone(), 0.0, false, p.text),
|
||||
(fmt::date(it.date), 104.0, false, p.text),
|
||||
(
|
||||
fmt::date(it.due),
|
||||
104.0,
|
||||
false,
|
||||
if late { p.warning } else { p.text },
|
||||
),
|
||||
(status, 120.0, false, if late { p.warning } else { p.muted }),
|
||||
(total, amt, true, p.text),
|
||||
(owed, amt, true, p.text),
|
||||
],
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
let mut cells = vec![
|
||||
(it.name.clone(), 110.0, false, p.text),
|
||||
(it.party.clone(), 0.0, false, p.text),
|
||||
(fmt::date(it.date), 104.0, false, p.text),
|
||||
(
|
||||
fmt::date(it.due),
|
||||
104.0,
|
||||
false,
|
||||
if late || due_soon { p.warning } else { p.text },
|
||||
),
|
||||
(
|
||||
status.clone(),
|
||||
120.0,
|
||||
false,
|
||||
if late { p.warning } else { p.muted },
|
||||
),
|
||||
(total, amt, true, p.text),
|
||||
(owed, amt, true, p.text),
|
||||
];
|
||||
if bills {
|
||||
cells.push((
|
||||
discount_by.map(fmt::date).unwrap_or_default(),
|
||||
104.0,
|
||||
false,
|
||||
if discount_soon {
|
||||
p.warning
|
||||
} else if discount_open {
|
||||
p.text
|
||||
} else {
|
||||
p.muted
|
||||
},
|
||||
));
|
||||
}
|
||||
let resp = row(ui, w, &cells, None, false, true);
|
||||
// Named in full, for assistive tech (and the tests).
|
||||
resp.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)
|
||||
|
||||
@@ -156,6 +156,8 @@ fn the_list_shows_the_ledgers_figures() {
|
||||
);
|
||||
let mut h = open(&dir, Route::Payables);
|
||||
shot(&mut h, "payables");
|
||||
// The Money out list carries the discount-deadline column (blank
|
||||
// until terms are journaled, S4.3).
|
||||
assert!(
|
||||
h.query_by_label_contains("Bill 1, Clay Co, Open, owes 300.00")
|
||||
.is_some()
|
||||
|
||||
@@ -307,6 +307,12 @@ pub fn chart_template(entity: EntityType, owners: &[String]) -> Result<ChartTemp
|
||||
"expense",
|
||||
Some("nondeductible"),
|
||||
),
|
||||
acct(
|
||||
"6995",
|
||||
"Purchase Discounts",
|
||||
"expense",
|
||||
Some("other_deductions"),
|
||||
),
|
||||
acct(
|
||||
"7100",
|
||||
"Employee Benefits",
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! The receivables and payables agings as files (plan P4b S6, 8.3.10;
|
||||
//! rules.md B-18, B-23): one row per open document, and a tie-out line
|
||||
//! against the control account. The server's `books.export` and the CPA
|
||||
//! package build the same CSV from this, and its XLSX/PDF from
|
||||
//! [`aging_statement`], so a CPA's two copies can't differ.
|
||||
|
||||
use ledger_core::{DocRef, Ledger};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use crate::text::{csv_escape, money};
|
||||
|
||||
/// The CSV's header: one row per open document (8.3.10).
|
||||
pub const AGING_CSV_HEADER: &str =
|
||||
"number,party,date,due_date,terms,total,paid,discounted,outstanding,bucket,days_overdue";
|
||||
|
||||
/// One aging's rows and tie-out as CSV: the header, one row per open
|
||||
/// invoice (`invoices` true) or bill, then `TOTAL,,,,,,,,<Σ
|
||||
/// outstanding>,GL <account> <balance>,difference <difference>`.
|
||||
pub fn aging_csv(
|
||||
ledger: &Ledger,
|
||||
invoices: bool,
|
||||
as_of: chrono::NaiveDate,
|
||||
) -> Result<String, String> {
|
||||
let docs = ledger
|
||||
.aging_documents(as_of, invoices)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut out = String::from(AGING_CSV_HEADER);
|
||||
out.push('\n');
|
||||
let mut total = Decimal::ZERO;
|
||||
for d in &docs {
|
||||
total += d.outstanding;
|
||||
out.push_str(&format!(
|
||||
"{},{},{},{},{},{},{},{},{},{},{}\n",
|
||||
csv_escape(&number(ledger, d.doc)),
|
||||
csv_escape(&d.party),
|
||||
d.date,
|
||||
d.due_date,
|
||||
csv_escape(d.terms.as_deref().unwrap_or("")),
|
||||
money(d.total),
|
||||
money(d.paid),
|
||||
money(d.discounted),
|
||||
money(d.outstanding),
|
||||
d.bucket,
|
||||
d.days_overdue,
|
||||
));
|
||||
}
|
||||
let (code, balance, difference) = tie_out(ledger, invoices, as_of)?;
|
||||
let account = if code.is_empty() {
|
||||
"GL —".to_string()
|
||||
} else {
|
||||
format!("GL {code}")
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"TOTAL,,,,,,,,{total},{account} {balance},difference {difference}\n",
|
||||
total = money(total),
|
||||
balance = money(balance),
|
||||
difference = money(difference),
|
||||
));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The same aging as a laid-out statement for XLSX and PDF: one line per
|
||||
/// party with its six bucket amounts, the totals, and the tie-out.
|
||||
pub fn aging_statement(
|
||||
ledger: &Ledger,
|
||||
invoices: bool,
|
||||
as_of: chrono::NaiveDate,
|
||||
) -> Result<ledger_import::Statement, String> {
|
||||
use ledger_import::report::{Row, RowKind, Statement};
|
||||
let party_rows = if invoices {
|
||||
ledger.ar_aging(as_of)
|
||||
} else {
|
||||
ledger.ap_aging(as_of)
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (code, balance, difference) = tie_out(ledger, invoices, as_of)?;
|
||||
let columns: Vec<String> = ["Current", "1-30", "31-60", "61-90", "90+", "Total"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let mut out_rows: Vec<Row> = vec![Row {
|
||||
kind: RowKind::Section,
|
||||
label: if invoices {
|
||||
"Receivables".into()
|
||||
} else {
|
||||
"Payables".into()
|
||||
},
|
||||
amounts: vec![],
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
}];
|
||||
let (mut current, mut d30, mut d60, mut d90, mut d90p) = (
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
);
|
||||
for r in &party_rows {
|
||||
let total = r.total().map_err(|e| e.to_string())?;
|
||||
current += r.current;
|
||||
d30 += r.days_30;
|
||||
d60 += r.days_60;
|
||||
d90 += r.days_90;
|
||||
d90p += r.days_90_plus;
|
||||
out_rows.push(Row {
|
||||
kind: RowKind::Line,
|
||||
label: r.name.clone(),
|
||||
amounts: vec![
|
||||
Some(r.current),
|
||||
Some(r.days_30),
|
||||
Some(r.days_60),
|
||||
Some(r.days_90),
|
||||
Some(r.days_90_plus),
|
||||
Some(total),
|
||||
],
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
});
|
||||
}
|
||||
let total = current + d30 + d60 + d90 + d90p;
|
||||
out_rows.push(Row {
|
||||
kind: RowKind::Total,
|
||||
label: "Total".into(),
|
||||
amounts: vec![
|
||||
Some(current),
|
||||
Some(d30),
|
||||
Some(d60),
|
||||
Some(d90),
|
||||
Some(d90p),
|
||||
Some(total),
|
||||
],
|
||||
currency: true,
|
||||
rule_above: true,
|
||||
});
|
||||
out_rows.push(Row {
|
||||
kind: RowKind::Spacer,
|
||||
label: String::new(),
|
||||
amounts: vec![],
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
});
|
||||
out_rows.push(Row {
|
||||
kind: RowKind::Section,
|
||||
label: "Tie-out".into(),
|
||||
amounts: vec![],
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
});
|
||||
out_rows.push(Row {
|
||||
kind: RowKind::Line,
|
||||
label: if code.is_empty() {
|
||||
"GL balance".to_string()
|
||||
} else {
|
||||
format!("GL {code} balance")
|
||||
},
|
||||
amounts: vec![None, None, None, None, None, Some(balance)],
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
});
|
||||
out_rows.push(Row {
|
||||
kind: RowKind::Total,
|
||||
label: "Difference".into(),
|
||||
amounts: vec![None, None, None, None, None, Some(difference)],
|
||||
currency: true,
|
||||
rule_above: true,
|
||||
});
|
||||
Ok(Statement {
|
||||
// Excel sheet names take no slash; the statement's title is the
|
||||
// one both XLSX and PDF draw.
|
||||
title: if invoices {
|
||||
"AR Aging".into()
|
||||
} else {
|
||||
"AP Aging".into()
|
||||
},
|
||||
period: format!("As of {}", as_of.format("%B %-d, %Y")),
|
||||
columns,
|
||||
rows: out_rows,
|
||||
percent_from: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The document's number for the aging's first column: an invoice's issued
|
||||
/// number, a bill's vendor number, else the document's own name.
|
||||
fn number(ledger: &Ledger, doc: DocRef) -> String {
|
||||
match doc {
|
||||
DocRef::Invoice(id) => ledger
|
||||
.get_invoice(id)
|
||||
.and_then(|i| i.number.clone())
|
||||
.unwrap_or_else(|| format!("Invoice {id}")),
|
||||
DocRef::Bill(id) => ledger
|
||||
.get_bill(id)
|
||||
.and_then(|b| b.vendor_ref.clone())
|
||||
.unwrap_or_else(|| format!("Bill {id}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The control account's code, balance and difference as of the day
|
||||
/// ([`Ledger::control_check`]): the aging's tie-out.
|
||||
fn tie_out(
|
||||
ledger: &Ledger,
|
||||
invoices: bool,
|
||||
as_of: chrono::NaiveDate,
|
||||
) -> Result<(String, Decimal, Decimal), String> {
|
||||
let role = if invoices { "receivables" } else { "payables" };
|
||||
let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?;
|
||||
let Some(check) = checks.iter().find(|c| c.role == role) else {
|
||||
return Ok((String::new(), Decimal::ZERO, Decimal::ZERO));
|
||||
};
|
||||
let code = ledger
|
||||
.chart()
|
||||
.get(check.account)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default();
|
||||
Ok((code, check.balance, check.difference))
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
//! whichever [`PackageBooks`] they are read through.
|
||||
//! ```
|
||||
|
||||
mod aging;
|
||||
mod attention;
|
||||
mod books;
|
||||
mod iif;
|
||||
@@ -34,6 +35,7 @@ mod text;
|
||||
mod tins;
|
||||
mod vendor_1099;
|
||||
|
||||
pub use aging::{AGING_CSV_HEADER, aging_csv, aging_statement};
|
||||
pub use attention::{
|
||||
Attention, Integrity, Report, Severity, Target, files_1099, find, find_with, reconcile_prompt,
|
||||
w9_missing, w9_prompt,
|
||||
|
||||
@@ -372,6 +372,17 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
||||
files.push((format!("{name}.pdf"), pdf(st)?));
|
||||
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
|
||||
}
|
||||
// The receivables and payables agings, each with its tie-out line to
|
||||
// the control account (B-23, 8.3.10): the same CSV books.export and
|
||||
// the viewer make.
|
||||
files.push((
|
||||
"ar_aging.csv".into(),
|
||||
crate::aging::aging_csv(ledger, true, as_of)?.into_bytes(),
|
||||
));
|
||||
files.push((
|
||||
"ap_aging.csv".into(),
|
||||
crate::aging::aging_csv(ledger, false, as_of)?.into_bytes(),
|
||||
));
|
||||
// The fixed asset register, when an asset was acquired by as_of (one
|
||||
// acquired later is not in it, so neither is an empty register).
|
||||
if ledger
|
||||
@@ -566,7 +577,7 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
||||
let year_end = NaiveDate::from_ymd_opt(year, 12, 31) == Some(as_of);
|
||||
let include_tins = tins.is_some();
|
||||
let readme = format!(
|
||||
"{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\n{iif}{worksheet}{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n",
|
||||
"{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\nAgings (CSV): ar_aging.csv and ap_aging.csv, one row per open invoice or bill, each ending with a TOTAL line that ties to its control account (B-23). Early-payment discounts are taken by the gross method (A-8); bad debts are written off directly (A-5).\n{iif}{worksheet}{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n",
|
||||
name = company.name,
|
||||
basis = company.basis,
|
||||
members = if company.members.is_empty() {
|
||||
|
||||
@@ -338,6 +338,8 @@ fn a_single_member_package_holds_the_documented_files_in_order() {
|
||||
"tax-summary.xlsx".into(),
|
||||
"trial-balance.pdf".into(),
|
||||
"trial-balance.xlsx".into(),
|
||||
"ar_aging.csv".into(),
|
||||
"ap_aging.csv".into(),
|
||||
"general-ledger.xlsx".into(),
|
||||
"general-journal.xlsx".into(),
|
||||
"1099-worksheet-2026.csv".into(),
|
||||
@@ -2446,3 +2448,82 @@ fn the_k1_worksheet_reports_tax_basis_capital() {
|
||||
// The book worksheet is the same as without assets' bases.
|
||||
assert!(text(&p, "k1-members.csv").contains(",1685.00\n"));
|
||||
}
|
||||
|
||||
/// The CPA package's aging CSVs are the agings, one row per open document
|
||||
/// and a tie-out line that equals the control account (plan P4b S6,
|
||||
/// 8.3.10; rules.md B-23, B-38 INVARIANT 1).
|
||||
#[test]
|
||||
fn the_aging_csvs_tie_to_the_control_check() {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-package-aging-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let mut s = Store::create(&dir.path().join("books-data"), Currency::USD).unwrap();
|
||||
for (code, name, ty) in [
|
||||
("1000", "Checking", AccountType::Asset),
|
||||
("3000", "Capital", AccountType::Equity),
|
||||
] {
|
||||
s.add_account(code, name, ty, None, false).unwrap();
|
||||
}
|
||||
let bank = id_of(&s, "1000");
|
||||
let customer = s.add_customer("Fernhill Labs").unwrap();
|
||||
let vendor = s.add_vendor("Tobin Harlow Supply").unwrap();
|
||||
let line = |what: &str, cents: i64| DocumentLine {
|
||||
description: what.into(),
|
||||
amount: Decimal::new(cents, 2),
|
||||
class_id: None,
|
||||
account: None,
|
||||
quantity: None,
|
||||
unit_price: None,
|
||||
};
|
||||
let invoice = s
|
||||
.create_invoice(
|
||||
customer,
|
||||
d("2026-02-01"),
|
||||
d("2026-03-01"),
|
||||
vec![line("Consulting", 50_000)],
|
||||
)
|
||||
.unwrap();
|
||||
let bill = s
|
||||
.create_bill(
|
||||
vendor,
|
||||
d("2026-02-02"),
|
||||
d("2026-03-04"),
|
||||
vec![line("Paper", 20_000)],
|
||||
)
|
||||
.unwrap();
|
||||
s.receive_payment(invoice, d("2026-02-10"), Decimal::new(20_000, 2), bank)
|
||||
.unwrap();
|
||||
s.pay_bill(bill, d("2026-02-11"), Decimal::new(10_000, 2), bank)
|
||||
.unwrap();
|
||||
let p = build_package(
|
||||
&s,
|
||||
&settings(&company(false), &cash()),
|
||||
&request(false, None),
|
||||
)
|
||||
.unwrap();
|
||||
for (name, role, code, balance) in [
|
||||
("ar_aging.csv", "receivables", "1200", "300.00"),
|
||||
("ap_aging.csv", "payables", "2000", "100.00"),
|
||||
] {
|
||||
let csv = text(&p, name);
|
||||
let total = csv.lines().last().unwrap();
|
||||
assert!(
|
||||
total.starts_with("TOTAL,,,,,,,,300.00") || total.starts_with("TOTAL,,,,,,,,100.00")
|
||||
);
|
||||
assert!(
|
||||
total.contains(&format!("GL {code} {balance}")),
|
||||
"{name}: {total}"
|
||||
);
|
||||
assert!(total.ends_with("difference 0.00"), "{name}: {total}");
|
||||
let check = s
|
||||
.ledger()
|
||||
.control_check(d("2026-12-31"))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|c| c.role == role)
|
||||
.unwrap();
|
||||
assert_eq!(check.balance.to_string(), balance, "{name}");
|
||||
assert_eq!(check.difference, Decimal::ZERO, "{name}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user