♻️ Splitted wallet service
This commit is contained in:
4
.github/workflows/docker-build.yml
vendored
4
.github/workflows/docker-build.yml
vendored
@@ -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
|
||||
|
||||
@@ -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++)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
142
DysonNetwork.Shared/Registry/RemotePaymentService.cs
Normal file
142
DysonNetwork.Shared/Registry/RemotePaymentService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
65
DysonNetwork.Shared/Registry/RemoteSubscriptionService.cs
Normal file
65
DysonNetwork.Shared/Registry/RemoteSubscriptionService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
37
DysonNetwork.Shared/Registry/RemoteWalletService.cs
Normal file
37
DysonNetwork.Shared/Registry/RemoteWalletService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
5
DysonNetwork.Wallet/Localization/NotificationResource.cs
Normal file
5
DysonNetwork.Wallet/Localization/NotificationResource.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace DysonNetwork.Wallet.Localization;
|
||||
|
||||
public class NotificationResource
|
||||
{
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
namespace DysonNetwork.Wallet.Payment;
|
||||
|
||||
public class FundExpirationJob(
|
||||
PaymentService paymentService,
|
||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
namespace DysonNetwork.Wallet.Payment;
|
||||
|
||||
public class GiftCleanupJob(
|
||||
AppDatabase db,
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
@@ -1,6 +1,6 @@
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet.PaymentHandlers;
|
||||
namespace DysonNetwork.Wallet.Payment.PaymentHandlers;
|
||||
|
||||
public interface ISubscriptionOrder
|
||||
{
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
@@ -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)
|
||||
{
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user