♻️ 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: |
files="${{ steps.changed-files.outputs.files }}"
matrix="{\"include\":[]}"
services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight" "Zone" "Messager")
images=("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" "wallet")
changed_services=()
for file in $files; do

View File

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

View File

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

View File

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

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

@@ -293,3 +293,41 @@ message TransferRequest {
message GetWalletFundRequest {
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;
}
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()
{
services.AddGrpcClientWithSharedChannel<FileService.FileServiceClient>(

View File

@@ -51,32 +51,13 @@ public class AppDatabase(
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<SnPermissionGroupMember>()
.HasKey(pg => new { pg.GroupId, pg.Actor });
modelBuilder.Entity<SnPermissionGroupMember>()
.HasOne(pg => pg.Group)
.WithMany(g => g.Members)
.HasForeignKey(pg => pg.GroupId)
.OnDelete(DeleteBehavior.Cascade);
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);
// Ignore account-related entities that belong to Pass project
// These are referenced via navigation properties but tables are in Pass database
modelBuilder.Ignore<SnAccount>();
modelBuilder.Ignore<SnAccountProfile>();
modelBuilder.Ignore<SnPermissionGroupMember>();
modelBuilder.Ignore<SnAccountRelationship>();
modelBuilder.Ignore<SnRealmMember>();
modelBuilder.ApplySoftDeleteFilters();
}

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,22 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Wallet;
namespace DysonNetwork.Wallet.Payment;
[ApiController]
[Route("/api/orders")]
public class OrderController(
PaymentService payment,
Pass.Auth.AuthService auth,
AppDatabase db,
CustomAppService.CustomAppServiceClient customApps
IGrpcClientFactory<CustomAppService.CustomAppServiceClient> customAppsFactory
) : ControllerBase
{
private readonly CustomAppService.CustomAppServiceClient _customApps = customAppsFactory.CreateClient();
public class CreateOrderRequest
{
public string Currency { get; set; } = null!;
@@ -31,11 +33,11 @@ public class OrderController(
[HttpPost]
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");
var client = SnCustomApp.FromProtoValue(clientResp.App);
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
var secret = await _customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
{
AppId = client.Id.ToString(),
Secret = request.ClientSecret,
@@ -78,10 +80,6 @@ public class OrderController(
{
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
@@ -109,11 +107,11 @@ public class OrderController(
[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 });
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
var secret = await _customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
{
AppId = client.Id.ToString(),
Secret = request.ClientSecret,
@@ -140,4 +138,3 @@ public class OrderController(
return Ok(order);
}
}

View File

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

View File

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

View File

@@ -1,26 +1,28 @@
using System.Data;
using System.Globalization;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Wallet.Localization;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Queue;
using DysonNetwork.Shared.Registry;
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;
namespace DysonNetwork.Wallet.Payment;
public class PaymentService(
AppDatabase db,
WalletService wat,
RingService.RingServiceClient pusher,
IGrpcClientFactory<RingService.RingServiceClient> pusherFactory,
IStringLocalizer<NotificationResource> localizer,
INatsConnection nats
)
{
private readonly RingService.RingServiceClient _pusher = pusherFactory.CreateClient();
public async Task<SnWalletOrder> CreateOrderAsync(
Guid? payeeWalletId,
string currency,
@@ -178,21 +180,14 @@ public class PaymentService(
{
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(
await _pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
UserId = payerWallet.AccountId.ToString(),
Notification = new PushNotification
{
Topic = "wallets.transactions",
@@ -212,21 +207,14 @@ public class PaymentService(
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(
await _pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
UserId = payeeWallet.AccountId.ToString(),
Notification = new PushNotification
{
Topic = "wallets.transactions",
@@ -322,22 +310,15 @@ public class PaymentService(
{
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(
await _pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
UserId = payerWallet.AccountId.ToString(),
Notification = new PushNotification
{
Topic = "wallets.orders.paid",
@@ -353,21 +334,14 @@ public class PaymentService(
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(
await _pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
UserId = payeeWallet.AccountId.ToString(),
Notification = new PushNotification
{
Topic = "wallets.orders.received",
@@ -893,4 +867,4 @@ public class CurrencySummary
public decimal Income { get; set; }
public decimal Spending { get; set; }
public decimal Net { get; set; }
}
}

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ using NodaTime;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Wallet;
namespace DysonNetwork.Wallet.Payment;
[ApiController]
[Route("/api/subscriptions/gifts")]
@@ -74,9 +74,9 @@ public class SubscriptionGiftController(
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.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);

View File

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

View File

@@ -1,29 +1,33 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Wallet.PaymentHandlers;
using DysonNetwork.Wallet.Localization;
using DysonNetwork.Wallet.Payment.PaymentHandlers;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
using Duration = NodaTime.Duration;
namespace DysonNetwork.Pass.Wallet;
namespace DysonNetwork.Wallet.Payment;
public class SubscriptionService(
AppDatabase db,
PaymentService payment,
Account.AccountService accounts,
RingService.RingServiceClient pusher,
IGrpcClientFactory<AccountService.AccountServiceClient> accountsFactory,
IGrpcClientFactory<RingService.RingServiceClient> pusherFactory,
IStringLocalizer<NotificationResource> localizer,
IConfiguration configuration,
ICacheService cache,
ILogger<SubscriptionService> logger
)
{
private readonly AccountService.AccountServiceClient _accounts = accountsFactory.CreateClient();
private readonly RingService.RingServiceClient _pusher = pusherFactory.CreateClient();
public async Task<SnWalletSubscription> CreateSubscriptionAsync(
SnAccount account,
string identifier,
@@ -58,11 +62,7 @@ public class SubscriptionService(
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);
// Batch database queries for coupon and free trial check
var prevFreeTrialTask = isFreeTrial
? db.WalletSubscriptions.FirstOrDefaultAsync(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
: Task.FromResult((SnWalletSubscription?)null);
@@ -74,12 +74,10 @@ public class SubscriptionService(
: 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.");
@@ -139,9 +137,21 @@ public class SubscriptionService(
SnAccount? account = null;
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))
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)
throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}");
@@ -250,14 +260,6 @@ public class SubscriptionService(
: 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,
@@ -412,11 +414,6 @@ public class SubscriptionService(
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
@@ -436,10 +433,10 @@ public class SubscriptionService(
}),
IsSavable = true
};
await pusher.SendPushNotificationToUserAsync(
await _pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
UserId = subscription.AccountId.ToString(),
Notification = notification
}
);
@@ -601,10 +598,9 @@ public class SubscriptionService(
SnAccount? recipient = null;
if (recipientId.HasValue)
{
recipient = await db.Accounts
.Where(a => a.Id == recipientId.Value)
.Include(a => a.Profile)
.FirstOrDefaultAsync();
var accountProto = await _accounts.GetAccountAsync(new GetAccountRequest { Id = recipientId.Value.ToString() });
if (accountProto != null)
recipient = SnAccount.FromProtoValue(accountProto);
if (recipient is null)
throw new ArgumentOutOfRangeException(nameof(recipientId), "Recipient account not found.");
}
@@ -748,8 +744,7 @@ public class SubscriptionService(
}
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);
await NotifyGiftClaimedByRecipient(gift, sameTypeSubscription, gift.GifterId, redeemer);
return (gift, sameTypeSubscription);
}
@@ -815,13 +810,7 @@ public class SubscriptionService(
// 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);
}
}
await NotifyGiftClaimedByRecipient(gift, subscription, gift.GifterId, redeemer);
return (gift, subscription);
}
@@ -832,9 +821,6 @@ public class SubscriptionService(
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);
}
@@ -846,9 +832,6 @@ public class SubscriptionService(
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();
@@ -857,9 +840,6 @@ public class SubscriptionService(
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();
@@ -933,10 +913,8 @@ public class SubscriptionService(
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 =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
@@ -956,10 +934,10 @@ public class SubscriptionService(
IsSavable = true
};
await pusher.SendPushNotificationToUserAsync(
await _pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = gifter.Id.ToString(),
UserId = gifterId.ToString(),
Notification = notification
}
);

View File

@@ -1,6 +1,4 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
@@ -9,7 +7,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Wallet;
namespace DysonNetwork.Wallet.Payment;
[ApiController]
[Route("/api/wallets")]
@@ -17,7 +15,6 @@ public class WalletController(
AppDatabase db,
WalletService ws,
PaymentService payment,
AuthService auth,
ICacheService cache,
ILogger<WalletController> logger
) : ControllerBase
@@ -139,11 +136,9 @@ public class WalletController(
.Skip(offset)
.Take(take)
.Include(t => t.PayerWallet)
.ThenInclude(w => w.Account)
.ThenInclude(w => w.Profile)
.ThenInclude(w => w!.Account)
.Include(t => t.PayeeWallet)
.ThenInclude(w => w.Account)
.ThenInclude(w => w.Profile)
.ThenInclude(w => w!.Account)
.ToListAsync();
return Ok(transactions);
@@ -228,10 +223,6 @@ public class WalletController(
{
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
@@ -260,7 +251,6 @@ public class WalletController(
[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")]
@@ -269,10 +259,6 @@ public class WalletController(
{
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;
@@ -313,9 +299,7 @@ public class WalletController(
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();
@@ -343,9 +327,7 @@ public class WalletController(
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)
@@ -362,12 +344,12 @@ public class WalletController(
try
{
var transaction = await payment.ReceiveFundAsync(
var walletTransaction = await payment.ReceiveFundAsync(
recipientAccountId: currentUser.Id,
fundId: id
);
return Ok(transaction);
return Ok(walletTransaction);
}
catch (Exception err)
{

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Networking;
using DysonNetwork.Wallet.Payment;
namespace DysonNetwork.Wallet.Startup;
@@ -25,6 +26,8 @@ public static class ApplicationConfiguration
public static WebApplication ConfigureGrpcServices(this WebApplication app)
{
app.MapGrpcService<WalletServiceGrpc>();
app.MapGrpcService<PaymentServiceGrpc>();
app.MapGrpcReflectionService();
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.JetStream.Models;
using NATS.Net;
using NodaTime;
namespace DysonNetwork.Wallet.Startup;
@@ -8,8 +18,103 @@ public class BroadcastEventHandler(
IServiceProvider serviceProvider
) : 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 DysonNetwork.Wallet.Payment;
namespace DysonNetwork.Wallet.Startup;
@@ -13,6 +14,33 @@ public static class ScheduledJobsConfiguration
.ForJob("AppDatabaseRecycling")
.WithIdentity("AppDatabaseRecyclingTrigger")
.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);

View File

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

View File

@@ -1,9 +1,52 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"Debug": true,
"BaseUrl": "http://localhost:8009",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"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"
]
}