refactor(architecture): harden module boundaries
Some checks failed
ci / release-gate (push) Has been cancelled

This commit is contained in:
2026-08-04 12:10:36 +08:00
parent b38f12e60b
commit 33375a38d7
218 changed files with 5124 additions and 3828 deletions

View File

@@ -21,7 +21,7 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class CommercialBillingProcessor(
TikuDbContext directoryDbContext,
IPlatformBillingPersistence directoryDbContext,
ITenantContext tenantContext,
ITenantExecutionScope tenantExecutionScope,
IOptions<CommercialBillingOptions> options,
@@ -70,7 +70,7 @@ internal sealed class CommercialBillingProcessor(
Scope(candidate.TenantId, "Generate subscription renewal receivable", candidate.SubscriptionId),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var subscription =
await db.TenantSaasSubscriptions.SingleAsync(value => value.Id == candidate.SubscriptionId,
token);
@@ -144,7 +144,7 @@ internal sealed class CommercialBillingProcessor(
Scope(candidate.TenantId, "Generate billing reminder", candidate.InvoiceId),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var invoice =
await db.PlatformBillingInvoices.SingleAsync(value => value.Id == candidate.InvoiceId, token);
var dueDate = invoice.DueDate!.Value;
@@ -237,7 +237,7 @@ internal sealed class CommercialBillingProcessor(
Scope(candidate.TenantId, "Execute approved SaaS refund", candidate.RefundId),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var refund =
await db.PlatformBillingRefunds.SingleAsync(value => value.Id == candidate.RefundId, token);
var payment =
@@ -284,7 +284,7 @@ internal sealed class CommercialBillingProcessor(
TargetType = "platform_billing_refunds",
TargetId = refund.Id.ToString(),
Details = JsonSerializer.SerializeToElement(new
{ refund.AmountCents, refund.SubscriptionEffect })
{ refund.AmountCents, refund.SubscriptionEffect })
});
}
catch (Exception exception)
@@ -318,7 +318,7 @@ internal sealed class CommercialBillingProcessor(
Scope(candidate.TenantId, "Dispatch billing dunning notification", candidate.EventId),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var item = await db.PlatformBillingDunningNotificationEvents.SingleAsync(
value => value.Id == candidate.EventId, token);
var channel = await db.PlatformBillingDunningNotificationChannels.AsNoTracking()
@@ -385,7 +385,7 @@ internal sealed class CommercialBillingProcessor(
return candidates.Length;
}
private async Task ApplyRefundEffectAsync(TikuDbContext db, PlatformBillingRefund refund,
private async Task ApplyRefundEffectAsync(IPlatformBillingPersistence db, PlatformBillingRefund refund,
CancellationToken cancellationToken)
{
if (refund.SubscriptionEffect == PlatformBillingRefundSubscriptionEffect.KeepService) return;
@@ -478,7 +478,7 @@ internal sealed class CommercialBillingProcessor(
};
}
private static async Task<JsonElement> BillingProfileSnapshotAsync(TikuDbContext db, Guid tenantId,
private static async Task<JsonElement> BillingProfileSnapshotAsync(IPlatformBillingPersistence db, Guid tenantId,
CancellationToken cancellationToken)
{
var profile = await db.TenantBillingProfiles.AsNoTracking()

View File

@@ -20,7 +20,7 @@ internal sealed class PlatformBillingAdminService(
{
return ExecuteAsync<IReadOnlyCollection<PlatformBillingOrder>>("list SaaS orders", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var values = db.PlatformBillingOrders.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status))
@@ -36,7 +36,7 @@ internal sealed class PlatformBillingAdminService(
return ExecuteAsync<IReadOnlyCollection<PlatformBillingPayment>>("list SaaS payments",
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var values = db.PlatformBillingPayments.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status))
@@ -51,7 +51,7 @@ internal sealed class PlatformBillingAdminService(
{
return ExecuteAsync<IReadOnlyCollection<PlatformBillingRefund>>("list SaaS refunds", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var values = db.PlatformBillingRefunds.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status))
@@ -67,7 +67,7 @@ internal sealed class PlatformBillingAdminService(
return ExecuteAsync<IReadOnlyCollection<PlatformBillingInvoice>>("list SaaS invoices",
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var values = db.PlatformBillingInvoices.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status))
@@ -82,7 +82,7 @@ internal sealed class PlatformBillingAdminService(
{
return ExecuteAsync<IReadOnlyCollection<TenantFeatureUsage>>("list SaaS usage", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var values = db.TenantFeatureUsages.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
return await values.OrderByDescending(value => value.PeriodStart).ThenBy(value => value.MetricCode)
@@ -96,7 +96,7 @@ internal sealed class PlatformBillingAdminService(
return ExecuteAsync<IReadOnlyCollection<PlatformBillingInvoiceReminder>>("list SaaS invoice reminders",
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var values = db.PlatformBillingInvoiceReminders.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status))
@@ -113,7 +113,7 @@ internal sealed class PlatformBillingAdminService(
return ExecuteAsync<IReadOnlyCollection<TenantSaasSubscription>>("list SaaS subscriptions",
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var values = db.TenantSaasSubscriptions.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status))
@@ -130,7 +130,7 @@ internal sealed class PlatformBillingAdminService(
{
return ExecuteAsync("confirm manual SaaS payment", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var payment = await db.PlatformBillingPayments.AsNoTracking()
.SingleOrDefaultAsync(value => value.Id == command.PaymentId, token)
?? throw Error("Platform billing payment was not found.",
@@ -161,7 +161,7 @@ internal sealed class PlatformBillingAdminService(
{
return ExecuteAsync("upsert tenant feature override", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var featureCode = command.FeatureCode.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(command.Reason))
throw Error("Override reason is required.", "platform_billing_reason_required");
@@ -188,7 +188,7 @@ internal sealed class PlatformBillingAdminService(
TargetType = "tenant_feature_overrides",
TargetId = item.Id.ToString(),
Details = JsonSerializer.SerializeToElement(new
{ item.FeatureCode, item.Mode, item.ExpiresAt, item.Reason })
{ item.FeatureCode, item.Mode, item.ExpiresAt, item.Reason })
});
await db.SaveChangesAsync(token);
await services.GetRequiredService<ITenantFeatureCacheInvalidator>()
@@ -204,7 +204,7 @@ internal sealed class PlatformBillingAdminService(
{
return ExecuteAsync("grant tenant SaaS trial", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var key = Required(command.IdempotencyKey, "idempotencyKey");
var existingRequest = await db.PlatformOperationIdempotencies.AsNoTracking()
.SingleOrDefaultAsync(value => value.ActorUserId == actor.UserId &&
@@ -308,7 +308,7 @@ internal sealed class PlatformBillingAdminService(
{
return ExecuteAsync("request SaaS refund", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var key = Required(command.IdempotencyKey, "idempotencyKey");
var payment = await db.PlatformBillingPayments.AsNoTracking()
.SingleOrDefaultAsync(value => value.Id == command.PaymentId, token)
@@ -375,7 +375,7 @@ internal sealed class PlatformBillingAdminService(
{
return ExecuteAsync("retry SaaS refund", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var refund =
await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.Id == command.RefundId, token)
?? throw Error("Refund was not found.", "platform_billing_refund_not_found");
@@ -395,7 +395,7 @@ internal sealed class PlatformBillingAdminService(
{
return ExecuteAsync<CommercialMetrics>("get SaaS commercial metrics", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var now = DateTimeOffset.UtcNow;
var periodStart = new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero);
var subscriptions = await (
@@ -449,7 +449,7 @@ internal sealed class PlatformBillingAdminService(
{
return ExecuteAsync($"{action} SaaS subscription", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var subscription =
await db.TenantSaasSubscriptions.SingleOrDefaultAsync(value => value.Id == command.SubscriptionId,
token)
@@ -525,7 +525,7 @@ internal sealed class PlatformBillingAdminService(
{
return ExecuteAsync(approve ? "approve SaaS refund" : "reject SaaS refund", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var refund =
await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.Id == command.RefundId, token)
?? throw Error("Refund was not found.", "platform_billing_refund_not_found");
@@ -545,7 +545,7 @@ internal sealed class PlatformBillingAdminService(
}, cancellationToken);
}
private static void AddAudit(TikuDbContext db, Guid actorUserId, Guid tenantId, string action, Guid targetId,
private static void AddAudit(IPlatformBillingPersistence db, Guid actorUserId, Guid tenantId, string action, Guid targetId,
string? reason)
{
db.AuditLogs.Add(new AuditLog

View File

@@ -27,7 +27,7 @@ internal sealed class PlatformBillingNotificationService(
"Settle platform billing notification", parsed.EventId, true),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var db = services.GetRequiredService<IPlatformBillingPersistence>();
var order = await db.PlatformBillingOrders.AsNoTracking()
.SingleOrDefaultAsync(value => value.OrderNo == parsed.OrderNo, token)
?? throw Error("Platform billing order was not found.", "platform_billing_order_not_found");

View File

@@ -56,7 +56,7 @@ internal sealed class PlatformBillingPaymentGateway(
true),
async (services, token) =>
{
var dbContext = services.GetRequiredService<TikuDbContext>();
var dbContext = services.GetRequiredService<IPlatformBillingPersistence>();
var platformTenantId = await dbContext.Tenants.AsNoTracking()
.Where(value => value.Mode == TenantMode.PlatformOwned)
.Select(value => value.Id)

View File

@@ -11,7 +11,10 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class PlatformBillingSettlementService(
TikuDbContext dbContext,
IPlatformControlPlanePersistence platformControlPlanePersistence,
ITenancyPersistence tenancyPersistence,
ITenantAdministrationPersistence tenantAdministrationPersistence,
IJobsOperationsPersistence jobsOperationsPersistence,
ITenantFeatureCacheInvalidator featureCacheInvalidator) : IPlatformBillingSettlementService
{
public async Task<PlatformBillingPayment> MarkPaidAsync(
@@ -25,21 +28,21 @@ internal sealed class PlatformBillingSettlementService(
CancellationToken cancellationToken = default)
{
var payment =
await dbContext.PlatformBillingPayments.SingleOrDefaultAsync(value => value.Id == paymentId,
await platformControlPlanePersistence.PlatformBillingPayments.SingleOrDefaultAsync(value => value.Id == paymentId,
cancellationToken)
?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found");
if (await dbContext.PlatformBillingPaymentEvents.AnyAsync(value =>
if (await platformControlPlanePersistence.PlatformBillingPaymentEvents.AnyAsync(value =>
value.TenantId == payment.TenantId &&
value.Provider == payment.Provider &&
value.ProviderEventId == providerEventId, cancellationToken))
return payment;
var order = await dbContext.PlatformBillingOrders.SingleAsync(value =>
var order = await platformControlPlanePersistence.PlatformBillingOrders.SingleAsync(value =>
value.TenantId == payment.TenantId && value.Id == payment.OrderId, cancellationToken);
if (payment.AmountCents != order.TotalAmountCents)
throw Error("Payment amount does not match the order.", "platform_billing_payment_amount_mismatch");
dbContext.PlatformBillingPaymentEvents.Add(new PlatformBillingPaymentEvent
platformControlPlanePersistence.PlatformBillingPaymentEvents.Add(new PlatformBillingPaymentEvent
{
TenantId = payment.TenantId,
PaymentId = payment.Id,
@@ -50,7 +53,7 @@ internal sealed class PlatformBillingSettlementService(
});
if (payment.Status == PlatformBillingPaymentStatus.Succeeded)
{
await dbContext.SaveChangesAsync(cancellationToken);
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
return payment;
}
@@ -64,17 +67,17 @@ internal sealed class PlatformBillingSettlementService(
payment.PaidAt = paidAt;
order.Status = PlatformBillingOrderStatus.Paid;
order.PaidAt = paidAt;
var tenant = await dbContext.Tenants.SingleAsync(value => value.Id == order.TenantId, cancellationToken);
var tenant = await tenancyPersistence.Tenants.SingleAsync(value => value.Id == order.TenantId, cancellationToken);
tenant.BillingStatus = BillingStatus.Active;
var orderItems = await dbContext.PlatformBillingOrderItems.AsNoTracking()
var orderItems = await platformControlPlanePersistence.PlatformBillingOrderItems.AsNoTracking()
.Where(value => value.TenantId == order.TenantId && value.OrderId == order.Id)
.ToArrayAsync(cancellationToken);
var baseItem = orderItems.SingleOrDefault(value => value.ItemType == PlatformBillingItemType.BasePlan)
?? throw Error("Order base plan item is missing.", "platform_billing_base_plan_missing");
var baseVersion = await dbContext.SaasOfferingVersions.AsNoTracking()
var baseVersion = await platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking()
.SingleAsync(value => value.Id == baseItem.OfferingVersionId, cancellationToken);
var subscription = await dbContext.TenantSaasSubscriptions
var subscription = await platformControlPlanePersistence.TenantSaasSubscriptions
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(value => value.TenantId == order.TenantId, cancellationToken);
var now = paidAt;
@@ -89,7 +92,7 @@ internal sealed class PlatformBillingSettlementService(
CurrentPeriodStart = now,
CurrentPeriodEnd = AddCycle(now, baseVersion.BillingCycle)
};
dbContext.TenantSaasSubscriptions.Add(subscription);
platformControlPlanePersistence.TenantSaasSubscriptions.Add(subscription);
}
else if (order.Purpose == PlatformBillingOrderPurpose.Renewal)
{
@@ -122,7 +125,7 @@ internal sealed class PlatformBillingSettlementService(
if (order.Purpose != PlatformBillingOrderPurpose.Downgrade)
{
var existingItems = await dbContext.TenantSaasSubscriptionItems
var existingItems = await platformControlPlanePersistence.TenantSaasSubscriptionItems
.Where(value => value.TenantId == order.TenantId && value.SubscriptionId == subscription.Id &&
value.Status == TenantSaasSubscriptionItemStatus.Active)
.ToArrayAsync(cancellationToken);
@@ -132,7 +135,7 @@ internal sealed class PlatformBillingSettlementService(
existing.EndsAt = now > existing.StartsAt ? now : existing.StartsAt.AddTicks(1);
}
dbContext.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem
platformControlPlanePersistence.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem
{
TenantId = order.TenantId,
SubscriptionId = subscription.Id,
@@ -148,7 +151,7 @@ internal sealed class PlatformBillingSettlementService(
}
else
{
var existingScheduledItems = await dbContext.TenantSaasSubscriptionItems
var existingScheduledItems = await platformControlPlanePersistence.TenantSaasSubscriptionItems
.Where(value => value.TenantId == order.TenantId &&
value.SubscriptionId == subscription.Id &&
value.Status == TenantSaasSubscriptionItemStatus.Scheduled)
@@ -159,7 +162,7 @@ internal sealed class PlatformBillingSettlementService(
existing.EndsAt = now > existing.StartsAt ? now : existing.StartsAt.AddTicks(1);
}
dbContext.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem
platformControlPlanePersistence.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem
{
TenantId = order.TenantId,
SubscriptionId = subscription.Id,
@@ -174,7 +177,7 @@ internal sealed class PlatformBillingSettlementService(
}));
}
var invoice = await dbContext.PlatformBillingInvoices.SingleOrDefaultAsync(value =>
var invoice = await platformControlPlanePersistence.PlatformBillingInvoices.SingleOrDefaultAsync(value =>
value.TenantId == order.TenantId && value.OrderId == order.Id, cancellationToken);
if (invoice is null)
{
@@ -188,12 +191,12 @@ internal sealed class PlatformBillingSettlementService(
IssuedAt = now,
BillingProfileSnapshot = await LoadBillingProfileSnapshotAsync(order.TenantId, cancellationToken)
};
dbContext.PlatformBillingInvoices.Add(invoice);
platformControlPlanePersistence.PlatformBillingInvoices.Add(invoice);
}
invoice.Status = PlatformBillingInvoiceStatus.Paid;
invoice.PaidAt = now;
dbContext.AuditLogs.Add(new AuditLog
jobsOperationsPersistence.AuditLogs.Add(new AuditLog
{
TenantId = order.TenantId,
ActorUserId = actorUserId,
@@ -201,9 +204,9 @@ internal sealed class PlatformBillingSettlementService(
TargetType = "platform_billing_orders",
TargetId = order.Id.ToString(),
Details = JsonSerializer.SerializeToElement(new
{ order.OrderNo, payment.PaymentNo, payment.Provider, order.Purpose })
{ order.OrderNo, payment.PaymentNo, payment.Provider, order.Purpose })
});
await dbContext.SaveChangesAsync(cancellationToken);
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
await featureCacheInvalidator.InvalidateAsync(order.TenantId, cancellationToken);
return payment;
@@ -211,7 +214,7 @@ internal sealed class PlatformBillingSettlementService(
private async Task<JsonElement> LoadBillingProfileSnapshotAsync(Guid tenantId, CancellationToken cancellationToken)
{
var profile = await dbContext.TenantBillingProfiles.AsNoTracking()
var profile = await tenantAdministrationPersistence.TenantBillingProfiles.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken);
return profile is null
? JsonDefaults.Object()

View File

@@ -9,7 +9,7 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class SaasCatalogAdminService(
TikuDbContext dbContext,
IPlatformControlPlanePersistence dbContext,
IOperationAuditService auditService) : ISaasCatalogAdminService
{
public async Task<SaasCatalogSnapshot> GetCatalogAsync(
@@ -297,8 +297,8 @@ internal sealed class SaasCatalogAdminService(
CancellationToken cancellationToken)
{
var query = from version in dbContext.SaasOfferingVersions.AsNoTracking()
join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
select new { Version = version, Offering = offering };
join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
select new { Version = version, Offering = offering };
if (versionId.HasValue) query = query.Where(value => value.Version.Id == versionId);
var rows = await query.OrderBy(value => value.Offering.SortOrder).ThenBy(value => value.Offering.Code)
.ThenByDescending(value => value.Version.Version).ToArrayAsync(cancellationToken);

View File

@@ -12,7 +12,7 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class SaasSubscriptionLifecycleService(
TikuDbContext directoryDbContext,
IPlatformControlPlanePersistence directoryPersistence,
ITenantContext tenantContext,
ITenantExecutionScope tenantExecutionScope,
IOptions<SaasSubscriptionLifecycleOptions> options) : ISaasSubscriptionLifecycleService
@@ -29,7 +29,7 @@ internal sealed class SaasSubscriptionLifecycleService(
"SaaS subscription lifecycle discovery requires a global system context.");
var effectiveAt = asOf ?? DateTimeOffset.UtcNow;
var tenantIds = await directoryDbContext.TenantSaasSubscriptions.AsNoTracking()
var tenantIds = await directoryPersistence.TenantSaasSubscriptions.AsNoTracking()
.Where(subscription =>
subscription.CurrentPeriodEnd <= effectiveAt &&
(subscription.Status == TenantSaasSubscriptionStatus.Trial ||
@@ -66,7 +66,9 @@ internal sealed class SaasSubscriptionLifecycleService(
DateTimeOffset asOf,
CancellationToken cancellationToken)
{
var dbContext = services.GetRequiredService<TikuDbContext>();
var dbContext = services.GetRequiredService<IPlatformControlPlanePersistence>();
var tenancyPersistence = services.GetRequiredService<ITenancyPersistence>();
var jobsOperationsPersistence = services.GetRequiredService<IJobsOperationsPersistence>();
var subscription = await dbContext.TenantSaasSubscriptions
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(value =>
@@ -83,7 +85,7 @@ internal sealed class SaasSubscriptionLifecycleService(
var items = await dbContext.TenantSaasSubscriptionItems
.Where(value => value.TenantId == tenantId && value.SubscriptionId == subscription.Id)
.ToArrayAsync(cancellationToken);
var tenant = await dbContext.Tenants.SingleAsync(value => value.Id == tenantId, cancellationToken);
var tenant = await tenancyPersistence.Tenants.SingleAsync(value => value.Id == tenantId, cancellationToken);
string transition;
if (subscription.CancelAtPeriodEnd)
@@ -167,7 +169,7 @@ internal sealed class SaasSubscriptionLifecycleService(
}
subscription.LifecycleVersion++;
dbContext.AuditLogs.Add(new AuditLog
jobsOperationsPersistence.AuditLogs.Add(new AuditLog
{
TenantId = tenantId,
Action = $"platform_billing.subscription.{transition}",
@@ -217,4 +219,4 @@ internal sealed class SaasSubscriptionLifecycleService(
item.Status = TenantSaasSubscriptionItemStatus.Cancelled;
}
}
}
}

View File

@@ -12,7 +12,8 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class TenantBillingService(
TikuDbContext dbContext,
IPlatformControlPlanePersistence platformControlPlanePersistence,
IJobsOperationsPersistence jobsOperationsPersistence,
IPlatformBillingPaymentGateway paymentGateway,
IPlatformBillingSettlementService settlementService,
IFeatureAccessService featureAccessService,
@@ -26,7 +27,7 @@ internal sealed class TenantBillingService(
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var features = await dbContext.SaasFeatures.AsNoTracking()
var features = await platformControlPlanePersistence.SaasFeatures.AsNoTracking()
.Where(value => value.Status == SaasFeatureStatus.Active && !value.IsCore)
.OrderBy(value => value.SortOrder).ThenBy(value => value.Code)
.ToArrayAsync(cancellationToken);
@@ -43,7 +44,7 @@ internal sealed class TenantBillingService(
CancellationToken cancellationToken = default)
{
var idempotencyKey = Required(command.IdempotencyKey, "idempotencyKey");
var existingQuote = await dbContext.PlatformBillingQuotes.AsNoTracking()
var existingQuote = await platformControlPlanePersistence.PlatformBillingQuotes.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == idempotencyKey,
cancellationToken);
if (existingQuote is not null) return await LoadQuoteAsync(actor.TenantId, existingQuote.Id, cancellationToken);
@@ -54,8 +55,8 @@ internal sealed class TenantBillingService(
.ToArray();
var now = DateTimeOffset.UtcNow;
var versions = await (
from version in dbContext.SaasOfferingVersions.AsNoTracking()
join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
from version in platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking()
join offering in platformControlPlanePersistence.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
where requestedIds.Contains(version.Id) &&
version.Status == SaasOfferingVersionStatus.Published &&
offering.Status == SaasOfferingStatus.Active &&
@@ -71,10 +72,10 @@ internal sealed class TenantBillingService(
if (versions.Select(value => value.Version.Currency).Distinct(StringComparer.Ordinal).Count() != 1)
throw Error("All quote items must use the same currency.", "platform_billing_currency_mismatch");
var featureRows = await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
var featureRows = await platformControlPlanePersistence.SaasOfferingVersionFeatures.AsNoTracking()
.Where(value => requestedIds.Contains(value.OfferingVersionId))
.ToArrayAsync(cancellationToken);
var limitRows = await dbContext.SaasOfferingVersionLimits.AsNoTracking()
var limitRows = await platformControlPlanePersistence.SaasOfferingVersionLimits.AsNoTracking()
.Where(value => requestedIds.Contains(value.OfferingVersionId))
.ToArrayAsync(cancellationToken);
var featureCodes = featureRows.Select(value => value.FeatureCode).Distinct(StringComparer.Ordinal)
@@ -97,8 +98,8 @@ internal sealed class TenantBillingService(
FeatureSnapshot = JsonSerializer.SerializeToElement(featureCodes),
LimitSnapshot = JsonSerializer.SerializeToElement(limits)
};
dbContext.PlatformBillingQuotes.Add(quote);
dbContext.PlatformBillingQuoteItems.AddRange(versions.Select(value => new PlatformBillingQuoteItem
platformControlPlanePersistence.PlatformBillingQuotes.Add(quote);
platformControlPlanePersistence.PlatformBillingQuoteItems.AddRange(versions.Select(value => new PlatformBillingQuoteItem
{
TenantId = actor.TenantId,
QuoteId = quote.Id,
@@ -123,7 +124,7 @@ internal sealed class TenantBillingService(
.ToDictionary(limit => limit.MetricCode, limit => limit.LimitValue)
})
}));
await dbContext.SaveChangesAsync(cancellationToken);
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
return await LoadQuoteAsync(actor.TenantId, quote.Id, cancellationToken);
}
@@ -133,23 +134,23 @@ internal sealed class TenantBillingService(
CancellationToken cancellationToken = default)
{
var key = Required(command.IdempotencyKey, "idempotencyKey");
var existing = await dbContext.PlatformBillingOrders.AsNoTracking()
var existing = await platformControlPlanePersistence.PlatformBillingOrders.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key,
cancellationToken);
if (existing is not null) return await LoadOrderAsync(actor.TenantId, existing.OrderNo, cancellationToken);
var now = DateTimeOffset.UtcNow;
var quote = await dbContext.PlatformBillingQuotes.SingleOrDefaultAsync(value =>
var quote = await platformControlPlanePersistence.PlatformBillingQuotes.SingleOrDefaultAsync(value =>
value.TenantId == actor.TenantId && value.Id == command.QuoteId, cancellationToken)
?? throw Error("Quote was not found.", "platform_billing_quote_not_found");
if (quote.Status != PlatformBillingQuoteStatus.Active || quote.ExpiresAt <= now)
{
quote.Status = quote.ExpiresAt <= now ? PlatformBillingQuoteStatus.Expired : quote.Status;
await dbContext.SaveChangesAsync(cancellationToken);
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
throw Error("Quote is no longer active.", "platform_billing_quote_expired");
}
var quoteItems = await dbContext.PlatformBillingQuoteItems.AsNoTracking()
var quoteItems = await platformControlPlanePersistence.PlatformBillingQuoteItems.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId && value.QuoteId == quote.Id)
.ToArrayAsync(cancellationToken);
var order = new PlatformBillingOrder
@@ -171,8 +172,8 @@ internal sealed class TenantBillingService(
quote.LimitSnapshot
})
};
dbContext.PlatformBillingOrders.Add(order);
dbContext.PlatformBillingOrderItems.AddRange(quoteItems.Select(value => new PlatformBillingOrderItem
platformControlPlanePersistence.PlatformBillingOrders.Add(order);
platformControlPlanePersistence.PlatformBillingOrderItems.AddRange(quoteItems.Select(value => new PlatformBillingOrderItem
{
TenantId = actor.TenantId,
OrderId = order.Id,
@@ -184,7 +185,7 @@ internal sealed class TenantBillingService(
Snapshot = value.Snapshot
}));
quote.Status = PlatformBillingQuoteStatus.Converted;
await dbContext.SaveChangesAsync(cancellationToken);
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
return await LoadOrderAsync(actor.TenantId, order.OrderNo, cancellationToken);
}
@@ -194,12 +195,12 @@ internal sealed class TenantBillingService(
CancellationToken cancellationToken = default)
{
var key = Required(command.IdempotencyKey, "idempotencyKey");
var existing = await dbContext.PlatformBillingPayments.AsNoTracking()
var existing = await platformControlPlanePersistence.PlatformBillingPayments.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key,
cancellationToken);
if (existing is not null) return ToPaymentView(existing);
var order = await dbContext.PlatformBillingOrders.SingleOrDefaultAsync(value =>
var order = await platformControlPlanePersistence.PlatformBillingOrders.SingleOrDefaultAsync(value =>
value.TenantId == actor.TenantId && value.OrderNo == command.OrderNo, cancellationToken)
?? throw Error("Order was not found.", "platform_billing_order_not_found");
if (order.Status != PlatformBillingOrderStatus.PendingPayment || order.ExpiresAt <= DateTimeOffset.UtcNow)
@@ -207,7 +208,7 @@ internal sealed class TenantBillingService(
if (order.ExpiresAt <= DateTimeOffset.UtcNow && order.Status == PlatformBillingOrderStatus.PendingPayment)
{
order.Status = PlatformBillingOrderStatus.Expired;
await dbContext.SaveChangesAsync(cancellationToken);
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
}
throw Error("Order does not allow a new payment.", "platform_billing_order_status_invalid");
@@ -224,8 +225,8 @@ internal sealed class TenantBillingService(
Method = Required(command.Method, "method"),
AmountCents = order.TotalAmountCents
};
dbContext.PlatformBillingPayments.Add(payment);
await dbContext.SaveChangesAsync(cancellationToken);
platformControlPlanePersistence.PlatformBillingPayments.Add(payment);
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
if (order.TotalAmountCents == 0)
{
@@ -259,13 +260,13 @@ internal sealed class TenantBillingService(
cancellationToken);
payment.ProviderTradeNo = result.ProviderTradeNo;
payment.ClientPayload = result.ClientPayload;
await dbContext.SaveChangesAsync(cancellationToken);
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
return ToPaymentView(payment);
}
catch
{
payment.Status = PlatformBillingPaymentStatus.Failed;
await dbContext.SaveChangesAsync(CancellationToken.None);
await platformControlPlanePersistence.SaveChangesAsync(CancellationToken.None);
throw;
}
}
@@ -275,7 +276,7 @@ internal sealed class TenantBillingService(
int limit,
CancellationToken cancellationToken = default)
{
var orderNos = await dbContext.PlatformBillingOrders.AsNoTracking()
var orderNos = await platformControlPlanePersistence.PlatformBillingOrders.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.CreatedAt)
.Take(Math.Clamp(limit, 1, 200))
@@ -296,7 +297,7 @@ internal sealed class TenantBillingService(
TenantBillingActor actor,
CancellationToken cancellationToken = default)
{
var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking()
var subscription = await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(cancellationToken);
@@ -309,16 +310,16 @@ internal sealed class TenantBillingService(
string idempotencyKey,
CancellationToken cancellationToken = default)
{
var current = await dbContext.TenantSaasSubscriptions.AsNoTracking()
var current = await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(cancellationToken);
var currentAmount = current is null
? 0
: await dbContext.SaasOfferingVersions.AsNoTracking()
: await platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking()
.Where(value => value.Id == current.BaseOfferingVersionId).Select(value => value.AmountCents)
.SingleAsync(cancellationToken);
var requestedAmount = await dbContext.SaasOfferingVersions.AsNoTracking()
var requestedAmount = await platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking()
.Where(value => value.Id == command.BaseOfferingVersionId)
.Select(value => (int?)value.AmountCents).SingleOrDefaultAsync(cancellationToken)
?? throw Error("Offering version was not found.", "saas_offering_version_not_found");
@@ -339,12 +340,12 @@ internal sealed class TenantBillingService(
string idempotencyKey,
CancellationToken cancellationToken = default)
{
var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking()
var subscription = await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(cancellationToken)
?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found");
var addOns = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
var addOns = await platformControlPlanePersistence.TenantSaasSubscriptionItems.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId && value.SubscriptionId == subscription.Id &&
value.ItemType == TenantSaasSubscriptionItemType.AddOn &&
value.Status == TenantSaasSubscriptionItemStatus.Active)
@@ -361,14 +362,14 @@ internal sealed class TenantBillingService(
TenantBillingActor actor,
CancellationToken cancellationToken = default)
{
var subscription = await dbContext.TenantSaasSubscriptions
var subscription = await platformControlPlanePersistence.TenantSaasSubscriptions
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(cancellationToken)
?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found");
subscription.CancelAtPeriodEnd = true;
subscription.CancelledAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
return await ToSubscriptionViewAsync(subscription, cancellationToken);
}
@@ -383,7 +384,7 @@ internal sealed class TenantBillingService(
int limit,
CancellationToken cancellationToken = default)
{
return await dbContext.PlatformBillingInvoices.AsNoTracking()
return await platformControlPlanePersistence.PlatformBillingInvoices.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.CreatedAt)
.Take(Math.Clamp(limit, 1, 200))
@@ -395,7 +396,7 @@ internal sealed class TenantBillingService(
int limit,
CancellationToken cancellationToken = default)
{
return await dbContext.PlatformBillingInvoices.AsNoTracking()
return await platformControlPlanePersistence.PlatformBillingInvoices.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.CreatedAt)
.Take(Math.Clamp(limit, 1, 200))
@@ -418,20 +419,20 @@ internal sealed class TenantBillingService(
CancellationToken cancellationToken = default)
{
var normalized = Required(orderNo, "orderNo");
var order = await dbContext.PlatformBillingOrders.SingleOrDefaultAsync(value =>
var order = await platformControlPlanePersistence.PlatformBillingOrders.SingleOrDefaultAsync(value =>
value.TenantId == actor.TenantId && value.OrderNo == normalized,
cancellationToken) ?? throw Error("Order was not found.", "platform_billing_order_not_found");
if (order.Status != PlatformBillingOrderStatus.PendingPayment)
throw Error("Only a pending order can be cancelled.", "platform_billing_order_not_cancellable");
order.Status = PlatformBillingOrderStatus.Cancelled;
order.CancelledAt = DateTimeOffset.UtcNow;
var receivables = await dbContext.PlatformBillingInvoices
var receivables = await platformControlPlanePersistence.PlatformBillingInvoices
.Where(value => value.TenantId == actor.TenantId && value.OrderId == order.Id &&
(value.Status == PlatformBillingInvoiceStatus.Draft ||
value.Status == PlatformBillingInvoiceStatus.Issued))
.ToArrayAsync(cancellationToken);
foreach (var receivable in receivables) receivable.Status = PlatformBillingInvoiceStatus.Void;
dbContext.AuditLogs.Add(new AuditLog
jobsOperationsPersistence.AuditLogs.Add(new AuditLog
{
TenantId = actor.TenantId,
ActorUserId = actor.UserId,
@@ -439,7 +440,7 @@ internal sealed class TenantBillingService(
TargetType = "platform_billing_orders",
TargetId = order.Id.ToString()
});
await dbContext.SaveChangesAsync(cancellationToken);
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
return await LoadOrderAsync(actor.TenantId, normalized, cancellationToken);
}
@@ -448,7 +449,7 @@ internal sealed class TenantBillingService(
int limit,
CancellationToken cancellationToken = default)
{
return await dbContext.PlatformBillingRefunds.AsNoTracking()
return await platformControlPlanePersistence.PlatformBillingRefunds.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.CreatedAt)
.Take(Math.Clamp(limit, 1, 200))
@@ -458,7 +459,7 @@ internal sealed class TenantBillingService(
private async Task<PlatformBillingQuoteView> LoadQuoteAsync(Guid tenantId, Guid quoteId,
CancellationToken cancellationToken)
{
var quote = await dbContext.PlatformBillingQuotes.AsNoTracking()
var quote = await platformControlPlanePersistence.PlatformBillingQuotes.AsNoTracking()
.SingleAsync(value => value.TenantId == tenantId && value.Id == quoteId, cancellationToken);
var items = await LoadQuoteItemsAsync(tenantId, quote.Id, cancellationToken);
return new PlatformBillingQuoteView(
@@ -480,15 +481,15 @@ internal sealed class TenantBillingService(
CancellationToken cancellationToken)
{
var normalized = Required(orderNo, "orderNo");
var order = await dbContext.PlatformBillingOrders.AsNoTracking()
var order = await platformControlPlanePersistence.PlatformBillingOrders.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == tenantId && value.OrderNo == normalized,
cancellationToken)
?? throw Error("Order was not found.", "platform_billing_order_not_found");
var items = await (
from item in dbContext.PlatformBillingOrderItems.AsNoTracking()
join version in dbContext.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version
from item in platformControlPlanePersistence.PlatformBillingOrderItems.AsNoTracking()
join version in platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version
.Id
join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
join offering in platformControlPlanePersistence.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
where item.TenantId == tenantId && item.OrderId == order.Id
orderby item.ItemType, offering.Code
select new PlatformBillingQuoteItemView(item.OfferingVersionId, offering.Code, offering.Name,
@@ -502,10 +503,10 @@ internal sealed class TenantBillingService(
CancellationToken cancellationToken)
{
return await (
from item in dbContext.PlatformBillingQuoteItems.AsNoTracking()
join version in dbContext.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version
from item in platformControlPlanePersistence.PlatformBillingQuoteItems.AsNoTracking()
join version in platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version
.Id
join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
join offering in platformControlPlanePersistence.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
where item.TenantId == tenantId && item.QuoteId == quoteId
orderby item.ItemType, offering.Code
select new PlatformBillingQuoteItemView(item.OfferingVersionId, offering.Code, offering.Name,
@@ -516,14 +517,14 @@ internal sealed class TenantBillingService(
private async Task<TenantSubscriptionView> ToSubscriptionViewAsync(TenantSaasSubscription subscription,
CancellationToken cancellationToken)
{
var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
var versionIds = await platformControlPlanePersistence.TenantSaasSubscriptionItems.AsNoTracking()
.Where(value => value.TenantId == subscription.TenantId && value.SubscriptionId == subscription.Id &&
value.Status == TenantSaasSubscriptionItemStatus.Active)
.Select(value => value.OfferingVersionId)
.ToArrayAsync(cancellationToken);
if (!versionIds.Contains(subscription.BaseOfferingVersionId))
versionIds = [.. versionIds, subscription.BaseOfferingVersionId];
var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
var features = await platformControlPlanePersistence.SaasOfferingVersionFeatures.AsNoTracking()
.Where(value => versionIds.Contains(value.OfferingVersionId))
.Select(value => value.FeatureCode)
.Distinct()
@@ -545,8 +546,8 @@ internal sealed class TenantBillingService(
CancellationToken cancellationToken)
{
var rows = await (
from version in dbContext.SaasOfferingVersions.AsNoTracking()
join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
from version in platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking()
join offering in platformControlPlanePersistence.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
where version.Status == SaasOfferingVersionStatus.Published &&
offering.Status == SaasOfferingStatus.Active &&
(version.EffectiveAt == null || version.EffectiveAt <= now)
@@ -555,9 +556,9 @@ internal sealed class TenantBillingService(
.ToArrayAsync(cancellationToken);
var latest = rows.GroupBy(value => value.Offering.Id).Select(group => group.First()).ToArray();
var ids = latest.Select(value => value.Version.Id).ToArray();
var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
var features = await platformControlPlanePersistence.SaasOfferingVersionFeatures.AsNoTracking()
.Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken);
var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking()
var limits = await platformControlPlanePersistence.SaasOfferingVersionLimits.AsNoTracking()
.Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken);
return latest.Select(row => new SaasOfferingVersionItem(
row.Version.Id, row.Offering.Id, row.Offering.Code, row.Offering.Name, row.Offering.Type,
@@ -620,4 +621,4 @@ internal sealed class TenantBillingService(
return value.EnumerateObject().Where(property => property.Value.TryGetInt64(out _))
.ToDictionary(property => property.Name, property => property.Value.GetInt64(), StringComparer.Ordinal);
}
}
}