♻️ Splitted wallet service

This commit is contained in:
2026-02-03 02:18:02 +08:00
parent bb9105c78c
commit 9a1f36ee26
43 changed files with 623 additions and 590 deletions

View File

@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Affiliation;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
@@ -20,7 +19,6 @@ public class AccountController(
AppDatabase db,
AuthService auth,
AccountService accounts,
SubscriptionService subscriptions,
AccountEventService events,
SocialCreditService socialCreditService,
AffiliationSpellService ars,

View File

@@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Networking;
@@ -20,7 +19,6 @@ namespace DysonNetwork.Pass.Account;
public class AccountCurrentController(
AppDatabase db,
AccountService accounts,
SubscriptionService subscriptions,
AccountEventService events,
AuthService auth,
FileService.FileServiceClient files,
@@ -41,9 +39,6 @@ public class AccountCurrentController(
.Where(e => e.Id == userId)
.FirstOrDefaultAsync();
var perk = await subscriptions.GetPerkSubscriptionAsync(account!.Id);
account.PerkSubscription = perk?.ToReference();
return Ok(account);
}

View File

@@ -1,5 +1,4 @@
using System.Globalization;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
@@ -14,11 +13,9 @@ namespace DysonNetwork.Pass.Account;
public class AccountEventService(
AppDatabase db,
Wallet.PaymentService payment,
ICacheService cache,
IStringLocalizer<Localization.AccountEventResource> localizer,
RingService.RingServiceClient pusher,
SubscriptionService subscriptions,
Pass.Leveling.ExperienceService experienceService,
INatsConnection nats
)
@@ -234,9 +231,6 @@ public class AccountEventService(
public async Task<bool> CheckInDailyDoAskCaptcha(SnAccount user)
{
var perkSubscription = await subscriptions.GetPerkSubscriptionAsync(user.Id);
if (perkSubscription is not null) return false;
var cacheKey = $"{CaptchaCacheKey}{user.Id}";
var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
if (needsCaptcha is not null)
@@ -426,22 +420,6 @@ public class AccountEventService(
CreatedAt = backdated ?? SystemClock.Instance.GetCurrentInstant(),
};
try
{
if (result.RewardPoints.HasValue)
await payment.CreateTransactionWithAccountAsync(
null,
user.Id,
WalletCurrency.SourcePoint,
result.RewardPoints.Value,
$"Check-in reward on {now:yyyy/MM/dd}"
);
}
catch
{
result.RewardPoints = null;
}
db.AccountCheckInResults.Add(result);
await db.SaveChangesAsync(); // Remember to save changes to the database
if (result.RewardExperience is not null)

View File

@@ -1,5 +1,4 @@
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Networking;
using Microsoft.AspNetCore.Mvc;
@@ -11,7 +10,6 @@ namespace DysonNetwork.Pass.Account;
[Route("/api/accounts")]
public class AccountPublicController(
AppDatabase db,
SubscriptionService subscriptions,
SocialCreditService socialCreditService
) : ControllerBase
{
@@ -28,9 +26,6 @@ public class AccountPublicController(
.FirstOrDefaultAsync();
if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
account.PerkSubscription = perk?.ToReference();
return account;
}

View File

@@ -1,4 +1,3 @@
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
@@ -11,7 +10,6 @@ public class AccountServiceGrpc(
AppDatabase db,
AccountEventService accountEvents,
RelationshipService relationships,
SubscriptionService subscriptions,
ILogger<AccountServiceGrpc> logger
)
: Shared.Proto.AccountService.AccountServiceBase
@@ -35,9 +33,6 @@ public class AccountServiceGrpc(
if (account == null)
throw new RpcException(new Status(StatusCode.NotFound, $"Account {request.Id} not found"));
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
account.PerkSubscription = perk?.ToReference();
return account.ToProtoValue();
}
@@ -56,9 +51,6 @@ public class AccountServiceGrpc(
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound,
$"Account with automated ID {request.AutomatedId} not found"));
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
account.PerkSubscription = perk?.ToReference();
return account.ToProtoValue();
}
@@ -77,13 +69,6 @@ public class AccountServiceGrpc(
.Include(a => a.Profile)
.ToListAsync();
var perks = await subscriptions.GetPerkSubscriptionsAsync(
accounts.Select(x => x.Id).ToList()
);
foreach (var account in accounts)
if (perks.TryGetValue(account.Id, out var perk))
account.PerkSubscription = perk?.ToReference();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
@@ -105,13 +90,6 @@ public class AccountServiceGrpc(
.Include(a => a.Profile)
.ToListAsync();
var perks = await subscriptions.GetPerkSubscriptionsAsync(
accounts.Select(x => x.Id).ToList()
);
foreach (var account in accounts)
if (perks.TryGetValue(account.Id, out var perk))
account.PerkSubscription = perk?.ToReference();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
@@ -148,13 +126,6 @@ public class AccountServiceGrpc(
.Include(a => a.Profile)
.ToListAsync();
var perks = await subscriptions.GetPerkSubscriptionsAsync(
accounts.Select(x => x.Id).ToList()
);
foreach (var account in accounts)
if (perks.TryGetValue(account.Id, out var perk))
account.PerkSubscription = perk?.ToReference();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
@@ -169,13 +140,6 @@ public class AccountServiceGrpc(
.Include(a => a.Profile)
.ToListAsync();
var perks = await subscriptions.GetPerkSubscriptionsAsync(
accounts.Select(x => x.Id).ToList()
);
foreach (var account in accounts)
if (perks.TryGetValue(account.Id, out var perk))
account.PerkSubscription = perk?.ToReference();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
@@ -212,13 +176,6 @@ public class AccountServiceGrpc(
.Include(a => a.Profile)
.ToListAsync();
var perks = await subscriptions.GetPerkSubscriptionsAsync(
accounts.Select(x => x.Id).ToList()
);
foreach (var account in accounts)
if (perks.TryGetValue(account.Id, out var perk))
account.PerkSubscription = perk?.ToReference();
var response = new ListAccountsResponse
{
TotalSize = totalCount,
@@ -247,7 +204,6 @@ public class AccountServiceGrpc(
var relatedRelationship = await relationships.ListAccountFriends(relatedId, true);
resp.AccountsId.AddRange(relatedRelationship.Select(x => x.ToString()));
return resp;
break;
case ListRelationshipSimpleRequest.RelationIdentifierOneofCase.None:
default:
throw new RpcException(new Status(StatusCode.InvalidArgument,

View File

@@ -1,6 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
@@ -13,8 +12,7 @@ public class TokenAuthService(
IConfiguration config,
ICacheService cache,
ILogger<TokenAuthService> logger,
OidcProvider.Services.OidcProviderService oidc,
SubscriptionService subscriptions
OidcProvider.Services.OidcProviderService oidc
)
{
/// <summary>
@@ -116,18 +114,6 @@ public class TokenAuthService(
(session.UserAgent ?? string.Empty).Length
);
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId);
session.Account.PerkSubscription = perk?.ToReference();
logger.LogInformation(
"AuthenticateTokenAsync: subscription attached (accountId={AccountId}, hasPerk={HasPerk}, identifier={Identifier}, status={Status}, available={Available})",
session.AccountId,
perk is not null,
perk?.Identifier,
perk?.Status,
perk?.IsAvailable
);
await cache.SetWithGroupsAsync(
cacheKey,
session,

View File

@@ -1,10 +1,9 @@
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Leveling;
public class ExperienceService(AppDatabase db, SubscriptionService subscriptions)
public class ExperienceService(AppDatabase db)
{
public async Task<SnExperienceRecord> AddRecord(string reasonType, string reason, long delta, Guid accountId)
{
@@ -16,20 +15,6 @@ public class ExperienceService(AppDatabase db, SubscriptionService subscriptions
AccountId = accountId,
};
var perkSubscription = await subscriptions.GetPerkSubscriptionAsync(accountId);
if (perkSubscription is not null)
{
record.BonusMultiplier = perkSubscription.Identifier switch
{
SubscriptionType.Stellar => 1.5,
SubscriptionType.Nova => 2,
SubscriptionType.Supernova => 2.5,
_ => 1
};
if (record.Delta >= 0)
record.Delta = (long)Math.Floor(record.Delta * record.BonusMultiplier);
}
db.ExperienceRecords.Add(record);
await db.SaveChangesAsync();

View File

@@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Auth;
using Microsoft.AspNetCore.Authorization;
@@ -14,43 +13,6 @@ namespace DysonNetwork.Pass.Lotteries;
[Route("/api/lotteries")]
public class LotteryController(AppDatabase db, LotteryService lotteryService) : ControllerBase
{
public class CreateLotteryRequest
{
[Required]
public List<int> RegionOneNumbers { get; set; } = null!;
[Required]
[Range(0, 99)]
public int RegionTwoNumber { get; set; }
[Range(1, int.MaxValue)]
public int Multiplier { get; set; } = 1;
}
[HttpPost]
[Authorize]
public async Task<ActionResult<SnWalletOrder>> CreateLottery([FromBody] CreateLotteryRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var order = await lotteryService.CreateLotteryOrderAsync(
accountId: currentUser.Id,
region1: request.RegionOneNumbers,
region2: request.RegionTwoNumber,
multiplier: request.Multiplier);
return Ok(order);
}
catch (ArgumentException err)
{
return BadRequest(err.Message);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpGet]
[Authorize]
public async Task<ActionResult<List<SnLottery>>> GetLotteries(

View File

@@ -1,9 +1,7 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Wallet;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NodaTime;
using System.Text.Json;
namespace DysonNetwork.Pass.Lotteries;
@@ -17,8 +15,6 @@ public class LotteryOrderMetaData
public class LotteryService(
AppDatabase db,
PaymentService paymentService,
WalletService walletService,
ILogger<LotteryService> logger)
{
private static bool ValidateNumbers(List<int> region1, int region2)
@@ -76,70 +72,6 @@ public class LotteryService(
return 10 + (multiplier - 1) * 10;
}
public async Task<SnWalletOrder> CreateLotteryOrderAsync(Guid accountId, List<int> region1, int region2,
int multiplier = 1)
{
if (!ValidateNumbers(region1, region2))
throw new ArgumentException("Invalid lottery numbers");
var now = SystemClock.Instance.GetCurrentInstant();
var todayStart = new LocalDateTime(now.InUtc().Year, now.InUtc().Month, now.InUtc().Day, 0, 0).InUtc()
.ToInstant();
var hasPurchasedToday = await db.Lotteries.AnyAsync(l =>
l.AccountId == accountId &&
l.CreatedAt >= todayStart &&
l.DrawStatus == LotteryDrawStatus.Pending
);
if (hasPurchasedToday)
throw new InvalidOperationException("You can only purchase one lottery per day.");
var price = CalculateLotteryPrice(multiplier);
var lotteryData = new LotteryOrderMetaData
{
AccountId = accountId,
RegionOneNumbers = region1,
RegionTwoNumber = region2,
Multiplier = multiplier
};
return await paymentService.CreateOrderAsync(
null,
WalletCurrency.SourcePoint,
price,
appIdentifier: "lottery",
productIdentifier: "lottery",
meta: new Dictionary<string, object>
{
["data"] = JsonSerializer.Serialize(lotteryData)
});
}
public async Task HandleLotteryOrder(SnWalletOrder order)
{
if (order.Status == OrderStatus.Finished)
return; // Already processed
if (order.Status != OrderStatus.Paid ||
!order.Meta.TryGetValue("data", out var dataValue) ||
dataValue is null ||
dataValue is not JsonElement { ValueKind: JsonValueKind.String } jsonElem)
throw new InvalidOperationException("Invalid order.");
var jsonString = jsonElem.GetString();
if (jsonString is null)
throw new InvalidOperationException("Invalid order.");
var data = JsonSerializer.Deserialize<LotteryOrderMetaData>(jsonString);
if (data is null)
throw new InvalidOperationException("Invalid order data.");
await CreateTicketAsync(data.AccountId, data.RegionOneNumbers, data.RegionTwoNumber, data.Multiplier);
order.Status = OrderStatus.Finished;
await db.SaveChangesAsync();
}
private static int CalculateReward(int region1Matches, bool region2Match)
{
var reward = region1Matches switch
@@ -221,27 +153,13 @@ public class LotteryService(
if (reward > 0)
{
var wallet = await walletService.GetWalletAsync(ticket.AccountId);
if (wallet != null)
{
await paymentService.CreateTransactionAsync(
payerWalletId: null,
payeeWalletId: wallet.Id,
currency: WalletCurrency.SourcePoint,
amount: reward,
remarks: $"Lottery prize: {region1Matches} matches{(region2Match ? " + special" : "")}"
);
logger.LogInformation(
"Awarded {Amount} to account {AccountId} for {Matches} matches{(Special ? \" + special\" : \"\")}",
reward, ticket.AccountId, region1Matches, region2Match ? " + special" : "");
totalPrizesAwarded++;
totalPrizeAmount += reward;
}
else
{
logger.LogWarning("Wallet not found for account {AccountId}, skipping prize award",
ticket.AccountId);
}
// Note: Prize awarding is now handled by the Wallet service
// The Wallet service will process lottery results and award prizes
logger.LogInformation(
"Lottery prize of {Amount} to account {AccountId} for {Matches} matches needs to be awarded via Wallet service",
reward, ticket.AccountId, region1Matches);
totalPrizesAwarded++;
totalPrizeAmount += reward;
}
ticket.DrawStatus = LotteryDrawStatus.Drawn;
@@ -271,4 +189,4 @@ public class LotteryService(
throw;
}
}
}
}

View File

@@ -4,7 +4,6 @@ using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Realm;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Networking;
namespace DysonNetwork.Pass.Startup;
@@ -38,8 +37,6 @@ public static class ApplicationConfiguration
app.MapGrpcService<SocialCreditServiceGrpc>();
app.MapGrpcService<ExperienceServiceGrpc>();
app.MapGrpcService<BotAccountReceiverGrpc>();
app.MapGrpcService<WalletServiceGrpc>();
app.MapGrpcService<PaymentServiceGrpc>();
app.MapGrpcService<RealmServiceGrpc>();
app.MapGrpcReflectionService();

View File

@@ -1,13 +1,11 @@
using System.Text.Json;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Queue;
using Google.Protobuf;
using NATS.Client.Core;
using NATS.Client.JetStream.Models;
using NATS.Net;
using NodaTime;
@@ -31,127 +29,7 @@ public class BroadcastEventHandler(
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var paymentTask = HandlePaymentEventsAsync(stoppingToken);
var webSocketTask = HandleWebSocketEventsAsync(stoppingToken);
await Task.WhenAll(paymentTask, webSocketTask);
}
private async Task HandlePaymentEventsAsync(CancellationToken stoppingToken)
{
var js = nats.CreateJetStreamContext();
await js.EnsureStreamCreated("payment_events", [PaymentOrderEventBase.Type]);
var consumer = await js.CreateOrUpdateConsumerAsync("payment_events",
new ConsumerConfig("pass_payment_handler"),
cancellationToken: stoppingToken);
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
{
PaymentOrderEvent? evt = null;
try
{
evt = JsonSerializer.Deserialize<PaymentOrderEvent>(msg.Data, GrpcTypeHelper.SerializerOptions);
logger.LogInformation(
"Received order event: {ProductIdentifier} {OrderId}",
evt?.ProductIdentifier,
evt?.OrderId
);
if (evt?.ProductIdentifier is null)
continue;
// Handle subscription orders
if (
evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram) &&
evt.Meta?.TryGetValue("gift_id", out var giftIdValue) == true
)
{
logger.LogInformation("Handling gift order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await subscriptions.HandleGiftOrder(order);
logger.LogInformation("Gift for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else if (evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
{
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await subscriptions.HandleSubscriptionOrder(order);
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else if (evt.ProductIdentifier == "lottery")
{
logger.LogInformation("Handling lottery order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var lotteries = scope.ServiceProvider.GetRequiredService<Lotteries.LotteryService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order == null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await lotteries.HandleLotteryOrder(order);
logger.LogInformation("Lottery ticket for order {OrderId} created successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else
{
// Not a subscription, gift, or lottery order, skip
continue;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing payment order event for order {OrderId}. Redelivering.",
evt?.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
}
}
await HandleWebSocketEventsAsync(stoppingToken);
}
private async Task HandleWebSocketEventsAsync(CancellationToken stoppingToken)

View File

@@ -2,7 +2,6 @@ using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Account.Presences;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Handlers;
using DysonNetwork.Pass.Wallet;
using Quartz;
namespace DysonNetwork.Pass.Startup;
@@ -37,33 +36,6 @@ public static class ScheduledJobsConfiguration
.RepeatForever())
);
q.AddJob<SubscriptionRenewalJob>(opts => opts.WithIdentity("SubscriptionRenewal"));
q.AddTrigger(opts => opts
.ForJob("SubscriptionRenewal")
.WithIdentity("SubscriptionRenewalTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInMinutes(30)
.RepeatForever())
);
q.AddJob<GiftCleanupJob>(opts => opts.WithIdentity("GiftCleanup"));
q.AddTrigger(opts => opts
.ForJob("GiftCleanup")
.WithIdentity("GiftCleanupTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInHours(1)
.RepeatForever())
);
q.AddJob<FundExpirationJob>(opts => opts.WithIdentity("FundExpiration"));
q.AddTrigger(opts => opts
.ForJob("FundExpiration")
.WithIdentity("FundExpirationTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInHours(1)
.RepeatForever())
);
q.AddJob<Lotteries.LotteryDrawJob>(opts => opts.WithIdentity("LotteryDraw"));
q.AddTrigger(opts => opts
.ForJob("LotteryDraw")

View File

@@ -4,7 +4,6 @@ using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Auth.OpenId;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet;
using Microsoft.AspNetCore.RateLimiting;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
@@ -22,7 +21,6 @@ using DysonNetwork.Pass.Mailer;
using DysonNetwork.Pass.Realm;
using DysonNetwork.Pass.Rewind;
using DysonNetwork.Pass.Safety;
using DysonNetwork.Pass.Wallet.PaymentHandlers;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Registry;
@@ -150,10 +148,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<AuthService>();
services.AddScoped<TokenAuthService>();
services.AddScoped<AccountUsernameService>();
services.AddScoped<WalletService>();
services.AddScoped<SubscriptionService>();
services.AddScoped<PaymentService>();
services.AddScoped<AfdianPaymentHandler>();
services.AddScoped<SafetyService>();
services.AddScoped<SocialCreditService>();
services.AddScoped<ExperienceService>();

View File

@@ -1,24 +0,0 @@
using Quartz;
namespace DysonNetwork.Pass.Wallet;
public class FundExpirationJob(
PaymentService paymentService,
ILogger<FundExpirationJob> logger
) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting fund expiration job...");
try
{
await paymentService.ProcessExpiredFundsAsync();
logger.LogInformation("Successfully processed expired funds");
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing expired funds");
}
}
}

View File

@@ -1,40 +0,0 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz;
namespace DysonNetwork.Pass.Wallet;
public class GiftCleanupJob(
AppDatabase db,
ILogger<GiftCleanupJob> logger
) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting gift cleanup job...");
var now = SystemClock.Instance.GetCurrentInstant();
// Clean up gifts that are in Created status and older than 24 hours
var cutoffTime = now.Minus(Duration.FromHours(24));
var oldCreatedGifts = await db.WalletGifts
.Where(g => g.Status == GiftStatus.Created)
.Where(g => g.CreatedAt < cutoffTime)
.ToListAsync();
if (oldCreatedGifts.Count == 0)
{
logger.LogInformation("No old created gifts to clean up");
return;
}
logger.LogInformation("Found {Count} old created gifts to clean up", oldCreatedGifts.Count);
// Remove the gifts
db.WalletGifts.RemoveRange(oldCreatedGifts);
await db.SaveChangesAsync();
logger.LogInformation("Successfully cleaned up {Count} old created gifts", oldCreatedGifts.Count);
}
}

View File

@@ -1,143 +0,0 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/orders")]
public class OrderController(
PaymentService payment,
Pass.Auth.AuthService auth,
AppDatabase db,
CustomAppService.CustomAppServiceClient customApps
) : ControllerBase
{
public class CreateOrderRequest
{
public string Currency { get; set; } = null!;
public decimal Amount { get; set; }
public string? Remarks { get; set; }
public string? ProductIdentifier { get; set; }
public Dictionary<string, object>? Meta { get; set; }
public int DurationHours { get; set; } = 24;
public string ClientId { get; set; } = null!;
public string ClientSecret { get; set; } = null!;
}
[HttpPost]
public async Task<ActionResult<SnWalletOrder>> CreateOrder([FromBody] CreateOrderRequest request)
{
var clientResp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId });
if (clientResp.App is null) return BadRequest("Client not found");
var client = SnCustomApp.FromProtoValue(clientResp.App);
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
{
AppId = client.Id.ToString(),
Secret = request.ClientSecret,
});
if (!secret.Valid) return BadRequest("Invalid client secret");
var order = await payment.CreateOrderAsync(
default,
request.Currency,
request.Amount,
NodaTime.Duration.FromHours(request.DurationHours),
request.ClientId,
request.ProductIdentifier,
request.Remarks,
request.Meta
);
return Ok(order);
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<SnWalletOrder>> GetOrderById(Guid id)
{
var order = await db.PaymentOrders.FindAsync(id);
if (order == null)
return NotFound();
return Ok(order);
}
public class PayOrderRequest
{
public string PinCode { get; set; } = string.Empty;
}
[HttpPost("{id:guid}/pay")]
[Authorize]
public async Task<ActionResult<SnWalletOrder>> PayOrder(Guid id, [FromBody] PayOrderRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
// Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
return StatusCode(403, "Invalid PIN Code");
try
{
// Get the wallet for the current user
var wallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == currentUser.Id);
if (wallet == null)
return BadRequest("Wallet was not found.");
// Pay the order
var paidOrder = await payment.PayOrderAsync(id, wallet);
return Ok(paidOrder);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
public class UpdateOrderStatusRequest
{
public string ClientId { get; set; } = null!;
public string ClientSecret { get; set; } = null!;
public Shared.Models.OrderStatus Status { get; set; }
}
[HttpPatch("{id:guid}/status")]
public async Task<ActionResult<SnWalletOrder>> UpdateOrderStatus(Guid id, [FromBody] UpdateOrderStatusRequest request)
{
var clientResp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId });
if (clientResp.App is null) return BadRequest("Client not found");
var client = SnCustomApp.FromProtoValue(clientResp.App);
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
{
AppId = client.Id.ToString(),
Secret = request.ClientSecret,
});
if (!secret.Valid) return BadRequest("Invalid client secret");
var order = await db.PaymentOrders.FindAsync(id);
if (order == null)
return NotFound();
if (order.AppIdentifier != request.ClientId)
{
return BadRequest("Order does not belong to this client.");
}
if (request.Status != Shared.Models.OrderStatus.Finished && request.Status != Shared.Models.OrderStatus.Cancelled)
return BadRequest("Invalid status. Available statuses are Finished, Cancelled.");
order.Status = request.Status;
await db.SaveChangesAsync();
return Ok(order);
}
}

View File

@@ -1,445 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using NodaTime;
namespace DysonNetwork.Pass.Wallet.PaymentHandlers;
public class AfdianPaymentHandler(
IHttpClientFactory httpClientFactory,
ILogger<AfdianPaymentHandler> logger,
IConfiguration configuration
)
{
private readonly IHttpClientFactory _httpClientFactory =
httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
private readonly ILogger<AfdianPaymentHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
private readonly IConfiguration _configuration =
configuration ?? throw new ArgumentNullException(nameof(configuration));
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private string CalculateSign(string token, string userId, string paramsJson, long ts)
{
var kvString = $"{token}params{paramsJson}ts{ts}user_id{userId}";
using (var md5 = MD5.Create())
{
var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(kvString));
return BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
}
}
public async Task<OrderResponse?> ListOrderAsync(int page = 1)
{
try
{
var token = _configuration["Payment:Auth:Afdian"] ?? "_:_";
var tokenParts = token.Split(':');
var userId = tokenParts[0];
token = tokenParts[1];
var paramsJson = JsonSerializer.Serialize(new { page }, JsonOptions);
var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1))
.TotalSeconds; // Current timestamp in seconds
var sign = CalculateSign(token, userId, paramsJson, ts);
var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
{
Content = new StringContent(JsonSerializer.Serialize(new
{
user_id = userId,
@params = paramsJson,
ts,
sign
}, JsonOptions), Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
$"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}");
return null;
}
var result = await JsonSerializer.DeserializeAsync<OrderResponse>(
await response.Content.ReadAsStreamAsync(), JsonOptions);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching orders");
throw;
}
}
/// <summary>
/// Get a specific order by its ID (out_trade_no)
/// </summary>
/// <param name="orderId">The order ID to query</param>
/// <returns>The order item if found, otherwise null</returns>
public async Task<OrderItem?> GetOrderAsync(string orderId)
{
if (string.IsNullOrEmpty(orderId))
{
_logger.LogWarning("Order ID cannot be null or empty");
return null;
}
try
{
var token = _configuration["Payment:Auth:Afdian"] ?? "_:_";
var tokenParts = token.Split(':');
var userId = tokenParts[0];
token = tokenParts[1];
var paramsJson = JsonSerializer.Serialize(new { out_trade_no = orderId }, JsonOptions);
var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1))
.TotalSeconds; // Current timestamp in seconds
var sign = CalculateSign(token, userId, paramsJson, ts);
var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
{
Content = new StringContent(JsonSerializer.Serialize(new
{
user_id = userId,
@params = paramsJson,
ts,
sign
}, JsonOptions), Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
$"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}");
return null;
}
var result = await JsonSerializer.DeserializeAsync<OrderResponse>(
await response.Content.ReadAsStreamAsync(), JsonOptions);
// Check if we have a valid response and orders in the list
if (result?.Data.Orders == null || result.Data.Orders.Count == 0)
{
_logger.LogWarning($"No order found with ID: {orderId}");
return null;
}
// Since we're querying by a specific order ID, we should only get one result
return result.Data.Orders.FirstOrDefault();
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error fetching order with ID: {orderId}");
throw;
}
}
/// <summary>
/// Get multiple orders by their IDs (out_trade_no)
/// </summary>
/// <param name="orderIds">A collection of order IDs to query</param>
/// <returns>A list of found order items</returns>
public async Task<List<OrderItem>> GetOrderBatchAsync(IEnumerable<string> orderIds)
{
var orders = orderIds.ToList();
if (orders.Count == 0)
{
_logger.LogWarning("Order IDs cannot be null or empty");
return [];
}
try
{
// Join the order IDs with commas as specified in the API documentation
var orderIdsParam = string.Join(",", orders);
var token = _configuration["Payment:Auth:Afdian"] ?? "_:_";
var tokenParts = token.Split(':');
var userId = tokenParts[0];
token = tokenParts[1];
var paramsJson = JsonSerializer.Serialize(new { out_trade_no = orderIdsParam }, JsonOptions);
var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1))
.TotalSeconds; // Current timestamp in seconds
var sign = CalculateSign(token, userId, paramsJson, ts);
var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
{
Content = new StringContent(JsonSerializer.Serialize(new
{
user_id = userId,
@params = paramsJson,
ts,
sign
}, JsonOptions), Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
$"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}");
return new List<OrderItem>();
}
var result = await JsonSerializer.DeserializeAsync<OrderResponse>(
await response.Content.ReadAsStreamAsync(), JsonOptions);
// Check if we have a valid response and orders in the list
if (result?.Data?.Orders != null && result.Data.Orders.Count != 0) return result.Data.Orders;
_logger.LogWarning($"No orders found with IDs: {orderIdsParam}");
return [];
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error fetching orders");
throw;
}
}
/// <summary>
/// Handle an incoming webhook from Afdian's payment platform
/// </summary>
/// <param name="request">The HTTP request containing webhook data</param>
/// <param name="processOrderAction">An action to process the received order</param>
/// <returns>A WebhookResponse object to be returned to Afdian</returns>
public async Task<WebhookResponse> HandleWebhook(
HttpRequest request,
Func<WebhookOrderData, Task>? processOrderAction
)
{
_logger.LogInformation("Received webhook request from afdian...");
try
{
// Read the request body
string requestBody;
using (var reader = new StreamReader(request.Body, Encoding.UTF8))
{
requestBody = await reader.ReadToEndAsync();
}
if (string.IsNullOrEmpty(requestBody))
{
_logger.LogError("Webhook request body is empty");
return new WebhookResponse { ErrorCode = 400, ErrorMessage = "Empty request body" };
}
_logger.LogInformation($"Received webhook: {requestBody}");
// Parse the webhook data
var webhook = JsonSerializer.Deserialize<WebhookRequest>(requestBody, JsonOptions);
if (webhook == null)
{
_logger.LogError("Failed to parse webhook data");
return new WebhookResponse { ErrorCode = 400, ErrorMessage = "Invalid webhook data" };
}
// Validate the webhook type
if (webhook.Data.Type != "order")
{
_logger.LogWarning($"Unsupported webhook type: {webhook.Data.Type}");
return WebhookResponse.Success;
}
// Process the order
try
{
// Check for duplicate order processing by storing processed order IDs
// (You would implement a more permanent storage mechanism for production)
if (processOrderAction != null)
await processOrderAction(webhook.Data);
else
_logger.LogInformation(
$"Order received but no processing action provided: {webhook.Data.Order.TradeNumber}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error processing order {webhook.Data.Order.TradeNumber}");
// Still returning success to Afdian to prevent repeated callbacks
// Your system should handle the error internally
}
// Return success response to Afdian
return WebhookResponse.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling webhook");
return WebhookResponse.Success;
}
}
public string? GetSubscriptionPlanId(string subscriptionKey)
{
var planId = _configuration[$"Payment:Subscriptions:Afdian:{subscriptionKey}"];
if (string.IsNullOrEmpty(planId))
{
_logger.LogWarning($"Unknown subscription key: {subscriptionKey}");
return null;
}
return planId;
}
}
public class OrderResponse
{
[JsonPropertyName("ec")] public int ErrorCode { get; set; }
[JsonPropertyName("em")] public string ErrorMessage { get; set; } = null!;
[JsonPropertyName("data")] public OrderData Data { get; set; } = null!;
}
public class OrderData
{
[JsonPropertyName("list")] public List<OrderItem> Orders { get; set; } = null!;
[JsonPropertyName("total_count")] public int TotalCount { get; set; }
[JsonPropertyName("total_page")] public int TotalPages { get; set; }
[JsonPropertyName("request")] public RequestDetails Request { get; set; } = null!;
}
public class OrderItem : ISubscriptionOrder
{
[JsonPropertyName("out_trade_no")] public string TradeNumber { get; set; } = null!;
[JsonPropertyName("user_id")] public string UserId { get; set; } = null!;
[JsonPropertyName("plan_id")] public string PlanId { get; set; } = null!;
[JsonPropertyName("month")] public int Months { get; set; }
[JsonPropertyName("total_amount")] public string TotalAmount { get; set; } = null!;
[JsonPropertyName("show_amount")] public string ShowAmount { get; set; } = null!;
[JsonPropertyName("status")] public int Status { get; set; }
[JsonPropertyName("remark")] public string Remark { get; set; } = null!;
[JsonPropertyName("redeem_id")] public string RedeemId { get; set; } = null!;
[JsonPropertyName("product_type")] public int ProductType { get; set; }
[JsonPropertyName("discount")] public string Discount { get; set; } = null!;
[JsonPropertyName("sku_detail")] public List<object> SkuDetail { get; set; } = null!;
[JsonPropertyName("create_time")] public long CreateTime { get; set; }
[JsonPropertyName("user_name")] public string UserName { get; set; } = null!;
[JsonPropertyName("plan_title")] public string PlanTitle { get; set; } = null!;
[JsonPropertyName("user_private_id")] public string UserPrivateId { get; set; } = null!;
[JsonPropertyName("address_person")] public string AddressPerson { get; set; } = null!;
[JsonPropertyName("address_phone")] public string AddressPhone { get; set; } = null!;
[JsonPropertyName("address_address")] public string AddressAddress { get; set; } = null!;
public Instant BegunAt => Instant.FromUnixTimeSeconds(CreateTime);
public Duration Duration => Duration.FromDays(Months * 30);
public string Provider => "afdian";
public string Id => TradeNumber;
public string SubscriptionId => PlanId;
public string AccountId => UserId;
}
public class RequestDetails
{
[JsonPropertyName("user_id")] public string UserId { get; set; } = null!;
[JsonPropertyName("params")] public string Params { get; set; } = null!;
[JsonPropertyName("ts")] public long Timestamp { get; set; }
[JsonPropertyName("sign")] public string Sign { get; set; } = null!;
}
/// <summary>
/// Request structure for Afdian webhook
/// </summary>
public class WebhookRequest
{
[JsonPropertyName("ec")] public int ErrorCode { get; set; }
[JsonPropertyName("em")] public string ErrorMessage { get; set; } = null!;
[JsonPropertyName("data")] public WebhookOrderData Data { get; set; } = null!;
}
/// <summary>
/// Order data contained in the webhook
/// </summary>
public class WebhookOrderData
{
[JsonPropertyName("type")] public string Type { get; set; } = null!;
[JsonPropertyName("order")] public WebhookOrderDetails Order { get; set; } = null!;
}
/// <summary>
/// Order details in the webhook
/// </summary>
public class WebhookOrderDetails : OrderItem
{
[JsonPropertyName("custom_order_id")] public string CustomOrderId { get; set; } = null!;
}
/// <summary>
/// Response structure to acknowledge webhook receipt
/// </summary>
public class WebhookResponse
{
[JsonPropertyName("ec")] public int ErrorCode { get; set; } = 200;
[JsonPropertyName("em")] public string ErrorMessage { get; set; } = "";
public static WebhookResponse Success => new()
{
ErrorCode = 200,
ErrorMessage = string.Empty
};
}
/// <summary>
/// SKU detail item
/// </summary>
public class SkuDetailItem
{
[JsonPropertyName("sku_id")] public string SkuId { get; set; } = null!;
[JsonPropertyName("count")] public int Count { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = null!;
[JsonPropertyName("album_id")] public string AlbumId { get; set; } = null!;
[JsonPropertyName("pic")] public string Picture { get; set; } = null!;
}

View File

@@ -1,18 +0,0 @@
using NodaTime;
namespace DysonNetwork.Pass.Wallet.PaymentHandlers;
public interface ISubscriptionOrder
{
public string Id { get; }
public string SubscriptionId { get; }
public Instant BegunAt { get; }
public Duration Duration { get; }
public string Provider { get; }
public string AccountId { get; }
}

View File

@@ -1,896 +0,0 @@
using System.Data;
using System.Globalization;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Queue;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NATS.Client.Core;
using NATS.Net;
using NodaTime;
using AccountService = DysonNetwork.Pass.Account.AccountService;
namespace DysonNetwork.Pass.Wallet;
public class PaymentService(
AppDatabase db,
WalletService wat,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
INatsConnection nats
)
{
public async Task<SnWalletOrder> CreateOrderAsync(
Guid? payeeWalletId,
string currency,
decimal amount,
Duration? expiration = null,
string? appIdentifier = null,
string? productIdentifier = null,
string? remarks = null,
Dictionary<string, object>? meta = null,
bool reuseable = true
)
{
// Check if there's an existing unpaid order that can be reused
var now = SystemClock.Instance.GetCurrentInstant();
if (reuseable && appIdentifier != null)
{
var existingOrder = await db.PaymentOrders
.Where(o => o.Status == Shared.Models.OrderStatus.Unpaid &&
o.PayeeWalletId == payeeWalletId &&
o.Currency == currency &&
o.Amount == amount &&
o.AppIdentifier == appIdentifier &&
o.ProductIdentifier == productIdentifier &&
o.ExpiredAt > now)
.FirstOrDefaultAsync();
// If an existing order is found, check if meta matches
if (existingOrder != null && meta != null && existingOrder.Meta != null)
{
// Compare the meta dictionary - if they are equivalent, reuse the order
var metaMatches = existingOrder.Meta.Count == meta.Count &&
!existingOrder.Meta.Except(meta).Any();
if (metaMatches)
return existingOrder;
}
}
// Create a new SnWalletOrder if no reusable order was found
var order = new SnWalletOrder
{
PayeeWalletId = payeeWalletId,
Currency = currency,
Amount = amount,
ExpiredAt = now.Plus(expiration ?? Duration.FromHours(24)),
AppIdentifier = appIdentifier,
ProductIdentifier = productIdentifier,
Remarks = remarks,
Meta = meta
};
db.PaymentOrders.Add(order);
await db.SaveChangesAsync();
return order;
}
public async Task<SnWalletTransaction> CreateTransactionWithAccountAsync(
Guid? payerAccountId,
Guid? payeeAccountId,
string currency,
decimal amount,
string? remarks = null,
Shared.Models.TransactionType type = Shared.Models.TransactionType.System
)
{
SnWallet? payer = null, payee = null;
if (payerAccountId.HasValue)
payer = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerAccountId.Value);
if (payeeAccountId.HasValue)
payee = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeAccountId.Value);
if (payer == null && payerAccountId.HasValue)
throw new ArgumentException("Payer account was specified, but wallet was not found");
if (payee == null && payeeAccountId.HasValue)
throw new ArgumentException("Payee account was specified, but wallet was not found");
return await CreateTransactionAsync(
payer?.Id,
payee?.Id,
currency,
amount,
remarks,
type
);
}
public async Task<SnWalletTransaction> CreateTransactionAsync(
Guid? payerWalletId,
Guid? payeeWalletId,
string currency,
decimal amount,
string? remarks = null,
Shared.Models.TransactionType type = Shared.Models.TransactionType.System,
bool silent = false,
bool autoSave = true
)
{
if (payerWalletId == null && payeeWalletId == null)
throw new ArgumentException("At least one wallet must be specified.");
if (amount <= 0) throw new ArgumentException("Cannot create transaction with negative or zero amount.");
var transaction = new SnWalletTransaction
{
PayerWalletId = payerWalletId,
PayeeWalletId = payeeWalletId,
Currency = currency,
Amount = amount,
Remarks = remarks,
Type = type
};
SnWallet? payerWallet = null, payeeWallet = null;
if (payerWalletId.HasValue)
{
payerWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerWalletId.Value);
var (payerPocket, isNewlyCreated) =
await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency);
if (isNewlyCreated || payerPocket.Amount < amount)
throw new InvalidOperationException("Insufficient funds");
await db.WalletPockets
.Where(p => p.Id == payerPocket.Id && p.Amount >= amount)
.ExecuteUpdateAsync(s =>
s.SetProperty(p => p.Amount, p => p.Amount - amount));
}
if (payeeWalletId.HasValue)
{
payeeWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeWalletId.Value);
var (payeePocket, isNewlyCreated) =
await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount);
if (!isNewlyCreated)
await db.WalletPockets
.Where(p => p.Id == payeePocket.Id)
.ExecuteUpdateAsync(s =>
s.SetProperty(p => p.Amount, p => p.Amount + amount));
}
db.PaymentTransactions.Add(transaction);
if (autoSave)
await db.SaveChangesAsync();
if (!silent)
await NotifyNewTransaction(transaction, payerWallet, payeeWallet);
return transaction;
}
private async Task NotifyNewTransaction(SnWalletTransaction transaction, SnWallet? payerWallet,
SnWallet? payeeWallet)
{
if (payerWallet is not null)
{
var account = await db.Accounts
.Where(a => a.Id == payerWallet.AccountId)
.FirstOrDefaultAsync();
if (account is null) return;
AccountService.SetCultureInfo(account);
// Due to ID is uuid, it longer than 8 words for sure
var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8];
var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}";
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "wallets.transactions",
Title = localizer["TransactionNewTitle", readableTransactionRemark],
Body = transaction.Amount > 0
? localizer["TransactionNewBodyMinus",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency]
: localizer["TransactionNewBodyPlus",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency],
IsSavable = true
}
}
);
}
if (payeeWallet is not null)
{
var account = await db.Accounts
.Where(a => a.Id == payeeWallet.AccountId)
.FirstOrDefaultAsync();
if (account is null) return;
AccountService.SetCultureInfo(account);
// Due to ID is uuid, it longer than 8 words for sure
var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8];
var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}";
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "wallets.transactions",
Title = localizer["TransactionNewTitle", readableTransactionRemark],
Body = transaction.Amount > 0
? localizer["TransactionNewBodyPlus",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency]
: localizer["TransactionNewBodyMinus",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency],
IsSavable = true
}
}
);
}
}
public async Task<SnWalletOrder> PayOrderAsync(Guid orderId, SnWallet payerWallet)
{
var order = await db.PaymentOrders
.Include(o => o.Transaction)
.Include(o => o.PayeeWallet)
.FirstOrDefaultAsync(o => o.Id == orderId) ?? throw new InvalidOperationException("Order not found");
var js = nats.CreateJetStreamContext();
if (order.Status == Shared.Models.OrderStatus.Paid)
{
await js.PublishAsync(
PaymentOrderEventBase.Type,
GrpcTypeHelper.ConvertObjectToByteString(new PaymentOrderEvent
{
OrderId = order.Id,
WalletId = payerWallet.Id,
AccountId = payerWallet.AccountId,
AppIdentifier = order.AppIdentifier,
ProductIdentifier = order.ProductIdentifier,
Meta = order.Meta ?? [],
Status = (int)order.Status,
}).ToByteArray()
);
return order;
}
if (order.Status != Shared.Models.OrderStatus.Unpaid)
{
throw new InvalidOperationException($"Order is in invalid status: {order.Status}");
}
if (order.ExpiredAt < SystemClock.Instance.GetCurrentInstant())
{
order.Status = Shared.Models.OrderStatus.Expired;
await db.SaveChangesAsync();
throw new InvalidOperationException("Order has expired");
}
var transaction = await CreateTransactionAsync(
payerWallet.Id,
order.PayeeWalletId,
order.Currency,
order.Amount,
order.Remarks ?? $"Payment for Order #{order.Id}",
type: Shared.Models.TransactionType.Order,
silent: true);
order.TransactionId = transaction.Id;
order.Transaction = transaction;
order.Status = Shared.Models.OrderStatus.Paid;
await db.SaveChangesAsync();
await NotifyOrderPaid(order, payerWallet, order.PayeeWallet);
await js.PublishAsync(
PaymentOrderEventBase.Type,
GrpcTypeHelper.ConvertObjectToByteString(new PaymentOrderEvent
{
OrderId = order.Id,
WalletId = payerWallet.Id,
AccountId = payerWallet.AccountId,
AppIdentifier = order.AppIdentifier,
ProductIdentifier = order.ProductIdentifier,
Meta = order.Meta ?? [],
Status = (int)order.Status,
}).ToByteArray()
);
return order;
}
private async Task NotifyOrderPaid(SnWalletOrder order, SnWallet? payerWallet, SnWallet? payeeWallet)
{
if (payerWallet is not null)
{
var account = await db.Accounts
.Where(a => a.Id == payerWallet.AccountId)
.FirstOrDefaultAsync();
if (account is null) return;
AccountService.SetCultureInfo(account);
// Due to ID is uuid, it longer than 8 words for sure
var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "wallets.orders.paid",
Title = localizer["OrderPaidTitle", $"#{readableOrderId}"],
Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture),
order.Currency,
readableOrderRemark],
IsSavable = true
}
}
);
}
if (payeeWallet is not null)
{
var account = await db.Accounts
.Where(a => a.Id == payeeWallet.AccountId)
.FirstOrDefaultAsync();
if (account is null) return;
AccountService.SetCultureInfo(account);
// Due to ID is uuid, it longer than 8 words for sure
var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "wallets.orders.received",
Title = localizer["OrderReceivedTitle", $"#{readableOrderId}"],
Body = localizer["OrderReceivedBody", order.Amount.ToString(CultureInfo.InvariantCulture),
order.Currency,
readableOrderRemark],
IsSavable = true
}
}
);
}
}
public async Task<SnWalletOrder> CancelOrderAsync(Guid orderId)
{
var order = await db.PaymentOrders.FindAsync(orderId);
if (order == null)
{
throw new InvalidOperationException("Order not found");
}
if (order.Status != Shared.Models.OrderStatus.Unpaid)
{
throw new InvalidOperationException($"Cannot cancel order in status: {order.Status}");
}
order.Status = Shared.Models.OrderStatus.Cancelled;
await db.SaveChangesAsync();
return order;
}
public async Task<(SnWalletOrder Order, SnWalletTransaction RefundTransaction)> RefundOrderAsync(Guid orderId)
{
var order = await db.PaymentOrders
.Include(o => o.Transaction)
.FirstOrDefaultAsync(o => o.Id == orderId) ?? throw new InvalidOperationException("Order not found");
if (order.Status != Shared.Models.OrderStatus.Paid)
{
throw new InvalidOperationException($"Cannot refund order in status: {order.Status}");
}
if (order.Transaction == null)
{
throw new InvalidOperationException("Order has no associated transaction");
}
var refundTransaction = await CreateTransactionAsync(
order.PayeeWalletId,
order.Transaction.PayerWalletId,
order.Currency,
order.Amount,
$"Refund for order {order.Id}");
order.Status = Shared.Models.OrderStatus.Finished;
await db.SaveChangesAsync();
return (order, refundTransaction);
}
public async Task<SnWalletTransaction> TransferAsync(Guid payerAccountId, Guid payeeAccountId, string currency,
decimal amount)
{
var payerWallet = await wat.GetWalletAsync(payerAccountId);
if (payerWallet == null)
{
throw new InvalidOperationException($"Payer wallet not found for account {payerAccountId}");
}
var payeeWallet = await wat.GetWalletAsync(payeeAccountId);
if (payeeWallet == null)
{
throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}");
}
// Calculate transfer fee (5%)
decimal fee = Math.Round(amount * 0.05m, 2);
decimal finalCost = amount + fee;
// Make sure the account has sufficient balanace for both fee and the transfer
var (payerPocket, isNewlyCreated) =
await wat.GetOrCreateWalletPocketAsync(payerWallet.Id, currency, amount);
if (isNewlyCreated || payerPocket.Amount < finalCost)
throw new InvalidOperationException("Insufficient funds");
// Create main transfer transaction
var transaction = await CreateTransactionAsync(
payerWallet.Id,
payeeWallet.Id,
currency,
amount,
$"Transfer from account {payerAccountId} to {payeeAccountId}",
Shared.Models.TransactionType.Transfer);
// Create fee transaction (to system)
await CreateTransactionAsync(
payerWallet.Id,
null,
currency,
fee,
$"Transfer fee for transaction #{transaction.Id}");
return transaction;
}
public async Task<SnWalletFund> CreateFundAsync(
Guid creatorAccountId,
List<Guid> recipientAccountIds,
string currency,
decimal totalAmount,
int amountOfSplits,
Shared.Models.FundSplitType splitType,
string? message = null,
Duration? expiration = null)
{
if (totalAmount <= 0)
throw new ArgumentException("Total amount must be positive");
// Check creator has sufficient funds
var creatorWallet = await wat.GetWalletAsync(creatorAccountId);
if (creatorWallet == null)
throw new InvalidOperationException($"Creator wallet not found for account {creatorAccountId}");
// Validate all recipient accounts exist and have wallets
foreach (var accountId in recipientAccountIds)
{
var wallet = await wat.GetWalletAsync(accountId);
if (wallet == null)
throw new InvalidOperationException($"Wallet not found for recipient account {accountId}");
}
var (creatorPocket, _) = await wat.GetOrCreateWalletPocketAsync(creatorWallet.Id, currency);
if (creatorPocket.Amount < totalAmount)
throw new InvalidOperationException("Insufficient funds");
var now = SystemClock.Instance.GetCurrentInstant();
var fund = new SnWalletFund
{
CreatorAccountId = creatorAccountId,
Currency = currency,
TotalAmount = totalAmount,
RemainingAmount = totalAmount,
AmountOfSplits = amountOfSplits,
SplitType = splitType,
Message = message,
ExpiredAt = now.Plus(expiration ?? Duration.FromHours(24)),
IsOpen = recipientAccountIds.Count == 0,
Recipients = recipientAccountIds.Select(accountId => new SnWalletFundRecipient
{
RecipientAccountId = accountId,
Amount = 0 // Amount will be calculated dynamically when claimed
}).ToList()
};
// Deduct from creator's wallet
await db.WalletPockets
.Where(p => p.Id == creatorPocket.Id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Amount, p => p.Amount - totalAmount));
db.WalletFunds.Add(fund);
await db.SaveChangesAsync();
// Load the fund with account data including profiles
var createdFund = await db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.FirstOrDefaultAsync(f => f.Id == fund.Id);
return createdFund!;
}
private List<decimal> SplitEvenly(decimal totalAmount, int recipientCount)
{
var baseAmount = Math.Floor(totalAmount / recipientCount * 100) / 100; // Round down to 2 decimal places
var remainder = totalAmount - (baseAmount * recipientCount);
var amounts = new List<decimal>();
for (int i = 0; i < recipientCount; i++)
{
var amount = baseAmount;
if (i < remainder * 100) // Distribute remainder as 0.01 increments
amount += 0.01m;
amounts.Add(amount);
}
return amounts;
}
private List<decimal> SplitRandomly(decimal totalAmount, int recipientCount)
{
var random = new Random();
var amounts = new List<decimal>();
// Generate random amounts that sum to total
decimal remaining = totalAmount;
for (int i = 0; i < recipientCount - 1; i++)
{
// Ensure each recipient gets at least 0.01 and leave enough for remaining recipients
var maxAmount = remaining - (recipientCount - i - 1) * 0.01m;
var minAmount = 0.01m;
var amount = Math.Round((decimal)random.NextDouble() * (maxAmount - minAmount) + minAmount, 2);
amounts.Add(amount);
remaining -= amount;
}
// Last recipient gets the remainder
amounts.Add(Math.Round(remaining, 2));
return amounts;
}
private decimal CalculateDynamicAmount(SnWalletFund fund)
{
if (fund.RemainingAmount <= 0)
return 0;
// For open mode funds: use split type calculation
if (fund.IsOpen)
{
var remainingRecipients = fund.AmountOfSplits - fund.Recipients.Count(r => r.IsReceived);
if (remainingRecipients == 0)
return 0;
var amount = fund.SplitType switch
{
Shared.Models.FundSplitType.Even => SplitEvenly(fund.RemainingAmount, remainingRecipients)[0],
Shared.Models.FundSplitType.Random => SplitRandomly(fund.RemainingAmount, remainingRecipients)[0],
_ => throw new ArgumentException("Invalid split type")
};
return Math.Max(amount, 0.01m);
}
// For closed mode funds: use split type calculation
var unclaimedRecipients = fund.Recipients.Count(r => !r.IsReceived);
if (unclaimedRecipients == 0)
return 0;
return fund.SplitType switch
{
Shared.Models.FundSplitType.Even => SplitEvenly(fund.RemainingAmount, unclaimedRecipients)[0],
Shared.Models.FundSplitType.Random => SplitRandomly(fund.RemainingAmount, unclaimedRecipients)[0],
_ => throw new ArgumentException("Invalid split type")
};
}
public async Task<SnWalletTransaction> ReceiveFundAsync(Guid recipientAccountId, Guid fundId)
{
// Use a transaction to ensure atomicity
await using var transactionScope = await db.Database.BeginTransactionAsync();
try
{
// Load fund with proper locking to prevent concurrent modifications
var fund = await db.WalletFunds
.Include(f => f.Recipients)
.FirstOrDefaultAsync(f => f.Id == fundId);
if (fund == null)
throw new InvalidOperationException("Fund not found");
if (fund.Status is Shared.Models.FundStatus.Expired or Shared.Models.FundStatus.Refunded)
throw new InvalidOperationException("Fund is no longer available");
var recipient = fund.Recipients.FirstOrDefault(r => r.RecipientAccountId == recipientAccountId);
// Handle open mode fund - create recipient if not exists
if (recipient is null && fund.IsOpen)
{
// Check if recipient has already claimed from this fund
var existingClaim = fund.Recipients.FirstOrDefault(r => r.RecipientAccountId == recipientAccountId);
if (existingClaim != null)
throw new InvalidOperationException("You have already claimed from this fund");
// Calculate amount for new recipient
var amount = CalculateDynamicAmount(fund);
if (amount <= 0)
throw new InvalidOperationException("No funds remaining to claim");
// Create new recipient
recipient = new SnWalletFundRecipient
{
RecipientAccountId = recipientAccountId,
Amount = amount,
IsReceived = false,
FundId = fund.Id,
};
db.WalletFundRecipients.Add(recipient);
await db.SaveChangesAsync();
fund.RemainingAmount -= amount;
}
else if (recipient is null)
{
throw new InvalidOperationException("You are not a recipient of this fund");
}
// For closed mode funds, calculate amount dynamically if not already set
if (!fund.IsOpen && recipient.Amount == 0)
{
var amount = CalculateDynamicAmount(fund);
if (amount <= 0)
throw new InvalidOperationException("No funds remaining to claim");
recipient.Amount = amount;
fund.RemainingAmount -= amount;
}
db.Update(fund);
await db.SaveChangesAsync();
if (recipient.IsReceived)
throw new InvalidOperationException("You have already received this fund");
var recipientWallet = await wat.GetWalletAsync(recipientAccountId);
if (recipientWallet == null)
throw new InvalidOperationException("Recipient wallet not found");
// Create transaction to transfer funds to recipient
// This call will save changes once
var walletTransaction = await CreateTransactionAsync(
payerWalletId: null, // System transfer
payeeWalletId: recipientWallet.Id,
currency: fund.Currency,
amount: recipient.Amount,
remarks: $"Received fund portion from {fund.CreatorAccountId}",
type: Shared.Models.TransactionType.System
);
// Mark as received
recipient.IsReceived = true;
recipient.ReceivedAt = SystemClock.Instance.GetCurrentInstant();
// Update fund status
if (fund.IsOpen)
{
fund.Status = fund.RemainingAmount <= 0
? Shared.Models.FundStatus.FullyReceived
: Shared.Models.FundStatus.PartiallyReceived;
}
else
{
var allReceived = fund.Recipients.All(r => r.IsReceived);
fund.Status = allReceived
? Shared.Models.FundStatus.FullyReceived
: Shared.Models.FundStatus.PartiallyReceived;
}
db.Update(fund);
await db.SaveChangesAsync();
await transactionScope.CommitAsync();
return walletTransaction;
}
catch
{
await transactionScope.RollbackAsync();
throw;
}
}
public async Task ProcessExpiredFundsAsync()
{
var now = SystemClock.Instance.GetCurrentInstant();
var expiredFunds = await db.WalletFunds
.Include(f => f.Recipients)
.Where(f => f.Status == Shared.Models.FundStatus.Created ||
f.Status == Shared.Models.FundStatus.PartiallyReceived)
.Where(f => f.ExpiredAt < now)
.ToListAsync();
foreach (var fund in expiredFunds)
{
// Calculate unclaimed amount
var unclaimedAmount = fund.Recipients
.Where(r => !r.IsReceived)
.Sum(r => r.Amount);
if (unclaimedAmount > 0)
{
// Refund to creator
var creatorWallet = await wat.GetWalletAsync(fund.CreatorAccountId);
if (creatorWallet != null)
{
await CreateTransactionAsync(
payerWalletId: null, // System refund
payeeWalletId: creatorWallet.Id,
currency: fund.Currency,
amount: unclaimedAmount,
remarks: $"Refund for expired fund {fund.Id}",
type: Shared.Models.TransactionType.System,
silent: true
);
}
}
fund.Status = Shared.Models.FundStatus.Expired;
}
await db.SaveChangesAsync();
}
public async Task<SnWalletFund> GetWalletFundAsync(Guid fundId)
{
var fund = await db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.FirstOrDefaultAsync(f => f.Id == fundId);
if (fund == null)
throw new InvalidOperationException("Fund not found");
return fund;
}
public async Task<WalletOverview> GetWalletOverviewAsync(Guid accountId, DateTime? startDate = null,
DateTime? endDate = null)
{
var wallet = await wat.GetWalletAsync(accountId);
if (wallet == null)
throw new InvalidOperationException("Wallet not found");
var query = db.PaymentTransactions
.Where(t => t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id);
if (startDate.HasValue)
query = query.Where(t => t.CreatedAt >= Instant.FromDateTimeUtc(startDate.Value.ToUniversalTime()));
if (endDate.HasValue)
query = query.Where(t => t.CreatedAt <= Instant.FromDateTimeUtc(endDate.Value.ToUniversalTime()));
var transactions = await query.ToListAsync();
var overview = new WalletOverview
{
AccountId = accountId,
StartDate = startDate?.ToString("O"),
EndDate = endDate?.ToString("O"),
Summary = new Dictionary<string, TransactionSummary>()
};
// Group transactions by type and currency
var groupedTransactions = transactions
.GroupBy(t => new { t.Type, t.Currency })
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var group in groupedTransactions)
{
var typeName = group.Key.Type.ToString();
var currency = group.Key.Currency;
if (!overview.Summary.ContainsKey(typeName))
{
overview.Summary[typeName] = new TransactionSummary
{
Type = typeName,
Currencies = new Dictionary<string, CurrencySummary>()
};
}
var currencySummary = new CurrencySummary
{
Currency = currency,
Income = 0,
Spending = 0,
Net = 0
};
foreach (var transaction in group.Value)
{
if (transaction.PayeeWalletId == wallet.Id)
{
// Money coming in
currencySummary.Income += transaction.Amount;
}
else if (transaction.PayerWalletId == wallet.Id)
{
// Money going out
currencySummary.Spending += transaction.Amount;
}
}
currencySummary.Net = currencySummary.Income - currencySummary.Spending;
overview.Summary[typeName].Currencies[currency] = currencySummary;
}
// Calculate totals
overview.TotalIncome = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Income));
overview.TotalSpending = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Spending));
overview.NetTotal = overview.TotalIncome - overview.TotalSpending;
return overview;
}
}
public class WalletOverview
{
public Guid AccountId { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public Dictionary<string, TransactionSummary> Summary { get; set; } = new();
public decimal TotalIncome { get; set; }
public decimal TotalSpending { get; set; }
public decimal NetTotal { get; set; }
}
public class TransactionSummary
{
public string Type { get; set; } = null!;
public Dictionary<string, CurrencySummary> Currencies { get; set; } = new();
}
public class CurrencySummary
{
public string Currency { get; set; } = null!;
public decimal Income { get; set; }
public decimal Spending { get; set; }
public decimal Net { get; set; }
}

View File

@@ -1,112 +0,0 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using NodaTime;
namespace DysonNetwork.Pass.Wallet;
public class PaymentServiceGrpc(PaymentService paymentService)
: Shared.Proto.PaymentService.PaymentServiceBase
{
public override async Task<Order> CreateOrder(
CreateOrderRequest request,
ServerCallContext context
)
{
var order = await paymentService.CreateOrderAsync(
request.HasPayeeWalletId ? Guid.Parse(request.PayeeWalletId) : null,
request.Currency,
decimal.Parse(request.Amount),
request.Expiration is not null
? Duration.FromSeconds(request.Expiration.Seconds)
: null,
request.HasAppIdentifier ? request.AppIdentifier : SnWalletOrder.InternalAppIdentifier,
request.HasProductIdentifier ? request.ProductIdentifier : null,
request.HasRemarks ? request.Remarks : null,
request.HasMeta
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object>>(request.Meta)
: null,
request.Reuseable
);
return order.ToProtoValue();
}
public override async Task<Transaction> CreateTransactionWithAccount(
CreateTransactionWithAccountRequest request,
ServerCallContext context
)
{
var transaction = await paymentService.CreateTransactionWithAccountAsync(
request.PayerAccountId is not null ? Guid.Parse(request.PayerAccountId) : null,
request.PayeeAccountId is not null ? Guid.Parse(request.PayeeAccountId) : null,
request.Currency,
decimal.Parse(request.Amount),
request.Remarks is not null ? request.Remarks : null,
(Shared.Models.TransactionType)request.Type
);
return transaction.ToProtoValue();
}
public override async Task<Shared.Proto.Transaction> CreateTransaction(
CreateTransactionRequest request,
ServerCallContext context
)
{
var transaction = await paymentService.CreateTransactionAsync(
request.PayerWalletId is not null ? Guid.Parse(request.PayerWalletId) : null,
request.PayeeWalletId is not null ? Guid.Parse(request.PayeeWalletId) : null,
request.Currency,
decimal.Parse(request.Amount),
request.Remarks is not null ? request.Remarks : null,
(Shared.Models.TransactionType)request.Type
);
return transaction.ToProtoValue();
}
public override async Task<Shared.Proto.Order> CancelOrder(
CancelOrderRequest request,
ServerCallContext context
)
{
var order = await paymentService.CancelOrderAsync(Guid.Parse(request.OrderId));
return order.ToProtoValue();
}
public override async Task<RefundOrderResponse> RefundOrder(
RefundOrderRequest request,
ServerCallContext context
)
{
var (order, refundTransaction) = await paymentService.RefundOrderAsync(
Guid.Parse(request.OrderId)
);
return new RefundOrderResponse
{
Order = order.ToProtoValue(),
RefundTransaction = refundTransaction.ToProtoValue(),
};
}
public override async Task<Transaction> Transfer(
TransferRequest request,
ServerCallContext context
)
{
var transaction = await paymentService.TransferAsync(
Guid.Parse(request.PayerAccountId),
Guid.Parse(request.PayeeAccountId),
request.Currency,
decimal.Parse(request.Amount)
);
return transaction.ToProtoValue();
}
public override async Task<WalletFund> GetWalletFund(
GetWalletFundRequest request,
ServerCallContext context
)
{
var walletFund = await paymentService.GetWalletFundAsync(Guid.Parse(request.FundId));
return walletFund.ToProtoValue();
}
}

View File

@@ -1,181 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Wallet.PaymentHandlers;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/subscriptions")]
public class SubscriptionController(SubscriptionService subscriptions, AfdianPaymentHandler afdian, AppDatabase db)
: ControllerBase
{
[HttpGet]
[Authorize]
public async Task<ActionResult<List<SnWalletSubscription>>> ListSubscriptions(
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var query = db.WalletSubscriptions.AsQueryable()
.Where(s => s.AccountId == currentUser.Id)
.Include(s => s.Coupon)
.OrderByDescending(s => s.BegunAt);
var totalCount = await query.CountAsync();
var subscriptionsList = await query
.Skip(offset)
.Take(take)
.ToListAsync();
Response.Headers["X-Total"] = totalCount.ToString();
return subscriptionsList;
}
[HttpGet("fuzzy/{prefix}")]
[Authorize]
public async Task<ActionResult<SnWalletSubscription>> GetSubscriptionFuzzy(string prefix)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var subscription = await db.WalletSubscriptions
.Where(s => s.AccountId == currentUser.Id && s.IsActive)
.Where(s => EF.Functions.ILike(s.Identifier, prefix + "%"))
.OrderByDescending(s => s.BegunAt)
.FirstOrDefaultAsync();
if (subscription is null || !subscription.IsAvailable) return NotFound();
return Ok(subscription);
}
[HttpGet("{identifier}")]
[Authorize]
public async Task<ActionResult<SnWalletSubscription>> GetSubscription(string identifier)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var subscription = await subscriptions.GetSubscriptionAsync(currentUser.Id, identifier);
if (subscription is null) return NotFound($"Subscription with identifier {identifier} was not found.");
return subscription;
}
public class CreateSubscriptionRequest
{
[Required] public string Identifier { get; set; } = null!;
[Required] public string PaymentMethod { get; set; } = null!;
[Required] public SnPaymentDetails PaymentDetails { get; set; } = null!;
public string? Coupon { get; set; }
public int? CycleDurationDays { get; set; }
public bool IsFreeTrial { get; set; } = false;
public bool IsAutoRenewal { get; set; } = true;
}
[HttpPost]
[Authorize]
public async Task<ActionResult<SnWalletSubscription>> CreateSubscription(
[FromBody] CreateSubscriptionRequest request,
[FromHeader(Name = "X-Noop")] bool noop = false
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
Duration? cycleDuration = null;
if (request.CycleDurationDays.HasValue)
cycleDuration = Duration.FromDays(request.CycleDurationDays.Value);
try
{
var subscription = await subscriptions.CreateSubscriptionAsync(
currentUser,
request.Identifier,
request.PaymentMethod,
request.PaymentDetails,
cycleDuration,
request.Coupon,
request.IsFreeTrial,
request.IsAutoRenewal,
noop
);
return subscription;
}
catch (ArgumentOutOfRangeException ex)
{
return BadRequest(ex.Message);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("{identifier}/cancel")]
[Authorize]
public async Task<ActionResult<SnWalletSubscription>> CancelSubscription(string identifier)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var subscription = await subscriptions.CancelSubscriptionAsync(currentUser.Id, identifier);
return subscription;
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("{identifier}/order")]
[Authorize]
public async Task<ActionResult<SnWalletOrder>> CreateSubscriptionOrder(string identifier)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var order = await subscriptions.CreateSubscriptionOrder(currentUser.Id, identifier);
return order;
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
public class RestorePurchaseRequest
{
[Required] public string OrderId { get; set; } = null!;
}
[HttpPost("order/restore/afdian")]
[Authorize]
public async Task<IActionResult> RestorePurchaseFromAfdian([FromBody] RestorePurchaseRequest request)
{
var order = await afdian.GetOrderAsync(request.OrderId);
if (order is null) return NotFound($"Order with ID {request.OrderId} was not found.");
var subscription = await subscriptions.CreateSubscriptionFromOrder(order);
return Ok(subscription);
}
[HttpPost("order/handle/afdian")]
public async Task<ActionResult<WebhookResponse>> AfdianWebhook()
{
var response = await afdian.HandleWebhook(Request, async webhookData =>
{
var order = webhookData.Order;
await subscriptions.CreateSubscriptionFromOrder(order);
});
return Ok(response);
}
}

