using Fitness_core_BE.Exceptions; using Fitness_core_BE.Models; namespace Fitness_core_BE.Domain { public sealed record CustomerSubscriptionAggregate( CustomerSubscription Subscription, CustomerSubItem InitialSubItem, SubscriptionMember PrimaryMember, IReadOnlyList Installments); public static class CustomerSubscriptionFactory { public static CustomerSubscriptionAggregate Create( CustomerSubscription subscription, CustomerPlan plan, CustomerSubItem initialSubItem, DateTime? nowUtc = null) { var now = nowUtc ?? DateTime.UtcNow; subscription.SubscriptionId = subscription.SubscriptionId == Guid.Empty ? Guid.NewGuid() : subscription.SubscriptionId; subscription.CreatedAt = now; subscription.OnboardDate = subscription.OnboardDate == default ? now : subscription.OnboardDate; if (plan.DurationMode == "FixedRange") { if (plan.ValidTo == null || plan.ValidFrom == null) throw new BadRequestException("Fixed-range plan is missing its validity dates."); if (now > plan.ValidTo.Value) throw new BadRequestException("This plan's validity period has already ended."); if (subscription.OnboardDate < plan.ValidFrom.Value) subscription.OnboardDate = plan.ValidFrom.Value; subscription.ExpiryDate ??= plan.ValidTo.Value; } else if (subscription.ExpiryDate == null && plan.DurationInDays > 0) { subscription.ExpiryDate = subscription.OnboardDate.AddDays(plan.DurationInDays); } // RenewalDate starts equal to ExpiryDate and is the only one of the two that ever // advances again (on each approved renewal cycle) -- ExpiryDate is frozen from here on. subscription.RenewalDate ??= subscription.ExpiryDate; initialSubItem.CustomerSubItemId = initialSubItem.CustomerSubItemId == Guid.Empty ? Guid.NewGuid() : initialSubItem.CustomerSubItemId; initialSubItem.FKCustomerSubscriptionId = subscription.SubscriptionId; initialSubItem.CreatedAt = now; var primaryMember = new SubscriptionMember { SubscriptionMemberId = Guid.NewGuid(), FKSubscriptionId = subscription.SubscriptionId, FKCustomerId = subscription.FKCustomerId, IsPrimary = true, JoinedAt = now, IsActive = true }; return new CustomerSubscriptionAggregate( subscription, initialSubItem, primaryMember, BuildInstallmentSchedule(subscription, plan, now)); } // Internal (not private): reused by SubscriptionRenewalApprovalService to build a fresh // installment schedule for a renewal cycle on a subscription that already exists (no new // CustomerSubscription row involved, unlike Create above). internal static IReadOnlyList BuildInstallmentSchedule( CustomerSubscription subscription, CustomerPlan plan, DateTime now, DateTime? anchorDate = null) { // Defaults to subscription.OnboardDate, exactly Create()'s existing behavior (a // brand-new subscription's OnboardDate is always "now" for that cycle). The // renewal-approval flow passes the approval moment explicitly instead: unlike the old // renewal path, the same CustomerSubscription row now persists across many cycles, so // OnboardDate stays frozen at the very first cycle and can no longer anchor a later // cycle's due dates on its own. var scheduleAnchor = anchorDate ?? subscription.OnboardDate; var count = Math.Max(1, plan.InstallmentCount); var onboardingFee = plan.HasOnboardingFee ? plan.OnboardingFeeAmount : 0m; // Mirrors CreateCustomerInvoiceFromSubscription's discount base (plan price + // onboarding fee) so the installment schedule shown to the client (and used to // prefill "record payment") matches what the invoice actually charges. Only the // plan-price portion absorbs the reduction -- discountAmount=0 when no discount was // given reproduces today's undiscounted split exactly. var discountAmount = DiscountCalculator.ComputeDiscountAmount( plan.PlanPrice + onboardingFee, subscription.DiscountType, subscription.DiscountValue); var discountedPlanPrice = Math.Max(0m, plan.PlanPrice - discountAmount); var perInstallment = Math.Round(discountedPlanPrice / count, 2); var rows = new List(count); decimal allocated = 0m; double? fixedRangeStepDays = null; if (plan.DurationMode == "FixedRange" && plan.ValidTo != null && count > 1) { var windowDays = (plan.ValidTo.Value - scheduleAnchor).TotalDays; if (windowDays > 0) fixedRangeStepDays = windowDays / count; } for (var index = 0; index < count; index++) { var membershipAmount = index == count - 1 ? discountedPlanPrice - allocated : perInstallment; allocated += membershipAmount; rows.Add(new SubscriptionInstallment { SubscriptionInstallmentId = Guid.NewGuid(), FKSubscriptionId = subscription.SubscriptionId, InstallmentNo = index + 1, DueDate = fixedRangeStepDays.HasValue ? scheduleAnchor.AddDays( fixedRangeStepDays.Value * index) : scheduleAnchor.AddMonths(index), Amount = membershipAmount + (index == 0 ? onboardingFee : 0m), PaidAmount = 0m, Status = "Pending", IsActive = true, CreatedAt = now }); } return rows; } } }