diff --git a/DysonNetwork.Insight/AppDatabase.cs b/DysonNetwork.Insight/AppDatabase.cs index eb22a48..5f7d3b0 100644 --- a/DysonNetwork.Insight/AppDatabase.cs +++ b/DysonNetwork.Insight/AppDatabase.cs @@ -12,6 +12,7 @@ public class AppDatabase( { public DbSet ThinkingSequences { get; set; } public DbSet ThinkingThoughts { get; set; } + public DbSet UnpaidAccounts { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { diff --git a/DysonNetwork.Insight/Controllers/BillingController.cs b/DysonNetwork.Insight/Controllers/BillingController.cs index 8b0f7ed..d8bc2a3 100644 --- a/DysonNetwork.Insight/Controllers/BillingController.cs +++ b/DysonNetwork.Insight/Controllers/BillingController.cs @@ -1,21 +1,49 @@ using DysonNetwork.Insight.Thought; -using DysonNetwork.Shared.Auth; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using DysonNetwork.Shared.Proto; namespace DysonNetwork.Insight.Controllers; [ApiController] -[Route("/api/billing")] -public class BillingController(ThoughtService thoughtService, ILogger logger) : ControllerBase +[Route("api/billing")] +public class BillingController(AppDatabase db, ThoughtService thoughtService, ILogger logger) + : ControllerBase { - [HttpPost("settle")] - [Authorize] - [RequiredPermission("maintenance", "insight.billing.settle")] - public async Task ProcessTokenBilling() + [HttpGet("status")] + public async Task GetBillingStatus() { - await thoughtService.SettleThoughtBills(logger); - return Ok(); + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); + + var isMarked = await db.UnpaidAccounts.AnyAsync(u => u.AccountId == accountId); + return Ok(isMarked ? new { status = "unpaid" } : new { status = "ok" }); } -} + + [HttpPost("retry")] + public async Task RetryBilling() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); + + var (success, cost) = await thoughtService.RetryBillingForAccountAsync(accountId, logger); + + if (success) + { + if (cost > 0) + { + return Ok(new { message = $"Billing retry successful. Billed {cost} points." }); + } + else + { + return Ok(new { message = "No outstanding payment found." }); + } + } + else + { + return BadRequest(new { message = "Billing retry failed. Please check your balance and try again." }); + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Insight/Migrations/20251115165833_AddUnpaidAccounts.Designer.cs b/DysonNetwork.Insight/Migrations/20251115165833_AddUnpaidAccounts.Designer.cs new file mode 100644 index 0000000..c373d0f --- /dev/null +++ b/DysonNetwork.Insight/Migrations/20251115165833_AddUnpaidAccounts.Designer.cs @@ -0,0 +1,159 @@ +// +using System; +using System.Collections.Generic; +using DysonNetwork.Insight; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Insight.Migrations +{ + [DbContext(typeof(AppDatabase))] + [Migration("20251115165833_AddUnpaidAccounts")] + partial class AddUnpaidAccounts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("PaidToken") + .HasColumnType("bigint") + .HasColumnName("paid_token"); + + b.Property("Topic") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("topic"); + + b.Property("TotalToken") + .HasColumnType("bigint") + .HasColumnName("total_token"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_thinking_sequences"); + + b.ToTable("thinking_sequences", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property>("Files") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("files"); + + b.Property("ModelName") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("model_name"); + + b.Property>("Parts") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parts"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("SequenceId") + .HasColumnType("uuid") + .HasColumnName("sequence_id"); + + b.Property("TokenCount") + .HasColumnType("bigint") + .HasColumnName("token_count"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_thinking_thoughts"); + + b.HasIndex("SequenceId") + .HasDatabaseName("ix_thinking_thoughts_sequence_id"); + + b.ToTable("thinking_thoughts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnUnpaidAccount", b => + { + b.Property("AccountId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("MarkedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("marked_at"); + + b.HasKey("AccountId") + .HasName("pk_unpaid_accounts"); + + b.ToTable("unpaid_accounts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence") + .WithMany() + .HasForeignKey("SequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id"); + + b.Navigation("Sequence"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Insight/Migrations/20251115165833_AddUnpaidAccounts.cs b/DysonNetwork.Insight/Migrations/20251115165833_AddUnpaidAccounts.cs new file mode 100644 index 0000000..5cb7f44 --- /dev/null +++ b/DysonNetwork.Insight/Migrations/20251115165833_AddUnpaidAccounts.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DysonNetwork.Insight.Migrations +{ + /// + public partial class AddUnpaidAccounts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "unpaid_accounts", + columns: table => new + { + account_id = table.Column(type: "uuid", nullable: false), + marked_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_unpaid_accounts", x => x.account_id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "unpaid_accounts"); + } + } +} diff --git a/DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs index 570cc74..a1a0266 100644 --- a/DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs @@ -122,6 +122,23 @@ namespace DysonNetwork.Insight.Migrations b.ToTable("thinking_thoughts", (string)null); }); + modelBuilder.Entity("DysonNetwork.Shared.Models.SnUnpaidAccount", b => + { + b.Property("AccountId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("MarkedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("marked_at"); + + b.HasKey("AccountId") + .HasName("pk_unpaid_accounts"); + + b.ToTable("unpaid_accounts", (string)null); + }); + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => { b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence") diff --git a/DysonNetwork.Insight/Thought/ThoughtService.cs b/DysonNetwork.Insight/Thought/ThoughtService.cs index 7f0b938..19bc45e 100644 --- a/DysonNetwork.Insight/Thought/ThoughtService.cs +++ b/DysonNetwork.Insight/Thought/ThoughtService.cs @@ -133,6 +133,13 @@ public class ThoughtService( foreach (var accountGroup in groupedByAccount) { var accountId = accountGroup.Key; + + if (await db.UnpaidAccounts.AnyAsync(u => u.AccountId == accountId)) + { + logger.LogWarning("Skipping billing for marked account {accountId}", accountId); + continue; + } + var totalUnpaidTokens = accountGroup.Sum(s => s.TotalToken - s.PaidToken); var cost = (long)Math.Ceiling(totalUnpaidTokens / 10.0); @@ -166,9 +173,86 @@ public class ThoughtService( catch (Exception ex) { logger.LogError(ex, "Error billing for account {accountId}", accountId); + if (!await db.UnpaidAccounts.AnyAsync(u => u.AccountId == accountId)) + { + db.UnpaidAccounts.Add(new SnUnpaidAccount { AccountId = accountId, MarkedAt = DateTime.UtcNow }); + } } } await db.SaveChangesAsync(); } + + public async Task<(bool success, long cost)> RetryBillingForAccountAsync(Guid accountId, ILogger logger) + { + var isMarked = await db.UnpaidAccounts.FirstOrDefaultAsync(u => u.AccountId == accountId); + if (isMarked == null) + { + logger.LogInformation("Account {accountId} is not marked for unpaid bills.", accountId); + return (true, 0); + } + + var sequences = await db + .ThinkingSequences.Where(s => s.AccountId == accountId && s.PaidToken < s.TotalToken) + .ToListAsync(); + + if (!sequences.Any()) + { + logger.LogInformation("No unpaid sequences found for account {accountId}. Unmarking.", accountId); + db.UnpaidAccounts.Remove(isMarked); + await db.SaveChangesAsync(); + return (true, 0); + } + + var totalUnpaidTokens = sequences.Sum(s => s.TotalToken - s.PaidToken); + var cost = (long)Math.Ceiling(totalUnpaidTokens / 10.0); + + if (cost == 0) + { + logger.LogInformation("Unpaid tokens for {accountId} resulted in zero cost. Marking as paid and unmarking.", accountId); + foreach (var sequence in sequences) + { + sequence.PaidToken = sequence.TotalToken; + } + db.UnpaidAccounts.Remove(isMarked); + await db.SaveChangesAsync(); + return (true, 0); + } + + try + { + var date = DateTime.Now.ToString("yyyy-MM-dd"); + await paymentService.CreateTransactionWithAccountAsync( + new CreateTransactionWithAccountRequest + { + PayerAccountId = accountId.ToString(), + Currency = WalletCurrency.SourcePoint, + Amount = cost.ToString(), + Remarks = $"Wage for SN-chan on {date} (Retry)", + Type = TransactionType.System, + } + ); + + foreach (var sequence in sequences) + { + sequence.PaidToken = sequence.TotalToken; + } + + db.UnpaidAccounts.Remove(isMarked); + + logger.LogInformation( + "Successfully billed {cost} points for account {accountId} on retry.", + cost, + accountId + ); + + await db.SaveChangesAsync(); + return (true, cost); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrying billing for account {accountId}", accountId); + return (false, cost); + } + } } diff --git a/DysonNetwork.Shared/Models/SnUnpaidAccount.cs b/DysonNetwork.Shared/Models/SnUnpaidAccount.cs new file mode 100644 index 0000000..3062cd3 --- /dev/null +++ b/DysonNetwork.Shared/Models/SnUnpaidAccount.cs @@ -0,0 +1,12 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace DysonNetwork.Shared.Models +{ + public class SnUnpaidAccount + { + [Key] + public Guid AccountId { get; set; } + public DateTime MarkedAt { get; set; } + } +}