View File

@@ -1,335 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/subscriptions/gifts")]
public class SubscriptionGiftController(
SubscriptionService subscriptions,
AppDatabase db
) : ControllerBase
{
/// <summary>
/// Lists gifts purchased by the current user.
/// </summary>
[HttpGet("sent")]
[Authorize]
public async Task<ActionResult<List<SnWalletGift>>> ListSentGifts(
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var query = await subscriptions.GetGiftsByGifterAsync(currentUser.Id);
var totalCount = query.Count;
var gifts = query
.Skip(offset)
.Take(take)
.ToList();
Response.Headers["X-Total"] = totalCount.ToString();
return gifts;
}
/// <summary>
/// Lists gifts received by the current user (both direct and redeemed open gifts).
/// </summary>
[HttpGet("received")]
[Authorize]
public async Task<ActionResult<List<SnWalletGift>>> ListReceivedGifts(
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var gifts = await subscriptions.GetGiftsByRecipientAsync(currentUser.Id);
var totalCount = gifts.Count;
gifts = gifts
.Skip(offset)
.Take(take)
.ToList();
Response.Headers["X-Total"] = totalCount.ToString();
return gifts;
}
/// <summary>
/// Gets a specific gift by ID (only if user is the gifter or recipient).
/// </summary>
[HttpGet("{giftId}")]
[Authorize]
public async Task<ActionResult<SnWalletGift>> GetGift(Guid giftId)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var gift = await db.WalletGifts
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Subscription)
.Include(g => g.Coupon)
.FirstOrDefaultAsync(g => g.Id == giftId);
if (gift is null) return NotFound();
if (gift.GifterId != currentUser.Id && gift.RecipientId != currentUser.Id &&
!(gift.IsOpenGift && gift.RedeemerId == currentUser.Id))
return NotFound();
return gift;
}
/// <summary>
/// Checks if a gift code is valid and redeemable.
/// </summary>
[HttpGet("check/{giftCode}")]
[Authorize]
public async Task<ActionResult<GiftCheckResponse>> CheckGiftCode(string giftCode)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var gift = await subscriptions.GetGiftByCodeAsync(giftCode);
if (gift is null) return NotFound("Gift code not found.");
var canRedeem = false;
var error = "";
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
{
error = gift.Status switch
{
DysonNetwork.Shared.Models.GiftStatus.Created => "Gift has not been sent yet.",
DysonNetwork.Shared.Models.GiftStatus.Redeemed => "Gift has already been redeemed.",
DysonNetwork.Shared.Models.GiftStatus.Expired => "Gift has expired.",
DysonNetwork.Shared.Models.GiftStatus.Cancelled => "Gift has been cancelled.",
_ => "Gift is not redeemable."
};
}
else if (gift.ExpiresAt < SystemClock.Instance.GetCurrentInstant())
{
error = "Gift has expired.";
}
else if (!gift.IsOpenGift && gift.RecipientId != currentUser.Id)
{
error = "This gift is intended for someone else.";
}
else
{
// Check if user already has this subscription type
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(gift.SubscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo != null)
{
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
.Select(s => s.Value.Identifier)
.ToArray()
: [gift.SubscriptionIdentifier];
var existingSubscription =
await subscriptions.GetSubscriptionAsync(currentUser.Id, subscriptionsInGroup);
if (existingSubscription is not null)
{
error = "You already have an active subscription of this type.";
}
else
{
canRedeem = true;
}
}
}
return new GiftCheckResponse
{
GiftCode = giftCode,
SubscriptionIdentifier = gift.SubscriptionIdentifier,
CanRedeem = canRedeem,
Error = error,
Message = gift.Message
};
}
public class GiftCheckResponse
{
public string GiftCode { get; set; } = null!;
public string SubscriptionIdentifier { get; set; } = null!;
public bool CanRedeem { get; set; }
public string Error { get; set; } = null!;
public string? Message { get; set; }
}
public class PurchaseGiftRequest
{
[Required] public string SubscriptionIdentifier { get; set; } = null!;
public Guid? RecipientId { get; set; }
[Required] public string PaymentMethod { get; set; } = null!;
[Required] public SnPaymentDetails PaymentDetails { get; set; } = null!;
public string? Message { get; set; }
public string? Coupon { get; set; }
public int? GiftDurationDays { get; set; } = 30; // Gift expires in 30 days by default
public int? SubscriptionDurationDays { get; set; } = 30; // Subscription lasts 30 days when redeemed
}
const int MinimumAccountLevel = 60;
/// <summary>
/// Purchases a gift subscription.
/// </summary>
[HttpPost("purchase")]
[Authorize]
public async Task<ActionResult<SnWalletGift>> PurchaseGift([FromBody] PurchaseGiftRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
if (currentUser.Profile.Level < MinimumAccountLevel)
{
if (currentUser.PerkSubscription is null)
return StatusCode(403, "Account level must be at least 60 or a member of the Stellar Program to purchase a gift.");
}
Duration? giftDuration = null;
if (request.GiftDurationDays.HasValue)
giftDuration = Duration.FromDays(request.GiftDurationDays.Value);
Duration? subscriptionDuration = null;
if (request.SubscriptionDurationDays.HasValue)
subscriptionDuration = Duration.FromDays(request.SubscriptionDurationDays.Value);
try
{
var gift = await subscriptions.PurchaseGiftAsync(
currentUser,
request.RecipientId,
request.SubscriptionIdentifier,
request.PaymentMethod,
request.PaymentDetails,
request.Message,
request.Coupon,
giftDuration,
subscriptionDuration
);
return gift;
}
catch (ArgumentOutOfRangeException ex)
{
return BadRequest(ex.Message);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
public class RedeemGiftRequest
{
[Required] public string GiftCode { get; set; } = null!;
}
/// <summary>
/// Redeems a gift using its code, creating a subscription for the current user.
/// </summary>
[HttpPost("redeem")]
[Authorize]
public async Task<ActionResult<RedeemGiftResponse>> RedeemGift([FromBody] RedeemGiftRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var (gift, subscription) = await subscriptions.RedeemGiftAsync(currentUser, request.GiftCode);
return new RedeemGiftResponse
{
Gift = gift,
Subscription = subscription
};
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
public class RedeemGiftResponse
{
public SnWalletGift Gift { get; set; } = null!;
public SnWalletSubscription Subscription { get; set; } = null!;
}
/// <summary>
/// Marks a gift as sent (ready for redemption).
/// </summary>
[HttpPost("{giftId}/send")]
[Authorize]
public async Task<ActionResult<SnWalletGift>> SendGift(Guid giftId)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var gift = await subscriptions.MarkGiftAsSentAsync(giftId, currentUser.Id);
return gift;
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
/// <summary>
/// Cancels a gift before it's redeemed.
/// </summary>
[HttpPost("{giftId}/cancel")]
[Authorize]
public async Task<ActionResult<SnWalletGift>> CancelGift(Guid giftId)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var gift = await subscriptions.CancelGiftAsync(giftId, currentUser.Id);
return gift;
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
/// <summary>
/// Creates an order for an unpaid gift.
/// </summary>
[HttpPost("{giftId}/order")]
[Authorize]
public async Task<ActionResult<SnWalletOrder>> CreateGiftOrder(Guid giftId)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var order = await subscriptions.CreateGiftOrder(currentUser.Id, giftId);
return order;
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@@ -1,139 +0,0 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz;
namespace DysonNetwork.Pass.Wallet;
public class SubscriptionRenewalJob(
AppDatabase db,
SubscriptionService subscriptionService,
PaymentService paymentService,
WalletService walletService,
ILogger<SubscriptionRenewalJob> logger
) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting subscription auto-renewal job...");
// First update expired subscriptions
var expiredCount = await subscriptionService.UpdateExpiredSubscriptionsAsync();
logger.LogInformation("Updated {ExpiredCount} expired subscriptions", expiredCount);
var now = SystemClock.Instance.GetCurrentInstant();
const int batchSize = 100; // Process in smaller batches
var processedCount = 0;
var renewedCount = 0;
var failedCount = 0;
// Find subscriptions that need renewal (due for renewal and are still active)
var subscriptionsToRenew = await db.WalletSubscriptions
.Where(s => s.RenewalAt.HasValue && s.RenewalAt.Value <= now) // Due for renewal
.Where(s => s.Status == SubscriptionStatus.Active) // Only paid subscriptions
.Where(s => s.IsActive) // Only active subscriptions
.Where(s => !s.IsFreeTrial) // Exclude free trials
.OrderBy(s => s.RenewalAt) // Process oldest first
.Take(batchSize)
.Include(s => s.Coupon) // Include coupon information
.ToListAsync();
var totalSubscriptions = subscriptionsToRenew.Count;
logger.LogInformation("Found {TotalSubscriptions} subscriptions due for renewal", totalSubscriptions);
foreach (var subscription in subscriptionsToRenew)
{
try
{
processedCount++;
logger.LogDebug(
"Processing renewal for subscription {SubscriptionId} (Identifier: {Identifier}) for account {AccountId}",
subscription.Id, subscription.Identifier, subscription.AccountId);
if (subscription.RenewalAt is null)
{
logger.LogWarning(
"Subscription {SubscriptionId} (Identifier: {Identifier}) has no renewal date or has been cancelled.",
subscription.Id, subscription.Identifier);
subscription.Status = SubscriptionStatus.Cancelled;
db.WalletSubscriptions.Update(subscription);
await db.SaveChangesAsync();
continue;
}
// Calculate next cycle duration based on current cycle
var currentCycle = subscription.EndedAt!.Value - subscription.BegunAt;
// Create an order for the renewal payment
var order = await paymentService.CreateOrderAsync(
null,
WalletCurrency.GoldenPoint,
subscription.FinalPrice,
appIdentifier: "internal",
productIdentifier: subscription.Identifier,
meta: new Dictionary<string, object>()
{
["subscription_id"] = subscription.Id.ToString(),
["subscription_identifier"] = subscription.Identifier,
["is_renewal"] = true
}
);
// Try to process the payment automatically
if (subscription.PaymentMethod == SubscriptionPaymentMethod.InAppWallet)
{
try
{
var wallet = await walletService.GetWalletAsync(subscription.AccountId);
if (wallet is null) continue;
// Process automatic payment from wallet
await paymentService.PayOrderAsync(order.Id, wallet);
// Update subscription details
subscription.BegunAt = subscription.EndedAt!.Value;
subscription.EndedAt = subscription.BegunAt.Plus(currentCycle);
subscription.RenewalAt = subscription.EndedAt;
db.WalletSubscriptions.Update(subscription);
await db.SaveChangesAsync();
renewedCount++;
logger.LogInformation("Successfully renewed subscription {SubscriptionId}", subscription.Id);
}
catch (Exception ex)
{
// If auto-payment fails, mark for manual payment
logger.LogWarning(ex, "Failed to auto-renew subscription {SubscriptionId} with wallet payment",
subscription.Id);
failedCount++;
}
}
else
{
// For other payment methods, mark as pending payment
logger.LogInformation("Subscription {SubscriptionId} requires manual payment via {PaymentMethod}",
subscription.Id, subscription.PaymentMethod);
failedCount++;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing subscription {SubscriptionId}", subscription.Id);
failedCount++;
}
// Log progress periodically
if (processedCount % 20 == 0 || processedCount == totalSubscriptions)
{
logger.LogInformation(
"Progress: processed {ProcessedCount}/{TotalSubscriptions} subscriptions, {RenewedCount} renewed, {FailedCount} failed",
processedCount, totalSubscriptions, renewedCount, failedCount);
}
}
logger.LogInformation(
"Completed subscription renewal job. Processed: {ProcessedCount}, Renewed: {RenewedCount}, Failed: {FailedCount}",
processedCount, renewedCount, failedCount);
}
}

View File

@@ -1,967 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Wallet.PaymentHandlers;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
using Duration = NodaTime.Duration;
namespace DysonNetwork.Pass.Wallet;
public class SubscriptionService(
AppDatabase db,
PaymentService payment,
Account.AccountService accounts,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
IConfiguration configuration,
ICacheService cache,
ILogger<SubscriptionService> logger
)
{
public async Task<SnWalletSubscription> CreateSubscriptionAsync(
SnAccount account,
string identifier,
string paymentMethod,
SnPaymentDetails paymentDetails,
Duration? cycleDuration = null,
string? coupon = null,
bool isFreeTrial = false,
bool isAutoRenewal = true,
bool noop = false
)
{
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(identifier, out var template)
? template
: null;
if (subscriptionInfo is null)
throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found.");
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
.Select(s => s.Value.Identifier)
.ToArray()
: [identifier];
cycleDuration ??= Duration.FromDays(30);
var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionsInGroup);
if (existingSubscription is not null && !noop)
throw new InvalidOperationException($"Active subscription with identifier {identifier} already exists.");
if (existingSubscription is not null)
return existingSubscription;
// Batch database queries for account profile and coupon to reduce round trips
var accountProfileTask = subscriptionInfo.RequiredLevel > 0
? db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == account.Id)
: Task.FromResult((Shared.Models.SnAccountProfile?)null);
var prevFreeTrialTask = isFreeTrial
? db.WalletSubscriptions.FirstOrDefaultAsync(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
: Task.FromResult((SnWalletSubscription?)null);
Guid couponGuidId = Guid.TryParse(coupon ?? "", out var parsedId) ? parsedId : Guid.Empty;
var couponTask = coupon != null
? db.WalletCoupons.FirstOrDefaultAsync(c =>
c.Id == couponGuidId || (c.Identifier != null && c.Identifier == coupon))
: Task.FromResult((SnWalletCoupon?)null);
// Await batched queries
var profile = await accountProfileTask;
var prevFreeTrial = await prevFreeTrialTask;
var couponData = await couponTask;
// Validation checks
if (isFreeTrial && prevFreeTrial != null)
throw new InvalidOperationException("Free trial already exists.");
if (coupon != null && couponData is null)
throw new InvalidOperationException($"Coupon {coupon} was not found.");
var now = SystemClock.Instance.GetCurrentInstant();
var subscription = new SnWalletSubscription
{
BegunAt = now,
EndedAt = now.Plus(cycleDuration.Value),
Identifier = identifier,
IsActive = true,
IsFreeTrial = isFreeTrial,
Status = Shared.Models.SubscriptionStatus.Unpaid,
PaymentMethod = paymentMethod,
PaymentDetails = paymentDetails,
BasePrice = subscriptionInfo.BasePrice,
CouponId = couponData?.Id,
Coupon = couponData,
RenewalAt = (isFreeTrial || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value),
AccountId = account.Id,
};
db.WalletSubscriptions.Add(subscription);
await db.SaveChangesAsync();
return subscription;
}
public async Task<SnWalletSubscription> CreateSubscriptionFromOrder(ISubscriptionOrder order)
{
var cfgSection = configuration.GetSection("Payment:Subscriptions");
var provider = order.Provider;
var currency = "irl";
var subscriptionIdentifier = order.SubscriptionId;
switch (provider)
{
case "afdian":
// Get the Afdian section first, then bind it to a dictionary
var afdianPlans = cfgSection.GetSection("Afdian").Get<Dictionary<string, string>>();
logger.LogInformation("Afdian plans configuration: {Plans}", JsonSerializer.Serialize(afdianPlans));
if (afdianPlans != null && afdianPlans.TryGetValue(subscriptionIdentifier, out var planName))
subscriptionIdentifier = planName;
currency = "cny";
break;
}
var subscriptionTemplate = SubscriptionTypeData
.SubscriptionDict.TryGetValue(subscriptionIdentifier, out var template)
? template
: null;
if (subscriptionTemplate is null)
throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier),
$@"Subscription {subscriptionIdentifier} was not found.");
SnAccount? account = null;
if (!string.IsNullOrEmpty(provider))
account = await accounts.LookupAccountByConnection(order.AccountId, provider);
else if (Guid.TryParse(order.AccountId, out var accountId))
account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId);
if (account is null)
throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}");
var cycleDuration = order.Duration;
var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionIdentifier);
if (existingSubscription is not null && existingSubscription.PaymentMethod != provider)
throw new InvalidOperationException(
$"Active subscription with identifier {subscriptionIdentifier} already exists.");
if (existingSubscription?.PaymentDetails.OrderId == order.Id)
return existingSubscription;
if (existingSubscription is not null)
{
// Same provider, but different order, renew the subscription
existingSubscription.PaymentDetails.OrderId = order.Id;
existingSubscription.EndedAt = order.BegunAt.Plus(cycleDuration);
existingSubscription.RenewalAt = order.BegunAt.Plus(cycleDuration);
existingSubscription.Status = Shared.Models.SubscriptionStatus.Active;
db.Update(existingSubscription);
await db.SaveChangesAsync();
return existingSubscription;
}
var subscription = new SnWalletSubscription
{
BegunAt = order.BegunAt,
EndedAt = order.BegunAt.Plus(cycleDuration),
IsActive = true,
Status = Shared.Models.SubscriptionStatus.Active,
Identifier = subscriptionIdentifier,
PaymentMethod = provider,
PaymentDetails = new Shared.Models.SnPaymentDetails
{
Currency = currency,
OrderId = order.Id,
},
BasePrice = subscriptionTemplate.BasePrice,
RenewalAt = order.BegunAt.Plus(cycleDuration),
AccountId = account.Id,
};
db.WalletSubscriptions.Add(subscription);
await db.SaveChangesAsync();
await NotifySubscriptionBegun(subscription);
return subscription;
}
/// <summary>
/// Cancel the renewal of the current activated subscription.
/// </summary>
/// <param name="accountId">The user who requested the action.</param>
/// <param name="identifier">The subscription identifier</param>
/// <returns></returns>
/// <exception cref="InvalidOperationException">The active subscription was not found</exception>
public async Task<SnWalletSubscription> CancelSubscriptionAsync(Guid accountId, string identifier)
{
var subscription = await GetSubscriptionAsync(accountId, identifier);
if (subscription is null)
throw new InvalidOperationException($"Subscription with identifier {identifier} was not found.");
if (subscription.Status != Shared.Models.SubscriptionStatus.Active)
throw new InvalidOperationException("Subscription is already cancelled.");
if (subscription.RenewalAt is null)
throw new InvalidOperationException("Subscription is no need to be cancelled.");
if (subscription.PaymentMethod != SubscriptionPaymentMethod.InAppWallet)
throw new InvalidOperationException(
"Only in-app wallet subscription can be cancelled. For other payment methods, please head to the payment provider."
);
subscription.RenewalAt = null;
await db.SaveChangesAsync();
// Invalidate the cache for this subscription
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifier}";
await cache.RemoveAsync(cacheKey);
return subscription;
}
/// <summary>
/// Creates a subscription order for an unpaid or expired subscription.
/// If the subscription is active, it will extend its expiration date.
/// </summary>
/// <param name="accountId">The unique identifier for the account associated with the subscription.</param>
/// <param name="identifier">The unique subscription identifier.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the created subscription order.</returns>
/// <exception cref="InvalidOperationException">Thrown when no matching unpaid or expired subscription is found.</exception>
public async Task<SnWalletOrder> CreateSubscriptionOrder(Guid accountId, string identifier)
{
var subscription = await db.WalletSubscriptions
.Where(s => s.AccountId == accountId && s.Identifier == identifier)
.Where(s => s.Status != Shared.Models.SubscriptionStatus.Expired)
.Include(s => s.Coupon)
.OrderByDescending(s => s.BegunAt)
.FirstOrDefaultAsync();
if (subscription is null) throw new InvalidOperationException("No matching subscription found.");
var subscriptionInfo = SubscriptionTypeData.SubscriptionDict
.TryGetValue(subscription.Identifier, out var template)
? template
: null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == subscription.AccountId);
if (profile is null) throw new InvalidOperationException("Account must have a profile");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException("Account level must be at least 60 to purchase a gift.");
}
return await payment.CreateOrderAsync(
null,
subscriptionInfo.Currency,
subscription.FinalPrice,
appIdentifier: "internal",
productIdentifier: identifier,
meta: new Dictionary<string, object>()
{
["subscription_id"] = subscription.Id.ToString(),
["subscription_identifier"] = subscription.Identifier,
}
);
}
/// <summary>
/// Creates a gift order for an unpaid gift.
/// </summary>
/// <param name="accountId">The account ID of the gifter.</param>
/// <param name="giftId">The unique identifier for the gift.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the created gift order.</returns>
/// <exception cref="InvalidOperationException">Thrown when the gift is not found or not in payable status.</exception>
public async Task<SnWalletOrder> CreateGiftOrder(Guid accountId, Guid giftId)
{
var gift = await db.WalletGifts
.Where(g => g.Id == giftId && g.GifterId == accountId)
.Where(g => g.Status == DysonNetwork.Shared.Models.GiftStatus.Created)
.Include(g => g.Coupon)
.FirstOrDefaultAsync();
if (gift is null) throw new InvalidOperationException("No matching gift found.");
var subscriptionInfo = SubscriptionTypeData.SubscriptionDict
.TryGetValue(gift.SubscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
return await payment.CreateOrderAsync(
null,
subscriptionInfo.Currency,
gift.FinalPrice,
appIdentifier: "gift",
productIdentifier: gift.SubscriptionIdentifier,
meta: new Dictionary<string, object>()
{
["gift_id"] = gift.Id.ToString()
}
);
}
public async Task<SnWalletSubscription> HandleSubscriptionOrder(SnWalletOrder order)
{
if (order.Status != Shared.Models.OrderStatus.Paid || order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson)
throw new InvalidOperationException("Invalid order.");
var subscriptionId = Guid.TryParse(subscriptionIdJson.ToString(), out var parsedSubscriptionId)
? parsedSubscriptionId
: Guid.Empty;
if (subscriptionId == Guid.Empty)
throw new InvalidOperationException("Invalid order.");
var subscription = await db.WalletSubscriptions
.Where(s => s.Id == subscriptionId)
.Include(s => s.Coupon)
.FirstOrDefaultAsync();
if (subscription is null)
throw new InvalidOperationException("Invalid order.");
if (subscription.Status == Shared.Models.SubscriptionStatus.Expired)
{
// Calculate original cycle duration and extend from the current ended date
Duration originalCycle = subscription.EndedAt.Value - subscription.BegunAt;
subscription.RenewalAt = subscription.RenewalAt.HasValue ? subscription.RenewalAt.Value.Plus(originalCycle) : subscription.EndedAt.Value.Plus(originalCycle);
subscription.EndedAt = subscription.EndedAt.Value.Plus(originalCycle);
}
subscription.Status = Shared.Models.SubscriptionStatus.Active;
db.Update(subscription);
await db.SaveChangesAsync();
await NotifySubscriptionBegun(subscription);
return subscription;
}
public async Task<SnWalletGift> HandleGiftOrder(SnWalletOrder order)
{
if (order.Status != Shared.Models.OrderStatus.Paid || order.Meta?["gift_id"] is not JsonElement giftIdJson)
throw new InvalidOperationException("Invalid order.");
var giftId = Guid.TryParse(giftIdJson.ToString(), out var parsedGiftId)
? parsedGiftId
: Guid.Empty;
if (giftId == Guid.Empty)
throw new InvalidOperationException("Invalid order.");
var gift = await db.WalletGifts
.Where(g => g.Id == giftId)
.Include(g => g.Coupon)
.FirstOrDefaultAsync();
if (gift is null)
throw new InvalidOperationException("Invalid order.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
throw new InvalidOperationException("Gift is not in payable status.");
// Mark gift as sent after payment
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(gift);
await db.SaveChangesAsync();
return gift;
}
/// <summary>
/// Updates the status of expired subscriptions to reflect their current state.
/// This helps maintain accurate subscription records and is typically called periodically.
/// </summary>
/// <param name="batchSize">Maximum number of subscriptions to process</param>
/// <returns>Number of subscriptions that were marked as expired</returns>
public async Task<int> UpdateExpiredSubscriptionsAsync(int batchSize = 100)
{
var now = SystemClock.Instance.GetCurrentInstant();
// Find active subscriptions that have passed their end date
var expiredSubscriptions = await db.WalletSubscriptions
.Where(s => s.IsActive)
.Where(s => s.Status == Shared.Models.SubscriptionStatus.Active)
.Where(s => s.EndedAt.HasValue && s.EndedAt.Value < now)
.Take(batchSize)
.ToListAsync();
if (expiredSubscriptions.Count == 0)
return 0;
// Mark as expired
foreach (var subscription in expiredSubscriptions)
{
subscription.Status = Shared.Models.SubscriptionStatus.Expired;
}
await db.SaveChangesAsync();
// Batch invalidate caches for better performance
var cacheTasks = expiredSubscriptions.Select(subscription =>
cache.RemoveAsync($"{SubscriptionCacheKeyPrefix}{subscription.AccountId}:{subscription.Identifier}"));
await Task.WhenAll(cacheTasks);
return expiredSubscriptions.Count;
}
private async Task NotifySubscriptionBegun(SnWalletSubscription subscription)
{
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == subscription.AccountId);
if (account is null) return;
Account.AccountService.SetCultureInfo(account);
var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
: subscription.Identifier;
var duration = subscription.EndedAt is not null
? subscription.EndedAt.Value.Minus(subscription.BegunAt).Days.ToString()
: "infinite";
var notification = new PushNotification
{
Topic = "subscriptions.begun",
Title = localizer["SubscriptionAppliedTitle", humanReadableName],
Body = localizer["SubscriptionAppliedBody", duration, humanReadableName],
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
{
["subscription_id"] = subscription.Id.ToString()
}),
IsSavable = true
};
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = notification
}
);
}
private const string SubscriptionCacheKeyPrefix = "subscription:";
public async Task<SnWalletSubscription?> GetSubscriptionAsync(Guid accountId, params string[] identifiers)
{
// Create a unique cache key for this subscription
var identifierPart = identifiers.Length == 1
? identifiers[0]
: Convert.ToHexStringLower(MD5.HashData(Encoding.UTF8.GetBytes(string.Join(",", identifiers))));
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifierPart}";
// Try to get the subscription from cache first
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey);
if (found && cachedSubscription != null)
{
return cachedSubscription;
}
// If not in cache, get from database
var subscription = await db.WalletSubscriptions
.Where(s => s.AccountId == accountId && identifiers.Contains(s.Identifier))
.OrderByDescending(s => s.BegunAt)
.FirstOrDefaultAsync();
if (subscription is { IsAvailable: false }) subscription = null;
// Cache the result if found (with 5 minutes expiry)
await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(5));
return subscription;
}
private const string SubscriptionPerkCacheKeyPrefix = "subscription:perk:";
private static readonly List<string> PerkIdentifiers =
[SubscriptionType.Stellar, SubscriptionType.Nova, SubscriptionType.Supernova];
public async Task<SnWalletSubscription?> GetPerkSubscriptionAsync(Guid accountId)
{
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}";
// Try to get the subscription from cache first
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey);
if (found && cachedSubscription != null)
{
return cachedSubscription;
}
// If not in cache, get from database
var now = SystemClock.Instance.GetCurrentInstant();
var subscription = await db.WalletSubscriptions
.Where(s => s.AccountId == accountId && PerkIdentifiers.Contains(s.Identifier))
.Where(s => s.Status == Shared.Models.SubscriptionStatus.Active)
.Where(s => s.EndedAt == null || s.EndedAt > now)
.OrderByDescending(s => s.BegunAt)
.FirstOrDefaultAsync();
if (subscription is { IsAvailable: false }) subscription = null;
// Cache the result if found (with 5 minutes expiry)
await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(5));
return subscription;
}
public async Task<Dictionary<Guid, SnWalletSubscription?>> GetPerkSubscriptionsAsync(List<Guid> accountIds)
{
var result = new Dictionary<Guid, SnWalletSubscription?>();
var missingAccountIds = new List<Guid>();
// Try to get the subscription from cache first
var cacheTasks = accountIds.Select(async accountId =>
{
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}";
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey);
return (accountId, found, cachedSubscription);
});
var cacheResults = await Task.WhenAll(cacheTasks);
foreach (var (accountId, found, cachedSubscription) in cacheResults)
{
if (found && cachedSubscription != null)
result[accountId] = cachedSubscription;
else
missingAccountIds.Add(accountId);
}
if (missingAccountIds.Count == 0) return result;
// If not in cache, get from database
var now = SystemClock.Instance.GetCurrentInstant();
var subscriptions = await db.WalletSubscriptions
.Where(s => missingAccountIds.Contains(s.AccountId))
.Where(s => PerkIdentifiers.Contains(s.Identifier))
.Where(s => s.Status == Shared.Models.SubscriptionStatus.Active)
.Where(s => s.EndedAt == null || s.EndedAt > now)
.OrderByDescending(s => s.BegunAt)
.ToListAsync();
// Group by account and select latest available subscription
var groupedSubscriptions = subscriptions
.Where(s => s.IsAvailable)
.GroupBy(s => s.AccountId)
.ToDictionary(g => g.Key, g => g.First());
// Update results and batch cache operations
var cacheSetTasks = new List<Task>();
foreach (var kvp in groupedSubscriptions)
{
result[kvp.Key] = kvp.Value;
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{kvp.Key}";
cacheSetTasks.Add(cache.SetAsync(cacheKey, kvp.Value, TimeSpan.FromMinutes(30)));
}
await Task.WhenAll(cacheSetTasks);
return result;
}
/// <summary>
/// Purchases a gift subscription that can be redeemed by another user.
/// </summary>
/// <param name="gifter">The account purchasing the gift.</param>
/// <param name="recipientId">Optional specific recipient. If null, creates an open gift anyone can redeem.</param>
/// <param name="subscriptionIdentifier">The subscription type being gifted.</param>
/// <param name="paymentMethod">Payment method used by the gifter.</param>
/// <param name="paymentDetails">Payment details from the gifter.</param>
/// <param name="message">Optional personal message from the gifter.</param>
/// <param name="coupon">Optional coupon code for discount.</param>
/// <param name="giftDuration">How long the gift can be redeemed (default 30 days).</param>
/// <param name="cycleDuration">The duration of the subscription once redeemed (default 30 days).</param>
/// <returns>The created gift record.</returns>
public async Task<SnWalletGift> PurchaseGiftAsync(
SnAccount gifter,
Guid? recipientId,
string subscriptionIdentifier,
string paymentMethod,
SnPaymentDetails paymentDetails,
string? message = null,
string? coupon = null,
Duration? giftDuration = null,
Duration? cycleDuration = null)
{
// Validate subscription exists
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(subscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo is null)
throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier),
$@"Subscription {subscriptionIdentifier} was not found.");
// Check if recipient account exists (if specified)
SnAccount? recipient = null;
if (recipientId.HasValue)
{
recipient = await db.Accounts
.Where(a => a.Id == recipientId.Value)
.Include(a => a.Profile)
.FirstOrDefaultAsync();
if (recipient is null)
throw new ArgumentOutOfRangeException(nameof(recipientId), "Recipient account not found.");
}
// Validate and get coupon if provided
Guid couponGuidId = Guid.TryParse(coupon ?? "", out var parsedId) ? parsedId : Guid.Empty;
var couponData = coupon != null
? await db.WalletCoupons.FirstOrDefaultAsync(c =>
c.Id == couponGuidId || (c.Identifier != null && c.Identifier == coupon))
: null;
if (coupon != null && couponData is null)
throw new InvalidOperationException($"Coupon {coupon} was not found.");
// Set defaults
giftDuration ??= Duration.FromDays(30); // Gift expires in 30 days
cycleDuration ??= Duration.FromDays(30); // Subscription lasts 30 days once redeemed
var now = SystemClock.Instance.GetCurrentInstant();
// Generate unique gift code
var giftCode = await GenerateUniqueGiftCodeAsync();
// Calculate final price (with potential coupon discount)
var tempSubscription = new SnWalletSubscription
{
BasePrice = subscriptionInfo.BasePrice,
CouponId = couponData?.Id,
Coupon = couponData,
BegunAt = now // Need for price calculation
};
var finalPrice = tempSubscription.CalculateFinalPriceAt(now);
var gift = new SnWalletGift
{
GifterId = gifter.Id,
RecipientId = recipientId,
GiftCode = giftCode,
Message = message,
SubscriptionIdentifier = subscriptionIdentifier,
BasePrice = subscriptionInfo.BasePrice,
FinalPrice = finalPrice,
Status = DysonNetwork.Shared.Models.GiftStatus.Created,
ExpiresAt = now.Plus(giftDuration.Value),
IsOpenGift = !recipientId.HasValue,
PaymentMethod = paymentMethod,
PaymentDetails = paymentDetails,
CouponId = couponData?.Id,
CreatedAt = now,
UpdatedAt = now
};
db.WalletGifts.Add(gift);
await db.SaveChangesAsync();
gift.Gifter = gifter;
return gift;
}
/// <summary>
/// Activates a gift using the redemption code, creating a subscription for the redeemer.
/// </summary>
/// <param name="redeemer">The account redeeming the gift.</param>
/// <param name="giftCode">The unique redemption code.</param>
/// <returns>A tuple containing the activated gift and the created subscription.</returns>
public async Task<(SnWalletGift Gift, SnWalletSubscription Subscription)> RedeemGiftAsync(
SnAccount redeemer,
string giftCode)
{
var now = SystemClock.Instance.GetCurrentInstant();
// Find and validate the gift
var gift = await db.WalletGifts
.Include(g => g.Coupon) // Include coupon for price calculation
.FirstOrDefaultAsync(g => g.GiftCode == giftCode);
if (gift is null)
throw new InvalidOperationException("Gift code not found.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
throw new InvalidOperationException("Gift is not available for redemption.");
if (now > gift.ExpiresAt)
throw new InvalidOperationException("Gift has expired.");
if (gift.GifterId == redeemer.Id)
throw new InvalidOperationException("You cannot redeem your own gift.");
// Validate redeemer permissions
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
throw new InvalidOperationException("This gift is not intended for you.");
// Check if redeemer already has this subscription type
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(gift.SubscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo is null)
throw new InvalidOperationException("Invalid gift subscription type.");
var sameTypeSubscription = await GetSubscriptionAsync(redeemer.Id, gift.SubscriptionIdentifier);
if (sameTypeSubscription is not null)
{
// Extend existing subscription
var subscriptionDuration = Duration.FromDays(28);
if (sameTypeSubscription.EndedAt.HasValue && sameTypeSubscription.EndedAt.Value > now)
{
sameTypeSubscription.EndedAt = sameTypeSubscription.EndedAt.Value.Plus(subscriptionDuration);
}
else
{
sameTypeSubscription.EndedAt = now.Plus(subscriptionDuration);
}
if (sameTypeSubscription.RenewalAt.HasValue)
{
sameTypeSubscription.RenewalAt = sameTypeSubscription.RenewalAt.Value.Plus(subscriptionDuration);
}
// Update gift status and link
gift.Status = Shared.Models.GiftStatus.Redeemed;
gift.RedeemedAt = now;
gift.RedeemerId = redeemer.Id;
gift.SubscriptionId = sameTypeSubscription.Id;
gift.UpdatedAt = now;
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
db.WalletSubscriptions.Update(sameTypeSubscription);
db.WalletGifts.Update(gift);
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
if (gift.GifterId == redeemer.Id) return (gift, sameTypeSubscription);
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId);
if (gifter != null) await NotifyGiftClaimedByRecipient(gift, sameTypeSubscription, gifter, redeemer);
return (gift, sameTypeSubscription);
}
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
.Select(s => s.Value.Identifier)
.ToArray()
: [gift.SubscriptionIdentifier];
var existingSubscription = await GetSubscriptionAsync(redeemer.Id, subscriptionsInGroup);
if (existingSubscription is not null)
throw new InvalidOperationException("You already have an active subscription of this type.");
// We do not check account level requirement, since it is a gift
// Create the subscription from the gift
var cycleDuration = Duration.FromDays(28);
var subscription = new SnWalletSubscription
{
BegunAt = now,
EndedAt = now.Plus(cycleDuration),
Identifier = gift.SubscriptionIdentifier,
IsActive = true,
IsFreeTrial = false,
Status = Shared.Models.SubscriptionStatus.Active,
PaymentMethod = "gift", // Special payment method indicating gift redemption
PaymentDetails = new Shared.Models.SnPaymentDetails
{
Currency = "gift",
OrderId = gift.Id.ToString()
},
BasePrice = gift.BasePrice,
CouponId = gift.CouponId,
Coupon = gift.Coupon,
RenewalAt = now.Plus(cycleDuration),
AccountId = redeemer.Id,
};
// Update the gift status
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Redeemed;
gift.RedeemedAt = now;
gift.RedeemerId = redeemer.Id;
gift.Subscription = subscription;
gift.UpdatedAt = now;
// Save both gift and subscription
using var createTransaction = await db.Database.BeginTransactionAsync();
try
{
db.WalletSubscriptions.Add(subscription);
db.WalletGifts.Update(gift);
await db.SaveChangesAsync();
await createTransaction.CommitAsync();
}
catch
{
await createTransaction.RollbackAsync();
throw;
}
// Send notification to gifter if different from redeemer
if (gift.GifterId == redeemer.Id) return (gift, subscription);
{
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId);
if (gifter != null)
{
await NotifyGiftClaimedByRecipient(gift, subscription, gifter, redeemer);
}
}
return (gift, subscription);
}
/// <summary>
/// Retrieves a gift by its code (for redemption checking).
/// </summary>
public async Task<SnWalletGift?> GetGiftByCodeAsync(string giftCode)
{
return await db.WalletGifts
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Coupon)
.FirstOrDefaultAsync(g => g.GiftCode == giftCode);
}
/// <summary>
/// Retrieves gifts purchased by a specific account.
/// Only returns gifts that have been sent or processed (not created/unpaid ones).
/// </summary>
public async Task<List<SnWalletGift>> GetGiftsByGifterAsync(Guid gifterId)
{
return await db.WalletGifts
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Subscription)
.Where(g => g.GifterId == gifterId && g.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
.OrderByDescending(g => g.CreatedAt)
.ToListAsync();
}
public async Task<List<SnWalletGift>> GetGiftsByRecipientAsync(Guid recipientId)
{
return await db.WalletGifts
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Subscription)
.Where(g => g.RecipientId == recipientId || (g.IsOpenGift && g.RedeemerId == recipientId))
.OrderByDescending(g => g.CreatedAt)
.ToListAsync();
}
/// <summary>
/// Marks a gift as sent (ready for redemption).
/// </summary>
public async Task<SnWalletGift> MarkGiftAsSentAsync(Guid giftId, Guid gifterId)
{
var gift = await db.WalletGifts.FirstOrDefaultAsync(g => g.Id == giftId && g.GifterId == gifterId);
if (gift is null)
throw new InvalidOperationException("Gift not found or access denied.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
throw new InvalidOperationException("Gift cannot be marked as sent.");
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
return gift;
}
/// <summary>
/// Cancels a gift before it's redeemed.
/// </summary>
public async Task<SnWalletGift> CancelGiftAsync(Guid giftId, Guid gifterId)
{
var gift = await db.WalletGifts.FirstOrDefaultAsync(g => g.Id == giftId && g.GifterId == gifterId);
if (gift is null)
throw new InvalidOperationException("Gift not found or access denied.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created && gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
throw new InvalidOperationException("Gift cannot be cancelled.");
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Cancelled;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
return gift;
}
private async Task<string> GenerateUniqueGiftCodeAsync()
{
const int maxAttempts = 10;
const int codeLength = 12;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
// Generate a random code
var code = GenerateRandomCode(codeLength);
// Check if it already exists
var existingGift = await db.WalletGifts.FirstOrDefaultAsync(g => g.GiftCode == code);
if (existingGift is null)
return code;
}
throw new InvalidOperationException("Unable to generate unique gift code.");
}
private static string GenerateRandomCode(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var result = new char[length];
for (int i = 0; i < length; i++)
{
result[i] = chars[Random.Shared.Next(chars.Length)];
}
return new string(result);
}
private async Task NotifyGiftClaimedByRecipient(SnWalletGift gift, SnWalletSubscription subscription, SnAccount gifter, SnAccount redeemer)
{
Account.AccountService.SetCultureInfo(gifter);
var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
: subscription.Identifier;
var notification = new PushNotification
{
Topic = "gifts.claimed",
Title = localizer["GiftClaimedTitle"],
Body = localizer["GiftClaimedBody", humanReadableName, redeemer.Name],
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString(),
["subscription_id"] = subscription.Id.ToString(),
["redeemer_id"] = redeemer.Id.ToString()
}),
IsSavable = true
};
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = gifter.Id.ToString(),
Notification = notification
}
);
}
}

