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,77 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.OwnerReport;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Day-end owner report settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5) — recipients, schedule,
/// preview, "send test now" and the send log. Gated on the Settings nav policy like the other
/// configuration screens (BranchConfigController, GlAccountConfigController).
/// </summary>
[Route("api/v1/settings/owner-report")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class OwnerReportController : ApiControllerBase
{
// One test send per minute (whole deployment) so the button can't be used to spam the owner.
private static readonly SemaphoreSlim TestGate = new(1, 1);
private static DateTime _lastTestUtc = DateTime.MinValue;
private readonly IOwnerReportService _service;
public OwnerReportController(IOwnerReportService service) => _service = service;
[HttpGet]
[ProducesResponseType(typeof(OwnerReportConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<OwnerReportConfigDto>> Get(CancellationToken ct)
{
var result = await _service.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(OwnerReportConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<OwnerReportConfigDto>> Update([FromBody] UpdateOwnerReportConfigRequest request, CancellationToken ct)
{
var result = await _service.UpdateAsync(request, RequireIfMatch(), ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("preview")]
[ProducesResponseType(typeof(OwnerReportPreviewDto), StatusCodes.Status200OK)]
public async Task<ActionResult<OwnerReportPreviewDto>> Preview([FromQuery] DateOnly? date, CancellationToken ct)
=> Ok(await _service.PreviewAsync(date, ct));
[HttpPost("test")]
[ProducesResponseType(typeof(IReadOnlyList<OwnerReportSendResultDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<ActionResult<IReadOnlyList<OwnerReportSendResultDto>>> SendTest([FromBody] OwnerReportTestRequest? request, CancellationToken ct)
{
await TestGate.WaitAsync(ct);
try
{
if (DateTime.UtcNow - _lastTestUtc < TimeSpan.FromMinutes(1))
return StatusCode(StatusCodes.Status429TooManyRequests,
new { title = "A test was sent less than a minute ago — wait a moment before sending another." });
_lastTestUtc = DateTime.UtcNow;
}
finally
{
TestGate.Release();
}
return Ok(await _service.SendTestAsync(request?.Date, ct));
}
[HttpGet("dispatches")]
[ProducesResponseType(typeof(PagedResponse<OwnerReportDispatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<OwnerReportDispatchDto>>> Dispatches([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _service.ListDispatchesAsync(query, ct));
}
@@ -0,0 +1,79 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Day-end owner report settings — a singleton row, same shape as <see cref="SalesConfig"/>
/// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.1). When <see cref="Enabled"/>, OwnerReportJob sends the
/// day's figures to every active <see cref="OwnerReportRecipient"/> at <see cref="SendAtLocalTime"/>.
/// </summary>
public class OwnerReportConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
/// <summary>Master switch — nothing is sent automatically while off ("Send test now" still works).</summary>
public bool Enabled { get; set; }
public bool SmsEnabled { get; set; } = true;
public bool EmailEnabled { get; set; } = true;
/// <summary>Local time of day (DayEndReport:TimeZone) after which the day's report goes out.</summary>
public TimeOnly SendAtLocalTime { get; set; } = new(21, 0);
/// <summary>Adds one summary row per active branch when branching is switched on.</summary>
public bool IncludeBranchBreakdown { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
public uint RowVersion { get; set; }
}
/// <summary>Someone who receives the day-end report — by SMS, email or both.</summary>
public class OwnerReportRecipient
{
public int RecipientId { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>Normalised to +94XXXXXXXXX (Sri Lankan rule, see PhoneRules).</summary>
public string? Mobile { get; set; }
public string? Email { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>
/// One send of one day's report on one channel to one recipient — the send log, and the guard
/// against sending twice: (BusinessDate, Channel, RecipientId, IsTest) is unique for automatic
/// sends, so a restart or a second server instance can't send the same day again.
/// </summary>
public class OwnerReportDispatch
{
public long DispatchId { get; set; }
public DateOnly BusinessDate { get; set; }
public OwnerReportChannel Channel { get; set; }
public int? RecipientId { get; set; }
public OwnerReportRecipient? Recipient { get; set; }
/// <summary>Number / email address at send time — kept even if the recipient is edited later.</summary>
public string Destination { get; set; } = string.Empty;
public string RecipientName { get; set; } = string.Empty;
public OwnerReportDispatchStatus Status { get; set; }
public int Attempts { get; set; }
public string? Error { get; set; }
/// <summary>Test sends ("Send test now") — never block or count as the day's automatic send.</summary>
public bool IsTest { get; set; }
/// <summary>For tests: a unique token so repeated tests don't collide on the unique index.</summary>
public string DedupeKey { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? SentAt { get; set; }
public DateTime? NextAttemptAt { get; set; }
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Enums;
/// <summary>How the day-end owner report is delivered (docs/DAY_END_OWNER_REPORT_PLAN.md).</summary>
public enum OwnerReportChannel
{
Sms,
Email,
}
/// <summary>State of one owner-report send.</summary>
public enum OwnerReportDispatchStatus
{
Pending,
Sent,
Failed,
}
@@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Day-end owner report settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5).</summary>
/// <param name="SendAtLocalTime">"HH:mm" in <paramref name="TimeZone"/>.</param>
public sealed record OwnerReportConfigDto(
bool Enabled, bool SmsEnabled, bool EmailEnabled, string SendAtLocalTime, bool IncludeBranchBreakdown,
IReadOnlyList<OwnerReportRecipientDto> Recipients, string TimeZone, DateTime? UpdatedAt, int? UpdatedBy);
public sealed record OwnerReportRecipientDto(int RecipientId, string Name, string? Mobile, string? Email, bool IsActive);
public sealed class UpdateOwnerReportConfigRequest
{
[Required] public bool? Enabled { get; set; }
[Required] public bool? SmsEnabled { get; set; }
[Required] public bool? EmailEnabled { get; set; }
[Required] public bool? IncludeBranchBreakdown { get; set; }
/// <summary>"HH:mm", 24-hour.</summary>
[Required, RegularExpression(@"^([01]\d|2[0-3]):[0-5]\d$", ErrorMessage = "Send time must be HH:mm (24-hour).")]
public string SendAtLocalTime { get; set; } = "21:00";
/// <summary>The full list — recipients not in it are removed. At most 5.</summary>
[Required, MaxLength(5, ErrorMessage = "At most 5 recipients.")]
public List<OwnerReportRecipientInput> Recipients { get; set; } = new();
}
public sealed class OwnerReportRecipientInput
{
/// <summary>Existing recipient to update; null adds a new one.</summary>
public int? RecipientId { get; set; }
[Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
[StringLength(20)] public string? Mobile { get; set; }
[StringLength(200), EmailAddress] public string? Email { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>The report as it would be sent for <paramref name="Date"/>.</summary>
public sealed record OwnerReportPreviewDto(DateOnly Date, string SmsText, string EmailSubject, string EmailHtml);
public sealed class OwnerReportTestRequest
{
/// <summary>Day to report; null = today in the report's time zone.</summary>
public DateOnly? Date { get; set; }
}
public sealed record OwnerReportSendResultDto(string Channel, string RecipientName, string Destination, bool Ok, string? Error);
public sealed record OwnerReportDispatchDto(
long DispatchId, DateOnly BusinessDate, string Channel, string RecipientName, string Destination,
string Status, int Attempts, string? Error, bool IsTest, DateTime CreatedAt, DateTime? SentAt);
@@ -11,11 +11,11 @@ public sealed record WarehouseDto(int WarehouseId, string Code, string Name, str
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType); public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
/// <summary>Sri Lankan phone rule for master data — mirrors the frontend's `lib/validations/phone.ts`: /// <summary>Sri Lankan phone rule for master data — mirrors the frontend's `lib/validations/phone.ts`:
/// 10 digits starting with 0, or +94 followed by 9 digits; spaces and hyphens allowed between digits.</summary> /// 10 digits starting with 0, or 94 followed by 9 digits with or without a leading +; spaces and hyphens allowed between digits.</summary>
public static class PhoneRules public static class PhoneRules
{ {
public const string Pattern = @"^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$"; public const string Pattern = @"^(?:0(?:[\s-]*\d){9}|\+?94(?:[\s-]*\d){9})[\s-]*$";
public const string Message = "Enter a valid Sri Lankan phone number: 10 digits starting with 0 (0771234567) or +94 followed by 9 digits (+94771234567)."; public const string Message = "Enter a valid Sri Lankan phone number: 10 digits starting with 0 (0771234567), or 94 followed by 9 digits (94771234567 or +94771234567).";
} }
public sealed class CreateWarehouseRequest public sealed class CreateWarehouseRequest
@@ -154,6 +154,67 @@ public sealed class AuthHexClient : IAuthHexClient
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct) public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct); => CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
// ---- Notify -------------------------------------------------------------
public Task<IReadOnlyList<AuthHexNotifyResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
=> NotifyAsync("email", new { to, subject, htmlBody, textBody }, ct);
public Task<IReadOnlyList<AuthHexNotifyResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
=> NotifyAsync("sms", new { to, text }, ct);
/// <summary>
/// /api/notify/* is plain JSON (<c>{ results: [{ to, ok, error }] }</c>), not the function
/// envelope the manager routes use, so it bypasses <see cref="CallAsync{T}"/>.
/// </summary>
private async Task<IReadOnlyList<AuthHexNotifyResult>> NotifyAsync(string channel, object body, CancellationToken ct)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/notify/{channel}");
foreach (var (key, value) in AdminHeaders())
httpRequest.Headers.Add(key, value);
httpRequest.Content = JsonContent.Create(body, options: JsonOptions);
HttpResponseMessage httpResponse;
try
{
httpResponse = await _http.SendAsync(httpRequest, ct);
}
catch (HttpRequestException)
{
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service is unreachable.", 503);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service timed out.", 503);
}
NotifyResponse? payload = null;
try
{
payload = await httpResponse.Content.ReadFromJsonAsync<NotifyResponse>(JsonOptions, ct);
}
catch (JsonException)
{
// Fall through — reported below with the HTTP status.
}
if (!httpResponse.IsSuccessStatusCode || payload?.Results is null)
{
var message = payload?.Message
?? (httpResponse.StatusCode == global::System.Net.HttpStatusCode.NotFound
? "The authentication service does not support sending messages yet (no /api/notify)."
: $"The authentication service could not send the {channel} (HTTP {(int)httpResponse.StatusCode}).");
throw new DomainException(ErrorCodes.AuthUpstreamError, message, 502);
}
return payload.Results;
}
private sealed class NotifyResponse
{
public List<AuthHexNotifyResult>? Results { get; set; }
public string? Message { get; set; }
}
// ---- Transport -------------------------------------------------------- // ---- Transport --------------------------------------------------------
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct) private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
@@ -57,4 +57,12 @@ public interface IAuthHexClient
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct); Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct); Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct); Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
// Notify (POST /api/notify/email|sms) — service-to-service messaging through AuthHex's own
// SMTP / SMS gateway settings, gated by the admin API key (docs/DAY_END_OWNER_REPORT_PLAN.md §3.2).
Task<IReadOnlyList<AuthHexNotifyResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct);
Task<IReadOnlyList<AuthHexNotifyResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct);
} }
/// <summary>Per-recipient outcome of an AuthHex notify call.</summary>
public sealed record AuthHexNotifyResult(string To, bool Ok, string? Error);
@@ -0,0 +1,55 @@
using ERPCore.Infra.Auth.AuthHex;
namespace ERPCore.Infra.Messaging;
/// <summary>Per-recipient outcome of a send.</summary>
public sealed record SendResult(string To, bool Ok, string? Error);
/// <summary>
/// Sends email and SMS. ERPCore holds no SMTP or SMS credentials — the real implementation asks
/// AuthHex to send with its own settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.2).
/// Implementations report per-recipient failures in the result and only throw when the whole
/// call failed (e.g. AuthHex unreachable).
/// </summary>
public interface IMessageSender
{
Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct);
Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct);
}
/// <summary>Default: sends through AuthHex's /api/notify endpoints (Messaging:Provider = AuthHex).</summary>
public sealed class AuthHexMessageSender : IMessageSender
{
private readonly IAuthHexClient _authHex;
public AuthHexMessageSender(IAuthHexClient authHex) => _authHex = authHex;
public async Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
=> (await _authHex.SendEmailAsync(to, subject, htmlBody, textBody, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
public async Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
=> (await _authHex.SendSmsAsync(to, text, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
}
/// <summary>
/// Writes messages to the log instead of sending (Messaging:Provider = Log) — for local
/// development and tests, so nothing reaches real phones or inboxes.
/// </summary>
public sealed class LogOnlyMessageSender : IMessageSender
{
private readonly ILogger<LogOnlyMessageSender> _logger;
public LogOnlyMessageSender(ILogger<LogOnlyMessageSender> logger) => _logger = logger;
public Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
{
_logger.LogInformation("[LogOnly] Email to {To}: {Subject}\n{Body}", string.Join(", ", to), subject, textBody ?? htmlBody);
return Task.FromResult<IReadOnlyList<SendResult>>(to.Select(t => new SendResult(t, true, null)).ToList());
}
public Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
{
_logger.LogInformation("[LogOnly] SMS to {To}:\n{Text}", string.Join(", ", to), text);
return Task.FromResult<IReadOnlyList<SendResult>>(to.Select(t => new SendResult(t, true, null)).ToList());
}
}
@@ -0,0 +1,75 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Singleton owner-report settings row (mirrors SalesConfigConfiguration).</summary>
public sealed class OwnerReportConfigConfiguration : IEntityTypeConfiguration<OwnerReportConfig>
{
public void Configure(EntityTypeBuilder<OwnerReportConfig> builder)
{
builder.ToTable("owner_report_config", t =>
t.HasCheckConstraint("ck_owner_report_config_singleton", $"`ConfigId` = {OwnerReportConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
builder.Property(c => c.ConfigId).ValueGeneratedNever();
builder.Property(c => c.Enabled).IsRequired().HasDefaultValue(false);
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert.
builder.Property(c => c.SmsEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.EmailEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.IncludeBranchBreakdown).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.SendAtLocalTime).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class OwnerReportRecipientConfiguration : IEntityTypeConfiguration<OwnerReportRecipient>
{
public void Configure(EntityTypeBuilder<OwnerReportRecipient> builder)
{
builder.ToTable("owner_report_recipients");
builder.HasKey(r => r.RecipientId);
builder.Property(r => r.Name).HasMaxLength(100).IsRequired();
builder.Property(r => r.Mobile).HasMaxLength(20);
builder.Property(r => r.Email).HasMaxLength(200);
builder.Property(r => r.IsActive).IsRequired().HasDefaultValue(true).HasSentinel(true);
}
}
public sealed class OwnerReportDispatchConfiguration : IEntityTypeConfiguration<OwnerReportDispatch>
{
public void Configure(EntityTypeBuilder<OwnerReportDispatch> builder)
{
builder.ToTable("owner_report_dispatches");
builder.HasKey(d => d.DispatchId);
builder.Property(d => d.Channel).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(d => d.Status).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(d => d.Destination).HasMaxLength(200).IsRequired();
builder.Property(d => d.RecipientName).HasMaxLength(100).IsRequired();
builder.Property(d => d.Error).HasMaxLength(1000);
builder.Property(d => d.DedupeKey).HasMaxLength(40).IsRequired();
// The "send once" guard: one automatic send per day, channel and recipient. Test sends get
// a unique DedupeKey so any number of them can coexist.
builder.HasIndex(d => new { d.BusinessDate, d.Channel, d.RecipientId, d.IsTest, d.DedupeKey })
.IsUnique()
.HasDatabaseName("ux_owner_report_dispatch_once");
builder.HasIndex(d => new { d.Status, d.NextAttemptAt });
builder.HasIndex(d => d.CreatedAt);
// Recipients can be removed; their past sends stay in the log (Destination/RecipientName keep the detail).
builder.HasOne(d => d.Recipient)
.WithMany()
.HasForeignKey(d => d.RecipientId)
.OnDelete(DeleteBehavior.SetNull);
}
}
@@ -64,6 +64,7 @@ public static class DataSeeder
dirty |= await SeedProcurementConfigAsync(db, ct); dirty |= await SeedProcurementConfigAsync(db, ct);
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct); dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
dirty |= await SeedNotificationConfigAsync(db, ct); dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedOwnerReportConfigAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct); if (dirty) await db.SaveChangesAsync(ct);
@@ -309,6 +310,15 @@ public static class DataSeeder
}); });
return true; return true;
} }
// Off by default: the owner report only starts sending once someone adds recipients and switches it on.
private static async Task<bool> SeedOwnerReportConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.OwnerReportConfig.AnyAsync(c => c.ConfigId == OwnerReportConfig.SingletonId, ct)) return false;
db.OwnerReportConfig.Add(new OwnerReportConfig { ConfigId = OwnerReportConfig.SingletonId, Enabled = false });
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct) private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{ {
@@ -55,6 +55,9 @@ public class ErpDbContext : DbContext
public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>(); public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary> /// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>(); public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<OwnerReportConfig> OwnerReportConfig => Set<OwnerReportConfig>();
public DbSet<OwnerReportRecipient> OwnerReportRecipients => Set<OwnerReportRecipient>();
public DbSet<OwnerReportDispatch> OwnerReportDispatches => Set<OwnerReportDispatch>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>(); public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
public DbSet<Notification> Notifications => Set<Notification>(); public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>(); public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>();
@@ -17,8 +17,8 @@ namespace ERPCore.Migrations
/// <remarks> /// <remarks>
/// Written by hand rather than scaffolded, and the drop/add is emitted as raw SQL instead of /// Written by hand rather than scaffolded, and the drop/add is emitted as raw SQL instead of
/// <c>DropCheckConstraint</c>: MariaDB spells the removal <c>DROP CONSTRAINT</c> (MySQL's /// <c>DropCheckConstraint</c>: MariaDB spells the removal <c>DROP CONSTRAINT</c> (MySQL's
/// <c>DROP CHECK</c> is not a safe assumption here), and <c>IF EXISTS</c> keeps this /// <c>DROP CHECK</c> is not a safe assumption here). It is a plain drop, not <c>IF EXISTS</c>:
/// re-runnable on a database where the broken constraint was already removed by hand. /// that form is MariaDB-only and a syntax error on MySQL 8, which broke fresh local databases.
/// </remarks> /// </remarks>
/// <inheritdoc /> /// <inheritdoc />
public partial class FixCheckConstraintQuotingForMySql : Migration public partial class FixCheckConstraintQuotingForMySql : Migration
@@ -26,13 +26,13 @@ namespace ERPCore.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.Sql("ALTER TABLE `grn_config` DROP CONSTRAINT IF EXISTS `ck_grn_config_singleton`;"); DropCheck(migrationBuilder, "grn_config", "ck_grn_config_singleton");
migrationBuilder.Sql("ALTER TABLE `grn_config` ADD CONSTRAINT `ck_grn_config_singleton` CHECK (`ConfigId` = 1);"); migrationBuilder.Sql("ALTER TABLE `grn_config` ADD CONSTRAINT `ck_grn_config_singleton` CHECK (`ConfigId` = 1);");
migrationBuilder.Sql("ALTER TABLE `sales_config` DROP CONSTRAINT IF EXISTS `ck_sales_config_singleton`;"); DropCheck(migrationBuilder, "sales_config", "ck_sales_config_singleton");
migrationBuilder.Sql("ALTER TABLE `sales_config` ADD CONSTRAINT `ck_sales_config_singleton` CHECK (`ConfigId` = 1);"); migrationBuilder.Sql("ALTER TABLE `sales_config` ADD CONSTRAINT `ck_sales_config_singleton` CHECK (`ConfigId` = 1);");
migrationBuilder.Sql("ALTER TABLE `warranty_periods` DROP CONSTRAINT IF EXISTS `ck_warranty_periods_months`;"); DropCheck(migrationBuilder, "warranty_periods", "ck_warranty_periods_months");
migrationBuilder.Sql("ALTER TABLE `warranty_periods` ADD CONSTRAINT `ck_warranty_periods_months` CHECK (`Months` BETWEEN 1 AND 600);"); migrationBuilder.Sql("ALTER TABLE `warranty_periods` ADD CONSTRAINT `ck_warranty_periods_months` CHECK (`Months` BETWEEN 1 AND 600);");
} }
@@ -40,14 +40,23 @@ namespace ERPCore.Migrations
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
// Restores the original (broken) expressions so the migration is a faithful inverse. // Restores the original (broken) expressions so the migration is a faithful inverse.
migrationBuilder.Sql("ALTER TABLE `grn_config` DROP CONSTRAINT IF EXISTS `ck_grn_config_singleton`;"); DropCheck(migrationBuilder, "grn_config", "ck_grn_config_singleton");
migrationBuilder.Sql("ALTER TABLE `grn_config` ADD CONSTRAINT `ck_grn_config_singleton` CHECK (\"ConfigId\" = 1);"); migrationBuilder.Sql("ALTER TABLE `grn_config` ADD CONSTRAINT `ck_grn_config_singleton` CHECK (\"ConfigId\" = 1);");
migrationBuilder.Sql("ALTER TABLE `sales_config` DROP CONSTRAINT IF EXISTS `ck_sales_config_singleton`;"); DropCheck(migrationBuilder, "sales_config", "ck_sales_config_singleton");
migrationBuilder.Sql("ALTER TABLE `sales_config` ADD CONSTRAINT `ck_sales_config_singleton` CHECK (\"ConfigId\" = 1);"); migrationBuilder.Sql("ALTER TABLE `sales_config` ADD CONSTRAINT `ck_sales_config_singleton` CHECK (\"ConfigId\" = 1);");
migrationBuilder.Sql("ALTER TABLE `warranty_periods` DROP CONSTRAINT IF EXISTS `ck_warranty_periods_months`;"); DropCheck(migrationBuilder, "warranty_periods", "ck_warranty_periods_months");
migrationBuilder.Sql("ALTER TABLE `warranty_periods` ADD CONSTRAINT `ck_warranty_periods_months` CHECK (\"Months\" BETWEEN 1 AND 600);"); migrationBuilder.Sql("ALTER TABLE `warranty_periods` ADD CONSTRAINT `ck_warranty_periods_months` CHECK (\"Months\" BETWEEN 1 AND 600);");
} }
/// <summary>
/// Plain <c>DROP CONSTRAINT</c> — valid on MariaDB and on MySQL 8.0.19+. Deliberately not
/// <c>DROP CONSTRAINT IF EXISTS</c>, which is MariaDB-only and is a syntax error on MySQL 8,
/// breaking fresh local databases. The constraints always exist at this point: the earlier
/// migrations create them.
/// </summary>
private static void DropCheck(MigrationBuilder migrationBuilder, string table, string constraint)
=> migrationBuilder.Sql($"ALTER TABLE `{table}` DROP CONSTRAINT `{constraint}`;");
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddOwnerReport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "owner_report_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "int", nullable: false),
Enabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false),
SmsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: true),
EmailEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: true),
SendAtLocalTime = table.Column<TimeOnly>(type: "time(6)", nullable: false),
IncludeBranchBreakdown = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: true),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
UpdatedBy = table.Column<int>(type: "int", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_owner_report_config", x => x.ConfigId);
table.CheckConstraint("ck_owner_report_config_singleton", "`ConfigId` = 1");
table.ForeignKey(
name: "FK_owner_report_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "owner_report_recipients",
columns: table => new
{
RecipientId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
Mobile = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
Email = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true, collation: "utf8mb4_unicode_ci"),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: true)
},
constraints: table =>
{
table.PrimaryKey("PK_owner_report_recipients", x => x.RecipientId);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "owner_report_dispatches",
columns: table => new
{
DispatchId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
BusinessDate = table.Column<DateOnly>(type: "date", nullable: false),
Channel = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false, collation: "utf8mb4_unicode_ci"),
RecipientId = table.Column<int>(type: "int", nullable: true),
Destination = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false, collation: "utf8mb4_unicode_ci"),
RecipientName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
Status = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false, collation: "utf8mb4_unicode_ci"),
Attempts = table.Column<int>(type: "int", nullable: false),
Error = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true, collation: "utf8mb4_unicode_ci"),
IsTest = table.Column<bool>(type: "tinyint(1)", nullable: false),
DedupeKey = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false, collation: "utf8mb4_unicode_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
SentAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
NextAttemptAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_owner_report_dispatches", x => x.DispatchId);
table.ForeignKey(
name: "FK_owner_report_dispatches_owner_report_recipients_RecipientId",
column: x => x.RecipientId,
principalTable: "owner_report_recipients",
principalColumn: "RecipientId",
onDelete: ReferentialAction.SetNull);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateIndex(
name: "IX_owner_report_config_UpdatedBy",
table: "owner_report_config",
column: "UpdatedBy");
migrationBuilder.CreateIndex(
name: "IX_owner_report_dispatches_CreatedAt",
table: "owner_report_dispatches",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_owner_report_dispatches_RecipientId",
table: "owner_report_dispatches",
column: "RecipientId");
migrationBuilder.CreateIndex(
name: "IX_owner_report_dispatches_Status_NextAttemptAt",
table: "owner_report_dispatches",
columns: new[] { "Status", "NextAttemptAt" });
migrationBuilder.CreateIndex(
name: "ux_owner_report_dispatch_once",
table: "owner_report_dispatches",
columns: new[] { "BusinessDate", "Channel", "RecipientId", "IsTest", "DedupeKey" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "owner_report_config");
migrationBuilder.DropTable(
name: "owner_report_dispatches");
migrationBuilder.DropTable(
name: "owner_report_recipients");
}
}
}
@@ -3733,6 +3733,158 @@ namespace ERPCore.Migrations
b.ToTable("opening_stock_lines", (string)null); b.ToTable("opening_stock_lines", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("int");
b.Property<bool>("EmailEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(false);
b.Property<bool>("IncludeBranchBreakdown")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<TimeOnly>("SendAtLocalTime")
.HasColumnType("time(6)");
b.Property<bool>("SmsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("UpdatedBy")
.HasColumnType("int");
b.HasKey("ConfigId");
b.HasIndex("UpdatedBy");
b.ToTable("owner_report_config", null, t =>
{
t.HasCheckConstraint("ck_owner_report_config_singleton", "`ConfigId` = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportDispatch", b =>
{
b.Property<long>("DispatchId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("DispatchId"));
b.Property<int>("Attempts")
.HasColumnType("int");
b.Property<DateOnly>("BusinessDate")
.HasColumnType("date");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DedupeKey")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Destination")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Error")
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<bool>("IsTest")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("NextAttemptAt")
.HasColumnType("datetime(6)");
b.Property<int?>("RecipientId")
.HasColumnType("int");
b.Property<string>("RecipientName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime?>("SentAt")
.HasColumnType("datetime(6)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.HasKey("DispatchId");
b.HasIndex("CreatedAt");
b.HasIndex("RecipientId");
b.HasIndex("Status", "NextAttemptAt");
b.HasIndex("BusinessDate", "Channel", "RecipientId", "IsTest", "DedupeKey")
.IsUnique()
.HasDatabaseName("ux_owner_report_dispatch_once");
b.ToTable("owner_report_dispatches", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportRecipient", b =>
{
b.Property<int>("RecipientId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("RecipientId"));
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<string>("Mobile")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("RecipientId");
b.ToTable("owner_report_recipients", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{ {
b.Property<int>("PayrollLineId") b.Property<int>("PayrollLineId")
@@ -9631,6 +9783,26 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse"); b.Navigation("Warehouse");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.OwnerReportDispatch", b =>
{
b.HasOne("ERPCore.Domain.Entities.OwnerReportRecipient", "Recipient")
.WithMany()
.HasForeignKey("RecipientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Recipient");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") b.HasOne("ERPCore.Domain.Entities.Employee", "Employee")
+19 -1
View File
@@ -50,9 +50,17 @@ if (string.IsNullOrWhiteSpace(connectionString))
"ConnectionStrings:DefaultConnection is not configured. Set it in appsettings.<Environment>.json " "ConnectionStrings:DefaultConnection is not configured. Set it in appsettings.<Environment>.json "
+ "or as the ConnectionStrings__DefaultConnection environment variable."); + "or as the ConnectionStrings__DefaultConnection environment variable.");
// Optional override for a different local server, e.g. "8.0.44-mysql" in user-secrets
// (Database:ServerVersion) — Pomelo otherwise emits MariaDB-only SQL (INSERT … RETURNING) that
// MySQL rejects. Unset everywhere else, so the MariaDB 10.11 default above still applies.
var serverVersionOverride = builder.Configuration["Database:ServerVersion"];
ServerVersion serverVersion = string.IsNullOrWhiteSpace(serverVersionOverride)
? new MariaDbServerVersion(new Version(10, 11, 15))
: ServerVersion.Parse(serverVersionOverride);
builder.Services.AddDbContext<ErpDbContext>(o => builder.Services.AddDbContext<ErpDbContext>(o =>
{ {
o.UseMySql(connectionString, new MariaDbServerVersion(new Version(10, 11, 15))); o.UseMySql(connectionString, serverVersion);
}); });
// ProblemDetails (RFC 7807) + domain-exception mapping // ProblemDetails (RFC 7807) + domain-exception mapping
@@ -126,6 +134,16 @@ builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>(); builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
builder.Services.AddScoped<IProcurementConfigService, ProcurementConfigService>(); builder.Services.AddScoped<IProcurementConfigService, ProcurementConfigService>();
builder.Services.AddScoped<IAccessConfigService, AccessConfigService>(); builder.Services.AddScoped<IAccessConfigService, AccessConfigService>();
// Day-end owner report (docs/DAY_END_OWNER_REPORT_PLAN.md). Messages go through AuthHex, which
// holds the SMTP / SMS credentials; Messaging:Provider = "Log" writes them to the log instead.
builder.Services.AddScoped<ERPCore.Services.OwnerReport.OwnerReportContentBuilder>();
builder.Services.AddScoped<ERPCore.Services.OwnerReport.IOwnerReportService, ERPCore.Services.OwnerReport.OwnerReportService>();
if (string.Equals(builder.Configuration["Messaging:Provider"], "Log", StringComparison.OrdinalIgnoreCase))
builder.Services.AddScoped<ERPCore.Infra.Messaging.IMessageSender, ERPCore.Infra.Messaging.LogOnlyMessageSender>();
else
builder.Services.AddScoped<ERPCore.Infra.Messaging.IMessageSender, ERPCore.Infra.Messaging.AuthHexMessageSender>();
builder.Services.AddHostedService<ERPCore.Services.OwnerReport.OwnerReportJob>();
builder.Services.AddScoped<IOpeningBalanceService, OpeningBalanceService>(); builder.Services.AddScoped<IOpeningBalanceService, OpeningBalanceService>();
builder.Services.AddScoped<IOpeningStockService, OpeningStockService>(); builder.Services.AddScoped<IOpeningStockService, OpeningStockService>();
builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>(); builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>();
@@ -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];
}
+9
View File
@@ -661,3 +661,12 @@ Spec: `docs/30-BACKEND-PHASE2.md` (model + rules **and** API — one doc, unlike
- **Fix in code:** `GlAccountRole.AccruedExpenses` + `gl_account_config.AccruedExpensesCode` (migration `AddAccruedExpensesGlRole`, **not applied**, fills existing config with `2300`; fresh databases seed it from the old appsettings key). `ExpenseService` / `ExpensePaymentService` read the mapped role, so it is edited, validated (must exist and be postable) and checked at startup like every other GL role, on Settings -> GL Accounts. - **Fix in code:** `GlAccountRole.AccruedExpenses` + `gl_account_config.AccruedExpensesCode` (migration `AddAccruedExpensesGlRole`, **not applied**, fills existing config with `2300`; fresh databases seed it from the old appsettings key). `ExpenseService` / `ExpensePaymentService` read the mapped role, so it is edited, validated (must exist and be postable) and checked at startup like every other GL role, on Settings -> GL Accounts.
- **GL data (done through the GL's own API, not SQL):** created `5200 Operating Expenses` (header) with `5200-01` Utility, `5200-02` Rental, `5200-03` Salaries and Wages, `5200-04` Other Expenses, and `2300 Accrued Expenses`. - **GL data (done through the GL's own API, not SQL):** created `5200 Operating Expenses` (header) with `5200-01` Utility, `5200-02` Rental, `5200-03` Salaries and Wages, `5200-04` Other Expenses, and `2300 Accrued Expenses`.
- **Still true for a brand-new GL:** the chart of accounts itself is created in the GL service, not in the ERP; the ERP only maps roles to existing accounts and now tells you which are missing. - **Still true for a brand-new GL:** the chart of accounts itself is created in the GL service, not in the ERP; the ERP only maps roles to existing accounts and now tells you which are missing.
### 2026-10-19 — Day-end owner report (SMS + email to the owner)
- **What:** every evening (default 21:00, `DayEndReport:TimeZone`) the day's figures from `DayEndReportService.GetDayAsync` go to the owner — short SMS + full HTML email (summary, cash, per-branch, top 10 sales, expenses). Plan: `docs/DAY_END_OWNER_REPORT_PLAN.md`.
- **Data:** `owner_report_config` (singleton, seeded off), `owner_report_recipients` (≤ 5; mobile normalised to `+94XXXXXXXXX`, Sri Lankan `PhoneRules`), `owner_report_dispatches` (send log + "send once" guard: unique `(BusinessDate, Channel, RecipientId, IsTest, DedupeKey)`). Migration `AddOwnerReport` (additive; generated in MariaDB mode).
- **Sending:** `IMessageSender` → `AuthHexMessageSender` → AuthHex `POST /api/notify/email|sms` (new, admin-key gated) — **SMTP/SMS credentials stay in AuthHex**. `Messaging:Provider = Log` swaps in `LogOnlyMessageSender`. Optional `OwnerReport:AppUrl` adds a link back to the Day-End Report.
- **Schedule:** `OwnerReportJob` (`BackgroundService`, 1-min tick) → `OwnerReportService.RunScheduledAsync`: creates today's rows once the time passes, claims each row by bumping `Attempts` (multi-instance safe), retries failures up to 3× every 5 min; only today is ever sent automatically.
- **API:** `GET/PUT /api/v1/settings/owner-report` (ETag), `GET .../preview?date`, `POST .../test` (1/min), `GET .../dispatches` — `settings` nav policy, like the other config screens.
- **AuthHex (`ERP_Auth_Service`):** `NotifyController`, `ThirdPartyService.SendCustomEmailAsync` / `SendTextSmsAsync` (OTP methods untouched), `SmtpSettings:SenderName` → "Hexa ERP".
- **Verified locally (MySQL 8, `Messaging:Provider=Log`):** migration + seed applied; with a test recipient and send time 00:00 the job sent 1 SMS + 1 email, and later ticks sent nothing more. Real AuthHex delivery **not yet tested** (AuthHex must be redeployed with `/api/notify`).
+5
View File
@@ -407,3 +407,8 @@ Spec: `docs/21-FRONTEND-PHASE2.md` (flows/screens) · contract: `docs/30-BACKEND
5. **Inline "add variant category":** a "+" icon button next to the checkboxes opens an inline name field that calls `variantCategoriesApi.create()` directly from `/new`, appends the result to the in-memory list, and auto-checks it — so a brand-new dimension (e.g. "Material") can be added without leaving the Item form, and it also then appears on `/dashboard/products/variants`. 5. **Inline "add variant category":** a "+" icon button next to the checkboxes opens an inline name field that calls `variantCategoriesApi.create()` directly from `/new`, appends the result to the in-memory list, and auto-checks it — so a brand-new dimension (e.g. "Material") can be added without leaving the Item form, and it also then appears on `/dashboard/products/variants`.
6. **Color gets a real color picker:** for whichever checked category is literally named "Color" (case-insensitive), the free-text input is replaced with a native `<input type="color">` swatch picker *plus* a required "Color name" text field — picking red alone isn't enough, a name is mandatory too. The pair is encoded as a single string `"<name>|<hex>"` in `valuesByCategory` (helpers `encodeColorValue`/`decodeColorValue`/`partLabel` in `new/page.tsx`) so the existing generic value-list plumbing didn't need a parallel data shape; every place that displays or SKU-generates from a color value decodes it back to just the name (the hex only ever drives the swatch dot next to chips and table cells) — so SKUs read `HAR-CRI` (from "Crimson"), never `HAR-EF4`. 6. **Color gets a real color picker:** for whichever checked category is literally named "Color" (case-insensitive), the free-text input is replaced with a native `<input type="color">` swatch picker *plus* a required "Color name" text field — picking red alone isn't enough, a name is mandatory too. The pair is encoded as a single string `"<name>|<hex>"` in `valuesByCategory` (helpers `encodeColorValue`/`decodeColorValue`/`partLabel` in `new/page.tsx`) so the existing generic value-list plumbing didn't need a parallel data shape; every place that displays or SKU-generates from a color value decodes it back to just the name (the hex only ever drives the swatch dot next to chips and table cells) — so SKUs read `HAR-CRI` (from "Crimson"), never `HAR-EF4`.
- Verified: `tsc --noEmit` clean after every step (same pre-existing `login/page.tsx` error only, confirmed unchanged throughout). Each change was driven end-to-end through a headless Playwright session against the dev server and screenshotted — field removal, subcategory always-visible + disabled state, checkbox show/hide of category builders, cartesian flat table with 2+ active categories, inline category creation followed by its builder appearing immediately, and the color picker + name → chip swatch → table swatch → final SKU/item name chain — with `console --errors` clean at every step and at least one full create-and-redirect-to-Items-list confirmed per major change. - Verified: `tsc --noEmit` clean after every step (same pre-existing `login/page.tsx` error only, confirmed unchanged throughout). Each change was driven end-to-end through a headless Playwright session against the dev server and screenshotted — field removal, subcategory always-visible + disabled state, checkbox show/hide of category builders, cartesian flat table with 2+ active categories, inline category creation followed by its builder appearing immediately, and the color picker + name → chip swatch → table swatch → final SKU/item name chain — with `console --errors` clean at every step and at least one full create-and-redirect-to-Items-list confirmed per major change.
### 2026-10-19 — Settings → Configuration → Day-End Owner Report
- New page `app/dashboard/settings/owner-report` (`lib/api/owner-report.ts`, `types/owner-report.ts`), card on Settings → Configuration, header + breadcrumb titles.
- Sections: Delivery (master switch, SMS, Email, branch breakdown, send time), Recipients (≤ 5; name / mobile with the Sri Lankan phone rule + input limiter / email; active switch), Preview (SMS bubble + sandboxed email iframe for any day), Send test now (confirm dialog; blocked while there are unsaved changes), Send log (cards on phones, table on desktop, pagination). Draft + sticky Save/Discard bar; every validation error also toasts.
- `tsc` + `eslint` clean for the new files; not yet clicked through in a browser.
@@ -1,5 +1,5 @@
import Link from "next/link" import Link from "next/link"
import { BookOpen, Building2, ClipboardList, LayoutTemplate, MapPin, PackageCheck, ShieldCheck, ShoppingCart, SlidersHorizontal } from "lucide-react" import { BookOpen, Building2, ClipboardList, LayoutTemplate, MapPin, PackageCheck, Send, ShieldCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
const cards = [ const cards = [
{ {
@@ -56,6 +56,12 @@ const cards = [
icon: LayoutTemplate, icon: LayoutTemplate,
description: "Logo, footer, terms, and template style for printed invoices", description: "Logo, footer, terms, and template style for printed invoices",
}, },
{
title: "Day-End Owner Report",
href: "/dashboard/settings/owner-report",
icon: Send,
description: "Send the day's revenue to the owner by SMS and email",
},
] ]
export default function ConfigurationPage() { export default function ConfigurationPage() {
@@ -0,0 +1,524 @@
"use client"
import { useEffect, useState } from "react"
import { Clock, Eye, Mail, MessageSquare, Plus, Send, Trash2, Users } from "lucide-react"
import { ownerReportApi } from "@/lib/api/owner-report"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { limitPhoneInput, phoneError } from "@/lib/validations/phone"
import { PaginationMeta } from "@/types/common"
import { OwnerReportConfig, OwnerReportDispatch, OwnerReportPreview, OwnerReportRecipientInput } from "@/types/owner-report"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const MAX_RECIPIENTS = 5
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const PANEL = "flex flex-col gap-4 rounded-2xl border bg-card p-4 shadow-sm sm:p-5"
/** Editable copy of the settings — saved together with one PUT (the recipients are a list). */
type Draft = {
enabled: boolean
smsEnabled: boolean
emailEnabled: boolean
sendAtLocalTime: string
includeBranchBreakdown: boolean
recipients: (OwnerReportRecipientInput & { key: number })[]
}
function toDraft(c: OwnerReportConfig): Draft {
return {
enabled: c.enabled,
smsEnabled: c.smsEnabled,
emailEnabled: c.emailEnabled,
sendAtLocalTime: c.sendAtLocalTime,
includeBranchBreakdown: c.includeBranchBreakdown,
recipients: c.recipients.map((r) => ({ ...r, key: r.recipientId })),
}
}
function localToday(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Per-recipient field errors, keyed `${key}-name|mobile|email`. */
function validate(draft: Draft): Record<string, string> {
const errors: Record<string, string> = {}
for (const r of draft.recipients) {
if (!r.name.trim()) errors[`${r.key}-name`] = "Enter a name"
const mobile = r.mobile?.trim() ?? ""
const email = r.email?.trim() ?? ""
if (!mobile && !email) errors[`${r.key}-mobile`] = "Add a mobile number, an email or both"
const pe = mobile ? phoneError(mobile) : null
if (pe) errors[`${r.key}-mobile`] = pe
if (email && !EMAIL_PATTERN.test(email)) errors[`${r.key}-email`] = "Enter a valid email address"
}
if (draft.enabled && !draft.recipients.some((r) => r.isActive)) errors.recipients = "Add at least one active recipient before switching the report on"
if (draft.enabled && !draft.smsEnabled && !draft.emailEnabled) errors.channels = "Switch on SMS, email or both"
return errors
}
/**
* Day-End Owner Report — sends the day's revenue and details to the owner by SMS and email at a
* set time (docs/DAY_END_OWNER_REPORT_PLAN.md). Messages go out through the auth service's own
* SMTP / SMS settings, so nothing here holds mail credentials.
*/
export default function OwnerReportSettingsPage() {
const [config, setConfig] = useState<OwnerReportConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [draft, setDraft] = useState<Draft | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
const [nextKey, setNextKey] = useState(-1)
const [previewDate, setPreviewDate] = useState(localToday())
const [preview, setPreview] = useState<OwnerReportPreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [testOpen, setTestOpen] = useState(false)
const [testing, setTesting] = useState(false)
const [log, setLog] = useState<OwnerReportDispatch[] | null>(null)
const [logPagination, setLogPagination] = useState<PaginationMeta | null>(null)
const [logPage, setLogPage] = useState(1)
const [logPageSize, setLogPageSize] = useState(10)
const [logReload, setLogReload] = useState(0)
function load() {
ownerReportApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
setDraft(toDraft(res.data))
setErrors({})
})
.catch((err) => {
setLoadError(errorMessage(err))
toast.error("Could not load the owner report settings", errorMessage(err))
})
}
useEffect(load, [])
useEffect(() => {
ownerReportApi
.dispatches({ page: logPage, pageSize: logPageSize })
.then((res) => {
setLog(res.items)
setLogPagination(res.pagination)
})
.catch((err) => {
setLog([])
toast.error("Could not load the send log", errorMessage(err))
})
}, [logPage, logPageSize, logReload])
const dirty = config !== null && draft !== null && JSON.stringify(toDraft(config)) !== JSON.stringify(draft)
function patch(next: Partial<Draft>) {
setDraft((d) => (d ? { ...d, ...next } : d))
}
function patchRecipient(key: number, next: Partial<OwnerReportRecipientInput>) {
setDraft((d) => (d ? { ...d, recipients: d.recipients.map((r) => (r.key === key ? { ...r, ...next } : r)) } : d))
setErrors((e) => {
const copy = { ...e }
for (const field of Object.keys(next)) delete copy[`${key}-${field}`]
delete copy.recipients
return copy
})
}
function addRecipient() {
if (!draft || draft.recipients.length >= MAX_RECIPIENTS) return
patch({ recipients: [...draft.recipients, { key: nextKey, recipientId: null, name: draft.recipients.length === 0 ? "Owner" : "", mobile: "", email: "", isActive: true }] })
setNextKey((k) => k - 1)
}
async function save() {
if (!draft) return
const nextErrors = validate(draft)
setErrors(nextErrors)
const messages = [...new Set(Object.values(nextErrors))]
if (messages.length > 0) {
toast.error("Please fix the highlighted fields", messages.length === 1 ? messages[0] : `${messages[0]} (and ${messages.length - 1} more)`)
return
}
setSaving(true)
try {
const res = await ownerReportApi.update(
{
enabled: draft.enabled,
smsEnabled: draft.smsEnabled,
emailEnabled: draft.emailEnabled,
sendAtLocalTime: draft.sendAtLocalTime,
includeBranchBreakdown: draft.includeBranchBreakdown,
recipients: draft.recipients.map((r) => ({
recipientId: r.recipientId,
name: r.name.trim(),
mobile: r.mobile?.trim() || null,
email: r.email?.trim() || null,
isActive: r.isActive,
})),
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
setDraft(toDraft(res.data))
toast.success("Owner report saved", res.data.enabled ? `It will be sent daily at ${res.data.sendAtLocalTime}.` : "The daily report is switched off.")
} catch (err) {
toast.error("Could not save the owner report settings", errorMessage(err))
} finally {
setSaving(false)
}
}
async function loadPreview() {
setPreviewLoading(true)
try {
setPreview(await ownerReportApi.preview(previewDate))
} catch (err) {
toast.error("Could not build the preview", errorMessage(err))
} finally {
setPreviewLoading(false)
}
}
async function sendTest() {
setTesting(true)
try {
const results = await ownerReportApi.sendTest(previewDate)
const failed = results.filter((r) => !r.ok)
if (failed.length === 0) toast.success("Test sent", `${results.length} message${results.length === 1 ? "" : "s"} sent.`)
else toast.error(`${failed.length} of ${results.length} messages failed`, failed.map((f) => `${f.destination}: ${f.error ?? "failed"}`).join(" · "))
setTestOpen(false)
setLogPage(1)
setLogReload((n) => n + 1)
} catch (err) {
toast.error("Could not send the test", errorMessage(err))
} finally {
setTesting(false)
}
}
return (
<div className="flex flex-col gap-4 pb-24 sm:gap-6">
<div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Day-End Owner Report</h1>
<p className="text-sm text-muted-foreground sm:text-base">
Send the day&apos;s revenue and details to the owner by SMS and email, automatically every evening.
</p>
</div>
{loadError && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{loadError}</div>}
{!loadError && !draft && <Skeleton className="h-64 w-full rounded-2xl" />}
{draft && config && (
<>
{/* Delivery */}
<section className={PANEL}>
<div className="flex items-center gap-2">
<Clock className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Delivery</h2>
</div>
<ToggleRow
checked={draft.enabled}
onChange={(v) => patch({ enabled: v })}
label="Send the daily report"
help="Off: nothing is sent automatically (you can still send a test)."
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ToggleRow checked={draft.smsEnabled} onChange={(v) => patch({ smsEnabled: v })} label="SMS" help="A short revenue summary." icon={MessageSquare} />
<ToggleRow checked={draft.emailEnabled} onChange={(v) => patch({ emailEnabled: v })} label="Email" help="The full details: summary, cash, top sales and expenses." icon={Mail} />
</div>
{errors.channels && <FieldError errors={[{ message: errors.channels }]} />}
<ToggleRow
checked={draft.includeBranchBreakdown}
onChange={(v) => patch({ includeBranchBreakdown: v })}
label="Include a per-branch breakdown"
help="Adds one row per branch to the email when branches are switched on."
/>
<div className="flex flex-col gap-2 sm:max-w-xs">
<Label htmlFor="or-time" className="text-base">Send time</Label>
<Input
id="or-time"
type="time"
value={draft.sendAtLocalTime}
onChange={(e) => patch({ sendAtLocalTime: e.target.value })}
className="h-11 text-base"
/>
<p className="text-xs text-muted-foreground">Time zone: {config.timeZone}. If the server is down then, it sends as soon as it is back the same day.</p>
</div>
</section>
{/* Recipients */}
<section className={PANEL}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Users className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Recipients</h2>
<span className="text-sm text-muted-foreground">({draft.recipients.length}/{MAX_RECIPIENTS})</span>
</div>
<Button type="button" variant="outline" className="h-10" onClick={addRecipient} disabled={draft.recipients.length >= MAX_RECIPIENTS}>
<Plus className="size-4" />
Add recipient
</Button>
</div>
{errors.recipients && <FieldError errors={[{ message: errors.recipients }]} />}
{draft.recipients.length === 0 ? (
<p className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
No recipients yet — add the owner&apos;s mobile number and email.
</p>
) : (
<ul className="flex flex-col gap-3">
{draft.recipients.map((r) => (
<li key={r.key} className={cn("grid grid-cols-1 gap-3 rounded-xl border p-3 sm:grid-cols-[1fr_1fr_1.4fr_auto] sm:items-start", !r.isActive && "opacity-60")}>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Name</Label>
<Input value={r.name} maxLength={100} onChange={(e) => patchRecipient(r.key, { name: e.target.value })} placeholder="Owner" aria-invalid={!!errors[`${r.key}-name`]} className="h-11 text-base sm:h-10" />
<FieldError errors={[errors[`${r.key}-name`] ? { message: errors[`${r.key}-name`] } : undefined]} />
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Mobile (SMS)</Label>
<Input
type="tel"
inputMode="tel"
maxLength={20}
value={r.mobile ?? ""}
onChange={(e) => patchRecipient(r.key, { mobile: limitPhoneInput(e.target.value) })}
placeholder="0771234567"
aria-invalid={!!errors[`${r.key}-mobile`]}
className="h-11 text-base sm:h-10"
/>
<FieldError errors={[errors[`${r.key}-mobile`] ? { message: errors[`${r.key}-mobile`] } : undefined]} />
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Email</Label>
<Input
type="email"
inputMode="email"
maxLength={200}
value={r.email ?? ""}
onChange={(e) => patchRecipient(r.key, { email: e.target.value })}
placeholder="owner@company.com"
aria-invalid={!!errors[`${r.key}-email`]}
className="h-11 text-base sm:h-10"
/>
<FieldError errors={[errors[`${r.key}-email`] ? { message: errors[`${r.key}-email`] } : undefined]} />
</div>
<div className="flex items-center justify-between gap-3 sm:flex-col sm:items-end sm:pt-5">
<label className="flex items-center gap-2 text-sm">
<Switch checked={r.isActive} onCheckedChange={(v) => patchRecipient(r.key, { isActive: v })} aria-label={`${r.name || "Recipient"} active`} />
Active
</label>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => patch({ recipients: draft.recipients.filter((x) => x.key !== r.key) })}
aria-label={`Remove ${r.name || "recipient"}`}
className="text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</section>
{/* Preview & test */}
<section className={PANEL}>
<div className="flex items-center gap-2">
<Eye className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Preview &amp; test</h2>
</div>
<div className="grid grid-cols-2 items-end gap-2 sm:flex sm:flex-wrap sm:gap-3">
<div className="col-span-2 flex flex-col gap-1 sm:col-span-1">
<Label htmlFor="or-date" className="text-xs text-muted-foreground">Day</Label>
<Input id="or-date" type="date" value={previewDate} max={localToday()} onChange={(e) => setPreviewDate(e.target.value)} className="h-11 text-base sm:h-10 sm:w-44" />
</div>
<Button type="button" variant="outline" className="h-11 sm:h-10" onClick={loadPreview} disabled={previewLoading}>
<Eye className="size-4" />
{previewLoading ? "Building…" : "Preview"}
</Button>
<Button
type="button"
className="h-11 sm:h-10"
onClick={() => {
if (dirty) {
toast.error("Save your changes first", "The test is sent to the saved recipients.")
return
}
setTestOpen(true)
}}
>
<Send className="size-4" />
Send test now
</Button>
</div>
{preview && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,18rem)_1fr]">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-foreground">SMS</p>
<div className="rounded-2xl rounded-tl-sm bg-muted px-4 py-3 font-mono text-sm leading-relaxed whitespace-pre-wrap">{preview.smsText}</div>
<p className="text-xs text-muted-foreground">{preview.smsText.length} characters</p>
</div>
<div className="flex min-w-0 flex-col gap-2">
<p className="text-sm font-medium text-foreground">Email — {preview.emailSubject}</p>
{/* Sandboxed: the HTML is our own template, but it never gets scripts or same-origin access. */}
<iframe title="Email preview" sandbox="" srcDoc={preview.emailHtml} className="h-112 w-full rounded-xl border bg-white" />
</div>
</div>
)}
</section>
{/* Send log */}
<section className={PANEL}>
<div className="flex items-center gap-2">
<Send className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Send log</h2>
</div>
{log === null ? (
<Skeleton className="h-24 w-full" />
) : log.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">Nothing sent yet.</p>
) : (
<>
<ul className="flex flex-col gap-2 md:hidden">
{log.map((d) => (
<li key={d.dispatchId} className="flex flex-col gap-1 rounded-lg border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{d.businessDate} · {d.channel === "Sms" ? "SMS" : "Email"}</span>
<StatusBadge status={d.status} isTest={d.isTest} />
</div>
<span className="truncate text-muted-foreground">{d.recipientName} — {d.destination}</span>
{d.error && <span className="text-xs text-destructive">{d.error}</span>}
</li>
))}
</ul>
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead>Day</TableHead>
<TableHead>Channel</TableHead>
<TableHead>Recipient</TableHead>
<TableHead>Status</TableHead>
<TableHead>Sent at</TableHead>
<TableHead>Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{log.map((d) => (
<TableRow key={d.dispatchId}>
<TableCell>{d.businessDate}</TableCell>
<TableCell>{d.channel === "Sms" ? "SMS" : "Email"}</TableCell>
<TableCell>
{d.recipientName}
<span className="block text-xs text-muted-foreground">{d.destination}</span>
</TableCell>
<TableCell><StatusBadge status={d.status} isTest={d.isTest} /></TableCell>
<TableCell>{d.sentAt ? new Date(d.sentAt + (d.sentAt.endsWith("Z") ? "" : "Z")).toLocaleString() : "—"}</TableCell>
<TableCell className="max-w-64 truncate text-destructive" title={d.error ?? undefined}>{d.error ?? ""}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{logPagination && logPagination.totalItems > 10 && (
<PaginationFooter
pagination={logPagination}
onPageChange={setLogPage}
pageSizeOptions={[10, 25, 50]}
onPageSizeChange={(size) => {
setLogPageSize(size)
setLogPage(1)
}}
/>
)}
</>
)}
</section>
{/* Save bar — sticky so it stays reachable on a long page / phone. */}
<div className="sticky bottom-0 z-10 -mx-6 -mb-6 flex flex-col-reverse gap-3 border-t bg-background/95 px-6 py-4 backdrop-blur sm:flex-row sm:items-center sm:justify-end">
{dirty && <p className="text-center text-sm text-warning sm:mr-auto sm:text-left">You have unsaved changes.</p>}
<Button type="button" variant="outline" size="lg" className="w-full sm:w-auto" disabled={!dirty || saving} onClick={() => { setDraft(toDraft(config)); setErrors({}) }}>
Discard
</Button>
<Button type="button" size="lg" className="w-full sm:w-auto sm:min-w-40" disabled={!dirty || saving} onClick={save}>
{saving ? "Saving…" : "Save"}
</Button>
</div>
</>
)}
<Dialog open={testOpen} onOpenChange={(open) => !testing && setTestOpen(open)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Send a test now?</DialogTitle>
<DialogDescription>
The report for {previewDate} will be sent right away to every active recipient
{config ? ` (${[config.smsEnabled && "SMS", config.emailEnabled && "email"].filter(Boolean).join(" + ")})` : ""}. It is marked as a test in the send log.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => setTestOpen(false)} disabled={testing}>
Cancel
</Button>
<Button className="h-11 sm:h-9" onClick={sendTest} disabled={testing}>
{testing ? "Sending…" : "Send test"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function ToggleRow({
checked, onChange, label, help, icon: Icon,
}: { checked: boolean; onChange: (v: boolean) => void; label: string; help: string; icon?: typeof Mail }) {
return (
<label className="flex cursor-pointer items-start gap-3 rounded-xl border p-3 hover:bg-muted/30">
<Switch checked={checked} onCheckedChange={onChange} className="mt-0.5 shrink-0" aria-label={label} />
<span className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{Icon && <Icon className="size-4 text-muted-foreground" />}
{label}
</span>
<span className="text-xs text-muted-foreground">{help}</span>
</span>
</label>
)
}
function StatusBadge({ status, isTest }: { status: OwnerReportDispatch["status"]; isTest: boolean }) {
const tone =
status === "Sent" ? "border-success/30 bg-success/10 text-success" : status === "Failed" ? "border-destructive/30 bg-destructive/10 text-destructive" : "border-warning/30 bg-warning/10 text-warning"
return (
<span className="flex items-center gap-1.5">
<Badge variant="outline" className={cn("text-xs", tone)}>{status}</Badge>
{isTest && <Badge variant="outline" className="text-xs">Test</Badge>}
</span>
)
}
@@ -45,6 +45,7 @@ const SEGMENT_LABELS: Record<string, string> = {
runs: "Runs", runs: "Runs",
invoices: "Sales", invoices: "Sales",
"day-end": "Approved Invoices", "day-end": "Approved Invoices",
"owner-report": "Day-End Owner Report",
} }
function labelFor(segment: string): string { function labelFor(segment: string): string {
@@ -110,6 +110,7 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/warranties") return "Warranties" if (pathname === "/dashboard/sales/warranties") return "Warranties"
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report" if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
if (pathname === "/dashboard/sales/day-end") return "Approved Invoices" if (pathname === "/dashboard/sales/day-end") return "Approved Invoices"
if (pathname === "/dashboard/settings/owner-report") return "Day-End Owner Report"
if (pathname === "/dashboard/hrm") return "HRM" if (pathname === "/dashboard/hrm") return "HRM"
if (pathname === "/dashboard/receiving/grn") return "Goods Received Notes" if (pathname === "/dashboard/receiving/grn") return "Goods Received Notes"
@@ -0,0 +1,37 @@
// Day-end owner report settings: recipients, schedule, preview, test send and send log
// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5). Singleton settings — no id; saves need the ETag.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
OwnerReportConfig,
OwnerReportDispatch,
OwnerReportPreview,
OwnerReportSendResult,
UpdateOwnerReportConfigRequest,
} from "@/types/owner-report"
const BASE = "/settings/owner-report"
export const ownerReportApi = {
get(): Promise<ApiResult<OwnerReportConfig>> {
return apiRequestWithETag<OwnerReportConfig>(BASE)
},
update(request: UpdateOwnerReportConfigRequest, ifMatch: string): Promise<ApiResult<OwnerReportConfig>> {
return apiRequestWithETag<OwnerReportConfig>(BASE, { method: "PUT", body: request, ifMatch })
},
/** The report as it would be sent for `date` (yyyy-MM-dd; default today) — nothing is sent. */
preview(date?: string): Promise<OwnerReportPreview> {
return apiRequest<OwnerReportPreview>(`${BASE}/preview${buildQuery({ date })}`)
},
/** Sends the report now to every active recipient (logged as a test). One per minute. */
sendTest(date?: string): Promise<OwnerReportSendResult[]> {
return apiRequest<OwnerReportSendResult[]>(`${BASE}/test`, { method: "POST", body: { date: date || null } })
},
dispatches(params: { page?: number; pageSize?: number } = {}): Promise<PagedResponse<OwnerReportDispatch>> {
return apiRequest<PagedResponse<OwnerReportDispatch>>(`${BASE}/dispatches${buildQuery(params)}`)
},
}
+15 -13
View File
@@ -1,17 +1,18 @@
/** /**
* Sri Lankan phone number rule shared by master-data forms (warehouses, …). Mirrors the * Sri Lankan phone number rule shared by master-data forms (warehouses, owner report, …). Mirrors
* backend's `PhoneRules.Pattern` so the client rejects exactly what the API would. * the backend's `PhoneRules.Pattern` so the client rejects exactly what the API would.
* *
* Accepted: 10 digits starting with 0 (0771234567, 011 234 5678) or +94 followed by 9 digits * Accepted: 10 digits starting with 0 (0771234567, 011 234 5678), +94 followed by 9 digits
* (+94771234567, +94 77 123 4567). Spaces and hyphens are allowed between digits. * (+94771234567, +94 77 123 4567), or 94 followed by 9 digits without the "+" (94771234567 — the
* SMS gateway's own format). Spaces and hyphens are allowed between digits.
*/ */
export const PHONE_PATTERN = /^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$/ export const PHONE_PATTERN = /^(?:0(?:[\s-]*\d){9}|\+?94(?:[\s-]*\d){9})[\s-]*$/
/** Digit count allowed after the prefix's own digits, per format. */ /** Digit count allowed after the prefix's own digits, per format. */
const LOCAL_DIGITS = 10 // 0 + 9 const LOCAL_DIGITS = 10 // 0 + 9
const INTL_DIGITS = 11 // 94 + 9 const INTL_DIGITS = 11 // 94 + 9
const PHONE_EXAMPLE = "e.g. 0771234567 or +94 77 123 4567" const PHONE_EXAMPLE = "e.g. 0771234567, 94771234567 or +94 77 123 4567"
/** /**
* Input filter for a phone field's onChange: drops disallowed characters and stops accepting * Input filter for a phone field's onChange: drops disallowed characters and stops accepting
@@ -19,7 +20,7 @@ const PHONE_EXAMPLE = "e.g. 0771234567 or +94 77 123 4567"
*/ */
export function limitPhoneInput(raw: string): string { export function limitPhoneInput(raw: string): string {
let out = "" let out = ""
let digits = 0 let digits = ""
for (const ch of raw) { for (const ch of raw) {
if (ch === "+") { if (ch === "+") {
if (out.length === 0) out += ch // "+" only as the very first character if (out.length === 0) out += ch // "+" only as the very first character
@@ -30,10 +31,11 @@ export function limitPhoneInput(raw: string): string {
continue continue
} }
if (!/\d/.test(ch)) continue if (!/\d/.test(ch)) continue
const max = out.startsWith("+") ? INTL_DIGITS : LOCAL_DIGITS // International (+94… or 94…) numbers carry two more digits than a local 0… number.
if (digits >= max) continue const max = out.startsWith("+") || digits.startsWith("94") || (digits === "9" && ch === "4") ? INTL_DIGITS : LOCAL_DIGITS
if (digits.length >= max) continue
out += ch out += ch
digits += 1 digits += ch
} }
return out return out
} }
@@ -44,11 +46,11 @@ export function phoneError(value: string, { required = false }: { required?: boo
if (!phone) return required ? "Enter a phone number" : null if (!phone) return required ? "Enter a phone number" : null
if (/[^\d\s\-+]/.test(phone)) return "Phone number can only contain digits, spaces, - and a leading +" if (/[^\d\s\-+]/.test(phone)) return "Phone number can only contain digits, spaces, - and a leading +"
const digits = phone.replace(/\D/g, "") const digits = phone.replace(/\D/g, "")
if (phone.startsWith("+")) { if (phone.startsWith("+") || digits.startsWith("94")) {
if (!digits.startsWith("94")) return `International numbers must start with +94 — ${PHONE_EXAMPLE}` if (!digits.startsWith("94")) return `International numbers must start with +94 — ${PHONE_EXAMPLE}`
if (digits.length !== INTL_DIGITS) return `+94 numbers need exactly 9 digits after +94 (${digits.length - 2} entered)` if (digits.length !== INTL_DIGITS) return `94 numbers need exactly 9 digits after 94 (${digits.length - 2} entered)`
} else { } else {
if (!digits.startsWith("0")) return `Phone number must start with 0 or +94 — ${PHONE_EXAMPLE}` if (!digits.startsWith("0")) return `Phone number must start with 0, 94 or +94 — ${PHONE_EXAMPLE}`
if (digits.length !== LOCAL_DIGITS) return `Phone number must be exactly 10 digits (${digits.length} entered)` if (digits.length !== LOCAL_DIGITS) return `Phone number must be exactly 10 digits (${digits.length} entered)`
} }
if (!PHONE_PATTERN.test(phone)) return `Enter a valid phone number, ${PHONE_EXAMPLE}` if (!PHONE_PATTERN.test(phone)) return `Enter a valid phone number, ${PHONE_EXAMPLE}`
+69
View File
@@ -0,0 +1,69 @@
/** Day-end owner report (docs/DAY_END_OWNER_REPORT_PLAN.md). */
export interface OwnerReportRecipient {
recipientId: number
name: string
/** Stored as +94XXXXXXXXX. */
mobile: string | null
email: string | null
isActive: boolean
}
export interface OwnerReportConfig {
enabled: boolean
smsEnabled: boolean
emailEnabled: boolean
/** "HH:mm" in `timeZone`. */
sendAtLocalTime: string
includeBranchBreakdown: boolean
recipients: OwnerReportRecipient[]
timeZone: string
updatedAt: string | null
updatedBy: number | null
}
export interface OwnerReportRecipientInput {
/** Null for a new recipient. */
recipientId: number | null
name: string
mobile: string | null
email: string | null
isActive: boolean
}
export interface UpdateOwnerReportConfigRequest {
enabled: boolean
smsEnabled: boolean
emailEnabled: boolean
sendAtLocalTime: string
includeBranchBreakdown: boolean
recipients: OwnerReportRecipientInput[]
}
export interface OwnerReportPreview {
date: string
smsText: string
emailSubject: string
emailHtml: string
}
export interface OwnerReportSendResult {
channel: "Sms" | "Email"
recipientName: string
destination: string
ok: boolean
error: string | null
}
export interface OwnerReportDispatch {
dispatchId: number
businessDate: string
channel: "Sms" | "Email"
recipientName: string
destination: string
status: "Pending" | "Sent" | "Failed"
attempts: number
error: string | null
isTest: boolean
createdAt: string
sentAt: string | null
}
+251
View File
@@ -0,0 +1,251 @@
# Day-End Owner Report — Implementation Plan
At the end of each business day, send the owner a summary of the day's revenue and key details
by **SMS** and **email**. The owner's mobile number(s) and email address(es) are set in
**Settings → Configuration**.
> **Naming:** this builds on the existing read-only **Day-End Sales Report**
> (`docs/DAY_END_SALES_REPORT_PLAN.md`, permission `sales.day-end-report`). It does not change the
> cashier close-out `SalesDayEnd` (shown as "Approved Invoices"). Access: the existing `settings`
> nav permission, like the other configuration screens.
>
> **Status (2026-10-19): implemented** — phases 1–5 (PDF attachment, phase 6, not done). Deviations
> from the design below: mobile numbers are stored as `+94XXXXXXXXX` (AuthHex converts to the gateway's `94…` form), one
> `IMessageSender` replaces separate SMS/email senders, and the API uses the `settings` policy.
---
## 1. Requirements
| # | Requirement |
|---|---|
| R1 | Once per business day, at a configured time (default **21:00 Asia/Colombo**), send the day's summary to the owner. |
| R2 | Owner contacts are configured in Settings: **one or more mobile numbers** and **one or more email addresses**. |
| R3 | Each channel (SMS, email) can be switched on/off independently; the whole feature has a master on/off. |
| R4 | SMS carries a short revenue summary; email carries the full details (summary + breakdowns). |
| R5 | A **"Send test now"** button sends today's report immediately to the configured contacts. |
| R6 | Every send attempt is logged (date, channel, recipient, success/failure, error) and visible in Settings. |
| R7 | A day is sent at most once per channel per recipient, even if the server restarts or runs on several instances. |
| R8 | If the server was down at send time, the missed day is sent when it comes back (same day only — no backfill of older days). |
| R9 | Branch-aware: one company-wide summary, plus a per-branch breakdown when branching is enabled. |
### Message content
**SMS** (≤ 2 SMS segments, ~300 chars, English, plain text):
```
Hexa ERP – Day end 19/10/2026
Sales: LKR 125,400.00 (42)
Paid: 98,200.00 | Due: 27,200.00
Cash: 61,000.00 | Card: 37,200.00
Expenses: 8,500.00
Net profit: 21,300.00
Cash in hand: 54,300.00
```
**Email** (HTML + plain-text alternative), subject `Day-end report — 19 Oct 2026 — Sales LKR 125,400.00`:
| Section | Content (all from `DayEndReportDto`) |
|---|---|
| Summary | Total sales, sales count, paid, due, paid by cash / card / other, expenses, income, COGS, gross profit, net profit (labelled **Loss** when negative) |
| Cash | Opening cash in hand, net cash today, deposited to bank, closing cash in hand |
| Collections | Collected on earlier invoices |
| Per branch | One summary row per branch (when branching is on) |
| Top sales | Largest 10 sales of the day (doc no., customer, total, paid by) |
| Expenses | Every expense of the day (doc no., category · title, amount) |
| Footer | Link to `/dashboard/sales/day-end-report?date=YYYY-MM-DD`; "Sent automatically by Hexa ERP" |
Optional (phase 2): attach the day's PDF (same layout as the Day-End Report's **Download PDF**).
---
## 2. Current state
| Area | What exists | Gap |
|---|---|---|
| Day figures | `DayEndReportService.GetDayAsync(date, branchId)` → `DayEndReportDto` (summary, sales, expenses, deposits) | Branch scoping reads `ICurrentBranchContext` (the HTTP user) — **no user in a background job** |
| Business day | `BusinessDayClock` (`DayEndReport:TimeZone`, `Asia/Colombo`) | — |
| Config pattern | Singleton config rows + service + `ETag`/`If-Match` (e.g. `SalesConfig`, `GrnConfig`, `NotificationConfig`) | — |
| Settings UI | `app/dashboard/settings/configuration/page.tsx` (card per config screen) | No owner-report card / page |
| Phone validation | `lib/validations/phone.ts` (Sri Lankan: `0XXXXXXXXX` / `+94XXXXXXXXX`), backend `PhoneRules` in `Dtos/Warehouses/WarehouseDtos.cs` | Move `PhoneRules` to a shared place |
| In-app notifications | `NotificationService`, `NotificationConfig` (per-module in-app alerts) | In-app only — **no SMS or email sending anywhere** |
| AuthHex (`D:\HexDive\ERP_Auth_Service`) | Owns the **SMTP** (`SmtpSettings` — Hostinger, port 587, SSL) and **SMS gateway** (`Sms` — Introps `Sms_common/send_sms`) credentials. `Utility/ThirdPartyService` already sends a custom-subject/HTML-body email (`EmailConfiguration`) and a custom-text SMS (`SendSmsAsync`), but only from its own OTP/recovery flows | **No endpoint** lets another service send a message — needs one (§3.2) |
| ERPCore → AuthHex | `IAuthHexClient` with `AuthHex:BaseUrl`; admin calls send `AuthHex:AdminApiKey` (`AdminHeaders()`) | — |
| Background jobs | None (no `BackgroundService`, Hangfire or Quartz) | **Need a scheduler** |
---
## 3. Design
### 3.1 Data model (migration `AddOwnerReportConfig`)
**`owner_report_config`** — singleton (`ConfigId = 1`, CHECK constraint like the other config rows):
| Column | Type | Notes |
|---|---|---|
| `ConfigId` | int PK | always 1 |
| `Enabled` | bool | master switch, default `false` |
| `SmsEnabled` | bool | default `true` |
| `EmailEnabled` | bool | default `true` |
| `SendAtLocalTime` | time | default `21:00`, in `DayEndReport:TimeZone` |
| `IncludeBranchBreakdown` | bool | default `true` |
| `UpdatedAt`, `UpdatedBy`, `RowVersion` | | same as other configs |
**`owner_report_recipients`**:
| Column | Type | Notes |
|---|---|---|
| `RecipientId` | int PK | |
| `Name` | varchar(100) | e.g. "Owner", "Director" |
| `Mobile` | varchar(20) null | normalised to `+94XXXXXXXXX`; validated with the shared Sri Lankan phone rule |
| `Email` | varchar(200) null | validated email |
| `IsActive` | bool | |
At least one of `Mobile` / `Email` required. Max **5** recipients.
**`owner_report_dispatch`** — the send log *and* the idempotency guard:
| Column | Type | Notes |
|---|---|---|
| `DispatchId` | bigint PK | |
| `BusinessDate` | date | |
| `Channel` | enum `Sms` / `Email` | |
| `RecipientId` | int FK | |
| `Destination` | varchar(200) | number/email at send time (kept if the recipient changes later) |
| `Status` | enum `Pending` / `Sent` / `Failed` | |
| `Attempts` | int | |
| `Error` | varchar(1000) null | provider error, truncated |
| `ProviderMessageId` | varchar(100) null | |
| `IsTest` | bool | "Send test now" sends |
| `CreatedAt`, `SentAt` | datetime | |
Unique index `(BusinessDate, Channel, RecipientId, IsTest)` → R7: a second worker or a restart can't
insert the same automatic send twice (`INSERT` wins once; the loser skips).
### 3.2 Sending channels — through AuthHex
**SMTP and SMS details come from AuthHex.** ERPCore does not hold any mail or SMS credentials; it
asks AuthHex to send, and AuthHex uses the `SmtpSettings` / `Sms` configuration it already has.
One place to rotate a password, one sender identity, nothing new to secure in ERPCore.
```
OwnerReportJob ──► IEmailSender / ISmsSender (ERPCore)
│ AuthHexEmailSender / AuthHexSmsSender
▼ POST {AuthHex:BaseUrl}/api/notify/... + AdminApiKey header
AuthHex NotifyController ──► ThirdPartyService ──► Hostinger SMTP / Introps SMS
```
**AuthHex side (`ERP_Auth_Service`) — new, small:**
| Method | Route | Body | Uses |
|---|---|---|---|
| `POST` | `/api/notify/email` | `{ to: string[], subject, htmlBody, textBody? }` | `ThirdPartyService.EmailConfiguration(to, code: "", subject, htmlBody)` per recipient |
| `POST` | `/api/notify/sms` | `{ to: string[], text }` | `ThirdPartyService.SendSmsAsync(to, otpCode: "-", message: text)` per recipient |
- Protected by the **same admin API key** ERPCore already sends for admin calls (`AuthHex:AdminApiKey`); no user session. Reject anything without it (401).
- Response: per-recipient `{ to, ok, error }` in the usual AuthHex envelope.
- Changes needed in `ThirdPartyService` (keep the OTP flows working):
- `SendSmsAsync` rejects an empty `otpCode` even when a custom `message` is given → allow an empty code when `message` is set (or add a `SendTextAsync(mobile, text)` overload).
- `SendEmailAsync` runs `body.Replace("{code}", code)` — harmless for the report, but add a `SendCustomEmailAsync(to, subject, html)` that skips OTP templating.
- `SmtpSettings:SenderName` is currently **"Gym Os"** — set it to **"Hexa ERP"** so owner emails show the right sender (`From: Hexa ERP <sender address>`).
- Uses `System.Net.Mail.SmtpClient`, 10 s timeout, SSL — fine for a few emails a day.
- Limits: ≤ 10 recipients per call; email body ≤ 200 KB; SMS text ≤ 480 chars.
**ERPCore side (`Infra/Messaging/`):**
```csharp
public interface ISmsSender { Task<IReadOnlyList<SendResult>> SendAsync(IReadOnlyList<string> toE164, string text, CancellationToken ct); }
public interface IEmailSender { Task<IReadOnlyList<SendResult>> SendAsync(EmailMessage message, CancellationToken ct); }
public sealed record SendResult(string To, bool Ok, string? Error);
```
| Implementation | Used when |
|---|---|
| `AuthHexEmailSender`, `AuthHexSmsSender` — two new methods on `IAuthHexClient` (`SendEmailAsync`, `SendSmsAsync`) reusing its HTTP client, base URL and `AdminHeaders()` | `Messaging:Provider = AuthHex` (default in Development/Production) |
| `LogOnlyEmailSender`, `LogOnlySmsSender` — write the message to the log instead of sending | `Messaging:Provider = Log` (local dev, tests, or while AuthHex lacks the endpoint) |
No new secrets in ERPCore. Each call has a 20 s timeout and no retries of its own — the job retries.
If AuthHex is unreachable the dispatch row is marked `Failed` with the error and retried (§3.4).
### 3.3 Building the report (`OwnerReportService`)
- `BuildAsync(DateOnly date)` → `OwnerReportContent { SmsText, EmailSubject, EmailHtml, EmailText }`.
- Calls `DayEndReportService.GetDayAsync(date, branchId: null)` for the company total, and once per active branch when `IncludeBranchBreakdown` and branching is on.
- **Branch scoping fix:** add an explicit "system / all branches" path to `DayEndReportService` (e.g. an overload taking `bool allBranches`) instead of relying on `ICurrentBranchContext`, which has no user inside the job. Must not widen access for HTTP callers.
- Amounts use the same formatting as the UI (`#,##0.00`, `LKR` prefix on the headline); a negative net profit is written as `Loss 1,200.00`.
- Email HTML is a simple inline-styled table template (`Infra/Messaging/Templates/OwnerReport.html`) — no external CSS/images.
### 3.4 Scheduler (`OwnerReportJob : BackgroundService`)
- Wakes every **60 s** (`PeriodicTimer`).
- If `Enabled` and local time (`BusinessDayClock`) ≥ `SendAtLocalTime` and today has no `Sent`/`Pending` dispatch rows → build once, then for each active recipient × enabled channel insert a `Pending` dispatch row (unique index guards duplicates) and send.
- Failures: retry up to **3** times, 5 min apart (`Attempts`, `Status = Failed` after the last).
- Missed send (server down at 21:00): picked up on the next tick the same day (R8). Days before today are never sent automatically.
- Runs in its own DI scope per tick; any exception is logged and the loop continues — it must never crash the host.
- Multi-instance safe through the unique index (no distributed lock needed).
### 3.5 API (`SettingsOwnerReportController`, policy `settings.owner-report`)
| Method | Route | Body / result |
|---|---|---|
| `GET` | `/api/v1/settings/owner-report` | config + recipients (ETag) |
| `PUT` | `/api/v1/settings/owner-report` | config + full recipients list, `If-Match` required |
| `POST` | `/api/v1/settings/owner-report/test` | `{ date?: "YYYY-MM-DD" }` → sends now to all active recipients (`IsTest = true`); returns per-recipient results |
| `GET` | `/api/v1/settings/owner-report/dispatches?from&to&page&pageSize` | send log, newest first |
| `GET` | `/api/v1/settings/owner-report/preview?date=` | `{ smsText, emailSubject, emailHtml }` — renders without sending |
Validation (422): recipient needs mobile or email; mobile must pass the Sri Lankan phone rule; email
must be valid; ≤ 5 recipients; `SendAtLocalTime` between 00:00 and 23:59. `POST /test` is rate-limited
(1 per minute) so the button can't be used to spam the owner.
### 3.6 Frontend — `app/dashboard/settings/owner-report/page.tsx`
- New card on **Settings → Configuration**: *"Day-End Owner Report — Send the day's revenue to the owner by SMS and email"* (icon `Send`).
- Page sections (mobile-first, same `StatCard`/card styles as the rest of the app):
1. **Delivery** — master switch, SMS on/off, Email on/off, send time (time input), "include branch breakdown".
2. **Recipients** — list of name / mobile / email rows with add / remove (max 5). Mobile uses `limitPhoneInput` + `phoneError` from `lib/validations/phone.ts`; email validated client-side too. All errors also shown as toasts.
3. **Preview** — SMS text in a phone-style bubble, email rendered in a sandboxed iframe (`srcdoc`), date picker to preview another day.
4. **Send test now** — confirm dialog, then per-recipient result toast.
5. **Send log** — paginated table (cards on phones): date, channel, recipient, status badge, error tooltip.
- Sidebar: no new entry (reached from Settings → Configuration), permission code `settings.owner-report` added to the role permission tree.
---
## 4. Security & privacy (02-SECURITY checklist items)
- SMTP and SMS credentials stay in AuthHex's configuration only — ERPCore never sees them. The
new AuthHex `/api/notify/*` endpoints accept only the admin API key, so they can't be used as an
open relay; the key itself stays in ERPCore's configuration/secrets, never in git or API responses.
- Owner contact data visible only to `settings.owner-report`; the send log masks numbers (`+94 77 *** 4567`) for other viewers if it is ever exposed elsewhere.
- SMS/email bodies contain financial figures — sent only to configured recipients, never to user-supplied addresses on the test endpoint.
- Audit log entries for config changes (who changed recipients / switched it on).
- The email footer link requires login — no figures are exposed through the link itself.
---
## 5. Phases & tracking
| Phase | Scope | Done when |
|---|---|---|
| 1 | Data model + migration, config API, Settings page (delivery + recipients), `LogOnly` senders | Config saves/loads with validation; test send writes to the log |
| 2 | `OwnerReportService` content + preview endpoint/UI; branch-scoping fix in `DayEndReportService` | Preview matches the Day-End Report figures for the same day |
| 3 | AuthHex: `POST /api/notify/email` + `ThirdPartyService` custom-email method + sender name; ERPCore: `AuthHexEmailSender`; send log UI | Test email arrives from the right sender with correct figures |
| 4 | AuthHex: `POST /api/notify/sms` + empty-OTP fix; ERPCore: `AuthHexSmsSender` | Test SMS arrives, ≤ 2 segments |
| 5 | `OwnerReportJob` scheduler, retries, idempotency | Sends once at the set time; restart and 2-instance tests don't double-send |
| 6 (opt.) | PDF attachment | Email carries the day's PDF |
Record each phase in `Backend/PROGRESS.md` / `Frontend/PROGRESS.md` per `01-DOC-GUIDE.md`.
---
## 6. Open questions
| # | Question | Default if not answered |
|---|---|---|
| Q1 | ~~Which SMS gateway?~~ **Decided:** AuthHex's existing gateway (Introps), via the new AuthHex endpoint. | — |
| Q2 | ~~Which SMTP account?~~ **Decided:** AuthHex's `SmtpSettings` (Hostinger), sender name **"Hexa ERP"**. Still open: the sender *address* — today `tester@hexdive.com`. | Keep the current address until a dedicated one (e.g. `erp@hexdive.com`) exists |
| Q2b | Is the AuthHex repo (`ERP_Auth_Service`) ours to change, or owned by another team who must add `/api/notify/*`? | We add it in the same release |
| Q3 | One company-wide report, or a separate report per branch to each branch's manager? | Company-wide + per-branch breakdown section |
| Q4 | Send time — fixed 21:00, or after the last cashier closes the day (Approved Invoices / `SalesDayEnd`)? | Fixed configurable time |
| Q5 | Should a day with no sales still send ("No sales today")? | Yes — confirms the system ran |
| Q6 | Languages — English only, or Sinhala/Tamil SMS too (Unicode halves SMS length)? | English only |