From cfda8f991a77f863d69c8f89da1e0723dbb995c5 Mon Sep 17 00:00:00 2001 From: Peter Drier Date: Mon, 13 Apr 2026 16:25:05 +0200 Subject: [PATCH 1/2] Service ownership phase 3: Ticket/Budget/Campaign triangle (#472) Enforce table ownership across the Ticket, Budget, and Campaign sections: cross-service writes that previously bypassed owning services now go through service interfaces. 3a. TicketSyncService no longer writes CampaignGrants directly. New ICampaignService.MarkGrantsRedeemedAsync accepts a list of DiscountCodeRedemption records; TicketSyncService aggregates ticket orders with discount codes and delegates the RedeemedAt updates to CampaignService, which owns the grant table. 3b. TicketingBudgetService no longer writes BudgetLineItems or TicketingProjections directly. The weekly line-item upsert, projection-from-actuals update, and projected-line materialization logic moved into BudgetService as SyncTicketingActualsAsync, RefreshTicketingProjectionsAsync, GetTicketingProjectionEntriesAsync, and GetActualTicketsSold. TicketingBudgetService is now a thin orchestrator: it reads TicketOrders (a ticket-section table it co-owns), aggregates them per ISO week into TicketingWeeklyActuals, and delegates the budget mutations to BudgetService. 3c. CampaignService no longer creates EmailOutboxMessages rows directly. New IEmailService.SendCampaignCodeAsync takes a CampaignCodeEmailRequest describing the campaign's raw template (subject + markdown body + {{Code}}/{{Name}} placeholders). OutboxEmailService renders and wraps the email; SmtpEmailService sends inline. CampaignService now persists grants first, then delegates email enqueuing per grant. 3d. ProfileService no longer queries ticket tables. New methods on ITicketQueryService (HasCurrentEventTicketAsync, GetUserTicketExportDataAsync) replace ProfileService's direct reads of TicketOrders / TicketAttendees / TicketSyncStates used by the event-hold date computation and GDPR data export. Ticket caches (UserTicketCount, UserIdsWithTickets, TicketEventSummary, TicketDashboardStats) are verified to be accessed only by ticket services and the central MemoryCacheExtensions helpers. Tests: 947 Application + 108 Domain tests pass. Existing campaign/ticket- sync tests were updated to verify delegation via NSubstitute rather than asserting on the outbox/grant tables directly. Integration tests require Docker/Testcontainers which is unavailable in this environment. Closes #472 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DTOs/TicketingBudgetDtos.cs | 15 + .../Interfaces/IBudgetService.cs | 33 ++ .../Interfaces/ICampaignService.cs | 17 + .../Interfaces/IEmailService.cs | 21 + .../Interfaces/ITicketQueryService.cs | 44 ++ .../Services/BudgetService.cs | 408 ++++++++++++++++++ .../Services/CampaignService.cs | 183 ++++---- .../Services/OutboxEmailService.cs | 55 +++ .../Services/ProfileService.cs | 44 +- .../Services/SmtpEmailService.cs | 21 + .../Services/StubEmailService.cs | 8 + .../Services/TicketQueryService.cs | 57 +++ .../Services/TicketSyncService.cs | 47 +- .../Services/TicketingBudgetService.cs | 399 ++--------------- .../Services/CampaignServiceTests.cs | 85 ++-- .../Services/ProfileServiceTests.cs | 6 +- .../Services/TicketSyncServiceTests.cs | 68 +-- 17 files changed, 900 insertions(+), 611 deletions(-) diff --git a/src/Humans.Application/DTOs/TicketingBudgetDtos.cs b/src/Humans.Application/DTOs/TicketingBudgetDtos.cs index b6f32e810..6a9f66693 100644 --- a/src/Humans.Application/DTOs/TicketingBudgetDtos.cs +++ b/src/Humans.Application/DTOs/TicketingBudgetDtos.cs @@ -15,3 +15,18 @@ public class TicketingWeekProjection public decimal ProjectedStripeFees { get; init; } public decimal ProjectedTtFees { get; init; } } + +/// +/// Aggregated ticket sales for a completed ISO week, used as input to +/// IBudgetService.SyncTicketingActualsAsync. Produced by the ticket +/// side (which owns TicketOrders) and consumed by the budget side (which +/// owns BudgetLineItems / TicketingProjections). +/// +public record TicketingWeeklyActuals( + LocalDate Monday, + LocalDate Sunday, + string WeekLabel, + int TicketCount, + decimal Revenue, + decimal StripeFees, + decimal TicketTailorFees); diff --git a/src/Humans.Application/Interfaces/IBudgetService.cs b/src/Humans.Application/Interfaces/IBudgetService.cs index 9d2407d7b..c11128f75 100644 --- a/src/Humans.Application/Interfaces/IBudgetService.cs +++ b/src/Humans.Application/Interfaces/IBudgetService.cs @@ -29,6 +29,39 @@ Task UpdateTicketingProjectionAsync(Guid budgetGroupId, LocalDate? startDate, Lo int initialSalesCount, decimal dailySalesRate, decimal averageTicketPrice, int vatRate, decimal stripeFeePercent, decimal stripeFeeFixed, decimal ticketTailorFeePercent, Guid actorUserId); + /// + /// Sync ticket sales actuals (already aggregated per ISO week by the ticket side) + /// into the ticketing budget group. Upserts auto-generated BudgetLineItems for + /// each completed week's revenue and processing fees, refreshes projection + /// parameters (average ticket price, stripe fee %, TicketTailor fee %) from + /// those actuals, and re-materializes projected line items for future weeks. + /// Returns the number of line items created or updated. + /// + Task SyncTicketingActualsAsync( + Guid budgetYearId, + IReadOnlyList weeklyActuals, + CancellationToken ct = default); + + /// + /// Re-materialize projected ticketing line items (no actuals sync). Called + /// after projection parameters change so the projected lines reflect the new inputs. + /// Returns the number of projected line items created. + /// + Task RefreshTicketingProjectionsAsync(Guid budgetYearId, CancellationToken ct = default); + + /// + /// Compute virtual (non-persisted) weekly ticket projections for future weeks. + /// Used by finance overview pages to display break-even forecasts. + /// + Task> GetTicketingProjectionEntriesAsync( + Guid budgetGroupId, CancellationToken ct = default); + + /// + /// Compute the total number of tickets sold through completed weeks, derived + /// from the revenue line item notes on an already-loaded ticketing group. + /// + int GetActualTicketsSold(BudgetGroup ticketingGroup); + // Budget Groups Task CreateGroupAsync(Guid budgetYearId, string name, bool isRestricted, Guid actorUserId); Task UpdateGroupAsync(Guid groupId, string name, int sortOrder, bool isRestricted, Guid actorUserId); diff --git a/src/Humans.Application/Interfaces/ICampaignService.cs b/src/Humans.Application/Interfaces/ICampaignService.cs index 077c48e4b..a1e0b965a 100644 --- a/src/Humans.Application/Interfaces/ICampaignService.cs +++ b/src/Humans.Application/Interfaces/ICampaignService.cs @@ -1,5 +1,6 @@ using Humans.Application.DTOs; using Humans.Domain.Entities; +using NodaTime; namespace Humans.Application.Interfaces; @@ -10,6 +11,12 @@ public record WaveSendPreview( int CodesAvailable, int CodesRemainingAfterSend); +/// +/// A discount-code redemption discovered by ticket sync — code string and the +/// instant the redeeming ticket order was purchased. +/// +public record DiscountCodeRedemption(string Code, Instant RedeemedAt); + public interface ICampaignService { Task CreateAsync(string title, string? description, @@ -31,4 +38,14 @@ Task UpdateAsync(Guid id, string title, string? description, Task SendWaveAsync(Guid campaignId, Guid teamId, CancellationToken ct = default); Task ResendToGrantAsync(Guid grantId, CancellationToken ct = default); Task RetryAllFailedAsync(Guid campaignId, CancellationToken ct = default); + + /// + /// Marks campaign grants as redeemed based on discovered discount-code redemptions. + /// Matches codes case-insensitively against active/completed campaigns' unredeemed grants. + /// When a code matches grants in multiple campaigns, the most recently created campaign wins. + /// Returns the number of grants marked as redeemed. + /// + Task MarkGrantsRedeemedAsync( + IReadOnlyCollection redemptions, + CancellationToken ct = default); } diff --git a/src/Humans.Application/Interfaces/IEmailService.cs b/src/Humans.Application/Interfaces/IEmailService.cs index d304c5cce..873fe32f5 100644 --- a/src/Humans.Application/Interfaces/IEmailService.cs +++ b/src/Humans.Application/Interfaces/IEmailService.cs @@ -291,4 +291,25 @@ Task SendWorkspaceCredentialsAsync( string tempPassword, string? culture = null, CancellationToken cancellationToken = default); + + /// + /// Enqueue a campaign code email to a recipient. Campaign content (subject and + /// markdown body with {{Code}}/{{Name}} placeholders) is rendered and wrapped in + /// the system email template by the outbox service, then linked to the grant + /// via for status tracking. + /// + Task SendCampaignCodeAsync(CampaignCodeEmailRequest request, CancellationToken cancellationToken = default); } + +/// +/// Payload for enqueuing a campaign-code email. +/// +public record CampaignCodeEmailRequest( + Guid UserId, + Guid CampaignGrantId, + string RecipientEmail, + string RecipientName, + string Subject, + string MarkdownBody, + string Code, + string? ReplyTo); diff --git a/src/Humans.Application/Interfaces/ITicketQueryService.cs b/src/Humans.Application/Interfaces/ITicketQueryService.cs index a7e15b631..7402ace06 100644 --- a/src/Humans.Application/Interfaces/ITicketQueryService.cs +++ b/src/Humans.Application/Interfaces/ITicketQueryService.cs @@ -117,8 +117,52 @@ Task GetWhoHasntBoughtAsync( /// Gets ticket order summaries for a specific user (as buyer), ordered by most recent first. /// Task> GetUserTicketOrderSummariesAsync(Guid userId); + + /// + /// Returns whether a user holds a valid ticket for the current sync's vendor event. + /// Checks paid orders matched to the user, then falls back to valid/checked-in attendees. + /// Used by profile services to compute account-deletion / event-hold dates without + /// touching ticket tables directly. + /// + Task HasCurrentEventTicketAsync(Guid userId, CancellationToken ct = default); + + /// + /// Returns ticket data for a user's GDPR data export — matched orders (as buyer) + /// and matched attendee records. Shape matches the existing profile export JSON + /// so GDPR exports stay stable after the service-ownership refactor. + /// + Task GetUserTicketExportDataAsync(Guid userId, CancellationToken ct = default); } +/// +/// Ticket data for a user's GDPR data export. +/// +public record UserTicketExportData( + IReadOnlyList Orders, + IReadOnlyList Attendees); + +/// +/// A single ticket order row in the user data export. +/// +public record UserTicketOrderExportRow( + string? BuyerName, + string? BuyerEmail, + decimal TotalAmount, + string Currency, + string PaymentStatus, + string? DiscountCode, + Instant PurchasedAt); + +/// +/// A single ticket attendee row in the user data export. +/// +public record UserTicketAttendeeExportRow( + string? AttendeeName, + string? AttendeeEmail, + string? TicketTypeName, + decimal Price, + string Status); + /// /// Summary of a ticket order for display on user-facing pages. /// diff --git a/src/Humans.Infrastructure/Services/BudgetService.cs b/src/Humans.Infrastructure/Services/BudgetService.cs index db284003b..ea5cb33bd 100644 --- a/src/Humans.Infrastructure/Services/BudgetService.cs +++ b/src/Humans.Infrastructure/Services/BudgetService.cs @@ -1159,4 +1159,412 @@ public LocalDate ComputeVatSettlementDate(LocalDate expectedDate) return quarterEnd.PlusDays(45); } + // ───────────────────────── Ticketing Budget Sync ───────────────────────── + // + // These methods own the BudgetLineItem / TicketingProjection mutations that + // used to live in TicketingBudgetService. The ticket side is responsible for + // aggregating ticket sales per ISO week and passing them in via + // TicketingWeeklyActuals — the actual upserts, projection-parameter updates, + // and projected-line materialization happen here because BudgetService owns + // all budget tables. + + // Prefix for auto-generated line item descriptions to identify them during sync + private const string TicketingRevenuePrefix = "Week of "; + private const string TicketingStripePrefix = "Stripe fees: "; + private const string TicketingTtPrefix = "TT fees: "; + private const string TicketingProjectedPrefix = "Projected: "; + + // Spanish IVA rate applied to Stripe and TicketTailor processing fees + private const int TicketingFeeVatRate = 21; + + public async Task SyncTicketingActualsAsync( + Guid budgetYearId, + IReadOnlyList weeklyActuals, + CancellationToken ct = default) + { + var ticketingGroup = await LoadTicketingGroupAsync(budgetYearId, ct); + if (ticketingGroup is null) + { + _logger.LogDebug("No ticketing group found for budget year {YearId}", budgetYearId); + return 0; + } + + var revenueCategory = ticketingGroup.Categories.FirstOrDefault(c => string.Equals(c.Name, "Ticket Revenue", StringComparison.Ordinal)); + var feesCategory = ticketingGroup.Categories.FirstOrDefault(c => string.Equals(c.Name, "Processing Fees", StringComparison.Ordinal)); + + if (revenueCategory is null || feesCategory is null) + { + _logger.LogWarning("Ticketing group missing expected categories for year {YearId}", budgetYearId); + return 0; + } + + var projectionVatRate = ticketingGroup.TicketingProjection?.VatRate ?? 0; + var now = _clock.GetCurrentInstant(); + var lineItemsCreated = 0; + + foreach (var week in weeklyActuals) + { + lineItemsCreated += UpsertTicketingLineItem(revenueCategory, + $"{TicketingRevenuePrefix}{week.WeekLabel}", + week.Revenue, week.Monday, projectionVatRate, false, $"{week.TicketCount} tickets", now); + + if (week.StripeFees > 0) + lineItemsCreated += UpsertTicketingLineItem(feesCategory, + $"{TicketingStripePrefix}{week.WeekLabel}", + -week.StripeFees, week.Monday, TicketingFeeVatRate, false, null, now); + + if (week.TicketTailorFees > 0) + lineItemsCreated += UpsertTicketingLineItem(feesCategory, + $"{TicketingTtPrefix}{week.WeekLabel}", + -week.TicketTailorFees, week.Monday, TicketingFeeVatRate, false, null, now); + } + + if (weeklyActuals.Count > 0 && ticketingGroup.TicketingProjection is not null) + { + var totalRevenue = weeklyActuals.Sum(w => w.Revenue); + var totalStripeFees = weeklyActuals.Sum(w => w.StripeFees); + var totalTtFees = weeklyActuals.Sum(w => w.TicketTailorFees); + var totalTickets = weeklyActuals.Sum(w => w.TicketCount); + + UpdateProjectionFromActuals(ticketingGroup.TicketingProjection, + totalRevenue, totalStripeFees, totalTtFees, totalTickets, now); + } + + lineItemsCreated += MaterializeTicketingProjections(ticketingGroup, revenueCategory, feesCategory, now); + + if (_dbContext.ChangeTracker.HasChanges()) + await _dbContext.SaveChangesAsync(ct); + + _logger.LogInformation( + "Ticketing budget sync: {Created} line items created/updated for {Weeks} actual weeks + projections", + lineItemsCreated, weeklyActuals.Count); + + return lineItemsCreated; + } + + public async Task RefreshTicketingProjectionsAsync(Guid budgetYearId, CancellationToken ct = default) + { + var ticketingGroup = await LoadTicketingGroupAsync(budgetYearId, ct); + if (ticketingGroup is null) return 0; + + var revenueCategory = ticketingGroup.Categories.FirstOrDefault(c => string.Equals(c.Name, "Ticket Revenue", StringComparison.Ordinal)); + var feesCategory = ticketingGroup.Categories.FirstOrDefault(c => string.Equals(c.Name, "Processing Fees", StringComparison.Ordinal)); + if (revenueCategory is null || feesCategory is null) return 0; + + var now = _clock.GetCurrentInstant(); + var created = MaterializeTicketingProjections(ticketingGroup, revenueCategory, feesCategory, now); + + if (_dbContext.ChangeTracker.HasChanges()) + await _dbContext.SaveChangesAsync(ct); + + _logger.LogInformation("Ticketing projections refreshed: {Count} line items", created); + return created; + } + + public async Task> GetTicketingProjectionEntriesAsync( + Guid budgetGroupId, CancellationToken ct = default) + { + var group = await _dbContext.BudgetGroups + .Include(g => g.TicketingProjection) + .Include(g => g.Categories) + .ThenInclude(c => c.LineItems) + .FirstOrDefaultAsync(g => g.Id == budgetGroupId && g.IsTicketingGroup, ct); + + if (group?.TicketingProjection is null) + return []; + + var projection = group.TicketingProjection; + + if (projection.StartDate is null || projection.EventDate is null || projection.AverageTicketPrice == 0) + return []; + + var today = _clock.GetCurrentInstant().InUtc().Date; + var currentWeekMonday = GetTicketingIsoMonday(today); + + var projectionStart = currentWeekMonday > projection.StartDate.Value + ? currentWeekMonday + : GetTicketingIsoMonday(projection.StartDate.Value); + var eventDate = projection.EventDate.Value; + + if (projectionStart >= eventDate) + return []; + + var dailyRate = projection.DailySalesRate; + var initialBurst = projection.InitialSalesCount; + var isFirstWeek = true; + + var projections = new List(); + var weekStart = projectionStart; + + while (weekStart < eventDate) + { + var weekEnd = weekStart.PlusDays(6); + if (weekEnd > eventDate) weekEnd = eventDate; + + var daysInWeek = Period.Between(weekStart, weekEnd.PlusDays(1), PeriodUnits.Days).Days; + var weekTickets = (int)Math.Round(dailyRate * daysInWeek); + if (isFirstWeek && projectionStart <= projection.StartDate.Value) + { + weekTickets += initialBurst; + isFirstWeek = false; + } + else + { + isFirstWeek = false; + } + if (weekTickets <= 0) weekTickets = 1; + + var weekRevenue = weekTickets * projection.AverageTicketPrice; + var stripeFees = weekRevenue * projection.StripeFeePercent / 100m + + weekTickets * projection.StripeFeeFixed; + var ttFees = weekRevenue * projection.TicketTailorFeePercent / 100m; + + projections.Add(new TicketingWeekProjection + { + WeekLabel = FormatTicketingWeekLabel(weekStart, weekEnd), + WeekStart = weekStart, + WeekEnd = weekEnd, + ProjectedTickets = weekTickets, + ProjectedRevenue = Math.Round(weekRevenue, 2), + ProjectedStripeFees = Math.Round(stripeFees, 2), + ProjectedTtFees = Math.Round(ttFees, 2) + }); + + weekStart = weekEnd.PlusDays(1); + // Snap to next Monday + weekStart = GetTicketingIsoMonday(weekStart); + if (weekStart <= weekEnd) weekStart = weekEnd.PlusDays(1); + } + + return projections; + } + + public int GetActualTicketsSold(BudgetGroup ticketingGroup) + { + var revenueCategory = ticketingGroup.Categories + .FirstOrDefault(c => string.Equals(c.Name, "Ticket Revenue", StringComparison.Ordinal)); + + if (revenueCategory is null) return 0; + + // Sum ticket counts from auto-generated (non-projected) revenue line items. + // These are the actuals lines with notes like "187 tickets". + var total = 0; + foreach (var item in revenueCategory.LineItems) + { + if (!item.IsAutoGenerated) continue; + if (item.Description.StartsWith(TicketingProjectedPrefix, StringComparison.Ordinal)) continue; + if (string.IsNullOrEmpty(item.Notes)) continue; + + // Notes format: "187 tickets" or "~42 tickets" (projected use ~) + var notes = item.Notes.TrimStart('~'); + var spaceIdx = notes.IndexOf(' ', StringComparison.Ordinal); + if (spaceIdx > 0 && int.TryParse( + notes.AsSpan(0, spaceIdx), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var count)) + { + total += count; + } + } + + return total; + } + + private async Task LoadTicketingGroupAsync(Guid budgetYearId, CancellationToken ct) + { + return await _dbContext.BudgetGroups + .Include(g => g.Categories) + .ThenInclude(c => c.LineItems) + .Include(g => g.TicketingProjection) + .FirstOrDefaultAsync(g => g.BudgetYearId == budgetYearId && g.IsTicketingGroup, ct); + } + + /// + /// Updates projection parameters (AvgTicketPrice, StripeFeePercent, TicketTailorFeePercent) + /// from actual order data so that future projections use real averages. + /// + private void UpdateProjectionFromActuals( + TicketingProjection projection, + decimal totalRevenue, decimal totalStripeFees, decimal totalTtFees, int totalTickets, Instant now) + { + if (totalTickets > 0) + { + projection.AverageTicketPrice = Math.Round(totalRevenue / totalTickets, 2); + } + + if (totalRevenue > 0) + { + projection.StripeFeePercent = Math.Round(totalStripeFees / totalRevenue * 100m, 2); + projection.TicketTailorFeePercent = Math.Round(totalTtFees / totalRevenue * 100m, 2); + } + + projection.UpdatedAt = now; + + _logger.LogInformation( + "Updated projection from actuals: AvgPrice={AvgPrice}, StripeFee={StripeFee}%, TtFee={TtFee}%, from {Tickets} tickets", + projection.AverageTicketPrice, projection.StripeFeePercent, projection.TicketTailorFeePercent, totalTickets); + } + + /// + /// Remove old projected line items, then create new ones from current projection parameters. + /// Returns number of items created. + /// + private int MaterializeTicketingProjections( + BudgetGroup ticketingGroup, + BudgetCategory revenueCategory, BudgetCategory feesCategory, Instant now) + { + var projection = ticketingGroup.TicketingProjection; + if (projection is null || projection.StartDate is null || projection.EventDate is null + || projection.AverageTicketPrice == 0) + { + // No projection configured — remove any stale projected items + RemoveTicketingProjectedItems(revenueCategory, feesCategory); + return 0; + } + + // Remove old projected items first + RemoveTicketingProjectedItems(revenueCategory, feesCategory); + + var today = _clock.GetCurrentInstant().InUtc().Date; + var currentWeekMonday = GetTicketingIsoMonday(today); + var eventDate = projection.EventDate.Value; + + var projectionStart = currentWeekMonday > projection.StartDate.Value + ? currentWeekMonday + : GetTicketingIsoMonday(projection.StartDate.Value); + + if (projectionStart >= eventDate) return 0; + + var dailyRate = projection.DailySalesRate; + var initialBurst = projection.InitialSalesCount; + var isFirstWeek = true; + var created = 0; + var weekStart = projectionStart; + + while (weekStart < eventDate) + { + var weekEnd = weekStart.PlusDays(6); + if (weekEnd > eventDate) weekEnd = eventDate; + + var daysInWeek = Period.Between(weekStart, weekEnd.PlusDays(1), PeriodUnits.Days).Days; + + // First projected week includes initial burst if start date hasn't passed + var weekTickets = (int)Math.Round(dailyRate * daysInWeek); + if (isFirstWeek && projectionStart <= projection.StartDate.Value) + { + weekTickets += initialBurst; + isFirstWeek = false; + } + else + { + isFirstWeek = false; + } + + if (weekTickets <= 0) weekTickets = 1; + + var weekRevenue = weekTickets * projection.AverageTicketPrice; + + // Fees on revenue only + var stripeFees = weekRevenue * projection.StripeFeePercent / 100m + + weekTickets * projection.StripeFeeFixed; + var ttFees = weekRevenue * projection.TicketTailorFeePercent / 100m; + + var weekLabel = FormatTicketingWeekLabel(weekStart, weekEnd); + + // Revenue with VatRate — existing VAT projection system handles VAT automatically + created += UpsertTicketingLineItem(revenueCategory, $"{TicketingProjectedPrefix}{TicketingRevenuePrefix}{weekLabel}", + Math.Round(weekRevenue, 2), weekStart, projection.VatRate, false, $"~{weekTickets} tickets", now); + + if (stripeFees > 0) + created += UpsertTicketingLineItem(feesCategory, $"{TicketingProjectedPrefix}{TicketingStripePrefix}{weekLabel}", + -Math.Round(stripeFees, 2), weekStart, TicketingFeeVatRate, false, null, now); + if (ttFees > 0) + created += UpsertTicketingLineItem(feesCategory, $"{TicketingProjectedPrefix}{TicketingTtPrefix}{weekLabel}", + -Math.Round(ttFees, 2), weekStart, TicketingFeeVatRate, false, null, now); + + weekStart = weekEnd.PlusDays(1); + weekStart = GetTicketingIsoMonday(weekStart); + if (weekStart <= weekEnd) weekStart = weekEnd.PlusDays(1); + } + + return created; + } + + private void RemoveTicketingProjectedItems(params BudgetCategory[] categories) + { + foreach (var category in categories) + { + var projected = category.LineItems + .Where(li => li.IsAutoGenerated && li.Description.StartsWith(TicketingProjectedPrefix, StringComparison.Ordinal)) + .ToList(); + + foreach (var item in projected) + { + category.LineItems.Remove(item); + _dbContext.BudgetLineItems.Remove(item); + } + } + } + + /// + /// Upsert a line item by description match within a category (auto-generated items only). + /// Returns 1 if created or updated, 0 if unchanged. + /// + private int UpsertTicketingLineItem( + BudgetCategory category, string description, decimal amount, + LocalDate expectedDate, int vatRate, bool isCashflowOnly, string? notes, Instant now) + { + var existing = category.LineItems + .FirstOrDefault(li => li.IsAutoGenerated && string.Equals(li.Description, description, StringComparison.Ordinal)); + + if (existing is not null) + { + // Update if values changed + if (existing.Amount == amount && existing.VatRate == vatRate + && string.Equals(existing.Notes, notes, StringComparison.Ordinal)) + return 0; + + existing.Amount = amount; + existing.VatRate = vatRate; + existing.Notes = notes; + existing.ExpectedDate = expectedDate; + existing.UpdatedAt = now; + return 1; + } + + // Create new + var maxSort = category.LineItems.Any() ? category.LineItems.Max(li => li.SortOrder) : -1; + var lineItem = new BudgetLineItem + { + Id = Guid.NewGuid(), + BudgetCategoryId = category.Id, + Description = description, + Amount = amount, + ExpectedDate = expectedDate, + VatRate = vatRate, + IsAutoGenerated = true, + IsCashflowOnly = isCashflowOnly, + Notes = notes, + SortOrder = maxSort + 1, + CreatedAt = now, + UpdatedAt = now + }; + + _dbContext.BudgetLineItems.Add(lineItem); + category.LineItems.Add(lineItem); + return 1; + } + + private static LocalDate GetTicketingIsoMonday(LocalDate date) + { + // NodaTime IsoDayOfWeek: Monday=1, Sunday=7 + var dayOfWeek = (int)date.DayOfWeek; + return date.PlusDays(-(dayOfWeek - 1)); + } + + private static string FormatTicketingWeekLabel(LocalDate monday, LocalDate sunday) + { + return $"{monday.ToString("MMM d", null)}–{sunday.ToString("MMM d", null)}"; + } } diff --git a/src/Humans.Infrastructure/Services/CampaignService.cs b/src/Humans.Infrastructure/Services/CampaignService.cs index 840c2ff47..8bd9ec8ed 100644 --- a/src/Humans.Infrastructure/Services/CampaignService.cs +++ b/src/Humans.Infrastructure/Services/CampaignService.cs @@ -1,16 +1,10 @@ -using System.Net; -using System.Text.Json; using Humans.Application.DTOs; using Humans.Application.Interfaces; using Humans.Domain.Entities; using Humans.Domain.Enums; -using Humans.Infrastructure.Configuration; using Humans.Infrastructure.Data; -using Humans.Infrastructure.Helpers; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using NodaTime; namespace Humans.Infrastructure.Services; @@ -19,30 +13,24 @@ public class CampaignService : ICampaignService { private readonly HumansDbContext _dbContext; private readonly IClock _clock; - private readonly IHumansMetrics _metrics; private readonly INotificationService _notificationService; private readonly ICommunicationPreferenceService _commPrefService; - private readonly EmailSettings _settings; - private readonly string _environmentName; + private readonly IEmailService _emailService; private readonly ILogger _logger; public CampaignService( HumansDbContext dbContext, IClock clock, - IHumansMetrics metrics, INotificationService notificationService, ICommunicationPreferenceService commPrefService, - IOptions settings, - IHostEnvironment hostEnvironment, + IEmailService emailService, ILogger logger) { _dbContext = dbContext; _clock = clock; - _metrics = metrics; _notificationService = notificationService; _commPrefService = commPrefService; - _settings = settings.Value; - _environmentName = hostEnvironment.EnvironmentName; + _emailService = emailService; _logger = logger; } @@ -389,6 +377,9 @@ public async Task SendWaveAsync(Guid campaignId, Guid teamId, CancellationT var now = _clock.GetCurrentInstant(); + // Persist grants first so each has an Id before we enqueue emails through + // the EmailOutboxService (which owns EmailOutboxMessages and commits on its own). + var grantsByUserId = new Dictionary(); for (var i = 0; i < eligibleUsers.Count; i++) { var user = eligibleUsers[i]; @@ -405,15 +396,20 @@ public async Task SendWaveAsync(Guid campaignId, Guid teamId, CancellationT LatestEmailAt = now }; _dbContext.CampaignGrants.Add(grant); - - var outboxMessage = RenderOutboxMessage(campaign, user, code.Code, grant.Id, now); - _dbContext.EmailOutboxMessages.Add(outboxMessage); - - _metrics.RecordEmailQueued("campaign_code"); + grantsByUserId[user.Id] = (grant, code); } await _dbContext.SaveChangesAsync(ct); + // Enqueue campaign emails via the Email service (owner of email_outbox_messages). + foreach (var user in eligibleUsers) + { + var (grant, code) = grantsByUserId[user.Id]; + await _emailService.SendCampaignCodeAsync( + BuildCampaignCodeRequest(campaign, user, code.Code, grant.Id), + ct); + } + _logger.LogInformation( "Campaign {CampaignId}: sent wave to team {TeamId}, {Count} grants created", campaignId, teamId, eligibleUsers.Count); @@ -450,19 +446,78 @@ public async Task ResendToGrantAsync(Guid grantId, CancellationToken ct = defaul var now = _clock.GetCurrentInstant(); - var outboxMessage = RenderOutboxMessage( - grant.Campaign, grant.User, grant.Code.Code, grant.Id, now); - _dbContext.EmailOutboxMessages.Add(outboxMessage); - grant.LatestEmailStatus = EmailOutboxStatus.Queued; grant.LatestEmailAt = now; await _dbContext.SaveChangesAsync(ct); - _metrics.RecordEmailQueued("campaign_code"); + await _emailService.SendCampaignCodeAsync( + BuildCampaignCodeRequest(grant.Campaign, grant.User, grant.Code.Code, grant.Id), + ct); + _logger.LogInformation("Resent campaign email for grant {GrantId}", grantId); } + public async Task MarkGrantsRedeemedAsync( + IReadOnlyCollection redemptions, + CancellationToken ct = default) + { + if (redemptions.Count == 0) + return 0; + + // Case-insensitive set of codes we need to look up in the database. + var codeStrings = redemptions + .Where(r => !string.IsNullOrEmpty(r.Code)) + .Select(r => r.Code) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (codeStrings.Count == 0) + return 0; + + // Load unredeemed grants on active/completed campaigns. Filter by code in + // memory so the DB query stays simple and collation-independent. + var unredeemed = (await _dbContext.CampaignGrants + .Include(g => g.Code) + .Include(g => g.Campaign) + .Where(g => g.Code != null + && (g.Campaign.Status == CampaignStatus.Active || g.Campaign.Status == CampaignStatus.Completed) + && g.RedeemedAt == null) + .ToListAsync(ct)) + .Where(g => g.Code != null && codeStrings.Contains(g.Code.Code)) + .ToList(); + + // Iterate redemptions in input order, matching one grant per redemption so + // that N orders with the same code redeem N distinct grants (matching the + // prior single-service behavior). When a code matches grants in multiple + // campaigns, the most recently created campaign wins. + var redeemedCount = 0; + foreach (var redemption in redemptions) + { + if (string.IsNullOrEmpty(redemption.Code)) + continue; + + var grant = unredeemed + .Where(g => string.Equals(g.Code!.Code, redemption.Code, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(g => g.Campaign.CreatedAt) + .FirstOrDefault(); + + if (grant is null) + continue; + + grant.RedeemedAt = redemption.RedeemedAt; + unredeemed.Remove(grant); + redeemedCount++; + } + + if (redeemedCount > 0) + { + await _dbContext.SaveChangesAsync(ct); + _logger.LogInformation("Marked {Count} campaign grants redeemed from discount-code matches", redeemedCount); + } + + return redeemedCount; + } + public async Task RetryAllFailedAsync(Guid campaignId, CancellationToken ct = default) { var failedGrants = await _dbContext.CampaignGrants @@ -480,18 +535,20 @@ public async Task RetryAllFailedAsync(Guid campaignId, CancellationToken ct = de foreach (var grant in failedGrants) { - var outboxMessage = RenderOutboxMessage( - grant.Campaign, grant.User, grant.Code.Code, grant.Id, now); - _dbContext.EmailOutboxMessages.Add(outboxMessage); - grant.LatestEmailStatus = EmailOutboxStatus.Queued; grant.LatestEmailAt = now; - - _metrics.RecordEmailQueued("campaign_code"); } await _dbContext.SaveChangesAsync(ct); + // Enqueue via the Email service (owner of email_outbox_messages). + foreach (var grant in failedGrants) + { + await _emailService.SendCampaignCodeAsync( + BuildCampaignCodeRequest(grant.Campaign, grant.User, grant.Code.Code, grant.Id), + ct); + } + _logger.LogInformation( "Campaign {CampaignId}: retried {Count} failed grants", campaignId, failedGrants.Count); @@ -513,54 +570,24 @@ private async Task CountAvailableCodesAsync(Guid campaignId, CancellationTo .CountAsync(ct); } - private EmailOutboxMessage RenderOutboxMessage( - Campaign campaign, User user, string code, Guid grantId, Instant now) + /// + /// Build a describing the raw campaign content + /// (unrendered markdown template, recipient, grant id). The outbox email service + /// performs the actual markdown→HTML rendering and template wrapping — keeping + /// email_outbox_messages ownership in a single place. + /// + private static CampaignCodeEmailRequest BuildCampaignCodeRequest( + Campaign campaign, User user, string code, Guid grantId) { - var recipientEmail = GetNotificationEmail(user); - var name = user.DisplayName; - - var encodedCode = WebUtility.HtmlEncode(code); - var encodedName = WebUtility.HtmlEncode(name); - - // Substitute placeholders in Markdown source, then render to HTML - var markdown = campaign.EmailBodyTemplate - .Replace("{{Code}}", encodedCode, StringComparison.Ordinal) - .Replace("{{Name}}", encodedName, StringComparison.Ordinal); - var renderedBody = Markdig.Markdown.ToHtml(markdown); - - var renderedSubject = campaign.EmailSubject - .Replace("{{Code}}", code, StringComparison.Ordinal) - .Replace("{{Name}}", name, StringComparison.Ordinal); - - // Generate unsubscribe headers and footer link for CampaignCodes category - var unsubHeaders = _commPrefService.GenerateUnsubscribeHeaders(user.Id, MessageCategory.CampaignCodes); - string? extraHeadersJson = null; - if (unsubHeaders is not null) - { - extraHeadersJson = JsonSerializer.Serialize(unsubHeaders); - } - var unsubscribeUrl = _commPrefService.GenerateBrowserUnsubscribeUrl(user.Id, MessageCategory.CampaignCodes); - - // Wrap in email template - var (wrappedHtml, plainText) = EmailBodyComposer.Compose( - renderedBody, _settings.BaseUrl, _environmentName, unsubscribeUrl); - - return new EmailOutboxMessage - { - Id = Guid.NewGuid(), - RecipientEmail = recipientEmail, - RecipientName = name, - Subject = renderedSubject, - HtmlBody = wrappedHtml, - PlainTextBody = plainText, - TemplateName = "campaign_code", - UserId = user.Id, - CampaignGrantId = grantId, - ReplyTo = campaign.ReplyToAddress, - ExtraHeaders = extraHeadersJson, - Status = EmailOutboxStatus.Queued, - CreatedAt = now - }; + return new CampaignCodeEmailRequest( + UserId: user.Id, + CampaignGrantId: grantId, + RecipientEmail: GetNotificationEmail(user), + RecipientName: user.DisplayName, + Subject: campaign.EmailSubject, + MarkdownBody: campaign.EmailBodyTemplate, + Code: code, + ReplyTo: campaign.ReplyToAddress); } private static string GetNotificationEmail(User user) diff --git a/src/Humans.Infrastructure/Services/OutboxEmailService.cs b/src/Humans.Infrastructure/Services/OutboxEmailService.cs index f84d7887e..3fd925740 100644 --- a/src/Humans.Infrastructure/Services/OutboxEmailService.cs +++ b/src/Humans.Infrastructure/Services/OutboxEmailService.cs @@ -317,6 +317,61 @@ await EnqueueAsync(recoveryEmail, userName, content, "workspace_credentials", ca triggerImmediate: true); } + /// + public async Task SendCampaignCodeAsync(CampaignCodeEmailRequest request, CancellationToken cancellationToken = default) + { + // Substitute placeholders in the Markdown source, then render to HTML. + // HTML-encode the substitutions so malicious codes/names cannot inject markup. + var encodedCode = System.Net.WebUtility.HtmlEncode(request.Code); + var encodedName = System.Net.WebUtility.HtmlEncode(request.RecipientName); + + var markdown = request.MarkdownBody + .Replace("{{Code}}", encodedCode, StringComparison.Ordinal) + .Replace("{{Name}}", encodedName, StringComparison.Ordinal); + var renderedBody = Markdig.Markdown.ToHtml(markdown); + + var renderedSubject = request.Subject + .Replace("{{Code}}", request.Code, StringComparison.Ordinal) + .Replace("{{Name}}", request.RecipientName, StringComparison.Ordinal); + + // Generate unsubscribe headers and footer link for the CampaignCodes category + var unsubHeaders = _commPrefService.GenerateUnsubscribeHeaders(request.UserId, MessageCategory.CampaignCodes); + string? extraHeadersJson = null; + if (unsubHeaders is not null) + { + extraHeadersJson = System.Text.Json.JsonSerializer.Serialize(unsubHeaders); + } + var unsubscribeUrl = _commPrefService.GenerateBrowserUnsubscribeUrl(request.UserId, MessageCategory.CampaignCodes); + + var (wrappedHtml, plainText) = EmailBodyComposer.Compose( + renderedBody, _settings.BaseUrl, _environmentName, unsubscribeUrl); + + var message = new EmailOutboxMessage + { + Id = Guid.NewGuid(), + RecipientEmail = request.RecipientEmail, + RecipientName = request.RecipientName, + Subject = renderedSubject, + HtmlBody = wrappedHtml, + PlainTextBody = plainText, + TemplateName = "campaign_code", + UserId = request.UserId, + CampaignGrantId = request.CampaignGrantId, + ReplyTo = request.ReplyTo, + ExtraHeaders = extraHeadersJson, + Status = EmailOutboxStatus.Queued, + CreatedAt = _clock.GetCurrentInstant() + }; + + _dbContext.EmailOutboxMessages.Add(message); + await _dbContext.SaveChangesAsync(cancellationToken); + + _metrics.RecordEmailQueued("campaign_code"); + _logger.LogInformation( + "Campaign code email queued for grant {GrantId} to {Recipient}", + request.CampaignGrantId, request.RecipientEmail); + } + private async Task EnqueueAsync( string recipientEmail, string recipientName, diff --git a/src/Humans.Infrastructure/Services/ProfileService.cs b/src/Humans.Infrastructure/Services/ProfileService.cs index bb061ec0a..b1995309a 100644 --- a/src/Humans.Infrastructure/Services/ProfileService.cs +++ b/src/Humans.Infrastructure/Services/ProfileService.cs @@ -22,6 +22,7 @@ public class ProfileService : IProfileService private readonly IAuditLogService _auditLogService; private readonly IMembershipCalculator _membershipCalculator; private readonly IConsentService _consentService; + private readonly ITicketQueryService _ticketQueryService; private readonly IClock _clock; private readonly IMemoryCache _cache; private readonly ILogger _logger; @@ -33,6 +34,7 @@ public ProfileService( IAuditLogService auditLogService, IMembershipCalculator membershipCalculator, IConsentService consentService, + ITicketQueryService ticketQueryService, IClock clock, IMemoryCache cache, ILogger logger) @@ -43,6 +45,7 @@ public ProfileService( _auditLogService = auditLogService; _membershipCalculator = membershipCalculator; _consentService = consentService; + _ticketQueryService = ticketQueryService; _clock = clock; _cache = cache; _logger = logger; @@ -405,27 +408,9 @@ public async Task CancelDeletionAsync(Guid userId, Cancellatio if (activeEvent is null) return null; - // Use the sync state's VendorEventId to scope to the current event's tickets - var syncState = await _dbContext.Set().FirstOrDefaultAsync(ct); - if (syncState is null || string.IsNullOrEmpty(syncState.VendorEventId)) - return null; - - var vendorEventId = syncState.VendorEventId; - - // Only hold for paid orders with valid/checked-in attendees - var hasTickets = await _dbContext.TicketOrders - .AnyAsync(o => o.MatchedUserId == userId - && o.VendorEventId == vendorEventId - && o.PaymentStatus == TicketPaymentStatus.Paid, ct); - - if (!hasTickets) - { - hasTickets = await _dbContext.TicketAttendees - .AnyAsync(a => a.MatchedUserId == userId - && a.VendorEventId == vendorEventId - && (a.Status == TicketAttendeeStatus.Valid || a.Status == TicketAttendeeStatus.CheckedIn), ct); - } - + // Ticket tables are owned by the Tickets section — route through ITicketQueryService + // to check whether this user holds a ticket for the current event. + var hasTickets = await _ticketQueryService.HasCurrentEventTicketAsync(userId, ct); if (!hasTickets) return null; @@ -549,16 +534,9 @@ public async Task ExportDataAsync(Guid userId, CancellationToken ct = de .OrderByDescending(nr => nr.Notification.CreatedAt) .ToListAsync(ct); - var ticketOrders = await _dbContext.TicketOrders - .AsNoTracking() - .Where(to => to.MatchedUserId == userId) - .OrderByDescending(to => to.PurchasedAt) - .ToListAsync(ct); - - var ticketAttendees = await _dbContext.TicketAttendees - .AsNoTracking() - .Where(ta => ta.MatchedUserId == userId) - .ToListAsync(ct); + // Ticket tables are owned by the Tickets section — route export reads + // through ITicketQueryService. + var ticketExport = await _ticketQueryService.GetUserTicketExportDataAsync(userId, ct); var campaignGrants = await _dbContext.CampaignGrants .AsNoTracking() @@ -803,7 +781,7 @@ public async Task ExportDataAsync(Guid userId, CancellationToken ct = de ReadAt = nr.ReadAt.ToInvariantInstantString(), ResolvedAt = nr.Notification.ResolvedAt.ToInvariantInstantString() }), - TicketOrders = ticketOrders.Select(to => new + TicketOrders = ticketExport.Orders.Select(to => new { to.BuyerName, to.BuyerEmail, @@ -813,7 +791,7 @@ public async Task ExportDataAsync(Guid userId, CancellationToken ct = de to.DiscountCode, PurchasedAt = to.PurchasedAt.ToInvariantInstantString() }), - TicketAttendeeMatches = ticketAttendees + TicketAttendeeMatches = ticketExport.Attendees .Select(ta => new { ta.AttendeeName, diff --git a/src/Humans.Infrastructure/Services/SmtpEmailService.cs b/src/Humans.Infrastructure/Services/SmtpEmailService.cs index a6c90e161..5b845ca7b 100644 --- a/src/Humans.Infrastructure/Services/SmtpEmailService.cs +++ b/src/Humans.Infrastructure/Services/SmtpEmailService.cs @@ -297,6 +297,27 @@ public async Task SendWorkspaceCredentialsAsync( _metrics.RecordEmailSent("workspace_credentials"); } + public async Task SendCampaignCodeAsync(CampaignCodeEmailRequest request, CancellationToken cancellationToken = default) + { + // SmtpEmailService sends inline (no outbox). Render the campaign markdown + // body with {{Code}}/{{Name}} placeholders and deliver via SMTP. We HTML- + // encode the substitutions so content cannot inject markup. + var encodedCode = System.Net.WebUtility.HtmlEncode(request.Code); + var encodedName = System.Net.WebUtility.HtmlEncode(request.RecipientName); + + var markdown = request.MarkdownBody + .Replace("{{Code}}", encodedCode, StringComparison.Ordinal) + .Replace("{{Name}}", encodedName, StringComparison.Ordinal); + var renderedBody = Markdig.Markdown.ToHtml(markdown); + + var renderedSubject = request.Subject + .Replace("{{Code}}", request.Code, StringComparison.Ordinal) + .Replace("{{Name}}", request.RecipientName, StringComparison.Ordinal); + + await SendEmailAsync(request.RecipientEmail, renderedSubject, renderedBody, cancellationToken, request.ReplyTo); + _metrics.RecordEmailSent("campaign_code"); + } + private async Task SendEmailAsync( string toAddress, string subject, diff --git a/src/Humans.Infrastructure/Services/StubEmailService.cs b/src/Humans.Infrastructure/Services/StubEmailService.cs index 454f2a76e..d1fef7da6 100644 --- a/src/Humans.Infrastructure/Services/StubEmailService.cs +++ b/src/Humans.Infrastructure/Services/StubEmailService.cs @@ -270,4 +270,12 @@ public Task SendWorkspaceCredentialsAsync( _logger.LogInformation("[STUB] Would send workspace credentials for {WorkspaceEmail} to {RecoveryEmail}", workspaceEmail, recoveryEmail); return Task.CompletedTask; } + + public Task SendCampaignCodeAsync(CampaignCodeEmailRequest request, CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "[STUB] Would send campaign code email to {Email} ({Name}) for grant {GrantId}", + request.RecipientEmail, request.RecipientName, request.CampaignGrantId); + return Task.CompletedTask; + } } diff --git a/src/Humans.Infrastructure/Services/TicketQueryService.cs b/src/Humans.Infrastructure/Services/TicketQueryService.cs index c7245ac2b..e69d175b6 100644 --- a/src/Humans.Infrastructure/Services/TicketQueryService.cs +++ b/src/Humans.Infrastructure/Services/TicketQueryService.cs @@ -896,6 +896,63 @@ public async Task> GetUserTicketOrderSummariesAsync .ToListAsync(); } + /// + public async Task HasCurrentEventTicketAsync(Guid userId, CancellationToken ct = default) + { + // Use the sync state's VendorEventId to scope to the current event's tickets. + var syncState = await _dbContext.TicketSyncStates.FirstOrDefaultAsync(ct); + if (syncState is null || string.IsNullOrEmpty(syncState.VendorEventId)) + return false; + + var vendorEventId = syncState.VendorEventId; + + // Only hold for paid orders with the user matched as buyer. + var hasPaidOrder = await _dbContext.TicketOrders + .AnyAsync(o => o.MatchedUserId == userId + && o.VendorEventId == vendorEventId + && o.PaymentStatus == TicketPaymentStatus.Paid, ct); + + if (hasPaidOrder) + return true; + + // Fallback: user matched as attendee on a valid/checked-in ticket. + return await _dbContext.TicketAttendees + .AnyAsync(a => a.MatchedUserId == userId + && a.VendorEventId == vendorEventId + && (a.Status == TicketAttendeeStatus.Valid || a.Status == TicketAttendeeStatus.CheckedIn), ct); + } + + /// + public async Task GetUserTicketExportDataAsync(Guid userId, CancellationToken ct = default) + { + var orders = await _dbContext.TicketOrders + .AsNoTracking() + .Where(o => o.MatchedUserId == userId) + .OrderByDescending(o => o.PurchasedAt) + .Select(o => new UserTicketOrderExportRow( + o.BuyerName, + o.BuyerEmail, + o.TotalAmount, + o.Currency, + o.PaymentStatus.ToString(), + o.DiscountCode, + o.PurchasedAt)) + .ToListAsync(ct); + + var attendees = await _dbContext.TicketAttendees + .AsNoTracking() + .Where(a => a.MatchedUserId == userId) + .Select(a => new UserTicketAttendeeExportRow( + a.AttendeeName, + a.AttendeeEmail, + a.TicketTypeName, + a.Price, + a.Status.ToString())) + .ToListAsync(ct); + + return new UserTicketExportData(orders, attendees); + } + private static bool HasSearchTerm([NotNullWhen(true)] string? value, int minLength = 2) => !string.IsNullOrWhiteSpace(value) && value.Trim().Length >= minLength; diff --git a/src/Humans.Infrastructure/Services/TicketSyncService.cs b/src/Humans.Infrastructure/Services/TicketSyncService.cs index 000d9dc5c..50d0474af 100644 --- a/src/Humans.Infrastructure/Services/TicketSyncService.cs +++ b/src/Humans.Infrastructure/Services/TicketSyncService.cs @@ -24,6 +24,7 @@ public class TicketSyncService : ITicketSyncService private readonly TicketVendorSettings _settings; private readonly IMemoryCache _cache; private readonly IUserService _userService; + private readonly ICampaignService _campaignService; private readonly ILogger _logger; public TicketSyncService( @@ -34,7 +35,8 @@ public TicketSyncService( IOptions settings, ILogger logger, IMemoryCache cache, - IUserService userService) + IUserService userService, + ICampaignService campaignService) { _dbContext = dbContext; _vendorService = vendorService; @@ -43,6 +45,7 @@ public TicketSyncService( _settings = settings.Value; _cache = cache; _userService = userService; + _campaignService = campaignService; _logger = logger; } @@ -313,49 +316,17 @@ private async Task MatchDiscountCodesAsync(CancellationToken ct) { var ordersWithCodes = await _dbContext.TicketOrders .Where(o => o.DiscountCode != null) - .Select(o => new { o.DiscountCode, o.PurchasedAt }) + .Select(o => new { Code = o.DiscountCode!, o.PurchasedAt }) .ToListAsync(ct); if (ordersWithCodes.Count == 0) return 0; - var codeStrings = new HashSet( - ordersWithCodes.Select(o => o.DiscountCode!), - StringComparer.OrdinalIgnoreCase); - - // Match codes with ordinal ignore-case semantics so database collation/casing rules - // do not cause valid imported codes to be skipped. - var unredeemed = (await _dbContext.Set() - .Include(g => g.Code) - .Include(g => g.Campaign) - .Where(g => g.Code != null - && (g.Campaign.Status == CampaignStatus.Active || g.Campaign.Status == CampaignStatus.Completed) - && g.RedeemedAt == null) - .ToListAsync(ct)) - .Where(g => codeStrings.Contains(g.Code!.Code)) + var redemptions = ordersWithCodes + .Select(o => new DiscountCodeRedemption(o.Code, o.PurchasedAt)) .ToList(); - var codesRedeemed = 0; - foreach (var order in ordersWithCodes) - { - if (order.DiscountCode is null) continue; - - var grant = unredeemed - .Where(g => string.Equals(g.Code!.Code, order.DiscountCode, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(g => g.Campaign.CreatedAt) - .FirstOrDefault(); - - if (grant is not null) - { - grant.RedeemedAt = order.PurchasedAt; - unredeemed.Remove(grant); - codesRedeemed++; - } - } - - if (codesRedeemed > 0) - await _dbContext.SaveChangesAsync(ct); - - return codesRedeemed; + // Delegate to CampaignService — CampaignGrants is owned by the Campaigns section. + return await _campaignService.MarkGrantsRedeemedAsync(redemptions, ct); } private async Task EnrichOrdersWithStripeDataAsync(CancellationToken ct) diff --git a/src/Humans.Infrastructure/Services/TicketingBudgetService.cs b/src/Humans.Infrastructure/Services/TicketingBudgetService.cs index 0bc94dbbb..5250c519c 100644 --- a/src/Humans.Infrastructure/Services/TicketingBudgetService.cs +++ b/src/Humans.Infrastructure/Services/TicketingBudgetService.cs @@ -9,27 +9,28 @@ namespace Humans.Infrastructure.Services; +/// +/// Orchestrator between ticket sales data (owned by the Tickets section) and the +/// ticketing budget group (owned by the Budget section). Queries TicketOrders — +/// which this service co-owns as part of the Tickets section — aggregates them +/// into completed ISO weeks, and delegates all BudgetLineItem / TicketingProjection +/// mutations to . +/// public class TicketingBudgetService : ITicketingBudgetService { private readonly HumansDbContext _dbContext; + private readonly IBudgetService _budgetService; private readonly IClock _clock; private readonly ILogger _logger; - // Prefix for auto-generated line item descriptions to identify them during sync - private const string RevenuePrefix = "Week of "; - private const string StripePrefix = "Stripe fees: "; - private const string TtPrefix = "TT fees: "; - private const string ProjectedPrefix = "Projected: "; - - // Spanish IVA rate applied to Stripe and TicketTailor processing fees - private const int FeeVatRate = 21; - public TicketingBudgetService( HumansDbContext dbContext, + IBudgetService budgetService, IClock clock, ILogger logger) { _dbContext = dbContext; + _budgetService = budgetService; _clock = clock; _logger = logger; } @@ -38,24 +39,8 @@ public async Task SyncActualsAsync(Guid budgetYearId, CancellationToken ct { try { - var ticketingGroup = await LoadTicketingGroupAsync(budgetYearId); - if (ticketingGroup is null) - { - _logger.LogDebug("No ticketing group found for budget year {YearId}", budgetYearId); - return 0; - } - - var revenueCategory = ticketingGroup.Categories.FirstOrDefault(c => string.Equals(c.Name, "Ticket Revenue", StringComparison.Ordinal)); - var feesCategory = ticketingGroup.Categories.FirstOrDefault(c => string.Equals(c.Name, "Processing Fees", StringComparison.Ordinal)); - - if (revenueCategory is null || feesCategory is null) - { - _logger.LogWarning("Ticketing group missing expected categories for year {YearId}", budgetYearId); - return 0; - } - - var projectionVatRate = ticketingGroup.TicketingProjection?.VatRate ?? 0; - + // Read ticket sales (TicketOrders is co-owned by the Tickets section, + // of which this service is a member) and aggregate them per ISO week. var orders = await _dbContext.TicketOrders .Where(o => o.PaymentStatus == TicketPaymentStatus.Paid) .Select(o => new @@ -72,7 +57,7 @@ public async Task SyncActualsAsync(Guid budgetYearId, CancellationToken ct var today = _clock.GetCurrentInstant().InUtc().Date; var currentWeekMonday = GetIsoMonday(today); - var weeklyData = orders + var weeklyActuals = orders .GroupBy(o => { var date = o.PurchasedAt.InUtc().Date; @@ -84,57 +69,20 @@ public async Task SyncActualsAsync(Guid budgetYearId, CancellationToken ct { var monday = g.Key; var sunday = monday.PlusDays(6); - return new - { - Monday = monday, - Sunday = sunday, - Label = FormatWeekLabel(monday, sunday), - TicketCount = g.Sum(o => o.TicketCount), - Revenue = g.Sum(o => o.TotalAmount), - StripeFees = g.Sum(o => o.StripeFee ?? 0m), - TtFees = g.Sum(o => o.ApplicationFee ?? 0m) - }; + return new TicketingWeeklyActuals( + Monday: monday, + Sunday: sunday, + WeekLabel: FormatWeekLabel(monday, sunday), + TicketCount: g.Sum(o => o.TicketCount), + Revenue: g.Sum(o => o.TotalAmount), + StripeFees: g.Sum(o => o.StripeFee ?? 0m), + TicketTailorFees: g.Sum(o => o.ApplicationFee ?? 0m)); }) .ToList(); - var now = _clock.GetCurrentInstant(); - var lineItemsCreated = 0; - - foreach (var week in weeklyData) - { - var weekDesc = week.Label; - - lineItemsCreated += UpsertLineItem(revenueCategory, $"{RevenuePrefix}{weekDesc}", - week.Revenue, week.Monday, projectionVatRate, false, $"{week.TicketCount} tickets", now); - - if (week.StripeFees > 0) - lineItemsCreated += UpsertLineItem(feesCategory, $"{StripePrefix}{weekDesc}", - -week.StripeFees, week.Monday, FeeVatRate, false, null, now); - if (week.TtFees > 0) - lineItemsCreated += UpsertLineItem(feesCategory, $"{TtPrefix}{weekDesc}", - -week.TtFees, week.Monday, FeeVatRate, false, null, now); - } - - if (weeklyData.Count > 0 && ticketingGroup.TicketingProjection is not null) - { - var totalRevenue = weeklyData.Sum(w => w.Revenue); - var totalStripeFees = weeklyData.Sum(w => w.StripeFees); - var totalTtFees = weeklyData.Sum(w => w.TtFees); - var totalTickets = weeklyData.Sum(w => w.TicketCount); - - UpdateProjectionFromActuals(ticketingGroup.TicketingProjection, - totalRevenue, totalStripeFees, totalTtFees, totalTickets, now); - } - - lineItemsCreated += MaterializeProjections(ticketingGroup, revenueCategory, feesCategory, now); - - if (_dbContext.ChangeTracker.HasChanges()) - await _dbContext.SaveChangesAsync(ct); - - _logger.LogInformation("Ticketing budget sync: {Created} line items created/updated for {Weeks} actual weeks + projections", - lineItemsCreated, weeklyData.Count); - - return lineItemsCreated; + // Delegate the BudgetLineItem / TicketingProjection mutations to + // BudgetService, which owns those tables. + return await _budgetService.SyncTicketingActualsAsync(budgetYearId, weeklyActuals, ct); } catch (Exception ex) { @@ -147,21 +95,7 @@ public async Task RefreshProjectionsAsync(Guid budgetYearId, CancellationTo { try { - var ticketingGroup = await LoadTicketingGroupAsync(budgetYearId); - if (ticketingGroup is null) return 0; - - var revenueCategory = ticketingGroup.Categories.FirstOrDefault(c => string.Equals(c.Name, "Ticket Revenue", StringComparison.Ordinal)); - var feesCategory = ticketingGroup.Categories.FirstOrDefault(c => string.Equals(c.Name, "Processing Fees", StringComparison.Ordinal)); - if (revenueCategory is null || feesCategory is null) return 0; - - var now = _clock.GetCurrentInstant(); - var created = MaterializeProjections(ticketingGroup, revenueCategory, feesCategory, now); - - if (_dbContext.ChangeTracker.HasChanges()) - await _dbContext.SaveChangesAsync(ct); - - _logger.LogInformation("Ticketing projections refreshed: {Count} line items", created); - return created; + return await _budgetService.RefreshTicketingProjectionsAsync(budgetYearId, ct); } catch (Exception ex) { @@ -170,291 +104,14 @@ public async Task RefreshProjectionsAsync(Guid budgetYearId, CancellationTo } } - /// - /// Updates projection parameters (AvgTicketPrice, StripeFeePercent, TicketTailorFeePercent) - /// from actual order data so that future projections use real averages. - /// - private void UpdateProjectionFromActuals(TicketingProjection projection, - decimal totalRevenue, decimal totalStripeFees, decimal totalTtFees, int totalTickets, Instant now) + public Task> GetProjectionsAsync(Guid budgetGroupId) { - if (totalTickets > 0) - { - projection.AverageTicketPrice = Math.Round(totalRevenue / totalTickets, 2); - } - - if (totalRevenue > 0) - { - projection.StripeFeePercent = Math.Round(totalStripeFees / totalRevenue * 100m, 2); - projection.TicketTailorFeePercent = Math.Round(totalTtFees / totalRevenue * 100m, 2); - } - - projection.UpdatedAt = now; - - _logger.LogInformation( - "Updated projection from actuals: AvgPrice={AvgPrice}, StripeFee={StripeFee}%, TtFee={TtFee}%, from {Tickets} tickets", - projection.AverageTicketPrice, projection.StripeFeePercent, projection.TicketTailorFeePercent, totalTickets); + return _budgetService.GetTicketingProjectionEntriesAsync(budgetGroupId); } public int GetActualTicketsSold(BudgetGroup ticketingGroup) { - var revenueCategory = ticketingGroup.Categories - .FirstOrDefault(c => string.Equals(c.Name, "Ticket Revenue", StringComparison.Ordinal)); - - if (revenueCategory is null) return 0; - - // Sum ticket counts from auto-generated (non-projected) revenue line items. - // These are the actuals lines with notes like "187 tickets". - var total = 0; - foreach (var item in revenueCategory.LineItems) - { - if (!item.IsAutoGenerated) continue; - if (item.Description.StartsWith(ProjectedPrefix, StringComparison.Ordinal)) continue; - if (string.IsNullOrEmpty(item.Notes)) continue; - - // Notes format: "187 tickets" or "~42 tickets" (projected use ~) - var notes = item.Notes.TrimStart('~'); - var spaceIdx = notes.IndexOf(' ', StringComparison.Ordinal); - if (spaceIdx > 0 && int.TryParse(notes.AsSpan(0, spaceIdx), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var count)) - { - total += count; - } - } - - return total; - } - - private async Task LoadTicketingGroupAsync(Guid budgetYearId) - { - return await _dbContext.BudgetGroups - .Include(g => g.Categories) - .ThenInclude(c => c.LineItems) - .Include(g => g.TicketingProjection) - .FirstOrDefaultAsync(g => g.BudgetYearId == budgetYearId && g.IsTicketingGroup); - } - - public async Task> GetProjectionsAsync(Guid budgetGroupId) - { - var group = await _dbContext.BudgetGroups - .Include(g => g.TicketingProjection) - .Include(g => g.Categories) - .ThenInclude(c => c.LineItems) - .FirstOrDefaultAsync(g => g.Id == budgetGroupId && g.IsTicketingGroup); - - if (group?.TicketingProjection is null) - return []; - - var projection = group.TicketingProjection; - - if (projection.StartDate is null || projection.EventDate is null || projection.AverageTicketPrice == 0) - return []; - - var today = _clock.GetCurrentInstant().InUtc().Date; - var currentWeekMonday = GetIsoMonday(today); - - var projectionStart = currentWeekMonday > projection.StartDate.Value - ? currentWeekMonday - : GetIsoMonday(projection.StartDate.Value); - var eventDate = projection.EventDate.Value; - - if (projectionStart >= eventDate) - return []; - - var dailyRate = projection.DailySalesRate; - var initialBurst = projection.InitialSalesCount; - var isFirstWeek = true; - - var projections = new List(); - var weekStart = projectionStart; - - while (weekStart < eventDate) - { - var weekEnd = weekStart.PlusDays(6); - if (weekEnd > eventDate) weekEnd = eventDate; - - var daysInWeek = Period.Between(weekStart, weekEnd.PlusDays(1), PeriodUnits.Days).Days; - var weekTickets = (int)Math.Round(dailyRate * daysInWeek); - if (isFirstWeek && projectionStart <= projection.StartDate.Value) - { - weekTickets += initialBurst; - isFirstWeek = false; - } - else - { - isFirstWeek = false; - } - if (weekTickets <= 0) weekTickets = 1; - - var weekRevenue = weekTickets * projection.AverageTicketPrice; - var stripeFees = weekRevenue * projection.StripeFeePercent / 100m + - weekTickets * projection.StripeFeeFixed; - var ttFees = weekRevenue * projection.TicketTailorFeePercent / 100m; - - projections.Add(new TicketingWeekProjection - { - WeekLabel = FormatWeekLabel(weekStart, weekEnd), - WeekStart = weekStart, - WeekEnd = weekEnd, - ProjectedTickets = weekTickets, - ProjectedRevenue = Math.Round(weekRevenue, 2), - ProjectedStripeFees = Math.Round(stripeFees, 2), - ProjectedTtFees = Math.Round(ttFees, 2) - }); - - weekStart = weekEnd.PlusDays(1); - // Snap to next Monday - weekStart = GetIsoMonday(weekStart); - if (weekStart <= weekEnd) weekStart = weekEnd.PlusDays(1); - } - - return projections; - } - - /// - /// Remove old projected line items, then create new ones from current projection parameters. - /// Returns number of items created. - /// - private int MaterializeProjections(BudgetGroup ticketingGroup, - BudgetCategory revenueCategory, BudgetCategory feesCategory, Instant now) - { - var projection = ticketingGroup.TicketingProjection; - if (projection is null || projection.StartDate is null || projection.EventDate is null - || projection.AverageTicketPrice == 0) - { - // No projection configured — remove any stale projected items - RemoveProjectedItems(revenueCategory, feesCategory); - return 0; - } - - // Remove old projected items first - RemoveProjectedItems(revenueCategory, feesCategory); - - var today = _clock.GetCurrentInstant().InUtc().Date; - var currentWeekMonday = GetIsoMonday(today); - var eventDate = projection.EventDate.Value; - - var projectionStart = currentWeekMonday > projection.StartDate.Value - ? currentWeekMonday - : GetIsoMonday(projection.StartDate.Value); - - if (projectionStart >= eventDate) return 0; - - var dailyRate = projection.DailySalesRate; - var initialBurst = projection.InitialSalesCount; - var isFirstWeek = true; - var created = 0; - var weekStart = projectionStart; - - while (weekStart < eventDate) - { - var weekEnd = weekStart.PlusDays(6); - if (weekEnd > eventDate) weekEnd = eventDate; - - var daysInWeek = Period.Between(weekStart, weekEnd.PlusDays(1), PeriodUnits.Days).Days; - - // First projected week includes initial burst if start date hasn't passed - var weekTickets = (int)Math.Round(dailyRate * daysInWeek); - if (isFirstWeek && projectionStart <= projection.StartDate.Value) - { - weekTickets += initialBurst; - isFirstWeek = false; - } - else - { - isFirstWeek = false; - } - - if (weekTickets <= 0) weekTickets = 1; - - var weekRevenue = weekTickets * projection.AverageTicketPrice; - - // Fees on revenue only - var stripeFees = weekRevenue * projection.StripeFeePercent / 100m + - weekTickets * projection.StripeFeeFixed; - var ttFees = weekRevenue * projection.TicketTailorFeePercent / 100m; - - var weekLabel = FormatWeekLabel(weekStart, weekEnd); - - // Revenue with VatRate — existing VAT projection system handles VAT automatically - created += UpsertLineItem(revenueCategory, $"{ProjectedPrefix}{RevenuePrefix}{weekLabel}", - Math.Round(weekRevenue, 2), weekStart, projection.VatRate, false, $"~{weekTickets} tickets", now); - - if (stripeFees > 0) - created += UpsertLineItem(feesCategory, $"{ProjectedPrefix}{StripePrefix}{weekLabel}", - -Math.Round(stripeFees, 2), weekStart, FeeVatRate, false, null, now); - if (ttFees > 0) - created += UpsertLineItem(feesCategory, $"{ProjectedPrefix}{TtPrefix}{weekLabel}", - -Math.Round(ttFees, 2), weekStart, FeeVatRate, false, null, now); - - weekStart = weekEnd.PlusDays(1); - weekStart = GetIsoMonday(weekStart); - if (weekStart <= weekEnd) weekStart = weekEnd.PlusDays(1); - } - - return created; - } - - private void RemoveProjectedItems(params BudgetCategory[] categories) - { - foreach (var category in categories) - { - var projected = category.LineItems - .Where(li => li.IsAutoGenerated && li.Description.StartsWith(ProjectedPrefix, StringComparison.Ordinal)) - .ToList(); - - foreach (var item in projected) - { - category.LineItems.Remove(item); - _dbContext.BudgetLineItems.Remove(item); - } - } - } - - /// - /// Upsert a line item by description match within a category (auto-generated items only). - /// Returns 1 if created or updated, 0 if unchanged. - /// - private int UpsertLineItem(BudgetCategory category, string description, decimal amount, - LocalDate expectedDate, int vatRate, bool isCashflowOnly, string? notes, Instant now) - { - var existing = category.LineItems - .FirstOrDefault(li => li.IsAutoGenerated && string.Equals(li.Description, description, StringComparison.Ordinal)); - - if (existing is not null) - { - // Update if values changed - if (existing.Amount == amount && existing.VatRate == vatRate - && string.Equals(existing.Notes, notes, StringComparison.Ordinal)) - return 0; - - existing.Amount = amount; - existing.VatRate = vatRate; - existing.Notes = notes; - existing.ExpectedDate = expectedDate; - existing.UpdatedAt = now; - return 1; - } - - // Create new - var maxSort = category.LineItems.Any() ? category.LineItems.Max(li => li.SortOrder) : -1; - var lineItem = new BudgetLineItem - { - Id = Guid.NewGuid(), - BudgetCategoryId = category.Id, - Description = description, - Amount = amount, - ExpectedDate = expectedDate, - VatRate = vatRate, - IsAutoGenerated = true, - IsCashflowOnly = isCashflowOnly, - Notes = notes, - SortOrder = maxSort + 1, - CreatedAt = now, - UpdatedAt = now - }; - - _dbContext.BudgetLineItems.Add(lineItem); - category.LineItems.Add(lineItem); - return 1; + return _budgetService.GetActualTicketsSold(ticketingGroup); } private static LocalDate GetIsoMonday(LocalDate date) diff --git a/tests/Humans.Application.Tests/Services/CampaignServiceTests.cs b/tests/Humans.Application.Tests/Services/CampaignServiceTests.cs index 57492611b..41f299337 100644 --- a/tests/Humans.Application.Tests/Services/CampaignServiceTests.cs +++ b/tests/Humans.Application.Tests/Services/CampaignServiceTests.cs @@ -1,16 +1,12 @@ using AwesomeAssertions; -using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; using NodaTime; using NodaTime.Testing; using NSubstitute; using Humans.Application.Interfaces; using Humans.Domain.Entities; using Humans.Domain.Enums; -using Humans.Infrastructure.Configuration; using Humans.Infrastructure.Data; using Humans.Infrastructure.Services; using Xunit; @@ -22,7 +18,7 @@ public class CampaignServiceTests : IDisposable private readonly HumansDbContext _dbContext; private readonly FakeClock _clock; private readonly CampaignService _service; - private readonly IHumansMetrics _metrics = Substitute.For(); + private readonly IEmailService _emailService = Substitute.For(); public CampaignServiceTests() { @@ -33,22 +29,12 @@ public CampaignServiceTests() _dbContext = new HumansDbContext(options); _clock = new FakeClock(Instant.FromUtc(2026, 3, 1, 12, 0)); - var hostEnvironment = Substitute.For(); - hostEnvironment.EnvironmentName.Returns("Production"); - - var emailSettings = Options.Create(new EmailSettings - { - BaseUrl = "https://humans.nobodies.team" - }); - _service = new CampaignService( _dbContext, _clock, - _metrics, Substitute.For(), Substitute.For(), - emailSettings, - hostEnvironment, + _emailService, NullLogger.Instance); } @@ -280,7 +266,7 @@ await act.Should().ThrowAsync() // ========================================================================== [Fact] - public async Task SendWaveAsync_AssignsCodeToTeamMember_CreatesGrantAndOutbox() + public async Task SendWaveAsync_AssignsCodeToTeamMember_CreatesGrantAndEnqueuesEmail() { var campaign = await SeedActiveCampaignWithCodesAsync( new[] { "CODE-A", "CODE-B" }, @@ -303,21 +289,23 @@ public async Task SendWaveAsync_AssignsCodeToTeamMember_CreatesGrantAndOutbox() grants[0].UserId.Should().Be(user.Id); grants[0].LatestEmailStatus.Should().Be(EmailOutboxStatus.Queued); - var outbox = await _dbContext.EmailOutboxMessages - .Where(m => m.CampaignGrantId == grants[0].Id) - .ToListAsync(); - outbox.Should().ContainSingle(); - outbox[0].TemplateName.Should().Be("campaign_code"); - outbox[0].RecipientEmail.Should().Be(user.Email); - outbox[0].Status.Should().Be(EmailOutboxStatus.Queued); + // CampaignService delegates to IEmailService — verify the request was passed through. + await _emailService.Received(1).SendCampaignCodeAsync( + Arg.Is(r => + r.CampaignGrantId == grants[0].Id + && r.UserId == user.Id + && r.RecipientEmail == user.Email + && (r.Code == "CODE-A" || r.Code == "CODE-B")), + Arg.Any()); } [Fact] - public async Task SendWaveAsync_SubstitutesNameInSubject() + public async Task SendWaveAsync_PassesTemplateBodyAndSubjectToEmailService() { var campaign = await SeedActiveCampaignWithCodesAsync( new[] { "CODE-1" }, - emailSubject: "Hi {{Name}}, your code"); + emailSubject: "Hi {{Name}}, your code", + emailBodyTemplate: "

Hi {{Name}}, your code is {{Code}}

"); var user = SeedUser(displayName: "Charlie"); var team = SeedTeam("Gamma"); @@ -326,8 +314,15 @@ public async Task SendWaveAsync_SubstitutesNameInSubject() await _service.SendWaveAsync(campaign.Id, team.Id); - var outbox = await _dbContext.EmailOutboxMessages.SingleAsync(); - outbox.Subject.Should().Be("Hi Charlie, your code"); + // CampaignService delegates rendering to IEmailService: it must pass through + // the raw template subject/body + code so OutboxEmailService can render it. + await _emailService.Received(1).SendCampaignCodeAsync( + Arg.Is(r => + r.Subject == "Hi {{Name}}, your code" + && r.MarkdownBody == "

Hi {{Name}}, your code is {{Code}}

" + && r.Code == "CODE-1" + && r.RecipientName == "Charlie"), + Arg.Any()); } [Fact] @@ -370,8 +365,11 @@ await act.Should().ThrowAsync() } [Fact] - public async Task SendWaveAsync_HtmlEncodesValuesInBody() + public async Task SendWaveAsync_PassesRawCodeAndRecipientToEmailService() { + // HTML-encoding of values happens inside OutboxEmailService (owner of the + // outbox table) — CampaignService must forward raw values to it so that + // encoding is applied consistently across all email templates. var campaign = await SeedActiveCampaignWithCodesAsync( new[] { "AC" }, emailBodyTemplate: "

Code: {{Code}}, Name: {{Name}}

"); @@ -383,10 +381,11 @@ public async Task SendWaveAsync_HtmlEncodesValuesInBody() await _service.SendWaveAsync(campaign.Id, team.Id); - var outbox = await _dbContext.EmailOutboxMessages.SingleAsync(); - // Body should contain HTML-encoded values - outbox.HtmlBody.Should().Contain("A<B>C"); - outbox.HtmlBody.Should().Contain("O'Brien & Co"); + await _emailService.Received(1).SendCampaignCodeAsync( + Arg.Is(r => + r.Code == "AC" + && r.RecipientName == "O'Brien & Co"), + Arg.Any()); } // ========================================================================== @@ -394,7 +393,7 @@ public async Task SendWaveAsync_HtmlEncodesValuesInBody() // ========================================================================== [Fact] - public async Task ResendToGrantAsync_CreatesNewOutboxMessage() + public async Task ResendToGrantAsync_EnqueuesNewEmailAndResetsGrantStatus() { var campaign = await SeedActiveCampaignWithCodesAsync(new[] { "RESEND-CODE" }); @@ -404,6 +403,7 @@ public async Task ResendToGrantAsync_CreatesNewOutboxMessage() await _dbContext.SaveChangesAsync(); await _service.SendWaveAsync(campaign.Id, team.Id); + _emailService.ClearReceivedCalls(); var grant = await _dbContext.CampaignGrants.SingleAsync(); grant.LatestEmailStatus = EmailOutboxStatus.Failed; @@ -411,10 +411,9 @@ public async Task ResendToGrantAsync_CreatesNewOutboxMessage() await _service.ResendToGrantAsync(grant.Id); - var outboxMessages = await _dbContext.EmailOutboxMessages - .Where(m => m.CampaignGrantId == grant.Id) - .ToListAsync(); - outboxMessages.Should().HaveCount(2); + await _emailService.Received(1).SendCampaignCodeAsync( + Arg.Is(r => r.CampaignGrantId == grant.Id), + Arg.Any()); var updatedGrant = await _dbContext.CampaignGrants.FindAsync(grant.Id); updatedGrant!.LatestEmailStatus.Should().Be(EmailOutboxStatus.Queued); @@ -425,7 +424,7 @@ public async Task ResendToGrantAsync_CreatesNewOutboxMessage() // ========================================================================== [Fact] - public async Task RetryAllFailedAsync_CreatesOutboxForFailedGrants() + public async Task RetryAllFailedAsync_EnqueuesEmailsForFailedGrantsOnly() { var campaign = await SeedActiveCampaignWithCodesAsync(new[] { "FAIL-1", "FAIL-2" }); @@ -437,6 +436,7 @@ public async Task RetryAllFailedAsync_CreatesOutboxForFailedGrants() await _dbContext.SaveChangesAsync(); await _service.SendWaveAsync(campaign.Id, team.Id); + _emailService.ClearReceivedCalls(); // Mark one as failed var grants = await _dbContext.CampaignGrants.ToListAsync(); @@ -445,9 +445,10 @@ public async Task RetryAllFailedAsync_CreatesOutboxForFailedGrants() await _service.RetryAllFailedAsync(campaign.Id); - // Should have 3 outbox messages total (2 original + 1 retry) - var outboxCount = await _dbContext.EmailOutboxMessages.CountAsync(); - outboxCount.Should().Be(3); + // Only the failed grant should be re-enqueued. + await _emailService.Received(1).SendCampaignCodeAsync( + Arg.Is(r => r.CampaignGrantId == grants[0].Id), + Arg.Any()); var retriedGrant = await _dbContext.CampaignGrants.FindAsync(grants[0].Id); retriedGrant!.LatestEmailStatus.Should().Be(EmailOutboxStatus.Queued); diff --git a/tests/Humans.Application.Tests/Services/ProfileServiceTests.cs b/tests/Humans.Application.Tests/Services/ProfileServiceTests.cs index 45e51e28f..9acdff88f 100644 --- a/tests/Humans.Application.Tests/Services/ProfileServiceTests.cs +++ b/tests/Humans.Application.Tests/Services/ProfileServiceTests.cs @@ -27,6 +27,7 @@ public class ProfileServiceTests : IDisposable private readonly IAuditLogService _auditLogService = Substitute.For(); private readonly IMembershipCalculator _membershipCalculator = Substitute.For(); private readonly IConsentService _consentService = Substitute.For(); + private readonly ITicketQueryService _ticketQueryService = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); public ProfileServiceTests() @@ -39,9 +40,12 @@ public ProfileServiceTests() _clock = new FakeClock(Instant.FromUtc(2026, 3, 1, 12, 0)); _service = new ProfileService( _dbContext, _onboardingService, _emailService, _auditLogService, - _membershipCalculator, _consentService, _clock, _cache, + _membershipCalculator, _consentService, _ticketQueryService, _clock, _cache, NullLogger.Instance); + _ticketQueryService.GetUserTicketExportDataAsync(Arg.Any(), Arg.Any()) + .Returns(new UserTicketExportData([], [])); + // Default: return all input IDs as Active (sufficient for most tests that don't filter by status) _membershipCalculator .PartitionUsersAsync(Arg.Any>(), Arg.Any()) diff --git a/tests/Humans.Application.Tests/Services/TicketSyncServiceTests.cs b/tests/Humans.Application.Tests/Services/TicketSyncServiceTests.cs index e0c2f20b6..79a74a18e 100644 --- a/tests/Humans.Application.Tests/Services/TicketSyncServiceTests.cs +++ b/tests/Humans.Application.Tests/Services/TicketSyncServiceTests.cs @@ -23,6 +23,7 @@ public class TicketSyncServiceTests : IDisposable private readonly FakeClock _clock; private readonly ITicketVendorService _vendorService; private readonly IStripeService _stripeService; + private readonly ICampaignService _campaignService; private readonly TicketSyncService _service; public TicketSyncServiceTests() @@ -44,6 +45,7 @@ public TicketSyncServiceTests() _stripeService = Substitute.For(); var userService = Substitute.For(); + _campaignService = Substitute.For(); _service = new TicketSyncService( _dbContext, _vendorService, @@ -52,7 +54,8 @@ public TicketSyncServiceTests() settings, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()), - userService); + userService, + _campaignService); // Seed the singleton TicketSyncState row _dbContext.TicketSyncStates.Add(new TicketSyncState @@ -171,51 +174,11 @@ public async Task SyncOrdersAndAttendeesAsync_UpsertDoesNotCreateDuplicates() // ========================================================================== [Fact] - public async Task SyncOrdersAndAttendeesAsync_MatchesDiscountCodeToCampaignGrant() + public async Task SyncOrdersAndAttendeesAsync_ForwardsDiscountCodesToCampaignService() { - // Seed campaign infrastructure - var creatorId = Guid.NewGuid(); - SeedUser(creatorId, "Creator"); - - var campaignId = Guid.NewGuid(); - var codeId = Guid.NewGuid(); - var grantUserId = Guid.NewGuid(); - SeedUser(grantUserId, "GrantUser"); - - var campaign = new Campaign - { - Id = campaignId, - Title = "Test Campaign", - EmailSubject = "Your code", - EmailBodyTemplate = "

{{Code}}

", - Status = CampaignStatus.Active, - CreatedAt = _clock.GetCurrentInstant(), - CreatedByUserId = creatorId - }; - _dbContext.Campaigns.Add(campaign); - - var code = new CampaignCode - { - Id = codeId, - CampaignId = campaignId, - Code = "DISCOUNT10", - ImportedAt = _clock.GetCurrentInstant() - }; - _dbContext.CampaignCodes.Add(code); - - var grant = new CampaignGrant - { - Id = Guid.NewGuid(), - CampaignId = campaignId, - CampaignCodeId = codeId, - UserId = grantUserId, - AssignedAt = _clock.GetCurrentInstant(), - RedeemedAt = null - }; - _dbContext.CampaignGrants.Add(grant); - await _dbContext.SaveChangesAsync(); - - // Order uses the discount code + // Order uses a discount code — TicketSyncService is expected to aggregate + // these into DiscountCodeRedemption instances and delegate to ICampaignService + // (which owns CampaignGrants) for the actual RedeemedAt updates. var orders = new List { MakeOrderDto("ord_disc", "Buyer", "buyer@example.com", discountCode: "discount10") @@ -226,12 +189,20 @@ public async Task SyncOrdersAndAttendeesAsync_MatchesDiscountCodeToCampaignGrant _vendorService.GetIssuedTicketsAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new List()); + _campaignService.MarkGrantsRedeemedAsync( + Arg.Any>(), + Arg.Any()) + .Returns(1); + var result = await _service.SyncOrdersAndAttendeesAsync(); result.CodesRedeemed.Should().Be(1); - var updatedGrant = await _dbContext.CampaignGrants.FindAsync(grant.Id); - updatedGrant!.RedeemedAt.Should().NotBeNull(); + // Verify the redemption was forwarded to the campaign service + await _campaignService.Received(1).MarkGrantsRedeemedAsync( + Arg.Is>(r => + r.Count == 1 && string.Equals(r.First().Code, "discount10", StringComparison.Ordinal)), + Arg.Any()); } // ========================================================================== @@ -290,7 +261,8 @@ public async Task SyncOrdersAndAttendeesAsync_SkipsWhenEventIdNotConfigured() settings, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()), - Substitute.For()); + Substitute.For(), + Substitute.For()); var result = await service.SyncOrdersAndAttendeesAsync(); From 845e90431d53527175af472aec407b709021a69f Mon Sep 17 00:00:00 2001 From: Peter Drier Date: Mon, 13 Apr 2026 18:00:13 +0200 Subject: [PATCH 2/2] Per-grant atomicity in SendWave/RetryAllFailed (#472 review) Codex flagged two P1 atomicity bugs introduced by the service-boundary split in #472: both SendWaveAsync and RetryAllFailedAsync saved the CampaignGrant rows as Queued in one transaction and then looped over SendCampaignCodeAsync to enqueue outbox messages. A throw mid-loop left the tail of grants in a lost state: - Persisted as Queued - No outbox row, so the email never goes out - Not Failed, so RetryAllFailedAsync skips them - Already granted, so SendWaveAsync's alreadyGrantedSet skips them on the next wave = silent data loss. Before #472 these writes happened in a single DbContext save to the same service boundary, so the atomicity question didn't arise. Move to per-iteration save-then-enqueue with a local catch: each grant is saved, the email is enqueued, and if the enqueue throws the grant is flipped to Failed in a follow-up save. The loop continues so a single bad email can't orphan the rest of the batch, and the Failed grants flow back through RetryAllFailedAsync normally. Same treatment for RetryAllFailedAsync: per-grant flip-and-enqueue so a retry failure leaves only that one grant in Failed state. --- .../Services/CampaignService.cs | 76 ++++++++++++------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/src/Humans.Infrastructure/Services/CampaignService.cs b/src/Humans.Infrastructure/Services/CampaignService.cs index 8bd9ec8ed..a99c05bfd 100644 --- a/src/Humans.Infrastructure/Services/CampaignService.cs +++ b/src/Humans.Infrastructure/Services/CampaignService.cs @@ -376,10 +376,14 @@ public async Task SendWaveAsync(Guid campaignId, Guid teamId, CancellationT $"Not enough codes available. Need {eligibleUsers.Count}, have {availableCodes.Count}."); var now = _clock.GetCurrentInstant(); - - // Persist grants first so each has an Id before we enqueue emails through - // the EmailOutboxService (which owns EmailOutboxMessages and commits on its own). - var grantsByUserId = new Dictionary(); + var failedCount = 0; + + // Persist and enqueue one grant at a time. If an enqueue throws mid-loop, we + // flip that single grant to Failed locally so subsequent grants still get + // processed and RetryAllFailedAsync picks the failed ones up on the next pass. + // A batch-level save-then-enqueue would orphan the tail: saved grants whose + // outbox row never landed, with LatestEmailStatus == Queued, neither retriable + // nor re-granted on the next wave. for (var i = 0; i < eligibleUsers.Count; i++) { var user = eligibleUsers[i]; @@ -396,23 +400,28 @@ public async Task SendWaveAsync(Guid campaignId, Guid teamId, CancellationT LatestEmailAt = now }; _dbContext.CampaignGrants.Add(grant); - grantsByUserId[user.Id] = (grant, code); - } - - await _dbContext.SaveChangesAsync(ct); + await _dbContext.SaveChangesAsync(ct); - // Enqueue campaign emails via the Email service (owner of email_outbox_messages). - foreach (var user in eligibleUsers) - { - var (grant, code) = grantsByUserId[user.Id]; - await _emailService.SendCampaignCodeAsync( - BuildCampaignCodeRequest(campaign, user, code.Code, grant.Id), - ct); + try + { + await _emailService.SendCampaignCodeAsync( + BuildCampaignCodeRequest(campaign, user, code.Code, grant.Id), + ct); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to enqueue campaign code email for user {UserId} grant {GrantId} in campaign {CampaignId}", + user.Id, grant.Id, campaignId); + grant.LatestEmailStatus = EmailOutboxStatus.Failed; + await _dbContext.SaveChangesAsync(ct); + failedCount++; + } } _logger.LogInformation( - "Campaign {CampaignId}: sent wave to team {TeamId}, {Count} grants created", - campaignId, teamId, eligibleUsers.Count); + "Campaign {CampaignId}: sent wave to team {TeamId}, {Count} grants created, {FailedCount} failed to enqueue", + campaignId, teamId, eligibleUsers.Count, failedCount); // In-app notification to each recipient (best-effort) try @@ -532,26 +541,37 @@ public async Task RetryAllFailedAsync(Guid campaignId, CancellationToken ct = de return; var now = _clock.GetCurrentInstant(); + var stillFailedCount = 0; + // Flip-and-enqueue one grant at a time. A batch flip-to-Queued + loop enqueue + // would lose grants whose enqueue throws: they would leave the Failed set + // without a corresponding outbox row and never be retriable again. foreach (var grant in failedGrants) { grant.LatestEmailStatus = EmailOutboxStatus.Queued; grant.LatestEmailAt = now; - } - - await _dbContext.SaveChangesAsync(ct); + await _dbContext.SaveChangesAsync(ct); - // Enqueue via the Email service (owner of email_outbox_messages). - foreach (var grant in failedGrants) - { - await _emailService.SendCampaignCodeAsync( - BuildCampaignCodeRequest(grant.Campaign, grant.User, grant.Code.Code, grant.Id), - ct); + try + { + await _emailService.SendCampaignCodeAsync( + BuildCampaignCodeRequest(grant.Campaign, grant.User, grant.Code.Code, grant.Id), + ct); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Retry failed to re-enqueue campaign code email for grant {GrantId} in campaign {CampaignId}", + grant.Id, campaignId); + grant.LatestEmailStatus = EmailOutboxStatus.Failed; + await _dbContext.SaveChangesAsync(ct); + stillFailedCount++; + } } _logger.LogInformation( - "Campaign {CampaignId}: retried {Count} failed grants", - campaignId, failedGrants.Count); + "Campaign {CampaignId}: retried {Count} failed grants, {StillFailedCount} still failed", + campaignId, failedGrants.Count, stillFailedCount); } private async Task> GetActiveTeamUserIdsAsync(Guid teamId, CancellationToken ct)