refactor finance.

This commit is contained in:
Chris Chen
2026-05-29 23:56:29 -07:00
parent 241870fe48
commit 769597d769
22 changed files with 1392 additions and 65 deletions
@@ -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));
}
+1 -1
View File
@@ -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; }
}
+217
View File
@@ -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');
+1
View File
@@ -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
+16 -2
View File
@@ -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();
}
}
+2 -1
View File
@@ -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);
}