147 lines
6.5 KiB
C#
147 lines
6.5 KiB
C#
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<SubscriptionInstallment> 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<SubscriptionInstallment> 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<SubscriptionInstallment>(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;
|
|
}
|
|
}
|
|
}
|