Files
tiku-backend.net/Tiku.Infrastructure/PlatformBilling/CommercialBillingProcessor.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

554 lines
29 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Tiku.Application.Commerce;
using Tiku.Application.Notifications;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class CommercialBillingProcessor(
TikuDbContext directoryDbContext,
ITenantContext tenantContext,
ITenantExecutionScope tenantExecutionScope,
IOptions<CommercialBillingOptions> options,
HttpClient httpClient) : ICommercialBillingProcessor
{
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(30),
TimeSpan.FromHours(2)
];
public async Task<int> ProcessDueAsync(CancellationToken cancellationToken = default)
{
if (!options.Value.Enabled) return 0;
if (!tenantContext.IsSystem || tenantContext.TenantId.HasValue)
throw new InvalidOperationException("Commercial billing discovery requires a global system context.");
var processed = 0;
processed += await ProcessRenewalsAsync(cancellationToken);
processed += await GenerateRemindersAsync(cancellationToken);
processed += await ProcessRefundsAsync(cancellationToken);
processed += await DispatchDunningAsync(cancellationToken);
return processed;
}
private async Task<int> ProcessRenewalsAsync(CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var candidates = await (
from subscription in directoryDbContext.TenantSaasSubscriptions.AsNoTracking()
join policy in directoryDbContext.TenantBillingPolicies.AsNoTracking() on subscription.TenantId equals
policy.TenantId
where policy.AutoGenerateRenewal &&
(subscription.Status == TenantSaasSubscriptionStatus.Active ||
subscription.Status == TenantSaasSubscriptionStatus.Trial) &&
subscription.CurrentPeriodEnd <= now.AddDays(policy.RenewalLeadDays)
orderby subscription.CurrentPeriodEnd
select new { subscription.TenantId, SubscriptionId = subscription.Id })
.Take(BatchSize())
.ToArrayAsync(cancellationToken);
foreach (var candidate in candidates)
await tenantExecutionScope.ExecuteAsync(
Scope(candidate.TenantId, "Generate subscription renewal receivable", candidate.SubscriptionId),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var subscription =
await db.TenantSaasSubscriptions.SingleAsync(value => value.Id == candidate.SubscriptionId,
token);
var key = $"auto-renewal:{subscription.Id:N}:{subscription.CurrentPeriodEnd:yyyyMMddHHmmss}";
var existingOrder = await db.PlatformBillingOrders.SingleOrDefaultAsync(value =>
value.TenantId == subscription.TenantId && value.IdempotencyKey == key, token);
PlatformBillingOrderView orderView;
if (existingOrder is null)
{
var ownerId = await db.Tenants.AsNoTracking()
.Where(value => value.Id == subscription.TenantId)
.Select(value => value.OwnerUserId)
.SingleAsync(token)
?? throw new PlatformBillingException(
"Tenant owner is required for renewal billing.", "tenant_owner_not_found");
orderView = await services.GetRequiredService<ITenantBillingService>()
.RenewSubscriptionAsync(new TenantBillingActor(ownerId, subscription.TenantId), key, token);
existingOrder =
await db.PlatformBillingOrders.SingleAsync(value => value.Id == orderView.Id, token);
existingOrder.ExpiresAt = subscription.CurrentPeriodEnd;
}
else
{
orderView = new PlatformBillingOrderView(
existingOrder.Id,
existingOrder.OrderNo,
existingOrder.Purpose,
existingOrder.Status,
existingOrder.TotalAmountCents,
existingOrder.Currency,
existingOrder.ExpiresAt,
existingOrder.PaidAt,
[]);
}
if (!await db.PlatformBillingInvoices.AnyAsync(value =>
value.TenantId == subscription.TenantId && value.OrderId == existingOrder.Id, token))
db.PlatformBillingInvoices.Add(new PlatformBillingInvoice
{
TenantId = subscription.TenantId,
OrderId = existingOrder.Id,
InvoiceNo = Number("AR"),
Status = PlatformBillingInvoiceStatus.Issued,
TotalAmountCents = orderView.TotalAmountCents,
Currency = orderView.Currency,
DueDate = DateOnly.FromDateTime(subscription.CurrentPeriodEnd.UtcDateTime),
IssuedAt = now,
BillingProfileSnapshot = await BillingProfileSnapshotAsync(db, subscription.TenantId, token)
});
await db.SaveChangesAsync(token);
},
cancellationToken);
return candidates.Length;
}
private async Task<int> GenerateRemindersAsync(CancellationToken cancellationToken)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var candidates = await directoryDbContext.PlatformBillingInvoices.AsNoTracking()
.Where(value => value.DueDate != null &&
(value.Status == PlatformBillingInvoiceStatus.Issued ||
value.Status == PlatformBillingInvoiceStatus.Overdue) &&
value.DueDate <= today.AddDays(7))
.OrderBy(value => value.DueDate)
.Select(value => new { value.TenantId, InvoiceId = value.Id })
.Take(BatchSize())
.ToArrayAsync(cancellationToken);
var created = 0;
foreach (var candidate in candidates)
created += await tenantExecutionScope.ExecuteAsync(
Scope(candidate.TenantId, "Generate billing reminder", candidate.InvoiceId),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var invoice =
await db.PlatformBillingInvoices.SingleAsync(value => value.Id == candidate.InvoiceId, token);
var dueDate = invoice.DueDate!.Value;
if (dueDate < today && invoice.Status == PlatformBillingInvoiceStatus.Issued)
invoice.Status = PlatformBillingInvoiceStatus.Overdue;
var schedule = ReminderFor(today, dueDate);
if (schedule is null || await db.PlatformBillingInvoiceReminders.AnyAsync(value =>
value.TenantId == invoice.TenantId && value.InvoiceId == invoice.Id &&
value.ReminderType == schedule.Value.Type &&
value.Channel == PlatformBillingInvoiceReminderChannel.Internal &&
value.ReminderDate == today, token))
{
await db.SaveChangesAsync(token);
return 0;
}
var reminder = new PlatformBillingInvoiceReminder
{
TenantId = invoice.TenantId,
InvoiceId = invoice.Id,
ReminderType = schedule.Value.Type,
Channel = PlatformBillingInvoiceReminderChannel.Internal,
Status = PlatformBillingInvoiceReminderStatus.Sent,
ReminderDate = today,
ReminderLevel = schedule.Value.Level,
DueDate = dueDate,
BalanceCentsSnapshot = invoice.TotalAmountCents,
Message = schedule.Value.Message,
SentAt = DateTimeOffset.UtcNow
};
db.PlatformBillingInvoiceReminders.Add(reminder);
var ownerId = await db.Tenants.AsNoTracking().Where(value => value.Id == invoice.TenantId)
.Select(value => value.OwnerUserId).SingleAsync(token);
if (ownerId.HasValue)
await services.GetRequiredService<INotificationProvider>().UpsertInAppAsync(
new InAppNotificationRequest(
invoice.TenantId,
ownerId.Value,
"saas_billing_reminder",
schedule.Value.Level >= 4 ? NotificationSeverity.Error : NotificationSeverity.Warning,
"SaaS 服务费账单提醒",
schedule.Value.Message,
SourceType: "platform_billing_invoices",
SourceId: invoice.Id,
DedupeKey: $"billing:{invoice.Id:N}:{schedule.Value.Type}:{today:yyyyMMdd}"),
token);
var channels = await db.PlatformBillingDunningNotificationChannels.AsNoTracking()
.Where(value => value.Enabled && value.MinReminderLevel <= schedule.Value.Level &&
(value.TenantIds.Length == 0 || value.TenantIds.Contains(invoice.TenantId)))
.ToArrayAsync(token);
foreach (var channel in channels.Where(value =>
value.ReminderTypes.Length == 0 ||
value.ReminderTypes.Contains(schedule.Value.Type.ToString().ToLowerInvariant())))
db.PlatformBillingDunningNotificationEvents.Add(new PlatformBillingDunningNotificationEvent
{
TenantId = invoice.TenantId,
ChannelId = channel.Id,
ReminderId = reminder.Id,
InvoiceId = invoice.Id,
Provider = channel.Provider,
Status = PlatformBillingDunningNotificationStatus.Pending,
ScheduledAt = DateTimeOffset.UtcNow,
RequestPayload = JsonSerializer.SerializeToElement(new
{
invoice.InvoiceNo,
invoice.TotalAmountCents,
invoice.Currency,
invoice.DueDate,
reminder.ReminderType,
reminder.Message
})
});
await db.SaveChangesAsync(token);
return 1;
},
cancellationToken);
return created;
}
private async Task<int> ProcessRefundsAsync(CancellationToken cancellationToken)
{
var candidates = await directoryDbContext.PlatformBillingRefunds.AsNoTracking()
.Where(value => value.Status == PlatformBillingRefundStatus.Processing)
.OrderBy(value => value.UpdatedAt)
.Select(value => new { value.TenantId, RefundId = value.Id })
.Take(BatchSize())
.ToArrayAsync(cancellationToken);
foreach (var candidate in candidates)
await tenantExecutionScope.ExecuteAsync(
Scope(candidate.TenantId, "Execute approved SaaS refund", candidate.RefundId),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var refund =
await db.PlatformBillingRefunds.SingleAsync(value => value.Id == candidate.RefundId, token);
var payment =
await db.PlatformBillingPayments.SingleAsync(value => value.Id == refund.PaymentId, token);
var order = await db.PlatformBillingOrders.SingleAsync(value => value.Id == refund.OrderId, token);
try
{
var result = await services.GetRequiredService<IPlatformBillingPaymentGateway>()
.CreateRefundAsync(
payment.Provider,
new CreateRefundProviderRequest(
refund.TenantId,
order.OrderNo,
refund.RefundNo,
payment.ProviderTradeNo,
refund.AmountCents,
refund.Reason,
JsonSerializer.SerializeToElement(new { refund.SubscriptionEffect })),
token);
if (!result.Succeeded)
throw new PlatformBillingException("Refund provider did not accept the refund.",
"platform_billing_refund_provider_failed");
refund.Status = PlatformBillingRefundStatus.Succeeded;
refund.ProviderRefundNo = result.ProviderRefundNo;
refund.CompletedAt = DateTimeOffset.UtcNow;
refund.LastError = null;
var totalRefunded = await db.PlatformBillingRefunds.AsNoTracking()
.Where(value => value.TenantId == refund.TenantId && value.PaymentId == payment.Id &&
value.Status == PlatformBillingRefundStatus.Succeeded &&
value.Id != refund.Id)
.SumAsync(value => (int?)value.AmountCents, token) ?? 0;
if (totalRefunded + refund.AmountCents >= payment.AmountCents)
{
payment.Status = PlatformBillingPaymentStatus.Refunded;
order.Status = PlatformBillingOrderStatus.Refunded;
}
await ApplyRefundEffectAsync(db, refund, token);
db.AuditLogs.Add(new AuditLog
{
TenantId = refund.TenantId,
ActorUserId = refund.ReviewedBy,
Action = "platform.saas.refund.succeeded",
TargetType = "platform_billing_refunds",
TargetId = refund.Id.ToString(),
Details = JsonSerializer.SerializeToElement(new
{ refund.AmountCents, refund.SubscriptionEffect })
});
}
catch (Exception exception)
{
refund.Status = PlatformBillingRefundStatus.Failed;
refund.LastError = Truncate(exception.Message, 2000);
}
await db.SaveChangesAsync(token);
await services.GetRequiredService<ITenantFeatureCacheInvalidator>()
.InvalidateAsync(refund.TenantId, token);
},
cancellationToken);
return candidates.Length;
}
private async Task<int> DispatchDunningAsync(CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var candidates = await directoryDbContext.PlatformBillingDunningNotificationEvents.AsNoTracking()
.Where(value => (value.Status == PlatformBillingDunningNotificationStatus.Pending ||
value.Status == PlatformBillingDunningNotificationStatus.Retrying) &&
value.Attempts < 5 && value.ScheduledAt <= now &&
(value.NextAttemptAt == null || value.NextAttemptAt <= now))
.OrderBy(value => value.NextAttemptAt).ThenBy(value => value.CreatedAt)
.Select(value => new { value.TenantId, EventId = value.Id })
.Take(BatchSize())
.ToArrayAsync(cancellationToken);
foreach (var candidate in candidates)
await tenantExecutionScope.ExecuteAsync(
Scope(candidate.TenantId, "Dispatch billing dunning notification", candidate.EventId),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var item = await db.PlatformBillingDunningNotificationEvents.SingleAsync(
value => value.Id == candidate.EventId, token);
var channel = await db.PlatformBillingDunningNotificationChannels.AsNoTracking()
.SingleAsync(value => value.Id == item.ChannelId, token);
item.Status = PlatformBillingDunningNotificationStatus.Processing;
item.Attempts++;
item.LastAttemptAt = DateTimeOffset.UtcNow;
try
{
var uri = await ValidateWebhookAsync(channel.WebhookUrl, token);
using var request = new HttpRequestMessage(HttpMethod.Post, uri)
{
Content = JsonContent.Create(item.RequestPayload)
};
if (!string.IsNullOrWhiteSpace(channel.SecretRef))
{
var platformTenantId = await db.Tenants.AsNoTracking()
.Where(value => value.Mode == TenantMode.PlatformOwned)
.Select(value => value.Id)
.SingleAsync(token);
var secret = await services.GetRequiredService<ITenantSecretService>()
.GetActiveSecretPayloadAsync(platformTenantId, channel.SecretRef, token);
var signingSecret = GetSecret(secret, "signingSecret", "secret");
var signature = Convert.ToHexString(HMACSHA256.HashData(
Encoding.UTF8.GetBytes(signingSecret),
Encoding.UTF8.GetBytes(item.RequestPayload.GetRawText()))).ToLowerInvariant();
request.Headers.Add("X-Tiku-Signature", $"sha256={signature}");
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(channel.TimeoutSeconds, 1, 60)));
using var response = await httpClient.SendAsync(request, timeout.Token);
var summary = RedactResponseSummary(await response.Content.ReadAsStringAsync(timeout.Token));
item.LastHttpCode = (int)response.StatusCode;
item.LastResponseSummary = summary;
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Dunning webhook returned {(int)response.StatusCode}.",
null, response.StatusCode);
item.Status = PlatformBillingDunningNotificationStatus.Sent;
item.SentAt = DateTimeOffset.UtcNow;
item.NextAttemptAt = null;
item.LastError = null;
}
catch (Exception exception)
{
item.LastError = Truncate(exception.Message, 2000);
if (item.Attempts >= 5)
{
item.Status = PlatformBillingDunningNotificationStatus.Failed;
item.NextAttemptAt = null;
}
else
{
item.Status = PlatformBillingDunningNotificationStatus.Retrying;
item.NextAttemptAt =
DateTimeOffset.UtcNow.Add(
RetryDelays[Math.Min(item.Attempts - 1, RetryDelays.Length - 1)]);
}
}
await db.SaveChangesAsync(token);
},
cancellationToken);
return candidates.Length;
}
private async Task ApplyRefundEffectAsync(TikuDbContext db, PlatformBillingRefund refund,
CancellationToken cancellationToken)
{
if (refund.SubscriptionEffect == PlatformBillingRefundSubscriptionEffect.KeepService) return;
var subscription = await db.TenantSaasSubscriptions
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(value => value.TenantId == refund.TenantId, cancellationToken);
if (subscription is null) return;
if (refund.SubscriptionEffect == PlatformBillingRefundSubscriptionEffect.CancelAtPeriodEnd)
{
subscription.CancelAtPeriodEnd = true;
subscription.CancelledAt = DateTimeOffset.UtcNow;
return;
}
subscription.Status = TenantSaasSubscriptionStatus.Cancelled;
subscription.CancelAtPeriodEnd = false;
subscription.CancelledAt = DateTimeOffset.UtcNow;
subscription.LifecycleVersion++;
var tenant = await db.Tenants.SingleAsync(value => value.Id == refund.TenantId, cancellationToken);
tenant.BillingStatus = BillingStatus.Cancelled;
var items = await db.TenantSaasSubscriptionItems
.Where(value => value.TenantId == refund.TenantId && value.SubscriptionId == subscription.Id &&
(value.Status == TenantSaasSubscriptionItemStatus.Active ||
value.Status == TenantSaasSubscriptionItemStatus.Scheduled))
.ToArrayAsync(cancellationToken);
foreach (var item in items)
{
item.Status = TenantSaasSubscriptionItemStatus.Cancelled;
item.EndsAt = DateTimeOffset.UtcNow > item.StartsAt ? DateTimeOffset.UtcNow : item.StartsAt.AddTicks(1);
}
}
private async Task<Uri> ValidateWebhookAsync(string value, CancellationToken cancellationToken)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || uri.IsLoopback)
throw new PlatformBillingException("Dunning webhook must be a non-loopback HTTPS URL.",
"dunning_webhook_rejected");
if (options.Value.AllowedWebhookHosts.Length == 0 ||
!options.Value.AllowedWebhookHosts.Contains(uri.Host, StringComparer.OrdinalIgnoreCase))
throw new PlatformBillingException("Dunning webhook host is not allowlisted.", "dunning_webhook_rejected");
IPAddress[] addresses;
try
{
addresses = IPAddress.TryParse(uri.DnsSafeHost, out var literal)
? [literal]
: await Dns.GetHostAddressesAsync(uri.DnsSafeHost, cancellationToken);
}
catch (SocketException)
{
throw new PlatformBillingException("Dunning webhook host could not be resolved.",
"dunning_webhook_rejected");
}
if (addresses.Length == 0 || addresses.Any(IsPrivate))
throw new PlatformBillingException("Dunning webhook cannot resolve to a private or reserved address.",
"dunning_webhook_rejected");
return uri;
}
private static bool IsPrivate(IPAddress address)
{
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
if (IPAddress.IsLoopback(address) || address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any) ||
address.Equals(IPAddress.None) || address.Equals(IPAddress.IPv6None))
return true;
var bytes = address.GetAddressBytes();
if (address.AddressFamily == AddressFamily.InterNetwork)
return bytes[0] == 0 || bytes[0] == 10 || bytes[0] == 127 || bytes[0] >= 224 ||
(bytes[0] == 100 && bytes[1] is >= 64 and <= 127) ||
(bytes[0] == 169 && bytes[1] == 254) ||
(bytes[0] == 172 && bytes[1] is >= 16 and <= 31) ||
(bytes[0] == 192 && bytes[1] == 168) ||
(bytes[0] == 198 && bytes[1] is 18 or 19);
return address.AddressFamily != AddressFamily.InterNetworkV6 ||
address.IsIPv6LinkLocal || address.IsIPv6Multicast || address.IsIPv6SiteLocal ||
(bytes[0] & 0xfe) == 0xfc;
}
private static (PlatformBillingInvoiceReminderType Type, int Level, string Message)? ReminderFor(DateOnly today,
DateOnly dueDate)
{
var days = dueDate.DayNumber - today.DayNumber;
return days switch
{
7 => (PlatformBillingInvoiceReminderType.DueSoon, 1, "SaaS 服务费账单将在 7 天后到期。"),
1 => (PlatformBillingInvoiceReminderType.DueSoon, 2, "SaaS 服务费账单将在明日到期。"),
-1 => (PlatformBillingInvoiceReminderType.Overdue, 3, "SaaS 服务费账单已逾期 1 天。"),
<= -6 => (PlatformBillingInvoiceReminderType.FinalNotice, 4, "SaaS 服务费账单即将超过宽限期。"),
_ => null
};
}
private static async Task<JsonElement> BillingProfileSnapshotAsync(TikuDbContext db, Guid tenantId,
CancellationToken cancellationToken)
{
var profile = await db.TenantBillingProfiles.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken);
return profile is null
? JsonDefaults.Object()
: JsonSerializer.SerializeToElement(new
{
profile.BillingName,
profile.TaxId,
profile.ContactName,
profile.ContactPhone,
profile.ContactEmail,
profile.InvoiceTitle,
profile.InvoiceType
});
}
private SystemScopeRequest Scope(Guid tenantId, string reason, Guid correlationId)
{
return new SystemScopeRequest(tenantId, SystemScopeCallerType.Worker, nameof(CommercialBillingProcessor),
reason,
correlationId.ToString("N"));
}
private int BatchSize()
{
return Math.Clamp(options.Value.BatchSize, 1, 1000);
}
private static string Number(string prefix)
{
return $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32];
}
private static string Truncate(string value, int length)
{
return value.Length <= length ? value : value[..length];
}
private static string RedactResponseSummary(string value)
{
var redacted = Regex.Replace(
value,
"(?i)(\\\"?(?:token|secret|password|authorization|access_token|refresh_token|signature)\\\"?\\s*[:=]\\s*\\\")[^\\\"]*(\\\")",
"$1****$2",
RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));
redacted = Regex.Replace(
redacted,
"(?i)bearer\\s+[a-z0-9._~+/-]+=*",
"Bearer ****",
RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));
redacted = Regex.Replace(
redacted,
"(?i)([?&](?:key|token|secret|signature)=)[^&\\s]+",
"$1****",
RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));
return Truncate(redacted, 500);
}
private static string GetSecret(JsonElement value, params string[] names)
{
foreach (var name in names)
if (value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) &&
property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(property.GetString()))
return property.GetString()!;
throw new PlatformBillingException("Dunning signing secret is missing.", "dunning_secret_missing");
}
}