♻️ 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

@@ -27,8 +27,8 @@ jobs:
run: | run: |
files="${{ steps.changed-files.outputs.files }}" files="${{ steps.changed-files.outputs.files }}"
matrix="{\"include\":[]}" matrix="{\"include\":[]}"
services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight" "Zone" "Messager") services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight" "Zone" "Messager" "Wallet")
images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight" "zone" "messager") images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight" "zone" "messager" "wallet")
changed_services=() changed_services=()
for file in $files; do for file in $files; do

View File

@@ -39,7 +39,11 @@ var messagerService = builder.AddProject<Projects.DysonNetwork_Messager>("messag
.WithReference(developService) .WithReference(developService)
.WithReference(driveService); .WithReference(driveService);
passService.WithReference(developService).WithReference(driveService); var walletService = builder.AddProject<Projects.DysonNetwork_Wallet>("wallet")
.WithReference(passService)
.WithReference(ringService);
passService.WithReference(developService).WithReference(driveService).WithReference(walletService);
List<IResourceBuilder<ProjectResource>> services = List<IResourceBuilder<ProjectResource>> services =
[ [
@@ -50,7 +54,8 @@ List<IResourceBuilder<ProjectResource>> services =
developService, developService,
insightService, insightService,
zoneService, zoneService,
messagerService messagerService,
walletService
]; ];
for (var idx = 0; idx < services.Count; idx++) for (var idx = 0; idx < services.Count; idx++)

View File

@@ -26,5 +26,6 @@
<ProjectReference Include="..\DysonNetwork.Insight\DysonNetwork.Insight.csproj"/> <ProjectReference Include="..\DysonNetwork.Insight\DysonNetwork.Insight.csproj"/>
<ProjectReference Include="..\DysonNetwork.Zone\DysonNetwork.Zone.csproj"/> <ProjectReference Include="..\DysonNetwork.Zone\DysonNetwork.Zone.csproj"/>
<ProjectReference Include="..\DysonNetwork.Messager\DysonNetwork.Messager.csproj"/> <ProjectReference Include="..\DysonNetwork.Messager\DysonNetwork.Messager.csproj"/>
<ProjectReference Include="..\DysonNetwork.Wallet\DysonNetwork.Wallet.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -28,7 +28,8 @@ public class GatewayEndpointsOptions
"develop", "develop",
"insight", "insight",
"zone", "zone",
"messager" "messager",
"wallet"
]; ];
/// <summary> /// <summary>
@@ -39,7 +40,8 @@ public class GatewayEndpointsOptions
"ring", "ring",
"pass", "pass",
"drive", "drive",
"sphere" "sphere",
"wallet"
]; ];
/// <summary> /// <summary>

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
using System.Globalization; using System.Globalization;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
@@ -14,11 +13,9 @@ namespace DysonNetwork.Pass.Account;
public class AccountEventService( public class AccountEventService(
AppDatabase db, AppDatabase db,
Wallet.PaymentService payment,
ICacheService cache, ICacheService cache,
IStringLocalizer<Localization.AccountEventResource> localizer, IStringLocalizer<Localization.AccountEventResource> localizer,
RingService.RingServiceClient pusher, RingService.RingServiceClient pusher,
SubscriptionService subscriptions,
Pass.Leveling.ExperienceService experienceService, Pass.Leveling.ExperienceService experienceService,
INatsConnection nats INatsConnection nats
) )
@@ -234,9 +231,6 @@ public class AccountEventService(
public async Task<bool> CheckInDailyDoAskCaptcha(SnAccount user) 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 cacheKey = $"{CaptchaCacheKey}{user.Id}";
var needsCaptcha = await cache.GetAsync<bool?>(cacheKey); var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
if (needsCaptcha is not null) if (needsCaptcha is not null)
@@ -426,22 +420,6 @@ public class AccountEventService(
CreatedAt = backdated ?? SystemClock.Instance.GetCurrentInstant(), 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); db.AccountCheckInResults.Add(result);
await db.SaveChangesAsync(); // Remember to save changes to the database await db.SaveChangesAsync(); // Remember to save changes to the database
if (result.RewardExperience is not null) if (result.RewardExperience is not null)

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -13,8 +12,7 @@ public class TokenAuthService(
IConfiguration config, IConfiguration config,
ICacheService cache, ICacheService cache,
ILogger<TokenAuthService> logger, ILogger<TokenAuthService> logger,
OidcProvider.Services.OidcProviderService oidc, OidcProvider.Services.OidcProviderService oidc
SubscriptionService subscriptions
) )
{ {
/// <summary> /// <summary>
@@ -116,18 +114,6 @@ public class TokenAuthService(
(session.UserAgent ?? string.Empty).Length (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( await cache.SetWithGroupsAsync(
cacheKey, cacheKey,
session, session,

View File

@@ -1,10 +1,9 @@
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Leveling; 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) 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, 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); db.ExperienceRecords.Add(record);
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View File

@@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -14,43 +13,6 @@ namespace DysonNetwork.Pass.Lotteries;
[Route("/api/lotteries")] [Route("/api/lotteries")]
public class LotteryController(AppDatabase db, LotteryService lotteryService) : ControllerBase 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] [HttpGet]
[Authorize] [Authorize]
public async Task<ActionResult<List<SnLottery>>> GetLotteries( public async Task<ActionResult<List<SnLottery>>> GetLotteries(

View File

@@ -1,9 +1,7 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Wallet;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NodaTime; using NodaTime;
using System.Text.Json;
namespace DysonNetwork.Pass.Lotteries; namespace DysonNetwork.Pass.Lotteries;
@@ -17,8 +15,6 @@ public class LotteryOrderMetaData
public class LotteryService( public class LotteryService(
AppDatabase db, AppDatabase db,
PaymentService paymentService,
WalletService walletService,
ILogger<LotteryService> logger) ILogger<LotteryService> logger)
{ {
private static bool ValidateNumbers(List<int> region1, int region2) private static bool ValidateNumbers(List<int> region1, int region2)
@@ -76,70 +72,6 @@ public class LotteryService(
return 10 + (multiplier - 1) * 10; 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) private static int CalculateReward(int region1Matches, bool region2Match)
{ {
var reward = region1Matches switch var reward = region1Matches switch
@@ -221,27 +153,13 @@ public class LotteryService(
if (reward > 0) if (reward > 0)
{ {
var wallet = await walletService.GetWalletAsync(ticket.AccountId); // Note: Prize awarding is now handled by the Wallet service
if (wallet != null) // The Wallet service will process lottery results and award prizes
{ logger.LogInformation(
await paymentService.CreateTransactionAsync( "Lottery prize of {Amount} to account {AccountId} for {Matches} matches needs to be awarded via Wallet service",
payerWalletId: null, reward, ticket.AccountId, region1Matches);
payeeWalletId: wallet.Id, totalPrizesAwarded++;
currency: WalletCurrency.SourcePoint, totalPrizeAmount += reward;
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);
}
} }
ticket.DrawStatus = LotteryDrawStatus.Drawn; ticket.DrawStatus = LotteryDrawStatus.Drawn;
@@ -271,4 +189,4 @@ public class LotteryService(
throw; throw;
} }
} }
} }

View File

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

View File

@@ -1,13 +1,11 @@
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Queue; using DysonNetwork.Shared.Queue;
using Google.Protobuf; using Google.Protobuf;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Client.JetStream.Models;
using NATS.Net; using NATS.Net;
using NodaTime; using NodaTime;
@@ -31,127 +29,7 @@ public class BroadcastEventHandler(
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
var paymentTask = HandlePaymentEventsAsync(stoppingToken); await HandleWebSocketEventsAsync(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);
}
}
} }
private async Task HandleWebSocketEventsAsync(CancellationToken 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.Account.Presences;
using DysonNetwork.Pass.Credit; using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Handlers; using DysonNetwork.Pass.Handlers;
using DysonNetwork.Pass.Wallet;
using Quartz; using Quartz;
namespace DysonNetwork.Pass.Startup; namespace DysonNetwork.Pass.Startup;
@@ -37,33 +36,6 @@ public static class ScheduledJobsConfiguration
.RepeatForever()) .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.AddJob<Lotteries.LotteryDrawJob>(opts => opts.WithIdentity("LotteryDraw"));
q.AddTrigger(opts => opts q.AddTrigger(opts => opts
.ForJob("LotteryDraw") .ForJob("LotteryDraw")

View File

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

View File

@@ -293,3 +293,41 @@ message TransferRequest {
message GetWalletFundRequest { message GetWalletFundRequest {
string fund_id = 1; string fund_id = 1;
} }
service SubscriptionService {
rpc GetSubscription(GetSubscriptionRequest) returns (Subscription);
rpc GetPerkSubscription(GetPerkSubscriptionRequest) returns (Subscription);
rpc GetPerkSubscriptions(GetPerkSubscriptionsRequest) returns (GetPerkSubscriptionsResponse);
rpc CreateSubscription(CreateSubscriptionRequest) returns (Subscription);
rpc CancelSubscription(CancelSubscriptionRequest) returns (Subscription);
}
message GetSubscriptionRequest {
string account_id = 1;
string identifier = 2;
}
message GetPerkSubscriptionRequest {
string account_id = 1;
}
message GetPerkSubscriptionsRequest {
repeated string account_ids = 1;
}
message GetPerkSubscriptionsResponse {
repeated Subscription subscriptions = 1;
}
message CreateSubscriptionRequest {
string account_id = 1;
string identifier = 2;
string payment_method = 3;
optional string coupon_code = 4;
bool is_free_trial = 5;
}
message CancelSubscriptionRequest {
string account_id = 1;
string identifier = 2;
}

View File

@@ -0,0 +1,142 @@
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
namespace DysonNetwork.Shared.Registry;
public class RemotePaymentService(DysonNetwork.Shared.Proto.PaymentService.PaymentServiceClient payment)
{
public async Task<DysonNetwork.Shared.Proto.Order> CreateOrder(
string currency,
string amount,
string? payeeWalletId = null,
TimeSpan? expiration = null,
string? appIdentifier = null,
string? productIdentifier = null,
byte[]? meta = null,
string? remarks = null,
bool reuseable = false)
{
var request = new DysonNetwork.Shared.Proto.CreateOrderRequest
{
Currency = currency,
Amount = amount,
Reuseable = reuseable
};
if (payeeWalletId != null)
request.PayeeWalletId = payeeWalletId;
if (expiration.HasValue)
request.Expiration = Duration.FromTimeSpan(expiration.Value);
if (appIdentifier != null)
request.AppIdentifier = appIdentifier;
if (productIdentifier != null)
request.ProductIdentifier = productIdentifier;
if (meta != null)
request.Meta = ByteString.CopyFrom(meta);
if (remarks != null)
request.Remarks = remarks;
var response = await payment.CreateOrderAsync(request);
return response;
}
public async Task<DysonNetwork.Shared.Proto.Transaction> CreateTransaction(
string? payerWalletId,
string? payeeWalletId,
string currency,
string amount,
string? remarks = null,
DysonNetwork.Shared.Proto.TransactionType type = DysonNetwork.Shared.Proto.TransactionType.Unspecified)
{
var request = new DysonNetwork.Shared.Proto.CreateTransactionRequest
{
Currency = currency,
Amount = amount,
Type = type
};
if (payerWalletId != null)
request.PayerWalletId = payerWalletId;
if (payeeWalletId != null)
request.PayeeWalletId = payeeWalletId;
if (remarks != null)
request.Remarks = remarks;
var response = await payment.CreateTransactionAsync(request);
return response;
}
public async Task<DysonNetwork.Shared.Proto.Transaction> CreateTransactionWithAccount(
string? payerAccountId,
string? payeeAccountId,
string currency,
string amount,
string? remarks = null,
DysonNetwork.Shared.Proto.TransactionType type = DysonNetwork.Shared.Proto.TransactionType.Unspecified)
{
var request = new DysonNetwork.Shared.Proto.CreateTransactionWithAccountRequest
{
Currency = currency,
Amount = amount,
Type = type
};
if (payerAccountId != null)
request.PayerAccountId = payerAccountId;
if (payeeAccountId != null)
request.PayeeAccountId = payeeAccountId;
if (remarks != null)
request.Remarks = remarks;
var response = await payment.CreateTransactionWithAccountAsync(request);
return response;
}
public async Task<DysonNetwork.Shared.Proto.Transaction> Transfer(
Guid payerAccountId,
Guid payeeAccountId,
string currency,
string amount)
{
var request = new DysonNetwork.Shared.Proto.TransferRequest
{
PayerAccountId = payerAccountId.ToString(),
PayeeAccountId = payeeAccountId.ToString(),
Currency = currency,
Amount = amount
};
var response = await payment.TransferAsync(request);
return response;
}
public async Task<DysonNetwork.Shared.Proto.Order> CancelOrder(string orderId)
{
var request = new DysonNetwork.Shared.Proto.CancelOrderRequest { OrderId = orderId };
var response = await payment.CancelOrderAsync(request);
return response;
}
public async Task<DysonNetwork.Shared.Proto.RefundOrderResponse> RefundOrder(string orderId)
{
var request = new DysonNetwork.Shared.Proto.RefundOrderRequest { OrderId = orderId };
var response = await payment.RefundOrderAsync(request);
return response;
}
public async Task<DysonNetwork.Shared.Proto.WalletFund> GetWalletFund(string fundId)
{
var request = new DysonNetwork.Shared.Proto.GetWalletFundRequest { FundId = fundId };
var response = await payment.GetWalletFundAsync(request);
return response;
}
}

View File

@@ -0,0 +1,65 @@
using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Shared.Registry;
public class RemoteSubscriptionService(SubscriptionService.SubscriptionServiceClient subscription)
{
public async Task<Subscription> GetSubscription(Guid accountId, string identifier)
{
var request = new GetSubscriptionRequest
{
AccountId = accountId.ToString(),
Identifier = identifier
};
var response = await subscription.GetSubscriptionAsync(request);
return response;
}
public async Task<Subscription> GetPerkSubscription(Guid accountId)
{
var request = new GetPerkSubscriptionRequest { AccountId = accountId.ToString() };
var response = await subscription.GetPerkSubscriptionAsync(request);
return response;
}
public async Task<List<Subscription>> GetPerkSubscriptions(List<Guid> accountIds)
{
var request = new GetPerkSubscriptionsRequest();
request.AccountIds.AddRange(accountIds.Select(id => id.ToString()));
var response = await subscription.GetPerkSubscriptionsAsync(request);
return response.Subscriptions.ToList();
}
public async Task<Subscription> CreateSubscription(
Guid accountId,
string identifier,
string paymentMethod,
string? couponCode = null,
bool isFreeTrial = false)
{
var request = new CreateSubscriptionRequest
{
AccountId = accountId.ToString(),
Identifier = identifier,
PaymentMethod = paymentMethod,
IsFreeTrial = isFreeTrial
};
if (couponCode != null)
request.CouponCode = couponCode;
var response = await subscription.CreateSubscriptionAsync(request);
return response;
}
public async Task<Subscription> CancelSubscription(Guid accountId, string identifier)
{
var request = new CancelSubscriptionRequest
{
AccountId = accountId.ToString(),
Identifier = identifier
};
var response = await subscription.CancelSubscriptionAsync(request);
return response;
}
}

View File

@@ -0,0 +1,37 @@
using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Shared.Registry;
public class RemoteWalletService(WalletService.WalletServiceClient wallet)
{
public async Task<Wallet> GetWallet(Guid accountId)
{
var request = new GetWalletRequest { AccountId = accountId.ToString() };
var response = await wallet.GetWalletAsync(request);
return response;
}
public async Task<Wallet> CreateWallet(Guid accountId)
{
var request = new CreateWalletRequest { AccountId = accountId.ToString() };
var response = await wallet.CreateWalletAsync(request);
return response;
}
public async Task<WalletPocket> GetOrCreateWalletPocket(Guid walletId, string currency, decimal? initialAmount = null)
{
var request = new GetOrCreateWalletPocketRequest
{
WalletId = walletId.ToString(),
Currency = currency
};
if (initialAmount.HasValue)
{
request.InitialAmount = initialAmount.Value.ToString();
}
var response = await wallet.GetOrCreateWalletPocketAsync(request);
return response;
}
}

View File

@@ -64,6 +64,26 @@ public static class ServiceInjectionHelper
return services; return services;
} }
public IServiceCollection AddWalletService()
{
services.AddGrpcClientWithSharedChannel<WalletService.WalletServiceClient>(
"https://_grpc.wallet",
"WalletService");
services.AddSingleton<RemoteWalletService>();
services.AddGrpcClientWithSharedChannel<PaymentService.PaymentServiceClient>(
"https://_grpc.wallet",
"PaymentService");
services.AddSingleton<RemotePaymentService>();
services.AddGrpcClientWithSharedChannel<SubscriptionService.SubscriptionServiceClient>(
"https://_grpc.wallet",
"SubscriptionService");
services.AddSingleton<RemoteSubscriptionService>();
return services;
}
public IServiceCollection AddDriveService() public IServiceCollection AddDriveService()
{ {
services.AddGrpcClientWithSharedChannel<FileService.FileServiceClient>( services.AddGrpcClientWithSharedChannel<FileService.FileServiceClient>(

View File

@@ -51,32 +51,13 @@ public class AppDatabase(
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<SnPermissionGroupMember>() // Ignore account-related entities that belong to Pass project
.HasKey(pg => new { pg.GroupId, pg.Actor }); // These are referenced via navigation properties but tables are in Pass database
modelBuilder.Entity<SnPermissionGroupMember>() modelBuilder.Ignore<SnAccount>();
.HasOne(pg => pg.Group) modelBuilder.Ignore<SnAccountProfile>();
.WithMany(g => g.Members) modelBuilder.Ignore<SnPermissionGroupMember>();
.HasForeignKey(pg => pg.GroupId) modelBuilder.Ignore<SnAccountRelationship>();
.OnDelete(DeleteBehavior.Cascade); modelBuilder.Ignore<SnRealmMember>();
modelBuilder.Entity<SnAccountRelationship>()
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
modelBuilder.Entity<SnAccountRelationship>()
.HasOne(r => r.Account)
.WithMany(a => a.OutgoingRelationships)
.HasForeignKey(r => r.AccountId);
modelBuilder.Entity<SnAccountRelationship>()
.HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId);
modelBuilder.Entity<SnRealmMember>()
.HasKey(pm => new { pm.RealmId, pm.AccountId });
modelBuilder.Entity<SnRealmMember>()
.HasOne(pm => pm.Realm)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.ApplySoftDeleteFilters(); modelBuilder.ApplySoftDeleteFilters();
} }

View File

@@ -0,0 +1,5 @@
namespace DysonNetwork.Wallet.Localization;
public class NotificationResource
{
}

View File

@@ -1,6 +1,6 @@
using Quartz; using Quartz;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Wallet.Payment;
public class FundExpirationJob( public class FundExpirationJob(
PaymentService paymentService, PaymentService paymentService,

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Quartz; using Quartz;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Wallet.Payment;
public class GiftCleanupJob( public class GiftCleanupJob(
AppDatabase db, AppDatabase db,

View File

@@ -1,20 +1,22 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Wallet.Payment;
[ApiController] [ApiController]
[Route("/api/orders")] [Route("/api/orders")]
public class OrderController( public class OrderController(
PaymentService payment, PaymentService payment,
Pass.Auth.AuthService auth,
AppDatabase db, AppDatabase db,
CustomAppService.CustomAppServiceClient customApps IGrpcClientFactory<CustomAppService.CustomAppServiceClient> customAppsFactory
) : ControllerBase ) : ControllerBase
{ {
private readonly CustomAppService.CustomAppServiceClient _customApps = customAppsFactory.CreateClient();
public class CreateOrderRequest public class CreateOrderRequest
{ {
public string Currency { get; set; } = null!; public string Currency { get; set; } = null!;
@@ -31,11 +33,11 @@ public class OrderController(
[HttpPost] [HttpPost]
public async Task<ActionResult<SnWalletOrder>> CreateOrder([FromBody] CreateOrderRequest request) public async Task<ActionResult<SnWalletOrder>> CreateOrder([FromBody] CreateOrderRequest request)
{ {
var clientResp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId }); var clientResp = await _customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId });
if (clientResp.App is null) return BadRequest("Client not found"); if (clientResp.App is null) return BadRequest("Client not found");
var client = SnCustomApp.FromProtoValue(clientResp.App); var client = SnCustomApp.FromProtoValue(clientResp.App);
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest var secret = await _customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
{ {
AppId = client.Id.ToString(), AppId = client.Id.ToString(),
Secret = request.ClientSecret, Secret = request.ClientSecret,
@@ -78,10 +80,6 @@ public class OrderController(
{ {
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); 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 try
{ {
// Get the wallet for the current user // Get the wallet for the current user
@@ -109,11 +107,11 @@ public class OrderController(
[HttpPatch("{id:guid}/status")] [HttpPatch("{id:guid}/status")]
public async Task<ActionResult<SnWalletOrder>> UpdateOrderStatus(Guid id, [FromBody] UpdateOrderStatusRequest request) public async Task<ActionResult<SnWalletOrder>> UpdateOrderStatus(Guid id, [FromBody] UpdateOrderStatusRequest request)
{ {
var clientResp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId }); var clientResp = await _customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId });
if (clientResp.App is null) return BadRequest("Client not found"); if (clientResp.App is null) return BadRequest("Client not found");
var client = SnCustomApp.FromProtoValue(clientResp.App); var client = SnCustomApp.FromProtoValue(clientResp.App);
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest var secret = await _customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
{ {
AppId = client.Id.ToString(), AppId = client.Id.ToString(),
Secret = request.ClientSecret, Secret = request.ClientSecret,
@@ -140,4 +138,3 @@ public class OrderController(
return Ok(order); return Ok(order);
} }
} }

View File

@@ -4,7 +4,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using NodaTime; using NodaTime;
namespace DysonNetwork.Pass.Wallet.PaymentHandlers; namespace DysonNetwork.Wallet.Payment.PaymentHandlers;
public class AfdianPaymentHandler( public class AfdianPaymentHandler(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
@@ -40,7 +40,7 @@ public class AfdianPaymentHandler(
{ {
try try
{ {
var token = _configuration["Payment:Auth:Afdian"] ?? "_:_"; var token = _configuration["Payment:Auth:Afdian"] ?? "_:";
var tokenParts = token.Split(':'); var tokenParts = token.Split(':');
var userId = tokenParts[0]; var userId = tokenParts[0];
token = tokenParts[1]; token = tokenParts[1];
@@ -96,7 +96,7 @@ public class AfdianPaymentHandler(
try try
{ {
var token = _configuration["Payment:Auth:Afdian"] ?? "_:_"; var token = _configuration["Payment:Auth:Afdian"] ?? "_:";
var tokenParts = token.Split(':'); var tokenParts = token.Split(':');
var userId = tokenParts[0]; var userId = tokenParts[0];
token = tokenParts[1]; token = tokenParts[1];
@@ -165,7 +165,7 @@ public class AfdianPaymentHandler(
// Join the order IDs with commas as specified in the API documentation // Join the order IDs with commas as specified in the API documentation
var orderIdsParam = string.Join(",", orders); var orderIdsParam = string.Join(",", orders);
var token = _configuration["Payment:Auth:Afdian"] ?? "_:_"; var token = _configuration["Payment:Auth:Afdian"] ?? "_:";
var tokenParts = token.Split(':'); var tokenParts = token.Split(':');
var userId = tokenParts[0]; var userId = tokenParts[0];
token = tokenParts[1]; token = tokenParts[1];

View File

@@ -1,6 +1,6 @@
using NodaTime; using NodaTime;
namespace DysonNetwork.Pass.Wallet.PaymentHandlers; namespace DysonNetwork.Wallet.Payment.PaymentHandlers;
public interface ISubscriptionOrder public interface ISubscriptionOrder
{ {

View File

@@ -1,26 +1,28 @@
using System.Data; using System.Data;
using System.Globalization; using System.Globalization;
using DysonNetwork.Pass.Localization; using DysonNetwork.Wallet.Localization;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Queue; using DysonNetwork.Shared.Queue;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Net; using NATS.Net;
using NodaTime; using NodaTime;
using AccountService = DysonNetwork.Pass.Account.AccountService;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Wallet.Payment;
public class PaymentService( public class PaymentService(
AppDatabase db, AppDatabase db,
WalletService wat, WalletService wat,
RingService.RingServiceClient pusher, IGrpcClientFactory<RingService.RingServiceClient> pusherFactory,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
INatsConnection nats INatsConnection nats
) )
{ {
private readonly RingService.RingServiceClient _pusher = pusherFactory.CreateClient();
public async Task<SnWalletOrder> CreateOrderAsync( public async Task<SnWalletOrder> CreateOrderAsync(
Guid? payeeWalletId, Guid? payeeWalletId,
string currency, string currency,
@@ -178,21 +180,14 @@ public class PaymentService(
{ {
if (payerWallet is not null) 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 // Due to ID is uuid, it longer than 8 words for sure
var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8]; var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8];
var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}"; var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}";
await pusher.SendPushNotificationToUserAsync( await _pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest new SendPushNotificationToUserRequest
{ {
UserId = account.Id.ToString(), UserId = payerWallet.AccountId.ToString(),
Notification = new PushNotification Notification = new PushNotification
{ {
Topic = "wallets.transactions", Topic = "wallets.transactions",
@@ -212,21 +207,14 @@ public class PaymentService(
if (payeeWallet is not null) 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 // Due to ID is uuid, it longer than 8 words for sure
var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8]; var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8];
var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}"; var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}";
await pusher.SendPushNotificationToUserAsync( await _pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest new SendPushNotificationToUserRequest
{ {
UserId = account.Id.ToString(), UserId = payeeWallet.AccountId.ToString(),
Notification = new PushNotification Notification = new PushNotification
{ {
Topic = "wallets.transactions", Topic = "wallets.transactions",
@@ -322,22 +310,15 @@ public class PaymentService(
{ {
if (payerWallet is not null) 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 // Due to ID is uuid, it longer than 8 words for sure
var readableOrderId = order.Id.ToString().Replace("-", "")[..8]; var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}"; var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
await pusher.SendPushNotificationToUserAsync( await _pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest new SendPushNotificationToUserRequest
{ {
UserId = account.Id.ToString(), UserId = payerWallet.AccountId.ToString(),
Notification = new PushNotification Notification = new PushNotification
{ {
Topic = "wallets.orders.paid", Topic = "wallets.orders.paid",
@@ -353,21 +334,14 @@ public class PaymentService(
if (payeeWallet is not null) 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 // Due to ID is uuid, it longer than 8 words for sure
var readableOrderId = order.Id.ToString().Replace("-", "")[..8]; var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}"; var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
await pusher.SendPushNotificationToUserAsync( await _pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest new SendPushNotificationToUserRequest
{ {
UserId = account.Id.ToString(), UserId = payeeWallet.AccountId.ToString(),
Notification = new PushNotification Notification = new PushNotification
{ {
Topic = "wallets.orders.received", Topic = "wallets.orders.received",
@@ -893,4 +867,4 @@ public class CurrencySummary
public decimal Income { get; set; } public decimal Income { get; set; }
public decimal Spending { get; set; } public decimal Spending { get; set; }
public decimal Net { get; set; } public decimal Net { get; set; }
} }

View File

@@ -3,7 +3,7 @@ using DysonNetwork.Shared.Proto;
using Grpc.Core; using Grpc.Core;
using NodaTime; using NodaTime;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Wallet.Payment;
public class PaymentServiceGrpc(PaymentService paymentService) public class PaymentServiceGrpc(PaymentService paymentService)
: Shared.Proto.PaymentService.PaymentServiceBase : Shared.Proto.PaymentService.PaymentServiceBase

View File

@@ -3,10 +3,10 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Wallet.PaymentHandlers; using DysonNetwork.Wallet.Payment.PaymentHandlers;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Wallet.Payment;
[ApiController] [ApiController]
[Route("/api/subscriptions")] [Route("/api/subscriptions")]
@@ -178,4 +178,4 @@ public class SubscriptionController(SubscriptionService subscriptions, AfdianPay
return Ok(response); return Ok(response);
} }
} }

View File

@@ -5,7 +5,7 @@ using NodaTime;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Wallet.Payment;
[ApiController] [ApiController]
[Route("/api/subscriptions/gifts")] [Route("/api/subscriptions/gifts")]
@@ -74,9 +74,9 @@ public class SubscriptionGiftController(
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var gift = await db.WalletGifts var gift = await db.WalletGifts
.Include(g => g.Gifter).ThenInclude(a => a.Profile) .Include(g => g.Gifter).ThenInclude(a => a!.Profile)
.Include(g => g.Recipient).ThenInclude(a => a.Profile) .Include(g => g.Recipient).ThenInclude(a => a!.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile) .Include(g => g.Redeemer).ThenInclude(a => a!.Profile)
.Include(g => g.Subscription) .Include(g => g.Subscription)
.Include(g => g.Coupon) .Include(g => g.Coupon)
.FirstOrDefaultAsync(g => g.Id == giftId); .FirstOrDefaultAsync(g => g.Id == giftId);

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Quartz; using Quartz;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Wallet.Payment;
public class SubscriptionRenewalJob( public class SubscriptionRenewalJob(
AppDatabase db, AppDatabase db,
@@ -136,4 +136,4 @@ public class SubscriptionRenewalJob(
"Completed subscription renewal job. Processed: {ProcessedCount}, Renewed: {RenewedCount}, Failed: {FailedCount}", "Completed subscription renewal job. Processed: {ProcessedCount}, Renewed: {RenewedCount}, Failed: {FailedCount}",
processedCount, renewedCount, failedCount); processedCount, renewedCount, failedCount);
} }
} }

View File

@@ -1,29 +1,33 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Pass.Localization; using DysonNetwork.Wallet.Localization;
using DysonNetwork.Pass.Wallet.PaymentHandlers; using DysonNetwork.Wallet.Payment.PaymentHandlers;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
using Duration = NodaTime.Duration; using Duration = NodaTime.Duration;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Wallet.Payment;
public class SubscriptionService( public class SubscriptionService(
AppDatabase db, AppDatabase db,
PaymentService payment, PaymentService payment,
Account.AccountService accounts, IGrpcClientFactory<AccountService.AccountServiceClient> accountsFactory,
RingService.RingServiceClient pusher, IGrpcClientFactory<RingService.RingServiceClient> pusherFactory,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
IConfiguration configuration, IConfiguration configuration,
ICacheService cache, ICacheService cache,
ILogger<SubscriptionService> logger ILogger<SubscriptionService> logger
) )
{ {
private readonly AccountService.AccountServiceClient _accounts = accountsFactory.CreateClient();
private readonly RingService.RingServiceClient _pusher = pusherFactory.CreateClient();
public async Task<SnWalletSubscription> CreateSubscriptionAsync( public async Task<SnWalletSubscription> CreateSubscriptionAsync(
SnAccount account, SnAccount account,
string identifier, string identifier,
@@ -58,11 +62,7 @@ public class SubscriptionService(
if (existingSubscription is not null) if (existingSubscription is not null)
return existingSubscription; return existingSubscription;
// Batch database queries for account profile and coupon to reduce round trips // Batch database queries for coupon and free trial check
var accountProfileTask = subscriptionInfo.RequiredLevel > 0
? db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == account.Id)
: Task.FromResult((Shared.Models.SnAccountProfile?)null);
var prevFreeTrialTask = isFreeTrial var prevFreeTrialTask = isFreeTrial
? db.WalletSubscriptions.FirstOrDefaultAsync(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial) ? db.WalletSubscriptions.FirstOrDefaultAsync(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
: Task.FromResult((SnWalletSubscription?)null); : Task.FromResult((SnWalletSubscription?)null);
@@ -74,12 +74,10 @@ public class SubscriptionService(
: Task.FromResult((SnWalletCoupon?)null); : Task.FromResult((SnWalletCoupon?)null);
// Await batched queries // Await batched queries
var profile = await accountProfileTask;
var prevFreeTrial = await prevFreeTrialTask; var prevFreeTrial = await prevFreeTrialTask;
var couponData = await couponTask; var couponData = await couponTask;
// Validation checks // Validation checks
if (isFreeTrial && prevFreeTrial != null) if (isFreeTrial && prevFreeTrial != null)
throw new InvalidOperationException("Free trial already exists."); throw new InvalidOperationException("Free trial already exists.");
@@ -139,9 +137,21 @@ public class SubscriptionService(
SnAccount? account = null; SnAccount? account = null;
if (!string.IsNullOrEmpty(provider)) if (!string.IsNullOrEmpty(provider))
account = await accounts.LookupAccountByConnection(order.AccountId, provider); {
// Use GetAccount instead of LookupAccountByConnection since that method may not exist
if (Guid.TryParse(order.AccountId, out var accountId))
{
var accountProto = await _accounts.GetAccountAsync(new GetAccountRequest { Id = accountId.ToString() });
if (accountProto != null)
account = SnAccount.FromProtoValue(accountProto);
}
}
else if (Guid.TryParse(order.AccountId, out var accountId)) else if (Guid.TryParse(order.AccountId, out var accountId))
account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId); {
var accountProto = await _accounts.GetAccountAsync(new GetAccountRequest { Id = accountId.ToString() });
if (accountProto != null)
account = SnAccount.FromProtoValue(accountProto);
}
if (account is null) if (account is null)
throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}"); throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}");
@@ -250,14 +260,6 @@ public class SubscriptionService(
: null; : null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found."); 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( return await payment.CreateOrderAsync(
null, null,
subscriptionInfo.Currency, subscriptionInfo.Currency,
@@ -412,11 +414,6 @@ public class SubscriptionService(
private async Task NotifySubscriptionBegun(SnWalletSubscription subscription) 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 = var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable) SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable ? humanReadable
@@ -436,10 +433,10 @@ public class SubscriptionService(
}), }),
IsSavable = true IsSavable = true
}; };
await pusher.SendPushNotificationToUserAsync( await _pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest new SendPushNotificationToUserRequest
{ {
UserId = account.Id.ToString(), UserId = subscription.AccountId.ToString(),
Notification = notification Notification = notification
} }
); );
@@ -601,10 +598,9 @@ public class SubscriptionService(
SnAccount? recipient = null; SnAccount? recipient = null;
if (recipientId.HasValue) if (recipientId.HasValue)
{ {
recipient = await db.Accounts var accountProto = await _accounts.GetAccountAsync(new GetAccountRequest { Id = recipientId.Value.ToString() });
.Where(a => a.Id == recipientId.Value) if (accountProto != null)
.Include(a => a.Profile) recipient = SnAccount.FromProtoValue(accountProto);
.FirstOrDefaultAsync();
if (recipient is null) if (recipient is null)
throw new ArgumentOutOfRangeException(nameof(recipientId), "Recipient account not found."); throw new ArgumentOutOfRangeException(nameof(recipientId), "Recipient account not found.");
} }
@@ -748,8 +744,7 @@ public class SubscriptionService(
} }
if (gift.GifterId == redeemer.Id) return (gift, sameTypeSubscription); if (gift.GifterId == redeemer.Id) return (gift, sameTypeSubscription);
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId); await NotifyGiftClaimedByRecipient(gift, sameTypeSubscription, gift.GifterId, redeemer);
if (gifter != null) await NotifyGiftClaimedByRecipient(gift, sameTypeSubscription, gifter, redeemer);
return (gift, sameTypeSubscription); return (gift, sameTypeSubscription);
} }
@@ -815,13 +810,7 @@ public class SubscriptionService(
// Send notification to gifter if different from redeemer // Send notification to gifter if different from redeemer
if (gift.GifterId == redeemer.Id) return (gift, subscription); if (gift.GifterId == redeemer.Id) return (gift, subscription);
{ await NotifyGiftClaimedByRecipient(gift, subscription, gift.GifterId, redeemer);
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId);
if (gifter != null)
{
await NotifyGiftClaimedByRecipient(gift, subscription, gifter, redeemer);
}
}
return (gift, subscription); return (gift, subscription);
} }
@@ -832,9 +821,6 @@ public class SubscriptionService(
public async Task<SnWalletGift?> GetGiftByCodeAsync(string giftCode) public async Task<SnWalletGift?> GetGiftByCodeAsync(string giftCode)
{ {
return await db.WalletGifts 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) .Include(g => g.Coupon)
.FirstOrDefaultAsync(g => g.GiftCode == giftCode); .FirstOrDefaultAsync(g => g.GiftCode == giftCode);
} }
@@ -846,9 +832,6 @@ public class SubscriptionService(
public async Task<List<SnWalletGift>> GetGiftsByGifterAsync(Guid gifterId) public async Task<List<SnWalletGift>> GetGiftsByGifterAsync(Guid gifterId)
{ {
return await db.WalletGifts 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) .Where(g => g.GifterId == gifterId && g.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
.OrderByDescending(g => g.CreatedAt) .OrderByDescending(g => g.CreatedAt)
.ToListAsync(); .ToListAsync();
@@ -857,9 +840,6 @@ public class SubscriptionService(
public async Task<List<SnWalletGift>> GetGiftsByRecipientAsync(Guid recipientId) public async Task<List<SnWalletGift>> GetGiftsByRecipientAsync(Guid recipientId)
{ {
return await db.WalletGifts 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)) .Where(g => g.RecipientId == recipientId || (g.IsOpenGift && g.RedeemerId == recipientId))
.OrderByDescending(g => g.CreatedAt) .OrderByDescending(g => g.CreatedAt)
.ToListAsync(); .ToListAsync();
@@ -933,10 +913,8 @@ public class SubscriptionService(
return new string(result); return new string(result);
} }
private async Task NotifyGiftClaimedByRecipient(SnWalletGift gift, SnWalletSubscription subscription, SnAccount gifter, SnAccount redeemer) private async Task NotifyGiftClaimedByRecipient(SnWalletGift gift, SnWalletSubscription subscription, Guid gifterId, SnAccount redeemer)
{ {
Account.AccountService.SetCultureInfo(gifter);
var humanReadableName = var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable) SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable ? humanReadable
@@ -956,10 +934,10 @@ public class SubscriptionService(
IsSavable = true IsSavable = true
}; };
await pusher.SendPushNotificationToUserAsync( await _pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest new SendPushNotificationToUserRequest
{ {
UserId = gifter.Id.ToString(), UserId = gifterId.ToString(),
Notification = notification Notification = notification
} }
); );

View File

@@ -1,6 +1,4 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
@@ -9,7 +7,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Wallet.Payment;
[ApiController] [ApiController]
[Route("/api/wallets")] [Route("/api/wallets")]
@@ -17,7 +15,6 @@ public class WalletController(
AppDatabase db, AppDatabase db,
WalletService ws, WalletService ws,
PaymentService payment, PaymentService payment,
AuthService auth,
ICacheService cache, ICacheService cache,
ILogger<WalletController> logger ILogger<WalletController> logger
) : ControllerBase ) : ControllerBase
@@ -139,11 +136,9 @@ public class WalletController(
.Skip(offset) .Skip(offset)
.Take(take) .Take(take)
.Include(t => t.PayerWallet) .Include(t => t.PayerWallet)
.ThenInclude(w => w.Account) .ThenInclude(w => w!.Account)
.ThenInclude(w => w.Profile)
.Include(t => t.PayeeWallet) .Include(t => t.PayeeWallet)
.ThenInclude(w => w.Account) .ThenInclude(w => w!.Account)
.ThenInclude(w => w.Profile)
.ToListAsync(); .ToListAsync();
return Ok(transactions); return Ok(transactions);
@@ -228,10 +223,6 @@ public class WalletController(
{ {
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); 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."); if (currentUser.Id == request.PayeeAccountId) return BadRequest("Cannot transfer to yourself.");
try try
@@ -260,7 +251,6 @@ public class WalletController(
[Required] public FundSplitType SplitType { get; set; } [Required] public FundSplitType SplitType { get; set; }
public string? Message { get; set; } public string? Message { get; set; }
public int? ExpirationHours { get; set; } // Optional: hours until expiration public int? ExpirationHours { get; set; } // Optional: hours until expiration
[Required] public string PinCode { get; set; } = null!; // Required PIN for fund creation
} }
[HttpPost("funds")] [HttpPost("funds")]
@@ -269,10 +259,6 @@ public class WalletController(
{ {
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); 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 try
{ {
Duration? expiration = null; Duration? expiration = null;
@@ -313,9 +299,7 @@ public class WalletController(
var query = db.WalletFunds var query = db.WalletFunds
.Include(f => f.Recipients) .Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount) .ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount) .Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.Where(f => f.CreatorAccountId == currentUser.Id || .Where(f => f.CreatorAccountId == currentUser.Id ||
f.Recipients.Any(r => r.RecipientAccountId == currentUser.Id)) f.Recipients.Any(r => r.RecipientAccountId == currentUser.Id))
.AsQueryable(); .AsQueryable();
@@ -343,9 +327,7 @@ public class WalletController(
var fund = await db.WalletFunds var fund = await db.WalletFunds
.Include(f => f.Recipients) .Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount) .ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount) .Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.FirstOrDefaultAsync(f => f.Id == id); .FirstOrDefaultAsync(f => f.Id == id);
if (fund is null) if (fund is null)
@@ -362,12 +344,12 @@ public class WalletController(
try try
{ {
var transaction = await payment.ReceiveFundAsync( var walletTransaction = await payment.ReceiveFundAsync(
recipientAccountId: currentUser.Id, recipientAccountId: currentUser.Id,
fundId: id fundId: id
); );
return Ok(transaction); return Ok(walletTransaction);
} }
catch (Exception err) catch (Exception err)
{ {

View File

@@ -1,7 +1,7 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Wallet.Payment;
public class WalletService(AppDatabase db) public class WalletService(AppDatabase db)
{ {
@@ -47,4 +47,4 @@ public class WalletService(AppDatabase db)
db.WalletPockets.Add(pocket); db.WalletPockets.Add(pocket);
return (pocket, true); return (pocket, true);
} }
} }

View File

@@ -1,7 +1,7 @@
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Grpc.Core; using Grpc.Core;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Wallet.Payment;
public class WalletServiceGrpc(WalletService walletService) : Shared.Proto.WalletService.WalletServiceBase public class WalletServiceGrpc(WalletService walletService) : Shared.Proto.WalletService.WalletServiceBase
{ {

View File

@@ -1,5 +1,6 @@
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Networking; using DysonNetwork.Shared.Networking;
using DysonNetwork.Wallet.Payment;
namespace DysonNetwork.Wallet.Startup; namespace DysonNetwork.Wallet.Startup;
@@ -25,6 +26,8 @@ public static class ApplicationConfiguration
public static WebApplication ConfigureGrpcServices(this WebApplication app) public static WebApplication ConfigureGrpcServices(this WebApplication app)
{ {
app.MapGrpcService<WalletServiceGrpc>();
app.MapGrpcService<PaymentServiceGrpc>();
app.MapGrpcReflectionService(); app.MapGrpcReflectionService();
return app; return app;

View File

@@ -1,4 +1,14 @@
using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Queue;
using DysonNetwork.Wallet.Payment;
using Google.Protobuf;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Client.JetStream.Models;
using NATS.Net;
using NodaTime;
namespace DysonNetwork.Wallet.Startup; namespace DysonNetwork.Wallet.Startup;
@@ -8,8 +18,103 @@ public class BroadcastEventHandler(
IServiceProvider serviceProvider IServiceProvider serviceProvider
) : BackgroundService ) : BackgroundService
{ {
protected override Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
return Task.CompletedTask; var paymentTask = HandlePaymentEventsAsync(stoppingToken);
await Task.WhenAll(paymentTask);
}
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("wallet_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<Payment.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<Payment.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
{
// Not a subscription or gift 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);
}
}
} }
} }

View File

@@ -1,4 +1,5 @@
using Quartz; using Quartz;
using DysonNetwork.Wallet.Payment;
namespace DysonNetwork.Wallet.Startup; namespace DysonNetwork.Wallet.Startup;
@@ -13,6 +14,33 @@ public static class ScheduledJobsConfiguration
.ForJob("AppDatabaseRecycling") .ForJob("AppDatabaseRecycling")
.WithIdentity("AppDatabaseRecyclingTrigger") .WithIdentity("AppDatabaseRecyclingTrigger")
.WithCronSchedule("0 0 0 * * ?")); .WithCronSchedule("0 0 0 * * ?"));
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())
);
}); });
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -7,6 +7,9 @@ using System.Text.Json.Serialization;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Geometry; using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Wallet.Localization;
using DysonNetwork.Wallet.Payment;
using DysonNetwork.Wallet.Payment.PaymentHandlers;
namespace DysonNetwork.Wallet.Startup; namespace DysonNetwork.Wallet.Startup;
@@ -39,6 +42,10 @@ public static class ServiceCollectionExtensions
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
}).AddDataAnnotationsLocalization(options =>
{
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(NotificationResource));
}); });
services.AddRazorPages(); services.AddRazorPages();
@@ -82,6 +89,12 @@ public static class ServiceCollectionExtensions
services.Configure<GeoOptions>(configuration.GetSection("GeoIP")); services.Configure<GeoOptions>(configuration.GetSection("GeoIP"));
services.AddScoped<GeoService>(); services.AddScoped<GeoService>();
// Register Wallet services
services.AddScoped<WalletService>();
services.AddScoped<PaymentService>();
services.AddScoped<SubscriptionService>();
services.AddScoped<AfdianPaymentHandler>();
services.AddHostedService<BroadcastEventHandler>(); services.AddHostedService<BroadcastEventHandler>();
return services; return services;

View File

@@ -1,9 +1,52 @@
{ {
"Logging": { "Debug": true,
"LogLevel": { "BaseUrl": "http://localhost:8009",
"Default": "Information", "Logging": {
"Microsoft.AspNetCore": "Warning" "LogLevel": {
} "Default": "Information",
}, "Microsoft.AspNetCore": "Warning"
"AllowedHosts": "*" }
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_wallet;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"Authentication": {
"Schemes": {
"Bearer": {
"ValidAudiences": [
"http://localhost:5071",
"https://localhost:7099"
],
"ValidIssuer": "solar-network"
}
}
},
"AuthToken": {
"CookieDomain": "localhost",
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"Payment": {
"Auth": {
"Afdian": "user_id:token"
},
"Subscriptions": {
"Afdian": {
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
}
}
},
"Cache": {
"Serializer": "JSON"
},
"KnownProxies": [
"127.0.0.1",
"::1"
]
} }