diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-28 00:17:11 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-28 00:17:11 +0100 |
| commit | 1fd17d4d07d25a810816e4e38ddc31ae72b8c91a (patch) | |
| tree | acd8e54ac1abea4fa9ba23430f4a80aba743aa11 /src/Costasdev.Busurbano.Backend/Services/FareService.cs | |
| parent | fbd2c1aa2dd25dd61483553d114c484060f71bd6 (diff) | |
Basic fare calculations for Galicia (Xunta)
Diffstat (limited to 'src/Costasdev.Busurbano.Backend/Services/FareService.cs')
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Services/FareService.cs | 212 |
1 files changed, 181 insertions, 31 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Services/FareService.cs b/src/Costasdev.Busurbano.Backend/Services/FareService.cs index 9a11a56..d0423e6 100644 --- a/src/Costasdev.Busurbano.Backend/Services/FareService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/FareService.cs @@ -1,65 +1,215 @@ using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Services.Providers; using Costasdev.Busurbano.Backend.Types.Planner; using Microsoft.Extensions.Options; namespace Costasdev.Busurbano.Backend.Services; -public record FareResult(double CashFareEuro, double CardFareEuro); +public record FareResult(decimal CashFareEuro, decimal CardFareEuro); public class FareService { private readonly AppConfiguration _config; + private readonly XuntaFareProvider _xuntaFareProvider; + private readonly ILogger<FareService> _logger; - public FareService(IOptions<AppConfiguration> config) + 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<AppConfiguration> config, + XuntaFareProvider xuntaFareProvider, + ILogger<FareService> logger + ) { _config = config.Value; + _xuntaFareProvider = xuntaFareProvider; + _logger = logger; } public FareResult CalculateFare(IEnumerable<Leg> legs) { - var busLegs = legs.Where(l => l.Mode != null && l.Mode.ToUpper() != "WALK").ToList(); + var transitLegs = legs + .Where(l => l.Mode != null && !l.Mode.Equals("WALK", StringComparison.CurrentCultureIgnoreCase)) + .ToList(); - // Cash fare logic - double cashFare = 0; - foreach (var leg in busLegs) + if (!transitLegs.Any()) { - // TODO: In the future, this should depend on the operator/feed - if (leg.FeedId == "vitrasa") - { - cashFare += 1.63; - } - else + return new FareResult(0, 0); + } + + return new FareResult( + CalculateCashTotal(transitLegs), + CalculateCardTotal(transitLegs) + ); + } + + private decimal CalculateCashTotal(IEnumerable<Leg> legs) + { + decimal total = 0L; + foreach (var leg in legs) + { + switch (leg.FeedId) { - cashFare += 1.63; // Default fallback + case "santiago": + total += SantiagoCashFare; + break; + case "coruna": + 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; } } - // Card fare logic (45-min transfer window) - int cardTicketsRequired = 0; - DateTime? lastTicketPurchased = null; - int tripsPaidWithTicket = 0; - string? lastFeedId = null; + return total; + } + + private decimal CalculateCardTotal(IEnumerable<Leg> legs) + { + List<TicketPurchased> wallet = []; + decimal totalCost = 0; - foreach (var leg in busLegs) + foreach (var leg in legs) { - // If no ticket purchased, ticket expired (no free transfers after 45 mins), or max trips with ticket reached - // Also check if we changed operator (assuming no free transfers between different operators for now) - if (lastTicketPurchased == null || - (leg.StartTime - lastTicketPurchased.Value).TotalMinutes > 45 || - tripsPaidWithTicket >= 3 || - leg.FeedId != lastFeedId) + _logger.LogDebug("Processing leg {leg}", leg); + + int maxMinutes; + int maxUsages; + string? metroArea = null; + decimal initialFare = 0; + + switch (leg.FeedId) + { + case "vitrasa": + maxMinutes = 45; + maxUsages = 3; + initialFare = VitrasaCardFare; + break; + case "coruna": + maxMinutes = 45; + maxUsages = 2; + initialFare = CorunaCardFare; + break; + case "santiago": + 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); + continue; + } + + var validTicket = wallet.FirstOrDefault(t => t.FeedId == leg.FeedId && t.IsValid(leg.StartTime, maxMinutes, maxUsages)); + + if (validTicket != null) { - cardTicketsRequired++; - lastTicketPurchased = leg.StartTime; - tripsPaidWithTicket = 1; - lastFeedId = leg.FeedId; + 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 { - tripsPaidWithTicket++; + // 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 new FareResult(cashFare, cardTicketsRequired * 0.67); + return totalCost; + } +} + +public class TicketPurchased +{ + public string FeedId { get; set; } + + public DateTime PurchasedAt { get; set; } + public string? MetroArea { get; set; } + public 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; } } |
