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
};
}
}