aboutsummaryrefslogtreecommitdiff
path: root/src/Costasdev.Busurbano.Backend/Services/FareService.cs
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-28 00:17:11 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-28 00:17:11 +0100
commit1fd17d4d07d25a810816e4e38ddc31ae72b8c91a (patch)
treeacd8e54ac1abea4fa9ba23430f4a80aba743aa11 /src/Costasdev.Busurbano.Backend/Services/FareService.cs
parentfbd2c1aa2dd25dd61483553d114c484060f71bd6 (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.cs212
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;
}
}