View File

@@ -1,403 +0,0 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/wallets")]
public class WalletController(
AppDatabase db,
WalletService ws,
PaymentService payment,
AuthService auth,
ICacheService cache,
ILogger<WalletController> logger
) : ControllerBase
{
[HttpPost]
[Authorize]
public async Task<ActionResult<SnWallet>> CreateWallet()
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var wallet = await ws.CreateWalletAsync(currentUser.Id);
return Ok(wallet);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
[HttpGet]
[Authorize]
public async Task<ActionResult<SnWallet>> GetWallet()
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var wallet = await ws.GetWalletAsync(currentUser.Id);
if (wallet is null) return NotFound("Wallet was not found, please create one first.");
return Ok(wallet);
}
public class WalletStats
{
public Instant PeriodBegin { get; set; }
public Instant PeriodEnd { get; set; }
public int TotalTransactions { get; set; }
public int TotalOrders { get; set; }
public Dictionary<string, decimal> IncomeCatgories { get; set; } = null!;
public Dictionary<string, decimal> OutgoingCategories { get; set; } = null!;
public decimal TotalIncome => IncomeCatgories.Values.Sum();
public decimal TotalOutgoing => OutgoingCategories.Values.Sum();
public decimal Sum => TotalIncome - TotalOutgoing;
}
[HttpGet("stats")]
[Authorize]
public async Task<ActionResult<WalletStats>> GetWalletStats([FromQuery] int period = 30)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var wallet = await ws.GetWalletAsync(currentUser.Id);
if (wallet is null) return NotFound("Wallet was not found, please create one first.");
var periodEnd = SystemClock.Instance.GetCurrentInstant();
var periodBegin = periodEnd.Minus(Duration.FromDays(period));
var cacheKey = $"wallet:stats:{currentUser.Id}:{period}";
var cached = await cache.GetAsync<WalletStats>(cacheKey);
if (cached != null)
{
return Ok(cached);
}
var transactions = await db.PaymentTransactions
.Where(t => (t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id) &&
t.CreatedAt >= periodBegin && t.CreatedAt <= periodEnd)
.ToListAsync();
var orders = await db.PaymentOrders
.Where(o => o.PayeeWalletId == wallet.Id &&
o.CreatedAt >= periodBegin && o.CreatedAt <= periodEnd)
.ToListAsync();
var incomeCategories = transactions
.Where(t => t.PayeeWalletId == wallet.Id)
.GroupBy(t => t.Type.ToString())
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
var outgoingCategories = transactions
.Where(t => t.PayerWalletId == wallet.Id)
.GroupBy(t => t.Type.ToString())
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
var stats = new WalletStats
{
PeriodBegin = periodBegin,
PeriodEnd = periodEnd,
TotalTransactions = transactions.Count,
TotalOrders = orders.Count,
IncomeCatgories = incomeCategories,
OutgoingCategories = outgoingCategories
};
await cache.SetAsync(cacheKey, stats, TimeSpan.FromHours(1));
return Ok(stats);
}
[HttpGet("transactions")]
[Authorize]
public async Task<ActionResult<List<SnWalletTransaction>>> GetTransactions(
[FromQuery] int offset = 0, [FromQuery] int take = 20
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountWallet = await db.Wallets.Where(w => w.AccountId == currentUser.Id).FirstOrDefaultAsync();
if (accountWallet is null) return NotFound();
var query = db.PaymentTransactions
.Where(t => t.PayeeWalletId == accountWallet.Id || t.PayerWalletId == accountWallet.Id)
.OrderByDescending(t => t.CreatedAt)
.AsQueryable();
var transactionCount = await query.CountAsync();
Response.Headers["X-Total"] = transactionCount.ToString();
var transactions = await query
.Skip(offset)
.Take(take)
.Include(t => t.PayerWallet)
.ThenInclude(w => w.Account)
.ThenInclude(w => w.Profile)
.Include(t => t.PayeeWallet)
.ThenInclude(w => w.Account)
.ThenInclude(w => w.Profile)
.ToListAsync();
return Ok(transactions);
}
[HttpGet("orders")]
[Authorize]
public async Task<ActionResult<List<SnWalletOrder>>> GetOrders(
[FromQuery] int offset = 0, [FromQuery] int take = 20
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountWallet = await db.Wallets.Where(w => w.AccountId == currentUser.Id).FirstOrDefaultAsync();
if (accountWallet is null) return NotFound();
var query = db.PaymentOrders.AsQueryable()
.Include(o => o.Transaction)
.Where(o => o.Transaction != null && (o.Transaction.PayeeWalletId == accountWallet.Id ||
o.Transaction.PayerWalletId == accountWallet.Id))
.AsQueryable();
var orderCount = await query.CountAsync();
Response.Headers["X-Total"] = orderCount.ToString();
var orders = await query
.Skip(offset)
.Take(take)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
return Ok(orders);
}
public class WalletBalanceRequest
{
public string? Remark { get; set; }
[Required] public decimal Amount { get; set; }
[Required] public string Currency { get; set; } = null!;
[Required] public Guid AccountId { get; set; }
}
public class WalletTransferRequest
{
public string? Remark { get; set; }
[Required] public decimal Amount { get; set; }
[Required] public string Currency { get; set; } = null!;
[Required] public Guid PayeeAccountId { get; set; }
[Required] public string PinCode { get; set; } = null!;
}
[HttpPost("balance")]
[Authorize]
[AskPermission("wallets.balance.modify")]
public async Task<ActionResult<SnWalletTransaction>> ModifyWalletBalance([FromBody] WalletBalanceRequest request)
{
var wallet = await ws.GetWalletAsync(request.AccountId);
if (wallet is null) return NotFound("Wallet was not found.");
var transaction = request.Amount >= 0
? await payment.CreateTransactionAsync(
payerWalletId: null,
payeeWalletId: wallet.Id,
currency: request.Currency,
amount: request.Amount,
remarks: request.Remark
)
: await payment.CreateTransactionAsync(
payerWalletId: wallet.Id,
payeeWalletId: null,
currency: request.Currency,
amount: request.Amount,
remarks: request.Remark
);
return Ok(transaction);
}
[HttpPost("transfer")]
[Authorize]
public async Task<ActionResult<SnWalletTransaction>> Transfer([FromBody] WalletTransferRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
// Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
return StatusCode(403, "Invalid PIN Code");
if (currentUser.Id == request.PayeeAccountId) return BadRequest("Cannot transfer to yourself.");
try
{
var transaction = await payment.TransferAsync(
payerAccountId: currentUser.Id,
payeeAccountId: request.PayeeAccountId,
currency: request.Currency,
amount: request.Amount
);
return Ok(transaction);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
public class CreateFundRequest
{
[Required] public List<Guid> RecipientAccountIds { get; set; } = new();
[Required] public string Currency { get; set; } = null!;
[Required] public decimal TotalAmount { get; set; }
[Required] public int AmountOfSplits { get; set; }
[Required] public FundSplitType SplitType { get; set; }
public string? Message { get; set; }
public int? ExpirationHours { get; set; } // Optional: hours until expiration
[Required] public string PinCode { get; set; } = null!; // Required PIN for fund creation
}
[HttpPost("funds")]
[Authorize]
public async Task<ActionResult<SnWalletFund>> CreateFund([FromBody] CreateFundRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
// Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
return StatusCode(403, "Invalid PIN Code");
try
{
Duration? expiration = null;
if (request.ExpirationHours.HasValue)
{
expiration = Duration.FromHours(request.ExpirationHours.Value);
}
var fund = await payment.CreateFundAsync(
creatorAccountId: currentUser.Id,
recipientAccountIds: request.RecipientAccountIds,
currency: request.Currency,
totalAmount: request.TotalAmount,
amountOfSplits: request.AmountOfSplits,
splitType: request.SplitType,
message: request.Message,
expiration: expiration
);
return Ok(fund);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
[HttpGet("funds")]
[Authorize]
public async Task<ActionResult<List<SnWalletFund>>> GetFunds(
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] FundStatus? status = null
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var query = db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.Where(f => f.CreatorAccountId == currentUser.Id ||
f.Recipients.Any(r => r.RecipientAccountId == currentUser.Id))
.AsQueryable();
if (status.HasValue)
{
query = query.Where(f => f.Status == status.Value);
}
var fundCount = await query.CountAsync();
Response.Headers["X-Total"] = fundCount.ToString();
var funds = await query
.OrderByDescending(f => f.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(funds);
}
[HttpGet("funds/{id:guid}")]
public async Task<ActionResult<SnWalletFund>> GetFund(Guid id)
{
var fund = await db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.FirstOrDefaultAsync(f => f.Id == id);
if (fund is null)
return NotFound("Fund not found");
return Ok(fund);
}
[HttpPost("funds/{id:guid}/receive")]
[Authorize]
public async Task<ActionResult<SnWalletTransaction>> ReceiveFund(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var transaction = await payment.ReceiveFundAsync(
recipientAccountId: currentUser.Id,
fundId: id
);
return Ok(transaction);
}
catch (Exception err)
{
logger.LogError(err, "Failed to receive fund...");
return BadRequest(err.Message);
}
}
[HttpGet("overview")]
[Authorize]
public async Task<ActionResult<WalletOverview>> GetWalletOverview(
[FromQuery] DateTime? startDate = null,
[FromQuery] DateTime? endDate = null
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var overview = await payment.GetWalletOverviewAsync(
accountId: currentUser.Id,
startDate: startDate,
endDate: endDate
);
return Ok(overview);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
}

View File

@@ -1,50 +0,0 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Wallet;
public class WalletService(AppDatabase db)
{
public async Task<SnWallet?> GetWalletAsync(Guid accountId)
{
return await db.Wallets
.Include(w => w.Pockets)
.FirstOrDefaultAsync(w => w.AccountId == accountId);
}
public async Task<SnWallet> CreateWalletAsync(Guid accountId)
{
var existingWallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == accountId);
if (existingWallet != null)
{
throw new InvalidOperationException($"Wallet already exists for account {accountId}");
}
var wallet = new SnWallet { AccountId = accountId };
db.Wallets.Add(wallet);
await db.SaveChangesAsync();
return wallet;
}
public async Task<(SnWalletPocket wallet, bool isNewlyCreated)> GetOrCreateWalletPocketAsync(
Guid walletId,
string currency,
decimal? initialAmount = null
)
{
var pocket = await db.WalletPockets.FirstOrDefaultAsync(p => p.Currency == currency && p.WalletId == walletId);
if (pocket != null) return (pocket, false);
pocket = new SnWalletPocket
{
Currency = currency,
Amount = initialAmount ?? 0,
WalletId = walletId
};
db.WalletPockets.Add(pocket);
return (pocket, true);
}
}

View File

@@ -1,25 +0,0 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
namespace DysonNetwork.Pass.Wallet;
public class WalletServiceGrpc(WalletService walletService) : Shared.Proto.WalletService.WalletServiceBase
{
public override async Task<Shared.Proto.Wallet> GetWallet(GetWalletRequest request, ServerCallContext context)
{
var wallet = await walletService.GetWalletAsync(Guid.Parse(request.AccountId));
return wallet == null ? throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found.")) : wallet.ToProtoValue();
}
public override async Task<Shared.Proto.Wallet> CreateWallet(CreateWalletRequest request, ServerCallContext context)
{
var wallet = await walletService.CreateWalletAsync(Guid.Parse(request.AccountId));
return wallet.ToProtoValue();
}
public override async Task<Shared.Proto.WalletPocket> GetOrCreateWalletPocket(GetOrCreateWalletPocketRequest request, ServerCallContext context)
{
var (pocket, _) = await walletService.GetOrCreateWalletPocketAsync(Guid.Parse(request.WalletId), request.Currency, request.HasInitialAmount ? decimal.Parse(request.InitialAmount) : null);
return pocket.ToProtoValue();
}
}