using EntitlementBLL.Common; using EntitlementBLL.Exceptions; using EntitlementBLL.Interfaces; using EntitlementBLL.Legal; using EntitlementBLL.Payment; using EntitlementDAL.DTOs; using EntitlementDAL.Enums; using EntitlementDAL.Interfaces; using GB5Shared.EventLogPublish; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Telemetry; using static GB5Shared.GB5Constant.Constant; namespace EntitlementBLL.Implementations; public class SubscriptionService : ISubscriptionService { private readonly ISubscriptionDAL _SubscriptionDAL; private readonly IPlanDAL _PlanDAL; private readonly IAuditDAL _AuditDAL; private readonly AutoNumber _AutoNumber; private readonly IQueryExecutor _QueryExecutor; private readonly IEntitlementLoginFactory _LoginFactory; private readonly EventLogPublish _EventLog; private readonly IPaySubscriptionClient _PayClient; private readonly IAgreementConsentProvider _AgreementConsentProvider; private readonly IClientProvisioningDAL _ClientProvisioningDAL; // Legal/Contract Agreement Consent (tracker §51.10) — the "buying" gate's own required // agreement type. A new, dedicated type distinct from TOS/EULA/PrivacyPolicy: subscription/ // billing terms (auto-renewal, non-refundable fees, etc.) are only relevant at the moment of // an actual commercial transaction, not at signup. private const string SubscriptionTermsAgreementTypeCode = "SUBSCRIPTIONTERMS"; public SubscriptionService( ISubscriptionDAL subscriptionDAL, IPlanDAL planDAL, IAuditDAL auditDAL, AutoNumber autoNumber, IQueryExecutor queryExecutor, IEntitlementLoginFactory loginFactory, EventLogPublish eventLog, IPaySubscriptionClient payClient, IAgreementConsentProvider agreementConsentProvider, IClientProvisioningDAL clientProvisioningDAL) { _SubscriptionDAL = subscriptionDAL; _PlanDAL = planDAL; _AuditDAL = auditDAL; _AutoNumber = autoNumber; _QueryExecutor = queryExecutor; _LoginFactory = loginFactory; _EventLog = eventLog; _PayClient = payClient; _AgreementConsentProvider = agreementConsentProvider; _ClientProvisioningDAL = clientProvisioningDAL; } private static SubscriptionDto ToDto(SubscriptionDTO s, string planCode) => new() { SubscriptionId = s.SubscriptionId, ClientId = s.ClientId, PlanId = s.PlanId, PlanCode = planCode, IdmsEngagementRef = s.IdmsEngagementRef, SubscriptionStatus = s.SubscriptionStatus, TrialFlag = s.TrialFlag != 0, TrialEndDate = s.TrialEndDate, StartDate = s.StartDate, LicenseValidTill = s.LicenseValidTill, SupportValidTill = s.SupportValidTill, HostingValidTill = s.HostingValidTill, GracePeriodDays = s.GracePeriodDays, AutoRenew = s.AutoRenew != 0, PayOrderId = s.PayOrderId }; public async Task> GetListAsync( int subscriptionStatus, int planId, int clientId, int page, int pageSize, CancellationToken ct) { var login = _LoginFactory.Create(clientId > 0 ? clientId : -1); var offset = Math.Max(0, (page - 1) * pageSize); var paged = await _SubscriptionDAL.GetListPagedAsync( subscriptionStatus, planId, clientId, offset, pageSize, login, ct).ConfigureAwait(false); var items = new List(); foreach (var s in paged.Items ?? Enumerable.Empty()) { var plan = await _PlanDAL.GetByIdAsync(s.PlanId, login, ct).ConfigureAwait(false); items.Add(ToDto(s, plan?.PlanCode ?? string.Empty)); } return new PagedResult { Items = items, TotalCount = paged.TotalCount }; } public async Task GetAsync(int clientId, CancellationToken ct) { var login = _LoginFactory.Create(clientId); var sub = await _SubscriptionDAL.GetByClientAsync(clientId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"No subscription found for ClientId {clientId}."); var plan = await _PlanDAL.GetByIdAsync(sub.PlanId, login, ct).ConfigureAwait(false); return ToDto(sub, plan?.PlanCode ?? string.Empty); } public async Task SaveAsync(SaveSubscriptionRequest req, CancellationToken ct) { if (req is null) throw new ArgumentNullException(nameof(req)); if (req.ClientId <= 0) throw new ArgumentException("ClientId is required.", nameof(req)); if (req.PlanId <= 0) throw new ArgumentException("PlanId is required.", nameof(req)); var login = _LoginFactory.Create(req.ClientId); bool isNew = req.SubscriptionId == 0; var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("validate-entitlement-subscription", new { req.ClientId, req.SubscriptionId, isNew }); var now = DateTime.UtcNow; var dto = new SubscriptionDTO { SubscriptionId = req.SubscriptionId, ClientId = req.ClientId, PlanId = req.PlanId, IdmsEngagementRef = req.IdmsEngagementRef, TrialFlag = (byte)(req.TrialFlag ? 1 : 0), TrialEndDate = req.TrialEndDate, StartDate = req.StartDate, LicenseValidTill = req.LicenseValidTill, SupportValidTill = req.SupportValidTill, HostingValidTill = req.HostingValidTill, GracePeriodDays = req.GracePeriodDays, AutoRenew = (byte)(req.AutoRenew ? 1 : 0), Remarks = req.Remarks, ModifiedById = login.UserId, ModifiedOn = now }; GB5Trace.Step("save-entitlement-subscription", new { req.ClientId, isNew }); if (isNew) { var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.ENTITLEMENTSUBSCRIPTION, login).ConfigureAwait(false); dto.SubscriptionId = auto.StartNumber; dto.SubscriptionStatus = (byte)SubscriptionStatusEnum.Pending; dto.CreatedById = login.UserId; dto.CreatedOn = now; await _SubscriptionDAL.SaveAsync(dto, login, tx, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = EventTypeConstant.SAVEENTITLEMENTSUBSCRIPTIONEVENTTYPEID }); await _AuditDAL.InsertEntitlementAuditAsync(new EntitlementAuditDTO { ClientId = req.ClientId, SubscriptionId = dto.SubscriptionId, Action = "SUBSCRIPTION_CREATED", NewValue = $"PlanId={req.PlanId}", CreatedById = login.UserId, CreatedOn = now }, login, tx, ct).ConfigureAwait(false); } else { await _SubscriptionDAL.UpdateAsync(dto, login, tx, ct).ConfigureAwait(false); } await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); var plan = await _PlanDAL.GetByIdAsync(dto.PlanId, login, ct).ConfigureAwait(false); return ToDto(dto, plan?.PlanCode ?? string.Empty); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("save-entitlement-subscription-failed", ex); throw; } } public async Task ChangePlanAsync(int subscriptionId, int newPlanId, int userId, CancellationToken ct) { var login = _LoginFactory.Create(-1, userId); var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("validate-change-plan", new { subscriptionId, newPlanId }); var sub = await _SubscriptionDAL.GetByIdAsync(subscriptionId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"Subscription {subscriptionId} not found."); var oldPlanId = sub.PlanId; var newPlan = await _PlanDAL.GetByIdAsync(newPlanId, login, ct).ConfigureAwait(false); if (newPlan is null || newPlan.Status != 1) throw new EntitlementNotFoundException($"Plan {newPlanId} not found or not active."); GB5Trace.Step("save-change-plan", new { subscriptionId, oldPlanId, newPlanId }); await _SubscriptionDAL.UpdatePlanAsync(subscriptionId, newPlanId, login, tx, ct).ConfigureAwait(false); await _AuditDAL.InsertEntitlementAuditAsync(new EntitlementAuditDTO { ClientId = sub.ClientId, SubscriptionId = subscriptionId, Action = "PLAN_CHANGED", OldValue = $"PlanId={oldPlanId}", NewValue = $"PlanId={newPlanId}", CreatedById = userId, CreatedOn = DateTime.UtcNow }, login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.ENTITLEMENTPLANCHANGEDEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Entitlement Subscription Plan Changed", sub, EventTypeConstant.ENTITLEMENTPLANCHANGEDEVENTTYPEID, subscriptionId, login, ct: ct).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("change-plan-failed", ex); throw; } } public async Task RenewAsync(int subscriptionId, DateTime newValidTill, int userId, CancellationToken ct) { var login = _LoginFactory.Create(-1, userId); var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("validate-renew-subscription", new { subscriptionId, newValidTill }); var sub = await _SubscriptionDAL.GetByIdAsync(subscriptionId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"Subscription {subscriptionId} not found."); var oldValidTill = sub.LicenseValidTill; GB5Trace.Step("save-renew-subscription", new { subscriptionId, oldValidTill, newValidTill }); await _SubscriptionDAL.UpdateRenewalAsync(subscriptionId, newValidTill, login, tx, ct).ConfigureAwait(false); await _AuditDAL.InsertEntitlementAuditAsync(new EntitlementAuditDTO { ClientId = sub.ClientId, SubscriptionId = subscriptionId, Action = "SUBSCRIPTION_RENEWED", OldValue = $"LicenseValidTill={oldValidTill:O}", NewValue = $"LicenseValidTill={newValidTill:O}", CreatedById = userId, CreatedOn = DateTime.UtcNow }, login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.ENTITLEMENTSUBSCRIPTIONRENEWEDEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Entitlement Subscription Renewed", sub, EventTypeConstant.ENTITLEMENTSUBSCRIPTIONRENEWEDEVENTTYPEID, subscriptionId, login, ct: ct).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("renew-subscription-failed", ex); throw; } } public async Task SetStatusAsync(int subscriptionId, SubscriptionStatusEnum status, CancellationToken ct) { var login = _LoginFactory.Create(-1); var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("validate-set-subscription-status", new { subscriptionId, status }); var sub = await _SubscriptionDAL.GetByIdAsync(subscriptionId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"Subscription {subscriptionId} not found."); await _SubscriptionDAL.UpdateStatusAsync(subscriptionId, (byte)status, login, tx, ct).ConfigureAwait(false); // Keep MCLIENT.SUBSCRIPTIONSTATUS (added in Thread 0, previously never written) in sync. await _SubscriptionDAL.UpdateClientSubscriptionStatusAsync(sub.ClientId, (byte)status, login, tx, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = EventTypeConstant.UPDATEENTITLEMENTSUBSCRIPTIONEVENTTYPEID }); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("set-subscription-status-failed", ex); throw; } } // ── Entitlement↔Payment loose coupling (§9.1) ────────────────────────────── public async Task InitiatePaymentAsync(InitiatePaymentRequest req, int userId, CancellationToken ct) { if (req is null) throw new ArgumentNullException(nameof(req)); if (req.SubscriptionId <= 0) throw new ArgumentException("SubscriptionId is required.", nameof(req)); if (string.IsNullOrWhiteSpace(req.GatewayCode)) throw new ArgumentException("GatewayCode is required.", nameof(req)); var login = _LoginFactory.Create(-1, userId); GB5Trace.Step("validate-initiate-payment", new { req.SubscriptionId }); var sub = await _SubscriptionDAL.GetByIdAsync(req.SubscriptionId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"Subscription {req.SubscriptionId} not found."); var plan = await _PlanDAL.GetByIdAsync(sub.PlanId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"Plan {sub.PlanId} not found."); // A real, legitimate state for a custom/enterprise plan — "contact sales" — not // something to silently default to 0 and let a client pay nothing. if (plan.PlanPrice is null) throw new InvalidOperationException($"Plan {plan.PlanCode} has no price set — contact sales to arrange payment."); // Legal/Contract Agreement Consent (tracker §51.10) — a real commercial transaction // requires real, current acceptance of the Subscription/Billing terms before any money // moves. Only enforced when a real version is currently Published for this type — an // unconfigured type never silently blocks every payment platform-wide. When something // IS configured, every version in the current bundle must be present in what the caller // submitted — not just "some prior acceptance," the SPECIFIC current terms. GB5Trace.Step("validate-payment-consent", new { sub.ClientId }); // Real jurisdiction resolution (tracker §51.12) — falls back to universal (empty) if the // client never had one captured, same as every other call site of this mechanism. var jurisdictionCode = await _ClientProvisioningDAL .GetClientJurisdictionCodeAsync(sub.ClientId, login, ct).ConfigureAwait(false) ?? string.Empty; var requiredAgreements = await _AgreementConsentProvider .GetApplicableAgreementsAsync(jurisdictionCode, new[] { SubscriptionTermsAgreementTypeCode }, login, ct) .ConfigureAwait(false); if (requiredAgreements.Versions.Count > 0) { var acceptedIds = req.AcceptedAgreementVersionIds ?? Array.Empty(); var missingAgreementVersionIds = requiredAgreements.Versions .Select(v => v.AgreementVersionId) .Except(acceptedIds) .ToArray(); if (missingAgreementVersionIds.Length > 0) throw new InvalidOperationException( $"Subscription/billing terms must be accepted before payment can be initiated (missing AgreementVersionId(s): {string.Join(",", missingAgreementVersionIds)})."); GB5Trace.Step("record-payment-consent", new { sub.ClientId, AcceptedCount = acceptedIds.Length }); await _AgreementConsentProvider.RecordAcceptanceAsync( AgreementSubjectType.Customer, sub.ClientId, acceptedIds, ipAddress: null, userAgent: null, AgreementAcceptanceMethod.Clickwrap, correlationKey: $"payment-initiate-{sub.SubscriptionId}-{DateTime.UtcNow:yyyyMMddHHmmssfff}", login, ct).ConfigureAwait(false); } GB5Trace.Step("initiate-payment", new { req.SubscriptionId, plan.PlanPrice, plan.PlanCurrency }); var payReq = new PayInitiatePaymentRequest { SubscriptionId = sub.SubscriptionId, OrderAmt = plan.PlanPrice.Value, OrderCurrency = plan.PlanCurrency, GatewayCode = req.GatewayCode, CustomerId = sub.ClientId, CustomerName = req.CustomerName, CustomerEmail = req.CustomerEmail, CustomerPhone = req.CustomerPhone, CallbackUrl = req.CallbackUrl }; var result = await _PayClient.InitiatePaymentAsync(payReq, login, ct).ConfigureAwait(false); await _SubscriptionDAL.UpdatePayOrderIdAsync(sub.SubscriptionId, result.PayOrderId, login, ct).ConfigureAwait(false); await _AuditDAL.InsertEntitlementAuditAsync(new EntitlementAuditDTO { ClientId = sub.ClientId, SubscriptionId = sub.SubscriptionId, Action = "PAYMENT_INITIATED", NewValue = $"PayOrderId={result.PayOrderId}", CreatedById = userId, CreatedOn = DateTime.UtcNow }, login, null, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.ENTITLEMENTPAYMENTINITIATEDEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Entitlement Payment Initiated", result, EventTypeConstant.ENTITLEMENTPAYMENTINITIATEDEVENTTYPEID, sub.SubscriptionId, login, ct: ct).ConfigureAwait(false); return result; } public async Task> GetPaymentHistoryAsync(int clientId, CancellationToken ct) { var login = _LoginFactory.Create(clientId); var sub = await _SubscriptionDAL.GetByClientAsync(clientId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"No subscription found for ClientId {clientId}."); return await _PayClient.GetPaymentHistoryAsync(sub.SubscriptionId, login, ct).ConfigureAwait(false); } public async Task HandlePaymentOutcomeAsync(int payOrderId, int orderStatus, CancellationToken ct) { // 1=Created, 2=Pending — payment still in flight, nothing to react to yet (PAY itself // has no named enum for this — confirmed via direct code read of PayOrderBLL/ // PayWebhookBLL; these are the only values either ever writes). if (orderStatus != 3 && orderStatus != 5) return; var login = _LoginFactory.Create(-1); var sub = await _SubscriptionDAL.GetByPayOrderIdAsync(payOrderId, login, ct).ConfigureAwait(false); if (sub is null) return; // not one of ours, or already superseded by a later order var now = DateTime.UtcNow; if (orderStatus == 3) // Paid { if (sub.SubscriptionStatus != (byte)SubscriptionStatusEnum.Active) { await SetStatusAsync(sub.SubscriptionId, SubscriptionStatusEnum.Active, ct).ConfigureAwait(false); } await _AuditDAL.InsertEntitlementAuditAsync(new EntitlementAuditDTO { ClientId = sub.ClientId, SubscriptionId = sub.SubscriptionId, Action = "PAYMENT_CONFIRMED", NewValue = $"PayOrderId={payOrderId}", CreatedById = -1, CreatedOn = now }, login, null, ct).ConfigureAwait(false); await _EventLog.PublishEventLogAsync( "Entitlement Payment Confirmed", sub, EventTypeConstant.ENTITLEMENTPAYMENTCONFIRMEDEVENTTYPEID, sub.SubscriptionId, login, ct: ct).ConfigureAwait(false); } else // Failed — deliberately does NOT change SubscriptionStatus; a failed payment // just means the client is still Pending and can retry, not that anything // regresses. Recorded so it's visible, not silently dropped. { await _AuditDAL.InsertEntitlementAuditAsync(new EntitlementAuditDTO { ClientId = sub.ClientId, SubscriptionId = sub.SubscriptionId, Action = "PAYMENT_FAILED", NewValue = $"PayOrderId={payOrderId}", CreatedById = -1, CreatedOn = now }, login, null, ct).ConfigureAwait(false); await _EventLog.PublishEventLogAsync( "Entitlement Payment Failed", sub, EventTypeConstant.ENTITLEMENTPAYMENTFAILEDEVENTTYPEID, sub.SubscriptionId, login, ct: ct).ConfigureAwait(false); } } }