547 lines
28 KiB
C#
547 lines
28 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) =>
|
|
new(tenantId, SystemScopeCallerType.Worker, nameof(CommercialBillingProcessor), reason, correlationId.ToString("N"));
|
|
|
|
private int BatchSize() => Math.Clamp(options.Value.BatchSize, 1, 1000);
|
|
private static string Number(string prefix) => $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32];
|
|
private static string Truncate(string value, int length) => 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");
|
|
}
|
|
}
|