diff --git a/src/Humans.Application/DTOs/TicketingBudgetDtos.cs b/src/Humans.Application/DTOs/TicketingBudgetDtos.cs index b6f32e81..6a9f6669 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 9d2407d7..c11128f7 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 077c48e4..a1e0b965 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 d304c5cc..873fe32f 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 a7e15b63..7402ace0 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 db284003..ea5cb33b 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 840c2ff4..a99c05bf 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; } @@ -388,7 +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(); - + 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]; @@ -405,18 +400,28 @@ public async Task SendWaveAsync(Guid campaignId, Guid teamId, CancellationT LatestEmailAt = now }; _dbContext.CampaignGrants.Add(grant); + await _dbContext.SaveChangesAsync(ct); - var outboxMessage = RenderOutboxMessage(campaign, user, code.Code, grant.Id, now); - _dbContext.EmailOutboxMessages.Add(outboxMessage); - - _metrics.RecordEmailQueued("campaign_code"); + 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++; + } } - await _dbContext.SaveChangesAsync(ct); - _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 @@ -450,19 +455,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 @@ -477,24 +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) { - 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"); + 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++; + } } - await _dbContext.SaveChangesAsync(ct); - _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) @@ -513,54 +590,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 f84d7887..3fd92574 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 bb061ec0..b1995309 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 a6c90e16..5b845ca7 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 454f2a76..d1fef7da 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 c7245ac2..e69d175b 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 000d9dc5..50d0474a 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 0bc94dbb..5250c519 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 57492611..41f29933 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 45e51e28..9acdff88 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 e0c2f20b..79a74a18 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();