From a304c24b32c0327436bbd8c2853e60668e161b42 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 29 Dec 2025 00:41:52 +0100 Subject: Rename a lot of stuff, add Santiago real time --- src/Enmarcha.Backend/Services/FareService.cs | 225 +++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 src/Enmarcha.Backend/Services/FareService.cs (limited to 'src/Enmarcha.Backend/Services/FareService.cs') diff --git a/src/Enmarcha.Backend/Services/FareService.cs b/src/Enmarcha.Backend/Services/FareService.cs new file mode 100644 index 0000000..bf85f03 --- /dev/null +++ b/src/Enmarcha.Backend/Services/FareService.cs @@ -0,0 +1,225 @@ +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Services.Providers; +using Enmarcha.Backend.Types.Planner; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Services; + +public record FareResult(decimal CashFareEuro, bool CashFareIsTotal, decimal CardFareEuro, bool CardFareIsTotal); + +public class FareService +{ + private readonly AppConfiguration _config; + private readonly XuntaFareProvider _xuntaFareProvider; + private readonly ILogger _logger; + + private const decimal VitrasaCashFare = 1.63M; + private const decimal VitrasaCardFare = 0.67M; + + private const decimal CorunaCashFare = 1.30M; + private const decimal CorunaCardFare = 0.45M; + + private const decimal SantiagoCashFare = 1.00M; + private const decimal SantiagoCardFare = 0.36M; + + public FareService( + IOptions config, + XuntaFareProvider xuntaFareProvider, + ILogger logger + ) + { + _config = config.Value; + _xuntaFareProvider = xuntaFareProvider; + _logger = logger; + } + + public FareResult CalculateFare(IEnumerable legs) + { + var transitLegs = legs + .Where(l => l.Mode != null && !l.Mode.Equals("WALK", StringComparison.CurrentCultureIgnoreCase)) + .ToList(); + + if (!transitLegs.Any()) + { + return new FareResult(0, true, 0, true); + } + + var cashResult = CalculateCashTotal(transitLegs); + var cardResult = CalculateCardTotal(transitLegs); + + return new FareResult( + cashResult.Item1, cashResult.Item2, + cardResult.Item1, cardResult.Item2 + ); + } + + private (decimal, bool) CalculateCashTotal(IEnumerable legs) + { + decimal total = 0L; + bool allLegsProcessed = true; + + foreach (var leg in legs) + { + switch (leg.FeedId) + { + case "tussa": + total += SantiagoCashFare; + break; + case "tranvias": + total += CorunaCashFare; + break; + case "vitrasa": + total += VitrasaCashFare; + break; + case "xunta": + // TODO: Handle potentiall blow-ups + if (leg.From is not { ZoneId: not null }) + { + _logger.LogInformation("Ignored fare calculation for leg without From ZoneId. {FromStop}", leg.From?.StopId); + } + + if (leg.To is not { ZoneId: not null }) + { + _logger.LogInformation("Ignored fare calculation for leg without To ZoneId. {ToStop}", leg.To?.StopId); + } + + total += _xuntaFareProvider.GetPrice(leg.From!.ZoneId!, leg.To!.ZoneId!)!.PriceCash; + break; + default: + allLegsProcessed = false; + _logger.LogWarning("Unknown FeedId: {FeedId}", leg.FeedId); + break; + } + } + + return (total, allLegsProcessed); + } + + private (decimal, bool) CalculateCardTotal(IEnumerable legs) + { + List wallet = []; + decimal totalCost = 0; + + bool allLegsProcessed = true; + + foreach (var leg in legs) + { + int maxMinutes; + int maxUsages; + string? metroArea = null; + decimal initialFare = 0; + + switch (leg.FeedId) + { + case "vitrasa": + maxMinutes = 45; + maxUsages = 3; + initialFare = VitrasaCardFare; + break; + case "tranvias": + maxMinutes = 45; + maxUsages = 2; + initialFare = CorunaCardFare; + break; + case "tussa": + maxMinutes = 60; + maxUsages = 2; + initialFare = SantiagoCardFare; + break; + case "xunta": + if (leg.From?.ZoneId == null || leg.To?.ZoneId == null) + { + _logger.LogWarning("Missing ZoneId for Xunta leg. From: {From}, To: {To}", leg.From?.StopId, leg.To?.StopId); + continue; + } + + var priceRecord = _xuntaFareProvider.GetPrice(leg.From.ZoneId, leg.To.ZoneId); + if (priceRecord == null) + { + _logger.LogWarning("No price record found for Xunta leg from {From} to {To}", leg.From.ZoneId, leg.To.ZoneId); + continue; + } + + metroArea = priceRecord.MetroArea; + initialFare = priceRecord.PriceCard; + maxMinutes = 60; + maxUsages = (metroArea != null && metroArea.StartsWith("ATM", StringComparison.OrdinalIgnoreCase)) ? 3 : 1; + break; + default: + _logger.LogWarning("Unknown FeedId: {FeedId}", leg.FeedId); + allLegsProcessed = false; + continue; + } + + var validTicket = wallet.FirstOrDefault(t => t.FeedId == leg.FeedId && t.IsValid(leg.StartTime, maxMinutes, maxUsages)); + + if (validTicket != null) + { + if (leg.FeedId == "xunta" && maxUsages > 1) // ATM upgrade logic + { + var upgradeRecord = _xuntaFareProvider.GetPrice(validTicket.StartZone, leg.To!.ZoneId!); + if (upgradeRecord != null) + { + decimal upgradeCost = Math.Max(0, upgradeRecord.PriceCard - validTicket.TotalPaid); + totalCost += upgradeCost; + validTicket.TotalPaid += upgradeCost; + validTicket.UsedTimes++; + _logger.LogDebug("Xunta ATM upgrade: added {Cost}€, total paid for ticket: {TotalPaid}€", upgradeCost, validTicket.TotalPaid); + } + else + { + // Fallback: treat as new ticket if upgrade path not found + totalCost += initialFare; + wallet.Add(new TicketPurchased + { + FeedId = leg.FeedId, + PurchasedAt = leg.StartTime, + MetroArea = metroArea, + StartZone = leg.From!.ZoneId!, + TotalPaid = initialFare + }); + } + } + else + { + // Free transfer for city systems or non-ATM Xunta (though non-ATM Xunta has maxUsages=1) + validTicket.UsedTimes++; + _logger.LogDebug("Free transfer for {FeedId}", leg.FeedId); + } + } + else + { + // New ticket + totalCost += initialFare; + wallet.Add(new TicketPurchased + { + FeedId = leg.FeedId!, + PurchasedAt = leg.StartTime, + MetroArea = metroArea, + StartZone = leg.FeedId == "xunta" ? leg.From!.ZoneId! : string.Empty, + TotalPaid = initialFare + }); + _logger.LogDebug("New ticket for {FeedId}: {Cost}€", leg.FeedId, initialFare); + } + } + + return (totalCost, allLegsProcessed); + } +} + +public class TicketPurchased +{ + public required string FeedId { get; set; } + + public DateTime PurchasedAt { get; set; } + public string? MetroArea { get; set; } + public required string StartZone { get; set; } + + public int UsedTimes = 1; + public decimal TotalPaid { get; set; } + + public bool IsValid(DateTime startTime, int maxMinutes, int maxUsagesIncluded) + { + return (startTime - PurchasedAt).TotalMinutes <= maxMinutes && UsedTimes < maxUsagesIncluded; + } +} -- cgit v1.3