using System.Text.Json; using EntitlementBLL.Common; using EntitlementBLL.Interfaces; using EntitlementDAL.Constants; using EntitlementDAL.DTOs; using EntitlementDAL.Enums; using EntitlementDAL.Interfaces; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Telemetry; using static GB5Shared.GB5Constant.Constant; namespace EntitlementBLL.Implementations; public class ProvisioningService : IProvisioningService { private readonly ISubscriptionDAL _SubscriptionDAL; private readonly IPlanDAL _PlanDAL; private readonly IEntitlementGrantDAL _GrantDAL; private readonly ILicenseDAL _LicenseDAL; private readonly IAuditDAL _AuditDAL; private readonly IProvisioningJobDAL _JobDAL; private readonly AutoNumber _AutoNumber; private readonly IQueryExecutor _QueryExecutor; private readonly IEntitlementLoginFactory _LoginFactory; public ProvisioningService( ISubscriptionDAL subscriptionDAL, IPlanDAL planDAL, IEntitlementGrantDAL grantDAL, ILicenseDAL licenseDAL, IAuditDAL auditDAL, IProvisioningJobDAL jobDAL, AutoNumber autoNumber, IQueryExecutor queryExecutor, IEntitlementLoginFactory loginFactory) { _SubscriptionDAL = subscriptionDAL; _PlanDAL = planDAL; _GrantDAL = grantDAL; _LicenseDAL = licenseDAL; _AuditDAL = auditDAL; _JobDAL = jobDAL; _AutoNumber = autoNumber; _QueryExecutor = queryExecutor; _LoginFactory = loginFactory; } // Two independent pipelines share MPROVISIONINGJOB but write disjoint CurrentStep // vocabularies (tracker §37 Decision 3, see ProvisioningStepNames' own doc comment) — the // fixed "TotalSteps = 2" this constant used to be computed a Running job onboarded via // ClientOnboardingOrchestratorBLL's 3-step sequence as already 100% complete the moment its // 2nd step (DB_PROVISIONING_CR_SUBMITTED) was reached, i.e. before the database had even // started provisioning. Progress below is now computed by CurrentStep's own index within // whichever pipeline's canonical sequence it belongs to (detected via ClientIdentityCreated, // which only the newer pipeline ever writes), not by CompletedSteps.Length against one // hardcoded denominator. /// Idempotency key is IdmsEngagementRef, now backed by MPROVISIONINGJOB's DB-level /// unique constraint (previously an app-check-only race condition). A synthetic /// "QS-AUTO-{timestamp}" ref is generated when the caller doesn't supply one, mirroring /// how a direct/self-service QuickStart signup (no IDMS engagement) would call this. public async Task ProvisionSubscriptionAsync(ProvisionSubscriptionRequest 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 idmsEngagementRef = string.IsNullOrWhiteSpace(req.IdmsEngagementRef) ? $"QS-AUTO-{DateTime.UtcNow:yyyyMMddHHmmssfff}" : req.IdmsEngagementRef; var login = _LoginFactory.Create(req.ClientId); GB5Trace.Step("validate-provision-subscription", new { req.ClientId, idmsEngagementRef }); var existingJob = await _JobDAL.GetByEngagementRefAsync(idmsEngagementRef, login, ct).ConfigureAwait(false); if (existingJob is not null) { var existingSub = await _SubscriptionDAL.GetByClientAsync(existingJob.ClientId, login, ct).ConfigureAwait(false); return new ProvisioningResultDto { SubscriptionId = existingSub?.SubscriptionId ?? 0, ClientId = existingJob.ClientId, AlreadyExisted = true, Status = existingSub is not null ? ((SubscriptionStatusEnum)existingSub.SubscriptionStatus).ToString() : ((ProvisioningJobStatusEnum)existingJob.JobStatus).ToString() }; } var now = DateTime.UtcNow; // Job row is created and committed in its own transaction first, so it survives // (and can be marked Failed) regardless of what happens in the work it tracks below — // its INSERT's unique constraint on IDMSENGAGEMENTREF is also the real idempotency guard // against a concurrent duplicate call racing past the read-check above. var jobAuto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.ENTITLEMENTPROVISIONINGJOB, login).ConfigureAwait(false); var jobId = jobAuto.StartNumber; var jobTx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { await _JobDAL.SaveAsync(new ProvisioningJobDTO { ProvisioningJobId = jobId, ClientId = req.ClientId, IdmsEngagementRef = idmsEngagementRef, ProvisioningMode = (byte)ProvisioningModeEnum.QuickStart, PlanId = req.PlanId, JobStatus = (byte)ProvisioningJobStatusEnum.Running, CurrentStep = ProvisioningStepNames.SubscriptionCreate, StartedOn = now, RequestPayload = JsonSerializer.Serialize(req), CreatedById = login.UserId, CreatedOn = now, ModifiedById = login.UserId, ModifiedOn = now }, login, jobTx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(jobTx).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(jobTx).ConfigureAwait(false); GB5Trace.MarkFailed("provision-job-create-failed", ex); throw; } var tx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.ENTITLEMENTSUBSCRIPTION, login).ConfigureAwait(false); var sub = new SubscriptionDTO { SubscriptionId = auto.StartNumber, ClientId = req.ClientId, PlanId = req.PlanId, IdmsEngagementRef = idmsEngagementRef, SubscriptionStatus = (byte)SubscriptionStatusEnum.Pending, TrialFlag = (byte)(req.TrialFlag ? 1 : 0), TrialEndDate = req.TrialEndDate, StartDate = req.StartDate, LicenseValidTill = req.LicenseValidTill, SupportValidTill = req.SupportValidTill, HostingValidTill = new DateTime(1900, 1, 1), GracePeriodDays = 30, AutoRenew = 0, CreatedById = login.UserId, CreatedOn = now, ModifiedById = login.UserId, ModifiedOn = now }; GB5Trace.Step("save-provision-subscription", new { sub.SubscriptionId }); await _SubscriptionDAL.SaveAsync(sub, login, tx, ct).ConfigureAwait(false); var completedSteps = new List { ProvisioningStepNames.SubscriptionCreated }; await _JobDAL.UpdateProgressAsync(jobId, (byte)ProvisioningJobStatusEnum.Running, ProvisioningStepNames.EntitlementSeed, JsonSerializer.Serialize(completedSteps), login, tx, ct).ConfigureAwait(false); // Seed entitlements from the plan's feature catalogue. var planFeatures = await _PlanDAL.GetPlanFeaturesAsync(req.PlanId, login, ct).ConfigureAwait(false); short slNo = 1; foreach (var pf in planFeatures) { var entAuto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.ENTITLEMENT, login).ConfigureAwait(false); await _GrantDAL.SaveAsync(new EntitlementDTO { EntitlementId = entAuto.StartNumber, SubscriptionId = sub.SubscriptionId, SlNo = slNo++, FeatureId = pf.FeatureId, IsEnabled = pf.IsEnabled, FeatureValue = pf.FeatureValue, EntitlementSource = (byte)EntitlementSourceEnum.Plan, ValidFrom = now, ValidUntil = new DateTime(9999, 12, 31) }, login, tx, ct).ConfigureAwait(false); } GB5Trace.Step("event-publish", new { EventTypeId = EventTypeConstant.SAVEENTITLEMENTSUBSCRIPTIONEVENTTYPEID }); await _AuditDAL.InsertEntitlementAuditAsync(new EntitlementAuditDTO { ClientId = req.ClientId, SubscriptionId = sub.SubscriptionId, Action = "SUBSCRIPTION_CREATED", NewValue = $"PlanId={req.PlanId};EngagementRef={idmsEngagementRef}", CreatedById = login.UserId, CreatedOn = now }, login, tx, ct).ConfigureAwait(false); await _AuditDAL.InsertEntitlementAuditAsync(new EntitlementAuditDTO { ClientId = req.ClientId, SubscriptionId = sub.SubscriptionId, Action = "ENTITLEMENT_SEEDED", NewValue = $"FeatureCount={planFeatures.Count()}", CreatedById = login.UserId, CreatedOn = now }, login, tx, ct).ConfigureAwait(false); completedSteps.Add(ProvisioningStepNames.EntitlementSeeded); await _JobDAL.MarkCompletedAsync(jobId, string.Empty, JsonSerializer.Serialize(completedSteps), login, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); return new ProvisioningResultDto { SubscriptionId = sub.SubscriptionId, ClientId = req.ClientId, AlreadyExisted = false, Status = SubscriptionStatusEnum.Pending.ToString() }; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("provision-subscription-failed", ex); var failTx = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { await _JobDAL.MarkFailedAsync(jobId, ex.Message, login, failTx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(failTx).ConfigureAwait(false); } catch (Exception markFailedEx) { await _QueryExecutor.RollbackAsync(failTx).ConfigureAwait(false); GB5Trace.MarkFailed("provision-job-mark-failed-failed", markFailedEx); } throw; } } public async Task GetStatusAsync(int clientId, CancellationToken ct) { var login = _LoginFactory.Create(clientId); var job = await _JobDAL.GetByClientAsync(clientId, login, ct).ConfigureAwait(false); if (job is null) { return new ProvisioningStatusDto { ClientId = clientId, Status = "not_found" }; } var completedSteps = JsonSerializer.Deserialize(job.CompletedSteps) ?? Array.Empty(); var jobStatus = (ProvisioningJobStatusEnum)job.JobStatus; int progressPct; if (jobStatus == ProvisioningJobStatusEnum.Completed) { progressPct = 100; } else { // ClientIdentityCreated only ever appears in ClientOnboardingOrchestratorBLL's own // richer pipeline — never written by this class's own ProvisionSubscriptionAsync — // so its presence reliably tells the two pipelines' step vocabularies apart. var sequence = completedSteps.Contains(ProvisioningStepNames.ClientIdentityCreated) ? ProvisioningStepNames.OnboardingOrchestratorSequence : ProvisioningStepNames.QuickStartSequence; // Count only sequence-recognized entries (not CompletedSteps.Length outright) so an // optional/best-effort step (SyncTriggered) never inflates the numerator past what's // actually guaranteed to happen — same reasoning as OnboardingOrchestratorSequence's // own doc comment for excluding it from the sequence in the first place. var doneCount = completedSteps.Count(s => sequence.Contains(s)); progressPct = Math.Min(100, (int)Math.Round(doneCount * 100.0 / sequence.Length)); } return new ProvisioningStatusDto { ClientId = clientId, Status = jobStatus.ToString(), CurrentStep = job.CurrentStep, CompletedSteps = completedSteps, ProgressPct = progressPct, ErrorMessage = job.LastError, CanRetry = jobStatus == ProvisioningJobStatusEnum.Failed, DockerPackageUrl = job.DockerPackageUrl }; } }