using FozFc.Api.Domain; using FozFc.Api.Infrastructure.Mongo; using MongoDB.Bson; namespace FozFc.Api.Application.Progress; public sealed class XpCalculator : IXpCalculator { private static readonly HashSet BillableStatuses = new() { "pending", "processing", "sent", "delivered", "completed" }; private readonly IDocumentRepository _repository; public XpCalculator(IDocumentRepository repository) => _repository = repository; public async Task ComputeSnapshotAsync(string uid, string email, CancellationToken ct = default) { var normalizedEmail = email.Trim().ToLowerInvariant(); var ordersByUid = await _repository.FindAsync( Collections.Orders, new BsonDocument("userId", uid), null, 0, ct); var ordersByEmail = string.IsNullOrEmpty(normalizedEmail) ? Array.Empty() : (await _repository.FindAsync(Collections.Orders, new BsonDocument("email", normalizedEmail), null, 0, ct)).ToArray(); // Merge unique orders by id. var orders = new Dictionary(); foreach (var order in ordersByUid.Concat(ordersByEmail)) { orders[order.GetValue("_id", BsonNull.Value).ToString() ?? Guid.NewGuid().ToString()] = order; } var totalSpent = orders.Values.Where(IsBillableNonMembership).Sum(OrderAmountEur); var member = await _repository.FindOneByFieldAsync(Collections.Members, "userId", uid, ct); var months = member is not null && member.GetValue("quotasPaid", false).ToBoolean() ? MembershipMonths(member.GetValue("memberSince", "").AsString) : 0; var xp = Math.Max(0, (int)Math.Round(totalSpent * FanRank.XpPerEuroSpent) + months * FanRank.XpPerMembershipMonth); var rank = FanRank.Compute(xp); return new BsonDocument { { "xp", xp }, { "fanRank", rank.FanRank }, { "nextRank", rank.NextRank is null ? BsonNull.Value : rank.NextRank }, { "xpProgressPercent", rank.XpProgressPercent }, { "xpToNextRank", rank.XpToNextRank }, { "totalSpentEUR", Math.Round(totalSpent, 2) }, { "membershipMonths", months }, { "xpLastCalculatedAt", DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") }, }; } private static bool IsBillableNonMembership(BsonDocument order) { var status = order.GetValue("status", "").ToString() ?? ""; if (!BillableStatuses.Contains(status)) return false; if (order.GetValue("purchaseType", "").ToString() == "membership") return false; if (order.GetValue("membership", false).ToBoolean()) return false; if (order.TryGetValue("items", out var items) && items is BsonArray array) { foreach (var item in array.OfType()) { if ((item.GetValue("id", "").ToString() ?? "").StartsWith("membership-", StringComparison.Ordinal)) return false; } } return true; } private static double OrderAmountEur(BsonDocument order) { var total = ToDouble(order.GetValue("total", BsonNull.Value)); if (total > 0) return total; var amountCents = ToDouble(order.GetValue("amount", BsonNull.Value)); return amountCents > 0 ? amountCents / 100.0 : 0; } private static double ToDouble(BsonValue value) => value.BsonType switch { BsonType.Int32 or BsonType.Int64 or BsonType.Double or BsonType.Decimal128 => value.ToDouble(), BsonType.String => double.TryParse(value.AsString, out var d) ? d : 0, _ => 0, }; private static int MembershipMonths(string? memberSince) { if (string.IsNullOrEmpty(memberSince) || !DateTime.TryParse(memberSince, out var start)) return 0; var now = DateTime.UtcNow; var months = (now.Year - start.Year) * 12 + now.Month - start.Month; if (now.Day < start.Day) months -= 1; return Math.Max(0, months); } }