refactor finance.
This commit is contained in:
@@ -25,10 +25,11 @@ public class ExpensesController : ControllerBase
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? search = null,
|
||||
[FromQuery] int? ministryId = null, [FromQuery] int? categoryGroupId = null,
|
||||
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
|
||||
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null)
|
||||
{
|
||||
if (!CanViewAll()) return Forbid();
|
||||
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to));
|
||||
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses));
|
||||
}
|
||||
|
||||
[HttpGet("mine")]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/finance-dashboard")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
public class FinanceDashboardController : ControllerBase
|
||||
{
|
||||
private readonly IFinanceDashboardService _svc;
|
||||
public FinanceDashboardController(IFinanceDashboardService svc) => _svc = svc;
|
||||
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> Summary()
|
||||
=> Ok(await _svc.GetSummaryAsync());
|
||||
|
||||
[HttpGet("income-expense")]
|
||||
public async Task<IActionResult> IncomeExpense([FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
|
||||
=> Ok(await _svc.GetIncomeExpenseAsync(from, to));
|
||||
|
||||
[HttpGet("expense-breakdown")]
|
||||
public async Task<IActionResult> ExpenseBreakdown(
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||
[FromQuery] int? ministryId, [FromQuery] int? categoryGroupId)
|
||||
=> Ok(await _svc.GetExpenseBreakdownAsync(from, to, ministryId, categoryGroupId));
|
||||
}
|
||||
@@ -19,11 +19,11 @@ public class ExpenseListItemDto
|
||||
public string? MemberName { get; set; }
|
||||
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
||||
public bool HasReceipt { get; set; }
|
||||
public string? CheckNumber { get; set; }
|
||||
}
|
||||
|
||||
public class ExpenseDto : ExpenseListItemDto
|
||||
{
|
||||
public string? CheckNumber { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? ReviewNotes { get; set; }
|
||||
public string? SubmittedBy { get; set; }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ROLAC.API.DTOs.Finance;
|
||||
|
||||
/// <summary>All-time finance position for the dashboard balance card.</summary>
|
||||
public class FinanceSummaryDto
|
||||
{
|
||||
public decimal TotalIncome { get; set; } // all-time sum of Giving.Amount
|
||||
public decimal TotalExpenses { get; set; } // all-time Paid+Approved expenses
|
||||
public decimal Balance { get; set; } // TotalIncome - TotalExpenses
|
||||
}
|
||||
|
||||
/// <summary>Income vs expense totals for a date range (the income/expense pie).</summary>
|
||||
public class IncomeExpenseDto
|
||||
{
|
||||
public decimal Income { get; set; } // Givings in [from,to]
|
||||
public decimal Expense { get; set; } // Paid+Approved expenses in [from,to]
|
||||
}
|
||||
|
||||
/// <summary>One slice of the expense drill-down pie. Id is a ministry / group / sub-category id by level.</summary>
|
||||
public class BreakdownSliceDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name_en { get; set; } = "";
|
||||
public string? Name_zh { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
-- ============================================================================
|
||||
-- ROLAC — Finance mock data (奉獻 / 支出) — ONE-OFF dev seed script
|
||||
-- ============================================================================
|
||||
-- Populates ~12 months of Givings (offering sessions) and Expenses so the
|
||||
-- Finance Dashboard has trends, pie charts, and breakdowns to render.
|
||||
--
|
||||
-- WHAT IT CREATES
|
||||
-- • ~8 mock Members (mixed EN/ZH names) — so some givings link to a person.
|
||||
-- • 52 weekly OfferingSessions (one per Sunday, last 12 months), each with
|
||||
-- ~8 Giving lines across all giving categories + payment methods. About
|
||||
-- half the lines are linked to a member, the rest are anonymous.
|
||||
-- • ~12 months of Expenses across every ministry / category group, mostly
|
||||
-- Paid/Approved (so they count in the dashboard) with a few Submitted.
|
||||
--
|
||||
-- ALL rows are stamped CreatedBy = 'mockdata'. Re-running this script first
|
||||
-- DELETEs the previous mock rows, so it is SAFE TO RUN MANY TIMES.
|
||||
--
|
||||
-- PREREQUISITE: reference data must already be seeded (run the API once so
|
||||
-- DbSeeder fills GivingCategories / Ministries / ExpenseCategoryGroups /
|
||||
-- ExpenseSubCategories). This script looks those up by Name_en.
|
||||
--
|
||||
-- HOW TO RUN (dev DB = PostgreSQL "ChurchCRM" on 192.168.68.55:49154):
|
||||
-- psql "Host=192.168.68.55;Port=49154;Database=ChurchCRM;Username=chris;Password=1124" -f MockFinanceData.sql
|
||||
-- …or just paste it into pgAdmin / DBeaver against the ChurchCRM database.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 0. Clean up any previous mock data (order respects FKs)
|
||||
-- ---------------------------------------------------------------------------
|
||||
DELETE FROM "Givings" WHERE "CreatedBy" = 'mockdata';
|
||||
DELETE FROM "OfferingSessions" WHERE "CreatedBy" = 'mockdata';
|
||||
DELETE FROM "Expenses" WHERE "CreatedBy" = 'mockdata';
|
||||
DELETE FROM "Members" WHERE "CreatedBy" = 'mockdata';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. Mock members (mixed EN/ZH) — some givings & reimbursements link to these
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO "Members"
|
||||
("FirstName_en","LastName_en","FirstName_zh","LastName_zh","Gender","Email",
|
||||
"Status","LanguagePreference","Country",
|
||||
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted")
|
||||
VALUES
|
||||
('Wei', 'Chen', '偉', '陳', 'M', 'mock.wei@example.com', 'Member','zh','USA', now(),'mockdata',now(),'mockdata',false),
|
||||
('Mei', 'Lin', '美', '林', 'F', 'mock.mei@example.com', 'Member','zh','USA', now(),'mockdata',now(),'mockdata',false),
|
||||
('David', 'Wang', '大衛', '王', 'M', 'mock.david@example.com', 'Member','en','USA', now(),'mockdata',now(),'mockdata',false),
|
||||
('Grace', 'Liu', '恩典', '劉', 'F', 'mock.grace@example.com', 'Member','en','USA', now(),'mockdata',now(),'mockdata',false),
|
||||
('Samuel', 'Huang', '撒母耳','黃','M', 'mock.samuel@example.com', 'Member','zh','USA', now(),'mockdata',now(),'mockdata',false),
|
||||
('Esther', 'Wu', '以斯帖','吳','F', 'mock.esther@example.com', 'Member','zh','USA', now(),'mockdata',now(),'mockdata',false),
|
||||
('Joshua', 'Tsai', '約書亞','蔡','M', 'mock.joshua@example.com', 'Member','en','USA', now(),'mockdata',now(),'mockdata',false),
|
||||
('Hannah', 'Hsu', '漢娜', '許', 'F', 'mock.hannah@example.com', 'Member','en','USA', now(),'mockdata',now(),'mockdata',false);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. Weekly offering sessions — last 52 Sundays
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- last_sunday = the most recent Sunday on/before today (dow: Sunday = 0)
|
||||
INSERT INTO "OfferingSessions"
|
||||
("SessionDate","Status","CashTotal","CheckTotal","SystemTotal","Difference",
|
||||
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy")
|
||||
SELECT w.d, 'Reconciled', 0, 0, 0, 0,
|
||||
w.d::timestamptz, 'mockdata', w.d::timestamptz, 'mockdata'
|
||||
FROM (
|
||||
SELECT ((current_date - (extract(dow from current_date))::int) - (g * 7)) AS d
|
||||
FROM generate_series(0, 51) AS g
|
||||
) w
|
||||
WHERE NOT EXISTS ( -- skip dates that already have a session
|
||||
SELECT 1 FROM "OfferingSessions" os WHERE os."SessionDate" = w.d
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. Giving lines — ~8 per mock session, spread over categories & methods
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Line "specs": n, category (by Name_en), payment method, link-to-member?,
|
||||
-- amount floor, amount range.
|
||||
WITH specs(n, cat_name, method, link, amin, arange) AS (
|
||||
VALUES
|
||||
(1, 'Tithe', 'Cash', true, 100, 900),
|
||||
(2, 'Tithe', 'Check', true, 100, 900),
|
||||
(3, 'Tithe', 'Zelle', false, 80, 600),
|
||||
(4, 'General Offering', 'Cash', false, 20, 180),
|
||||
(5, 'General Offering', 'PayPal', true, 20, 180),
|
||||
(6, 'Special Offering', 'Check', true, 50, 450),
|
||||
(7, 'Building Fund', 'Zelle', true, 100, 900),
|
||||
(8, 'Mission', 'Cash', false, 30, 270)
|
||||
)
|
||||
INSERT INTO "Givings"
|
||||
("MemberId","GivingCategoryId","OfferingSessionId","Amount","PaymentMethod",
|
||||
"CheckNumber","ZelleReferenceCode","PayPalTransactionId","GivingDate",
|
||||
"IsAnonymous","Notes","CreatedAt","CreatedBy","UpdatedAt","UpdatedBy")
|
||||
SELECT
|
||||
CASE WHEN sp.link
|
||||
THEN (SELECT m."Id" FROM "Members" m WHERE m."IsDeleted" = false ORDER BY random() LIMIT 1)
|
||||
END,
|
||||
gc."Id",
|
||||
s."Id",
|
||||
round((sp.amin + random() * sp.arange))::numeric(18,2),
|
||||
sp.method,
|
||||
CASE WHEN sp.method = 'Check' THEN (1000 + (random() * 8999)::int)::text END,
|
||||
CASE WHEN sp.method = 'Zelle' THEN 'ZL' || to_char(s."SessionDate",'YYYYMMDD') || sp.n END,
|
||||
CASE WHEN sp.method = 'PayPal' THEN 'PP-' || substr(md5(random()::text), 1, 10) END,
|
||||
s."SessionDate",
|
||||
(NOT sp.link), -- anonymous when not linked to a member
|
||||
NULL,
|
||||
s."SessionDate"::timestamptz, 'mockdata', s."SessionDate"::timestamptz, 'mockdata'
|
||||
FROM "OfferingSessions" s
|
||||
CROSS JOIN specs sp
|
||||
JOIN "GivingCategories" gc ON gc."Name_en" = sp.cat_name
|
||||
WHERE s."CreatedBy" = 'mockdata';
|
||||
|
||||
-- Roll the giving lines up into each session's Cash / Check / System totals.
|
||||
UPDATE "OfferingSessions" os SET
|
||||
"CashTotal" = COALESCE(sub.cash, 0),
|
||||
"CheckTotal" = COALESCE(sub.chk, 0),
|
||||
"SystemTotal" = COALESCE(sub.sys, 0),
|
||||
"Difference" = 0
|
||||
FROM (
|
||||
SELECT "OfferingSessionId" sid,
|
||||
SUM("Amount") FILTER (WHERE "PaymentMethod" = 'Cash') AS cash,
|
||||
SUM("Amount") FILTER (WHERE "PaymentMethod" = 'Check') AS chk,
|
||||
SUM("Amount") AS sys
|
||||
FROM "Givings"
|
||||
WHERE "CreatedBy" = 'mockdata'
|
||||
GROUP BY "OfferingSessionId"
|
||||
) sub
|
||||
WHERE os."Id" = sub.sid;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. Expenses — recurring monthly spend across ministries & category groups
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Spec: ministry, category group, sub-category (all by Name_en),
|
||||
-- is_reimbursement?, vendor name, description, amount floor, range.
|
||||
WITH specs(ministry, grp, sub, is_reimb, vendor, descr, amin, arange) AS (
|
||||
VALUES
|
||||
('Facility', 'Facility', 'Rent', false, 'Arcadia Property Mgmt', 'Monthly facility rent / 場地月租', 2000, 400),
|
||||
('Facility', 'Facility', 'Utilities', false, 'SoCal Edison', 'Electricity & water / 水電費', 250, 250),
|
||||
('Worship', 'Equipment', 'Maintenance & Repair', false, 'Guitar Center Service', 'Instrument/sound maintenance / 樂器維修', 80, 220),
|
||||
('Sound', 'Equipment', 'Rental', false, 'AV Rentals LA', 'AV equipment rental / 影音設備租借', 150, 350),
|
||||
('PPT/Media', 'Consumables', 'Accessories', false, 'B&H Photo', 'Cables & adapters / 線材配件', 40, 160),
|
||||
('Catering', 'Food & Beverage','Catering', false, '85C Bakery', 'Sunday fellowship meal / 主日愛筵', 200, 300),
|
||||
('Catering', 'Food & Beverage','Food Ingredients', true, NULL, 'Groceries for kitchen / 廚房食材', 80, 220),
|
||||
('Children', 'Materials', 'Craft Supplies', true, NULL, 'Sunday school crafts / 兒主手工材料', 40, 160),
|
||||
('Children', 'Materials', 'Printing', false, 'FedEx Office', 'Children lesson printing / 兒童教材印刷', 50, 150),
|
||||
('Administration','Printing', 'Bulletins', false, 'FedEx Office', 'Weekly bulletin printing / 週報印刷', 60, 120),
|
||||
('Administration','Consumables', 'Office Supplies', true, NULL, 'Office supplies / 辦公文具', 30, 120),
|
||||
('Hospitality', 'Consumables', 'Cleaning Supplies', true, NULL, 'Cleaning supplies / 清潔用品', 30, 90),
|
||||
('Preaching', 'Personnel', 'Honorarium', true, NULL, 'Guest speaker honorarium / 講員酬庸', 100, 300),
|
||||
('Administration','Missions', 'Missionary Support', false, 'OMF International', 'Monthly missionary support / 宣教士月支援', 200, 300)
|
||||
),
|
||||
-- 12 months back from the current month
|
||||
months AS (
|
||||
SELECT (date_trunc('month', current_date) - (g || ' month')::interval)::date AS m0
|
||||
FROM generate_series(0, 11) AS g
|
||||
),
|
||||
rows AS (
|
||||
SELECT
|
||||
mi."Id" AS ministry_id,
|
||||
gp."Id" AS group_id,
|
||||
sc."Id" AS sub_id,
|
||||
sp.is_reimb,
|
||||
sp.vendor,
|
||||
sp.descr,
|
||||
-- expense date: a day within that month, never in the future
|
||||
LEAST(mo.m0 + (random() * 27)::int, current_date) AS expense_date,
|
||||
round((sp.amin + random() * sp.arange))::numeric(18,2) AS amount,
|
||||
-- weighted status: 7 Paid / 2 Approved / 1 Submitted
|
||||
(ARRAY['Paid','Paid','Paid','Paid','Paid','Paid','Paid','Approved','Approved','Submitted'])
|
||||
[1 + (random() * 9)::int] AS status
|
||||
FROM specs sp
|
||||
CROSS JOIN months mo
|
||||
JOIN "Ministries" mi ON mi."Name_en" = sp.ministry
|
||||
JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp
|
||||
JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id"
|
||||
)
|
||||
INSERT INTO "Expenses"
|
||||
("MinistryId","CategoryGroupId","SubCategoryId","Type","Status","Amount",
|
||||
"Description","VendorName","MemberId","CheckNumber","ExpenseDate",
|
||||
"Notes","SubmittedBy","SubmittedAt","ReviewedBy","ReviewedAt","PaidBy","PaidAt",
|
||||
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted")
|
||||
SELECT
|
||||
r.ministry_id, r.group_id, r.sub_id,
|
||||
CASE WHEN r.is_reimb THEN 'StaffReimbursement' ELSE 'VendorPayment' END,
|
||||
r.status,
|
||||
r.amount,
|
||||
r.descr,
|
||||
CASE WHEN r.is_reimb THEN NULL ELSE r.vendor END,
|
||||
CASE WHEN r.is_reimb
|
||||
THEN (SELECT m."Id" FROM "Members" m WHERE m."IsDeleted" = false ORDER BY random() LIMIT 1)
|
||||
END,
|
||||
CASE WHEN NOT r.is_reimb AND r.status = 'Paid' THEN (4000 + (random() * 5999)::int)::text END,
|
||||
r.expense_date,
|
||||
NULL,
|
||||
'mockdata', r.expense_date::timestamptz,
|
||||
CASE WHEN r.status IN ('Approved','Paid') THEN 'mockdata' END,
|
||||
CASE WHEN r.status IN ('Approved','Paid') THEN r.expense_date::timestamptz END,
|
||||
CASE WHEN r.status = 'Paid' THEN 'mockdata' END,
|
||||
CASE WHEN r.status = 'Paid' THEN r.expense_date::timestamptz END,
|
||||
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata', false
|
||||
FROM rows r;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Quick verification (run after commit)
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT 'members' AS kind, count(*)::text AS value FROM "Members" WHERE "CreatedBy" = 'mockdata'
|
||||
UNION ALL
|
||||
SELECT 'sessions', count(*)::text FROM "OfferingSessions" WHERE "CreatedBy" = 'mockdata'
|
||||
UNION ALL
|
||||
SELECT 'givings', count(*)::text FROM "Givings" WHERE "CreatedBy" = 'mockdata'
|
||||
UNION ALL
|
||||
SELECT 'giving $', COALESCE(sum("Amount"), 0)::numeric(18,2)::text FROM "Givings" WHERE "CreatedBy" = 'mockdata'
|
||||
UNION ALL
|
||||
SELECT 'expenses', count(*)::text FROM "Expenses" WHERE "CreatedBy" = 'mockdata'
|
||||
UNION ALL
|
||||
SELECT 'expense $ (paid+appr)', COALESCE(sum("Amount"), 0)::numeric(18,2)::text
|
||||
FROM "Expenses" WHERE "CreatedBy" = 'mockdata' AND "Status" IN ('Paid','Approved');
|
||||
@@ -127,6 +127,7 @@ builder.Services.AddScoped<ROLAC.API.Services.Storage.IFileStorage,
|
||||
builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
|
||||
builder.Services.AddScoped<IExpenseService, ExpenseService>();
|
||||
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
||||
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Swagger / MVC
|
||||
|
||||
@@ -27,12 +27,24 @@ public class ExpenseService : IExpenseService
|
||||
|
||||
public async Task<PagedResult<ExpenseListItemDto>> GetPagedAsync(
|
||||
int page, int pageSize, string? search, int? ministryId,
|
||||
int? categoryGroupId, string? status, DateOnly? from, DateOnly? to)
|
||||
int? categoryGroupId, string? status, DateOnly? from, DateOnly? to,
|
||||
int? subCategoryId = null, string? statuses = null)
|
||||
{
|
||||
var query = _db.Expenses.AsNoTracking().AsQueryable();
|
||||
if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value);
|
||||
if (categoryGroupId.HasValue) query = query.Where(e => e.CategoryGroupId == categoryGroupId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(status)) query = query.Where(e => e.Status == status);
|
||||
if (subCategoryId.HasValue) query = query.Where(e => e.SubCategoryId == subCategoryId.Value);
|
||||
// `statuses` (comma-separated) takes precedence over single `status`; lets the dashboard
|
||||
// request the Paid+Approved set in one call.
|
||||
if (!string.IsNullOrWhiteSpace(statuses))
|
||||
{
|
||||
var set = statuses.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
query = query.Where(e => set.Contains(e.Status));
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
query = query.Where(e => e.Status == status);
|
||||
}
|
||||
if (from.HasValue) query = query.Where(e => e.ExpenseDate >= from.Value);
|
||||
if (to.HasValue) query = query.Where(e => e.ExpenseDate <= to.Value);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
@@ -41,6 +53,7 @@ public class ExpenseService : IExpenseService
|
||||
query = query.Where(e =>
|
||||
e.Description.ToLower().Contains(s) ||
|
||||
(e.VendorName != null && e.VendorName.ToLower().Contains(s)) ||
|
||||
(e.CheckNumber != null && e.CheckNumber.ToLower().Contains(s)) ||
|
||||
(e.Member != null && (
|
||||
(e.Member.FirstName_en + " " + e.Member.LastName_en).ToLower().Contains(s) ||
|
||||
(e.Member.FirstName_zh != null && e.Member.FirstName_zh.Contains(term)) ||
|
||||
@@ -80,6 +93,7 @@ public class ExpenseService : IExpenseService
|
||||
MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null,
|
||||
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
||||
HasReceipt = e.ReceiptBlobPath != null,
|
||||
CheckNumber = e.CheckNumber,
|
||||
}).ToList();
|
||||
|
||||
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Finance;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only aggregation over Givings + Expenses for the Finance Dashboard.
|
||||
/// Expense scope is Paid+Approved everywhere (decided with the user); income is Givings only.
|
||||
/// </summary>
|
||||
public class FinanceDashboardService : IFinanceDashboardService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public FinanceDashboardService(AppDbContext db) => _db = db;
|
||||
|
||||
// Paid+Approved expenses, optionally bounded by ExpenseDate. Shared by the
|
||||
// income/expense pie and the breakdown so the filter is identical in both.
|
||||
private IQueryable<Expense> PaidApproved(DateOnly? from, DateOnly? to)
|
||||
{
|
||||
var q = _db.Expenses.Where(e => e.Status == "Paid" || e.Status == "Approved");
|
||||
if (from.HasValue) q = q.Where(e => e.ExpenseDate >= from.Value);
|
||||
if (to.HasValue) q = q.Where(e => e.ExpenseDate <= to.Value);
|
||||
return q;
|
||||
}
|
||||
|
||||
public async Task<FinanceSummaryDto> GetSummaryAsync()
|
||||
{
|
||||
var income = await _db.Givings.SumAsync(g => (decimal?)g.Amount) ?? 0m;
|
||||
var expense = await PaidApproved(null, null).SumAsync(e => (decimal?)e.Amount) ?? 0m;
|
||||
return new FinanceSummaryDto
|
||||
{
|
||||
TotalIncome = income,
|
||||
TotalExpenses = expense,
|
||||
Balance = income - expense,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IncomeExpenseDto> GetIncomeExpenseAsync(DateOnly? from, DateOnly? to)
|
||||
{
|
||||
var giving = _db.Givings.AsQueryable();
|
||||
if (from.HasValue) giving = giving.Where(g => g.GivingDate >= from.Value);
|
||||
if (to.HasValue) giving = giving.Where(g => g.GivingDate <= to.Value);
|
||||
|
||||
return new IncomeExpenseDto
|
||||
{
|
||||
Income = await giving.SumAsync(g => (decimal?)g.Amount) ?? 0m,
|
||||
Expense = await PaidApproved(from, to).SumAsync(e => (decimal?)e.Amount) ?? 0m,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<BreakdownSliceDto>> GetExpenseBreakdownAsync(
|
||||
DateOnly? from, DateOnly? to, int? ministryId, int? categoryGroupId)
|
||||
{
|
||||
var q = PaidApproved(from, to);
|
||||
if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value);
|
||||
if (categoryGroupId.HasValue) q = q.Where(e => e.CategoryGroupId == categoryGroupId.Value);
|
||||
|
||||
// Group by the deepest level whose parent id is supplied.
|
||||
List<(int Id, decimal Amount)> grouped;
|
||||
if (categoryGroupId.HasValue)
|
||||
grouped = (await q.GroupBy(e => e.SubCategoryId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
||||
.Select(x => (x.Id, x.Amount)).ToList();
|
||||
else if (ministryId.HasValue)
|
||||
grouped = (await q.GroupBy(e => e.CategoryGroupId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
||||
.Select(x => (x.Id, x.Amount)).ToList();
|
||||
else
|
||||
grouped = (await q.GroupBy(e => e.MinistryId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
||||
.Select(x => (x.Id, x.Amount)).ToList();
|
||||
|
||||
var ids = grouped.Select(x => x.Id).ToHashSet();
|
||||
Dictionary<int, (string En, string? Zh)> names = categoryGroupId.HasValue
|
||||
? await _db.ExpenseSubCategories.Where(s => ids.Contains(s.Id))
|
||||
.ToDictionaryAsync(s => s.Id, s => (s.Name_en, s.Name_zh))
|
||||
: ministryId.HasValue
|
||||
? await _db.ExpenseCategoryGroups.Where(g => ids.Contains(g.Id))
|
||||
.ToDictionaryAsync(g => g.Id, g => (g.Name_en, g.Name_zh))
|
||||
: await _db.Ministries.Where(m => ids.Contains(m.Id))
|
||||
.ToDictionaryAsync(m => m.Id, m => (m.Name_en, m.Name_zh));
|
||||
|
||||
return grouped.Select(x =>
|
||||
{
|
||||
names.TryGetValue(x.Id, out var n);
|
||||
return new BreakdownSliceDto { Id = x.Id, Name_en = n.En ?? "", Name_zh = n.Zh, Amount = x.Amount };
|
||||
}).OrderByDescending(s => s.Amount).ToList();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ public interface IExpenseService
|
||||
{
|
||||
Task<PagedResult<ExpenseListItemDto>> GetPagedAsync(
|
||||
int page, int pageSize, string? search, int? ministryId,
|
||||
int? categoryGroupId, string? status, DateOnly? from, DateOnly? to);
|
||||
int? categoryGroupId, string? status, DateOnly? from, DateOnly? to,
|
||||
int? subCategoryId = null, string? statuses = null);
|
||||
Task<PagedResult<ExpenseListItemDto>> GetMineAsync(string userId, string? status, int page, int pageSize);
|
||||
Task<ExpenseDto?> GetByIdAsync(int id);
|
||||
Task<int> CreateAsync(CreateExpenseRequest r, bool isFinance);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using ROLAC.API.DTOs.Finance;
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public interface IFinanceDashboardService
|
||||
{
|
||||
/// <summary>All-time balance: total Givings minus total Paid+Approved expenses.</summary>
|
||||
Task<FinanceSummaryDto> GetSummaryAsync();
|
||||
|
||||
/// <summary>Income (Givings) vs expense (Paid+Approved) totals within the date range.</summary>
|
||||
Task<IncomeExpenseDto> GetIncomeExpenseAsync(DateOnly? from, DateOnly? to);
|
||||
|
||||
/// <summary>
|
||||
/// Expense totals grouped by the drill level implied by the supplied ids:
|
||||
/// none -> by Ministry; ministryId -> by CategoryGroup; ministryId+categoryGroupId -> by SubCategory.
|
||||
/// </summary>
|
||||
Task<List<BreakdownSliceDto>> GetExpenseBreakdownAsync(
|
||||
DateOnly? from, DateOnly? to, int? ministryId, int? categoryGroupId);
|
||||
}
|
||||
Reference in New Issue
Block a user