using EntitlementBLL.Common; using EntitlementBLL.Exceptions; using EntitlementBLL.Interfaces; using EntitlementDAL.DTOs; using EntitlementDAL.Enums; using EntitlementDAL.Interfaces; using GB5Shared.EventLogPublish; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using static GB5Shared.GB5Constant.Constant; namespace EntitlementBLL.Implementations; // Interim, Entitlement-owned request/approval mechanism — deliberately NOT built on the shared // WorkFlowEngine (no module has ever seeded a live workflow config; see the Thread 8 plan section // for the full rationale) and deliberately NOT reusing MROLE (a control-plane module has no single // tenant DB to join against). One review step only: any GOODBOOKS_ADMIN approves/rejects — no // multi-level escalation, that's what the generic engine would add later if this ever migrates. public class ChangeRequestBLL : IChangeRequestBLL { private readonly IChangeRequestDAL _ChangeRequestDAL; private readonly ISubscriptionDAL _SubscriptionDAL; private readonly ISubscriptionService _SubscriptionService; private readonly IBetaClientBLL _BetaClientBLL; private readonly AutoNumber _AutoNumber; private readonly IQueryExecutor _QueryExecutor; private readonly IEntitlementLoginFactory _LoginFactory; private readonly EventLogPublish _EventLog; public ChangeRequestBLL( IChangeRequestDAL changeRequestDAL, ISubscriptionDAL subscriptionDAL, ISubscriptionService subscriptionService, IBetaClientBLL betaClientBLL, AutoNumber autoNumber, IQueryExecutor queryExecutor, IEntitlementLoginFactory loginFactory, EventLogPublish eventLog) { _ChangeRequestDAL = changeRequestDAL; _SubscriptionDAL = subscriptionDAL; _SubscriptionService = subscriptionService; _BetaClientBLL = betaClientBLL; _AutoNumber = autoNumber; _QueryExecutor = queryExecutor; _LoginFactory = loginFactory; _EventLog = eventLog; } private async Task InsertRequestAsync( int clientId, ChangeRequestTypeEnum type, int? subscriptionId, int? proposedPlanId, DateTime? proposedValidTill, string? betaProgramName, int requestedByClientUserId, CancellationToken ct) { var login = _LoginFactory.Create(clientId); var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { GB5Trace.Step("validate-submit-change-request", new { clientId, type }); var now = DateTime.UtcNow; var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.ENTITLEMENTCHANGEREQUEST, login).ConfigureAwait(false); var dto = new ChangeRequestDTO { ChangeRequestId = auto.StartNumber, ClientId = clientId, RequestType = (byte)type, SubscriptionId = subscriptionId, ProposedPlanId = proposedPlanId, ProposedValidTill = proposedValidTill, BetaProgramName = betaProgramName, RequestStatus = (byte)ChangeRequestStatusEnum.Pending, RequestedByClientUserId = requestedByClientUserId, RequestedOn = now, CreatedById = requestedByClientUserId, CreatedOn = now, ModifiedById = requestedByClientUserId, ModifiedOn = now }; GB5Trace.Step("save-change-request", new { dto.ChangeRequestId, type }); await _ChangeRequestDAL.SaveAsync(dto, login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.ENTITLEMENTCHANGEREQUESTSUBMITTEDEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Entitlement Change Request Submitted", dto, EventTypeConstant.ENTITLEMENTCHANGEREQUESTSUBMITTEDEVENTTYPEID, dto.ChangeRequestId, login, ct: ct).ConfigureAwait(false); return dto.ChangeRequestId; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("submit-change-request-failed", ex); throw; } } public async Task SubmitChangePlanRequestAsync(int clientId, int subscriptionId, int newPlanId, int requestedByClientUserId, CancellationToken ct) { var login = _LoginFactory.Create(clientId); var sub = await _SubscriptionDAL.GetByIdAsync(subscriptionId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"Subscription {subscriptionId} not found."); if (sub.ClientId != clientId) throw new EntitlementNotFoundException($"Subscription {subscriptionId} does not belong to Client {clientId}."); return await InsertRequestAsync(clientId, ChangeRequestTypeEnum.ChangePlan, subscriptionId, newPlanId, null, null, requestedByClientUserId, ct) .ConfigureAwait(false); } public async Task SubmitRenewalRequestAsync(int clientId, int subscriptionId, DateTime proposedValidTill, int requestedByClientUserId, CancellationToken ct) { var login = _LoginFactory.Create(clientId); var sub = await _SubscriptionDAL.GetByIdAsync(subscriptionId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"Subscription {subscriptionId} not found."); if (sub.ClientId != clientId) throw new EntitlementNotFoundException($"Subscription {subscriptionId} does not belong to Client {clientId}."); return await InsertRequestAsync(clientId, ChangeRequestTypeEnum.Renew, subscriptionId, null, proposedValidTill, null, requestedByClientUserId, ct) .ConfigureAwait(false); } public async Task SubmitBetaEnrolRequestAsync(int clientId, string betaProgramName, DateTime validUntil, int requestedByClientUserId, CancellationToken ct) { if (string.IsNullOrWhiteSpace(betaProgramName)) throw new ArgumentException("BetaProgramName is required.", nameof(betaProgramName)); return await InsertRequestAsync(clientId, ChangeRequestTypeEnum.BetaEnrol, null, null, validUntil, betaProgramName, requestedByClientUserId, ct) .ConfigureAwait(false); } public async Task SubmitBetaOptOutRequestAsync(int clientId, string betaProgramName, int requestedByClientUserId, CancellationToken ct) { if (string.IsNullOrWhiteSpace(betaProgramName)) throw new ArgumentException("BetaProgramName is required.", nameof(betaProgramName)); return await InsertRequestAsync(clientId, ChangeRequestTypeEnum.BetaOptOut, null, null, null, betaProgramName, requestedByClientUserId, ct) .ConfigureAwait(false); } public async Task ApproveChangeRequestAsync(int changeRequestId, int reviewerUserId, string? comments, CancellationToken ct) { var adminLogin = _LoginFactory.Create(-1, reviewerUserId); var req = await _ChangeRequestDAL.GetByIdAsync(changeRequestId, adminLogin, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"ChangeRequest {changeRequestId} not found."); if (req.RequestStatus != (byte)ChangeRequestStatusEnum.Pending) throw new InvalidOperationException($"ChangeRequest {changeRequestId} is not Pending."); // Dispatch the real mutation first — if it throws, the request stays Pending so a reviewer // can retry, rather than being marked Approved against a mutation that never happened. switch ((ChangeRequestTypeEnum)req.RequestType) { case ChangeRequestTypeEnum.ChangePlan: await _SubscriptionService.ChangePlanAsync(req.SubscriptionId!.Value, req.ProposedPlanId!.Value, reviewerUserId, ct).ConfigureAwait(false); break; case ChangeRequestTypeEnum.Renew: await _SubscriptionService.RenewAsync(req.SubscriptionId!.Value, req.ProposedValidTill!.Value, reviewerUserId, ct).ConfigureAwait(false); break; case ChangeRequestTypeEnum.BetaEnrol: await _BetaClientBLL.EnrolAsync(req.ClientId, req.BetaProgramName!, DateTime.UtcNow, req.ProposedValidTill!.Value, adminLogin, ct) .ConfigureAwait(false); break; case ChangeRequestTypeEnum.BetaOptOut: await _BetaClientBLL.OptOutAsync(req.ClientId, req.BetaProgramName!, adminLogin, ct).ConfigureAwait(false); break; } var tx = await _QueryExecutor.BeginTransactionAsync(adminLogin).ConfigureAwait(false); try { GB5Trace.Step("approve-change-request", new { changeRequestId }); await _ChangeRequestDAL.UpdateReviewAsync( changeRequestId, (byte)ChangeRequestStatusEnum.Approved, reviewerUserId, comments, adminLogin, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.ENTITLEMENTCHANGEREQUESTAPPROVEDEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Entitlement Change Request Approved", req, EventTypeConstant.ENTITLEMENTCHANGEREQUESTAPPROVEDEVENTTYPEID, changeRequestId, adminLogin, ct: ct).ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("approve-change-request-failed", ex); throw; } } public async Task RejectChangeRequestAsync(int changeRequestId, int reviewerUserId, string? comments, CancellationToken ct) { var adminLogin = _LoginFactory.Create(-1, reviewerUserId); var req = await _ChangeRequestDAL.GetByIdAsync(changeRequestId, adminLogin, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"ChangeRequest {changeRequestId} not found."); if (req.RequestStatus != (byte)ChangeRequestStatusEnum.Pending) throw new InvalidOperationException($"ChangeRequest {changeRequestId} is not Pending."); var tx = await _QueryExecutor.BeginTransactionAsync(adminLogin).ConfigureAwait(false); try { GB5Trace.Step("reject-change-request", new { changeRequestId }); await _ChangeRequestDAL.UpdateReviewAsync( changeRequestId, (byte)ChangeRequestStatusEnum.Rejected, reviewerUserId, comments, adminLogin, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.ENTITLEMENTCHANGEREQUESTREJECTEDEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Entitlement Change Request Rejected", req, EventTypeConstant.ENTITLEMENTCHANGEREQUESTREJECTEDEVENTTYPEID, changeRequestId, adminLogin, ct: ct).ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("reject-change-request-failed", ex); throw; } } public async Task> GetChangeRequestListAsync(int clientId, int requestStatus, int page, int pageSize, CancellationToken ct) { var login = _LoginFactory.Create(clientId > 0 ? clientId : -1); var offset = Math.Max(0, (page - 1) * pageSize); return await _ChangeRequestDAL.GetListPagedAsync(clientId, requestStatus, offset, pageSize, login, ct).ConfigureAwait(false); } }