Sms & Email

This commit is contained in:
2026-10-20 10:45:03 +05:30
parent a40e463e8b
commit 0e423b806c
29 changed files with 13845 additions and 26 deletions
@@ -0,0 +1,193 @@
using System.Globalization;
using System.Net;
using System.Text;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Reports;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.OwnerReport;
/// <summary>The day-end report rendered for each channel.</summary>
public sealed record OwnerReportContent(DateOnly Date, string SmsText, string EmailSubject, string EmailHtml, string EmailText);
/// <summary>
/// Turns one day's <see cref="DayEndReportDto"/> into the owner SMS (short, ASCII so it stays a
/// cheap GSM message) and email (full detail) — docs/DAY_END_OWNER_REPORT_PLAN.md §1 / §3.3.
/// Figures come straight from <see cref="IDayEndReportService"/>, so they always match the
/// Day-End Report screen for the same day.
/// </summary>
public sealed class OwnerReportContentBuilder
{
public const string AppName = "Hexa ERP";
private const int TopSales = 10;
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
private readonly IDayEndReportService _report;
private readonly IRepository<Branch> _branches;
private readonly IRepository<BranchConfig> _branchConfig;
private readonly IConfiguration _configuration;
public OwnerReportContentBuilder(
IDayEndReportService report, IRepository<Branch> branches, IRepository<BranchConfig> branchConfig, IConfiguration configuration)
{
_report = report;
_branches = branches;
_branchConfig = branchConfig;
_configuration = configuration;
}
public async Task<OwnerReportContent> BuildAsync(DateOnly date, bool includeBranchBreakdown, CancellationToken ct)
{
// branchId null = every branch. Inside the background job there is no signed-in user, so
// ICurrentBranchContext is inactive and this really is the company-wide total.
var day = await _report.GetDayAsync(date, null, ct);
var branchRows = new List<(string Name, DayEndReportDayDto Summary)>();
var branchingOn = await _branchConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => c.BranchingEnabled).FirstOrDefaultAsync(ct);
if (includeBranchBreakdown && branchingOn)
{
var branches = await _branches.Query().AsNoTracking()
.Where(b => b.Status == EntityStatus.Active).OrderBy(b => b.Name).Select(b => new { b.BranchId, b.Name }).ToListAsync(ct);
if (branches.Count > 1)
foreach (var b in branches)
branchRows.Add((b.Name, (await _report.GetDayAsync(date, b.BranchId, ct)).Summary));
}
var s = day.Summary;
var subject = $"Day-end report - {date.ToString("dd MMM yyyy", Inv)} - Sales LKR {Amt(s.TotalSales)}";
return new OwnerReportContent(date, BuildSms(date, s), subject, BuildHtml(date, day, branchRows), BuildText(date, day));
}
// ---- SMS ------------------------------------------------------------------
private static string BuildSms(DateOnly date, DayEndReportDayDto s)
{
var sb = new StringBuilder();
sb.Append(AppName).Append(" - Day end ").Append(date.ToString("dd/MM/yyyy", Inv)).Append('\n');
sb.Append("Sales: LKR ").Append(Amt(s.TotalSales)).Append(" (").Append(s.SalesCount).Append(")\n");
sb.Append("Paid: ").Append(Amt(s.PaidAmount)).Append(" | Due: ").Append(Amt(s.DueAmount)).Append('\n');
sb.Append("Cash: ").Append(Amt(s.PaidByCash)).Append(" | Card: ").Append(Amt(s.PaidByCard)).Append('\n');
sb.Append("Expenses: ").Append(Amt(s.ExpenseTotal)).Append('\n');
sb.Append(ProfitLabel(s.NetProfit)).Append(": ").Append(Amt(Math.Abs(s.NetProfit))).Append('\n');
sb.Append("Cash in hand: ").Append(Amt(s.ClosingCashInHand));
return sb.ToString();
}
// ---- Email ------------------------------------------------------------------
private string BuildHtml(DateOnly date, DayEndReportDto day, List<(string Name, DayEndReportDayDto Summary)> branches)
{
var s = day.Summary;
var h = new StringBuilder();
h.Append("<div style=\"font-family:Arial,Helvetica,sans-serif;color:#1f2937;max-width:680px;margin:0 auto\">");
h.Append($"<h2 style=\"margin:0 0 4px\">Day-end report</h2>");
h.Append($"<p style=\"margin:0 0 16px;color:#6b7280\">{E(date.ToString("dddd, dd MMMM yyyy", Inv))} &middot; all branches</p>");
Section(h, "Summary", new (string, string)[]
{
("Total sales", $"LKR {Amt(s.TotalSales)} ({s.SalesCount} sales)"),
("Paid", Amt(s.PaidAmount)),
("Due", Amt(s.DueAmount)),
("Paid by cash", Amt(s.PaidByCash)),
("Paid by card", Amt(s.PaidByCard)),
("Paid by other (transfer / cheque)", Amt(s.PaidByOther)),
("Expenses", Amt(s.ExpenseTotal)),
("Income (sales - expenses)", Signed(s.Income)),
("Cost of goods sold", Amt(s.CostOfGoodsSold)),
("Gross profit", Signed(s.GrossProfit)),
(ProfitLabel(s.NetProfit), Amt(Math.Abs(s.NetProfit))),
}, emphasiseLast: true, lastIsLoss: s.NetProfit < 0);
Section(h, "Cash", new (string, string)[]
{
("Opening cash in hand", Amt(s.OpeningCashInHand)),
("Net cash today", Signed(s.NetCash)),
("Deposited to bank", Amt(s.CashDeposited)),
("Closing cash in hand", Amt(s.ClosingCashInHand)),
("Collected on earlier invoices", Amt(s.EarlierInvoiceCollections)),
}, emphasiseLast: false, lastIsLoss: false);
if (branches.Count > 0)
Table(h, "By branch", new[] { "Branch", "Sales", "Paid", "Expenses", "Net profit" }, firstNumericColumn: 1,
branches.Select(x => new[] { x.Name, Amt(x.Summary.TotalSales), Amt(x.Summary.PaidAmount), Amt(x.Summary.ExpenseTotal), Signed(x.Summary.NetProfit) }));
var top = day.Sales.OrderByDescending(x => x.Total).Take(TopSales).ToList();
if (top.Count > 0)
Table(h, day.Sales.Count > TopSales ? $"Top {TopSales} sales (of {day.Sales.Count})" : "Sales",
new[] { "No.", "Customer", "Paid by", "Total" }, firstNumericColumn: 3,
top.Select(x => new[] { x.DocNo, x.CustomerName, x.PaidBy, Amt(x.Total) }));
if (day.Expenses.Count > 0)
Table(h, "Expenses", new[] { "No.", "Category / title", "Amount" }, firstNumericColumn: 2,
day.Expenses.Select(x => new[] { x.DocNo, $"{x.CategoryName} / {x.TitleName}", Amt(x.Amount) }));
var link = ReportLink(date);
if (link is not null)
h.Append($"<p style=\"margin:20px 0 0\"><a href=\"{E(link)}\" style=\"color:#e11d48\">Open the Day-End Report</a></p>");
h.Append($"<p style=\"margin:20px 0 0;font-size:12px;color:#9ca3af\">Sent automatically by {AppName}.</p>");
h.Append("</div>");
return h.ToString();
}
private string BuildText(DateOnly date, DayEndReportDto day)
{
var text = new StringBuilder(BuildSms(date, day.Summary));
var link = ReportLink(date);
if (link is not null) text.Append("\n\nOpen the Day-End Report: ").Append(link);
text.Append("\n\nSent automatically by ").Append(AppName).Append('.');
return text.ToString();
}
/// <summary>Link back to the report screen, when OwnerReport:AppUrl is configured (e.g. https://erp.example.com).</summary>
private string? ReportLink(DateOnly date)
{
var baseUrl = _configuration["OwnerReport:AppUrl"]?.TrimEnd('/');
return string.IsNullOrWhiteSpace(baseUrl) ? null : $"{baseUrl}/dashboard/sales/day-end-report?date={date:yyyy-MM-dd}";
}
private static void Section(StringBuilder h, string title, (string Label, string Value)[] rows, bool emphasiseLast, bool lastIsLoss)
{
h.Append(SectionTitle(title));
h.Append("<table style=\"width:100%;border-collapse:collapse;font-size:14px\">");
for (var i = 0; i < rows.Length; i++)
{
var last = i == rows.Length - 1 && emphasiseLast;
var colour = last ? (lastIsLoss ? "#dc2626" : "#16a34a") : "#1f2937";
var weight = last ? "bold" : "normal";
h.Append($"<tr><td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;color:#4b5563\">{E(rows[i].Label)}</td>");
h.Append($"<td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;text-align:right;font-weight:{weight};color:{colour}\">{E(rows[i].Value)}</td></tr>");
}
h.Append("</table>");
}
private static string SectionTitle(string title)
=> $"<h3 style=\"margin:20px 0 6px;font-size:15px;color:#111827\">{E(title)}</h3>";
/// <summary>A titled table; columns from <paramref name="firstNumericColumn"/> on are right-aligned amounts. Cells are HTML-encoded here.</summary>
private static void Table(StringBuilder h, string title, string[] headers, int firstNumericColumn, IEnumerable<string[]> rows)
{
string Align(int i) => i >= firstNumericColumn ? "right" : "left";
h.Append(SectionTitle(title));
h.Append("<table style=\"width:100%;border-collapse:collapse;font-size:13px\"><tr>");
for (var i = 0; i < headers.Length; i++)
h.Append($"<th style=\"padding:6px 8px;background:#f9fafb;border-bottom:1px solid #e5e7eb;text-align:{Align(i)}\">{E(headers[i])}</th>");
h.Append("</tr>");
foreach (var cells in rows)
{
h.Append("<tr>");
for (var i = 0; i < cells.Length; i++)
h.Append($"<td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;text-align:{Align(i)}\">{E(cells[i])}</td>");
h.Append("</tr>");
}
h.Append("</table>");
}
private static string ProfitLabel(decimal net) => net < 0 ? "Net loss" : "Net profit";
private static string Amt(decimal v) => v.ToString("#,##0.00", Inv);
private static string Signed(decimal v) => (v < 0 ? "-" : "") + Amt(Math.Abs(v));
private static string E(string v) => WebUtility.HtmlEncode(v);
}
@@ -0,0 +1,49 @@
namespace ERPCore.Services.OwnerReport;
/// <summary>
/// Sends the day-end owner report once its configured time has passed
/// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.4). Wakes every minute; all the "is it time / already
/// sent / retry" logic lives in <see cref="IOwnerReportService.RunScheduledAsync"/>.
/// Never lets an exception escape — a failed tick is logged and the next one tries again.
/// </summary>
public sealed class OwnerReportJob : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
private readonly IServiceScopeFactory _scopes;
private readonly ILogger<OwnerReportJob> _logger;
public OwnerReportJob(IServiceScopeFactory scopes, ILogger<OwnerReportJob> logger)
{
_scopes = scopes;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Let startup (migrations, seeding) finish before the first tick.
try { await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); }
catch (OperationCanceledException) { return; }
using var timer = new PeriodicTimer(Interval);
do
{
try
{
// Fresh scope per tick: its own DbContext, and no HTTP user (so the day-end
// figures are company-wide, not limited to someone's branch).
using var scope = _scopes.CreateScope();
await scope.ServiceProvider.GetRequiredService<IOwnerReportService>().RunScheduledAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogError(ex, "Owner report tick failed; will retry on the next tick.");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
}
@@ -0,0 +1,360 @@
using System.Text.RegularExpressions;
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Config;
using ERPCore.Dtos.Warehouses;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Messaging;
using ERPCore.Infra.Persistence;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.OwnerReport;
public interface IOwnerReportService
{
Task<ETagged<OwnerReportConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<OwnerReportConfigDto>> UpdateAsync(UpdateOwnerReportConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<OwnerReportPreviewDto> PreviewAsync(DateOnly? date, CancellationToken ct = default);
Task<IReadOnlyList<OwnerReportSendResultDto>> SendTestAsync(DateOnly? date, CancellationToken ct = default);
Task<PagedResponse<OwnerReportDispatchDto>> ListDispatchesAsync(PageQuery query, CancellationToken ct = default);
/// <summary>One scheduler tick (OwnerReportJob): sends today's report once its time has come.</summary>
Task RunScheduledAsync(CancellationToken ct = default);
}
/// <summary>
/// Day-end owner report: settings, preview, test sends, the send log and the scheduled daily
/// send (docs/DAY_END_OWNER_REPORT_PLAN.md). Messages go out through <see cref="IMessageSender"/>
/// (AuthHex), so this service never handles SMTP or SMS credentials.
/// </summary>
public sealed class OwnerReportService : IOwnerReportService
{
private const int MaxRecipients = 5;
private const int MaxAttempts = 3;
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(5);
private static readonly Regex SriLankanPhone = new(PhoneRules.Pattern, RegexOptions.Compiled);
private readonly ErpDbContext _db;
private readonly OwnerReportContentBuilder _content;
private readonly IMessageSender _sender;
private readonly BusinessDayClock _clock;
private readonly ICurrentUser _currentUser;
private readonly ILogger<OwnerReportService> _logger;
public OwnerReportService(
ErpDbContext db, OwnerReportContentBuilder content, IMessageSender sender, BusinessDayClock clock,
ICurrentUser currentUser, ILogger<OwnerReportService> logger)
{
_db = db;
_content = content;
_sender = sender;
_clock = clock;
_currentUser = currentUser;
_logger = logger;
}
// ---- Settings ------------------------------------------------------------------
public async Task<ETagged<OwnerReportConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await LoadConfigAsync(asNoTracking: true, ct);
var recipients = await _db.OwnerReportRecipients.AsNoTracking().OrderBy(r => r.RecipientId).ToListAsync(ct);
return new ETagged<OwnerReportConfigDto>(Map(config, recipients), config.RowVersion);
}
public async Task<ETagged<OwnerReportConfigDto>> UpdateAsync(UpdateOwnerReportConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
// Validate everything before touching the rows.
if (request.Recipients.Count > MaxRecipients)
throw Invalid($"At most {MaxRecipients} recipients.");
var cleaned = request.Recipients.Select(CleanRecipient).ToList();
var config = await LoadConfigAsync(asNoTracking: false, ct);
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The owner report settings were changed by someone else — reload and try again.", 412);
config.Enabled = request.Enabled!.Value;
config.SmsEnabled = request.SmsEnabled!.Value;
config.EmailEnabled = request.EmailEnabled!.Value;
config.IncludeBranchBreakdown = request.IncludeBranchBreakdown!.Value;
config.SendAtLocalTime = TimeOnly.ParseExact(request.SendAtLocalTime, "HH:mm");
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
if (config.Enabled && !cleaned.Any(r => r.IsActive))
throw Invalid("Add at least one active recipient before switching the report on.");
if (config.Enabled && !config.SmsEnabled && !config.EmailEnabled)
throw Invalid("Switch on SMS, email or both before switching the report on.");
// Replace the recipient list: update kept rows, add new ones, remove the rest.
var existing = await _db.OwnerReportRecipients.ToListAsync(ct);
var keptIds = cleaned.Where(r => r.RecipientId is not null).Select(r => r.RecipientId!.Value).ToHashSet();
_db.OwnerReportRecipients.RemoveRange(existing.Where(e => !keptIds.Contains(e.RecipientId)));
foreach (var input in cleaned)
{
var row = input.RecipientId is int id ? existing.FirstOrDefault(e => e.RecipientId == id) : null;
if (input.RecipientId is not null && row is null)
throw Invalid($"Recipient {input.RecipientId} no longer exists — reload and try again.");
row ??= _db.OwnerReportRecipients.Add(new OwnerReportRecipient()).Entity;
row.Name = input.Name;
row.Mobile = input.Mobile;
row.Email = input.Email;
row.IsActive = input.IsActive;
}
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The owner report settings were changed by someone else — reload and try again.", 412);
}
return await GetAsync(ct);
}
// ---- Preview / test ---------------------------------------------------------------
public async Task<OwnerReportPreviewDto> PreviewAsync(DateOnly? date, CancellationToken ct = default)
{
var config = await LoadConfigAsync(asNoTracking: true, ct);
var content = await _content.BuildAsync(date ?? _clock.Today, config.IncludeBranchBreakdown, ct);
return new OwnerReportPreviewDto(content.Date, content.SmsText, content.EmailSubject, content.EmailHtml);
}
public async Task<IReadOnlyList<OwnerReportSendResultDto>> SendTestAsync(DateOnly? date, CancellationToken ct = default)
{
var config = await LoadConfigAsync(asNoTracking: true, ct);
if (!config.SmsEnabled && !config.EmailEnabled)
throw Invalid("Switch on SMS, email or both to send a test.");
var recipients = await _db.OwnerReportRecipients.AsNoTracking().Where(r => r.IsActive).ToListAsync(ct);
if (recipients.Count == 0)
throw Invalid("Add at least one active recipient and save before sending a test.");
var day = date ?? _clock.Today;
var content = await _content.BuildAsync(day, config.IncludeBranchBreakdown, ct);
// Tests are logged like real sends but flagged, with a unique key so they never collide
// with (or stand in for) the day's automatic send.
var batch = Guid.NewGuid().ToString("N");
var rows = Targets(config, recipients)
.Select(t => NewDispatch(day, t.Channel, t.Recipient, t.Destination, isTest: true, dedupeKey: batch))
.ToList();
_db.OwnerReportDispatches.AddRange(rows);
await _db.SaveChangesAsync(ct);
await SendAsync(rows, content, ct);
return rows.Select(r => new OwnerReportSendResultDto(
r.Channel.ToString(), r.RecipientName, r.Destination, r.Status == OwnerReportDispatchStatus.Sent, r.Error)).ToList();
}
public async Task<PagedResponse<OwnerReportDispatchDto>> ListDispatchesAsync(PageQuery query, CancellationToken ct = default)
{
var q = _db.OwnerReportDispatches.AsNoTracking();
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(d => d.DispatchId).Skip(query.Skip).Take(query.PageSize)
.Select(d => new OwnerReportDispatchDto(
d.DispatchId, d.BusinessDate, d.Channel.ToString(), d.RecipientName, d.Destination,
d.Status.ToString(), d.Attempts, d.Error, d.IsTest, d.CreatedAt, d.SentAt))
.ToListAsync(ct);
return PagedResponse<OwnerReportDispatchDto>.Create(rows, query.Page, query.PageSize, total);
}
// ---- Scheduled send ----------------------------------------------------------------
public async Task RunScheduledAsync(CancellationToken ct = default)
{
var config = await _db.OwnerReportConfig.AsNoTracking().FirstOrDefaultAsync(c => c.ConfigId == OwnerReportConfig.SingletonId, ct);
if (config is null || !config.Enabled || (!config.SmsEnabled && !config.EmailEnabled)) return;
var nowLocal = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _clock.Zone);
if (TimeOnly.FromDateTime(nowLocal) < config.SendAtLocalTime) return;
// Only today: a day missed while the server was down is sent once it is back, the same
// day — older days are never sent automatically (plan R8).
var today = DateOnly.FromDateTime(nowLocal);
await EnsureTodaysDispatchesAsync(config, today, ct);
var now = DateTime.UtcNow;
var due = await _db.OwnerReportDispatches
.Where(d => d.BusinessDate == today && !d.IsTest && d.Attempts < MaxAttempts
&& (d.Status == OwnerReportDispatchStatus.Pending || d.Status == OwnerReportDispatchStatus.Failed)
&& (d.NextAttemptAt == null || d.NextAttemptAt <= now))
.ToListAsync(ct);
if (due.Count == 0) return;
// Claim each row before sending (attempt counter as an optimistic lock), so a second
// server instance running the same tick skips it instead of sending it again.
var claimed = new List<OwnerReportDispatch>();
foreach (var d in due)
{
var updated = await _db.OwnerReportDispatches
.Where(x => x.DispatchId == d.DispatchId && x.Attempts == d.Attempts)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.NextAttemptAt, now.Add(RetryDelay)), ct);
if (updated == 1)
{
d.Attempts += 1;
d.NextAttemptAt = now.Add(RetryDelay);
claimed.Add(d);
}
}
if (claimed.Count == 0) return;
var content = await _content.BuildAsync(today, config.IncludeBranchBreakdown, ct);
await SendAsync(claimed, content, ct);
_logger.LogInformation("Owner report for {Date}: {Sent} sent, {Failed} failed.",
today, claimed.Count(d => d.Status == OwnerReportDispatchStatus.Sent), claimed.Count(d => d.Status == OwnerReportDispatchStatus.Failed));
}
/// <summary>Creates today's Pending row for each active recipient × enabled channel that doesn't have one yet.</summary>
private async Task EnsureTodaysDispatchesAsync(OwnerReportConfig config, DateOnly today, CancellationToken ct)
{
var recipients = await _db.OwnerReportRecipients.AsNoTracking().Where(r => r.IsActive).ToListAsync(ct);
var existing = (await _db.OwnerReportDispatches.AsNoTracking()
.Where(d => d.BusinessDate == today && !d.IsTest)
.Select(d => new { d.Channel, d.RecipientId })
.ToListAsync(ct))
.Select(d => (d.Channel, d.RecipientId))
.ToHashSet();
var missing = Targets(config, recipients)
.Where(t => !existing.Contains((t.Channel, (int?)t.Recipient.RecipientId)))
.Select(t => NewDispatch(today, t.Channel, t.Recipient, t.Destination, isTest: false, dedupeKey: string.Empty))
.ToList();
if (missing.Count == 0) return;
_db.OwnerReportDispatches.AddRange(missing);
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// Another instance created them first (unique index) — theirs stand.
_db.ChangeTracker.Clear();
}
}
// ---- Sending -----------------------------------------------------------------------
/// <summary>Sends <paramref name="rows"/> (grouped per channel) and records each outcome.</summary>
private async Task SendAsync(List<OwnerReportDispatch> rows, OwnerReportContent content, CancellationToken ct)
{
foreach (var group in rows.GroupBy(r => r.Channel))
{
var destinations = group.Select(r => r.Destination).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
IReadOnlyList<SendResult> results;
try
{
results = group.Key == OwnerReportChannel.Email
? await _sender.SendEmailAsync(destinations, content.EmailSubject, content.EmailHtml, content.EmailText, ct)
: await _sender.SendSmsAsync(destinations, content.SmsText, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
// The whole call failed (e.g. AuthHex unreachable) — every row of this channel failed.
_logger.LogWarning(ex, "Owner report {Channel} send failed.", group.Key);
results = destinations.Select(d => new SendResult(d, false, ex.Message)).ToList();
}
foreach (var row in group)
{
var result = results.FirstOrDefault(r => string.Equals(r.To, row.Destination, StringComparison.OrdinalIgnoreCase));
if (result is { Ok: true })
{
row.Status = OwnerReportDispatchStatus.Sent;
row.SentAt = DateTime.UtcNow;
row.Error = null;
row.NextAttemptAt = null;
}
else
{
row.Status = OwnerReportDispatchStatus.Failed;
row.Error = Truncate(result?.Error ?? "No result returned for this recipient.", 1000);
}
}
}
// Rows may be tracked (test) or detached copies (scheduled) — attach updates either way.
foreach (var row in rows)
{
var entry = _db.Entry(row);
if (entry.State == EntityState.Detached) _db.OwnerReportDispatches.Attach(row);
entry = _db.Entry(row);
entry.Property(r => r.Status).IsModified = true;
entry.Property(r => r.SentAt).IsModified = true;
entry.Property(r => r.Error).IsModified = true;
entry.Property(r => r.NextAttemptAt).IsModified = true;
}
await _db.SaveChangesAsync(ct);
}
private static IEnumerable<(OwnerReportChannel Channel, OwnerReportRecipient Recipient, string Destination)> Targets(
OwnerReportConfig config, IEnumerable<OwnerReportRecipient> recipients)
{
foreach (var r in recipients)
{
if (config.SmsEnabled && !string.IsNullOrWhiteSpace(r.Mobile)) yield return (OwnerReportChannel.Sms, r, r.Mobile!);
if (config.EmailEnabled && !string.IsNullOrWhiteSpace(r.Email)) yield return (OwnerReportChannel.Email, r, r.Email!);
}
}
private static OwnerReportDispatch NewDispatch(
DateOnly day, OwnerReportChannel channel, OwnerReportRecipient recipient, string destination, bool isTest, string dedupeKey)
=> new()
{
BusinessDate = day,
Channel = channel,
RecipientId = recipient.RecipientId,
RecipientName = recipient.Name,
Destination = destination,
Status = OwnerReportDispatchStatus.Pending,
Attempts = isTest ? 1 : 0,
IsTest = isTest,
DedupeKey = dedupeKey,
CreatedAt = DateTime.UtcNow,
};
// ---- Helpers ------------------------------------------------------------------------
private async Task<OwnerReportConfig> LoadConfigAsync(bool asNoTracking, CancellationToken ct)
{
var q = asNoTracking ? _db.OwnerReportConfig.AsNoTracking() : _db.OwnerReportConfig;
return await q.FirstOrDefaultAsync(c => c.ConfigId == OwnerReportConfig.SingletonId, ct)
?? throw new NotFoundException("Owner report settings have not been seeded.");
}
private OwnerReportConfigDto Map(OwnerReportConfig c, IEnumerable<OwnerReportRecipient> recipients) => new(
c.Enabled, c.SmsEnabled, c.EmailEnabled, c.SendAtLocalTime.ToString("HH:mm"), c.IncludeBranchBreakdown,
recipients.Select(r => new OwnerReportRecipientDto(r.RecipientId, r.Name, r.Mobile, r.Email, r.IsActive)).ToList(),
_clock.Zone.Id, c.UpdatedAt, c.UpdatedBy);
/// <summary>Trims, validates and normalises one recipient (mobile → +94XXXXXXXXX, whatever local/94/+94 form was typed).</summary>
private static OwnerReportRecipientInput CleanRecipient(OwnerReportRecipientInput input)
{
var name = input.Name.Trim();
var mobile = string.IsNullOrWhiteSpace(input.Mobile) ? null : input.Mobile.Trim();
var email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim();
if (name.Length == 0) throw Invalid("Every recipient needs a name.");
if (mobile is null && email is null) throw Invalid($"{name}: add a mobile number, an email or both.");
if (mobile is not null)
{
if (!SriLankanPhone.IsMatch(mobile)) throw Invalid($"{name}: {PhoneRules.Message}");
var digits = Regex.Replace(mobile, @"\D", "");
mobile = "+94" + (digits.StartsWith("94") ? digits[2..] : digits[1..]);
}
return new OwnerReportRecipientInput { RecipientId = input.RecipientId, Name = name, Mobile = mobile, Email = email?.ToLowerInvariant(), IsActive = input.IsActive };
}
private static DomainException Invalid(string message) => new(ErrorCodes.Validation, message, 422);
private static string Truncate(string value, int max) => value.Length <= max ? value : value[..max];
}