first commit
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Fitness_core_BE.Domain
|
||||
{
|
||||
public static class DiscountCalculator
|
||||
{
|
||||
public static decimal ComputeDiscountAmount(decimal subtotal, string? discountType, decimal? discountValue)
|
||||
{
|
||||
if (string.IsNullOrEmpty(discountType) || discountValue is not decimal value || value <= 0 || subtotal <= 0)
|
||||
return 0m;
|
||||
|
||||
var discount = discountType switch
|
||||
{
|
||||
"Percentage" => subtotal * Math.Clamp(value, 0m, 100m) / 100m,
|
||||
"Flat" => Math.Clamp(value, 0m, subtotal),
|
||||
_ => 0m
|
||||
};
|
||||
return Math.Round(discount, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user