✨ Afdian as payment handler
This commit is contained in:
parent
a23338c263
commit
be0b48cfd9
@ -57,6 +57,15 @@ public class AccountService(
|
|||||||
return contact?.Account;
|
return contact?.Account;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Account?> LookupAccountByConnection(string identifier, string provider)
|
||||||
|
{
|
||||||
|
var connection = await db.AccountConnections
|
||||||
|
.Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider)
|
||||||
|
.Include(c => c.Account)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
return connection?.Account;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<int?> GetAccountLevel(Guid accountId)
|
public async Task<int?> GetAccountLevel(Guid accountId)
|
||||||
{
|
{
|
||||||
var profile = await db.AccountProfiles
|
var profile = await db.AccountProfiles
|
||||||
|
@ -25,6 +25,7 @@ using StackExchange.Redis;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using DysonNetwork.Sphere.Connection.WebReader;
|
using DysonNetwork.Sphere.Connection.WebReader;
|
||||||
|
using DysonNetwork.Sphere.Wallet.PaymentHandlers;
|
||||||
using tusdotnet.Stores;
|
using tusdotnet.Stores;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Startup;
|
namespace DysonNetwork.Sphere.Startup;
|
||||||
@ -222,6 +223,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<PaymentService>();
|
services.AddScoped<PaymentService>();
|
||||||
services.AddScoped<IRealtimeService, LivekitRealtimeService>();
|
services.AddScoped<IRealtimeService, LivekitRealtimeService>();
|
||||||
services.AddScoped<WebReaderService>();
|
services.AddScoped<WebReaderService>();
|
||||||
|
services.AddScoped<AfdianPaymentHandler>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,420 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Wallet.PaymentHandlers;
|
||||||
|
|
||||||
|
public class AfdianPaymentHandler(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger<AfdianPaymentHandler> logger,
|
||||||
|
IConfiguration configuration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory =
|
||||||
|
httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||||
|
|
||||||
|
private readonly ILogger<AfdianPaymentHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
|
||||||
|
private readonly IConfiguration _configuration =
|
||||||
|
configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||||
|
|
||||||
|
private string CalculateSign(string token, string userId, string paramsJson, long ts)
|
||||||
|
{
|
||||||
|
var kvString = $"{token}params{paramsJson}ts{ts}user_id{userId}";
|
||||||
|
using (var md5 = MD5.Create())
|
||||||
|
{
|
||||||
|
var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(kvString));
|
||||||
|
return BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OrderResponse?> ListOrderAsync(int page = 1)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userId = "abc"; // Replace with your actual USER_ID
|
||||||
|
var token = _configuration["Payment:Auth:Afdian"] ?? "<token here>";
|
||||||
|
var paramsJson = JsonConvert.SerializeObject(new { page });
|
||||||
|
var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1))
|
||||||
|
.TotalSeconds; // Current timestamp in seconds
|
||||||
|
|
||||||
|
var sign = CalculateSign(token, userId, paramsJson, ts);
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
|
||||||
|
{
|
||||||
|
Content = new StringContent(JsonConvert.SerializeObject(new
|
||||||
|
{
|
||||||
|
user_id = userId,
|
||||||
|
@params = paramsJson,
|
||||||
|
ts,
|
||||||
|
sign
|
||||||
|
}), Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
$"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = JsonConvert.DeserializeObject<OrderResponse>(await response.Content.ReadAsStringAsync());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching orders");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a specific order by its ID (out_trade_no)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="orderId">The order ID to query</param>
|
||||||
|
/// <returns>The order item if found, otherwise null</returns>
|
||||||
|
public async Task<OrderItem?> GetOrderAsync(string orderId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(orderId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Order ID cannot be null or empty");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userId = "abc"; // Replace with your actual USER_ID
|
||||||
|
var token = _configuration["Payment:Auth:Afdian"] ?? "<token here>";
|
||||||
|
var paramsJson = JsonConvert.SerializeObject(new { out_trade_no = orderId });
|
||||||
|
var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1))
|
||||||
|
.TotalSeconds; // Current timestamp in seconds
|
||||||
|
|
||||||
|
var sign = CalculateSign(token, userId, paramsJson, ts);
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
|
||||||
|
{
|
||||||
|
Content = new StringContent(JsonConvert.SerializeObject(new
|
||||||
|
{
|
||||||
|
user_id = userId,
|
||||||
|
@params = paramsJson,
|
||||||
|
ts,
|
||||||
|
sign
|
||||||
|
}), Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
$"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = JsonConvert.DeserializeObject<OrderResponse>(await response.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
// Check if we have a valid response and orders in the list
|
||||||
|
if (result?.Data?.Orders == null || result.Data.Orders.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning($"No order found with ID: {orderId}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since we're querying by a specific order ID, we should only get one result
|
||||||
|
return result.Data.Orders.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error fetching order with ID: {orderId}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get multiple orders by their IDs (out_trade_no)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="orderIds">A collection of order IDs to query</param>
|
||||||
|
/// <returns>A list of found order items</returns>
|
||||||
|
public async Task<List<OrderItem>> GetOrders(IEnumerable<string> orderIds)
|
||||||
|
{
|
||||||
|
if (orderIds == null || !orderIds.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Order IDs cannot be null or empty");
|
||||||
|
return new List<OrderItem>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Join the order IDs with commas as specified in the API documentation
|
||||||
|
var orderIdsParam = string.Join(",", orderIds);
|
||||||
|
|
||||||
|
var userId = "abc"; // Replace with your actual USER_ID
|
||||||
|
var token = _configuration["Payment:Auth:Afdian"] ?? "<token here>";
|
||||||
|
var paramsJson = JsonConvert.SerializeObject(new { out_trade_no = orderIdsParam });
|
||||||
|
var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1))
|
||||||
|
.TotalSeconds; // Current timestamp in seconds
|
||||||
|
|
||||||
|
var sign = CalculateSign(token, userId, paramsJson, ts);
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
|
||||||
|
{
|
||||||
|
Content = new StringContent(JsonConvert.SerializeObject(new
|
||||||
|
{
|
||||||
|
user_id = userId,
|
||||||
|
@params = paramsJson,
|
||||||
|
ts,
|
||||||
|
sign
|
||||||
|
}), Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
$"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}");
|
||||||
|
return new List<OrderItem>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = JsonConvert.DeserializeObject<OrderResponse>(await response.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
// Check if we have a valid response and orders in the list
|
||||||
|
if (result?.Data?.Orders == null || result.Data.Orders.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning($"No orders found with IDs: {orderIdsParam}");
|
||||||
|
return new List<OrderItem>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Data.Orders;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error fetching orders");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handle an incoming webhook from Afdian's payment platform
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The HTTP request containing webhook data</param>
|
||||||
|
/// <param name="processOrderAction">An action to process the received order</param>
|
||||||
|
/// <returns>A WebhookResponse object to be returned to Afdian</returns>
|
||||||
|
public async Task<WebhookResponse> HandleWebhook(HttpRequest request, Func<WebhookOrderData, Task>? processOrderAction)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Read the request body
|
||||||
|
string requestBody;
|
||||||
|
using (var reader = new StreamReader(request.Body, Encoding.UTF8))
|
||||||
|
{
|
||||||
|
requestBody = await reader.ReadToEndAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(requestBody))
|
||||||
|
{
|
||||||
|
_logger.LogError("Webhook request body is empty");
|
||||||
|
return new WebhookResponse { ErrorCode = 400, ErrorMessage = "Empty request body" };
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation($"Received webhook: {requestBody}");
|
||||||
|
|
||||||
|
// Parse the webhook data
|
||||||
|
var webhook = JsonConvert.DeserializeObject<WebhookRequest>(requestBody);
|
||||||
|
|
||||||
|
if (webhook == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to parse webhook data");
|
||||||
|
return new WebhookResponse { ErrorCode = 400, ErrorMessage = "Invalid webhook data" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the webhook type
|
||||||
|
if (webhook.Data.Type != "order")
|
||||||
|
{
|
||||||
|
_logger.LogWarning($"Unsupported webhook type: {webhook.Data.Type}");
|
||||||
|
return new WebhookResponse { ErrorCode = 200, ErrorMessage = "Unsupported type, but acknowledged" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the order
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check for duplicate order processing by storing processed order IDs
|
||||||
|
// (You would implement a more permanent storage mechanism for production)
|
||||||
|
if (processOrderAction != null)
|
||||||
|
await processOrderAction(webhook.Data);
|
||||||
|
else
|
||||||
|
_logger.LogInformation($"Order received but no processing action provided: {webhook.Data.Order.TradeNumber}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error processing order {webhook.Data.Order.TradeNumber}");
|
||||||
|
// Still returning success to Afdian to prevent repeated callbacks
|
||||||
|
// Your system should handle the error internally
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success response to Afdian
|
||||||
|
return new WebhookResponse { ErrorCode = 200, ErrorMessage = "" };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error handling webhook");
|
||||||
|
return new WebhookResponse { ErrorCode = 500, ErrorMessage = "Internal server error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetSubscriptionPlanId(string subscriptionKey)
|
||||||
|
{
|
||||||
|
var planId = _configuration[$"Payment:Subscriptions:Afdian:{subscriptionKey}"];
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(planId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning($"Unknown subscription key: {subscriptionKey}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return planId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderResponse
|
||||||
|
{
|
||||||
|
[JsonProperty("ec")] public int ErrorCode { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("em")] public string ErrorMessage { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("data")] public OrderData Data { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderData
|
||||||
|
{
|
||||||
|
[JsonProperty("list")] public List<OrderItem> Orders { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("total_count")] public int TotalCount { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("total_page")] public int TotalPages { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("request")] public RequestDetails Request { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderItem : ISubscriptionOrder
|
||||||
|
{
|
||||||
|
[JsonProperty("out_trade_no")] public string TradeNumber { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("user_id")] public string UserId { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("plan_id")] public string PlanId { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("month")] public int Months { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("total_amount")] public string TotalAmount { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("show_amount")] public string ShowAmount { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("status")] public int Status { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("remark")] public string Remark { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("redeem_id")] public string RedeemId { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("product_type")] public int ProductType { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("discount")] public string Discount { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("sku_detail")] public List<object> SkuDetail { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("create_time")] public long CreateTime { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("user_name")] public string UserName { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("plan_title")] public string PlanTitle { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("user_private_id")] public string UserPrivateId { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("address_person")] public string AddressPerson { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("address_phone")] public string AddressPhone { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("address_address")] public string AddressAddress { get; set; } = null!;
|
||||||
|
|
||||||
|
public Instant BegunAt => Instant.FromUnixTimeSeconds(CreateTime);
|
||||||
|
|
||||||
|
public Duration Duration => Duration.FromDays(Months * 30);
|
||||||
|
|
||||||
|
public string Provider => "afdian";
|
||||||
|
|
||||||
|
public string Id => TradeNumber;
|
||||||
|
|
||||||
|
public string SubscriptionId => PlanId;
|
||||||
|
|
||||||
|
public string AccountId => UserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RequestDetails
|
||||||
|
{
|
||||||
|
[JsonProperty("user_id")] public string UserId { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("params")] public string Params { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("ts")] public long Timestamp { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("sign")] public string Sign { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request structure for Afdian webhook
|
||||||
|
/// </summary>
|
||||||
|
public class WebhookRequest
|
||||||
|
{
|
||||||
|
[JsonProperty("ec")] public int ErrorCode { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("em")] public string ErrorMessage { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("data")] public WebhookOrderData Data { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Order data contained in the webhook
|
||||||
|
/// </summary>
|
||||||
|
public class WebhookOrderData
|
||||||
|
{
|
||||||
|
[JsonProperty("type")] public string Type { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("order")] public WebhookOrderDetails Order { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Order details in the webhook
|
||||||
|
/// </summary>
|
||||||
|
public class WebhookOrderDetails : OrderItem
|
||||||
|
{
|
||||||
|
[JsonProperty("custom_order_id")] public string CustomOrderId { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response structure to acknowledge webhook receipt
|
||||||
|
/// </summary>
|
||||||
|
public class WebhookResponse
|
||||||
|
{
|
||||||
|
[JsonProperty("ec")] public int ErrorCode { get; set; } = 200;
|
||||||
|
|
||||||
|
[JsonProperty("em")] public string ErrorMessage { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SKU detail item
|
||||||
|
/// </summary>
|
||||||
|
public class SkuDetailItem
|
||||||
|
{
|
||||||
|
[JsonProperty("sku_id")] public string SkuId { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("count")] public int Count { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("name")] public string Name { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("album_id")] public string AlbumId { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonProperty("pic")] public string Picture { get; set; } = null!;
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Wallet.PaymentHandlers;
|
||||||
|
|
||||||
|
public interface ISubscriptionOrder
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string SubscriptionId { get; }
|
||||||
|
|
||||||
|
public Instant BegunAt { get; }
|
||||||
|
|
||||||
|
public Duration Duration { get; }
|
||||||
|
|
||||||
|
public string Provider { get; }
|
||||||
|
|
||||||
|
public string AccountId { get; }
|
||||||
|
}
|
@ -3,12 +3,13 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Sphere.Wallet.PaymentHandlers;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Wallet;
|
namespace DysonNetwork.Sphere.Wallet;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/subscriptions")]
|
[Route("/subscriptions")]
|
||||||
public class SubscriptionController(SubscriptionService subscriptions, AppDatabase db) : ControllerBase
|
public class SubscriptionController(SubscriptionService subscriptions, AfdianPaymentHandler afdian, AppDatabase db) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@ -172,4 +173,31 @@ public class SubscriptionController(SubscriptionService subscriptions, AppDataba
|
|||||||
return BadRequest(ex.Message);
|
return BadRequest(ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class RestorePurchaseRequest
|
||||||
|
{
|
||||||
|
[Required] public string OrderId { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("order/restore/afdian")]
|
||||||
|
public async Task<IActionResult> RestorePurchaseFromAfdian([FromBody] RestorePurchaseRequest request)
|
||||||
|
{
|
||||||
|
var order = await afdian.GetOrderAsync(request.OrderId);
|
||||||
|
if (order is null) return NotFound($"Order with ID {request.OrderId} was not found.");
|
||||||
|
|
||||||
|
var subscription = await subscriptions.CreateSubscriptionFromOrder(order);
|
||||||
|
return Ok(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("order/handle/afdian")]
|
||||||
|
public async Task<IActionResult> AfdianWebhook()
|
||||||
|
{
|
||||||
|
var response = await afdian.HandleWebhook(Request, async (webhookData) =>
|
||||||
|
{
|
||||||
|
var order = webhookData.Order;
|
||||||
|
await subscriptions.CreateSubscriptionFromOrder(order);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
}
|
}
|
@ -152,16 +152,15 @@ public class SubscriptionRenewalJob(
|
|||||||
var validSubscriptions = await db.WalletSubscriptions
|
var validSubscriptions = await db.WalletSubscriptions
|
||||||
.Where(s => membershipIds.Contains(s.Id))
|
.Where(s => membershipIds.Contains(s.Id))
|
||||||
.Where(s => s.IsActive)
|
.Where(s => s.IsActive)
|
||||||
.Where(s => !s.EndedAt.HasValue || s.EndedAt.Value > now)
|
|
||||||
.Select(s => s.Id)
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
var validSubscriptionsId = validSubscriptions
|
||||||
|
.Where(s => s.IsAvailable)
|
||||||
|
.Select(s => s.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
// Identify accounts that need updating (membership expired or not in validSubscriptions)
|
// Identify accounts that need updating (membership expired or not in validSubscriptions)
|
||||||
var accountIdsToUpdate = accountsWithMemberships
|
var accountIdsToUpdate = accountsWithMemberships
|
||||||
.Where(a => a.StellarMembership != null && (
|
.Where(a => a.StellarMembership != null && !validSubscriptionsId.Contains(a.StellarMembership.Id))
|
||||||
(a.StellarMembership.EndedAt.HasValue && a.StellarMembership.EndedAt.Value <= now) ||
|
|
||||||
!validSubscriptions.Contains(a.StellarMembership.Id)
|
|
||||||
))
|
|
||||||
.Select(a => a.Id)
|
.Select(a => a.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
@ -1,11 +1,19 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using DysonNetwork.Sphere.Account;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Sphere.Storage;
|
||||||
|
using DysonNetwork.Sphere.Wallet.PaymentHandlers;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Wallet;
|
namespace DysonNetwork.Sphere.Wallet;
|
||||||
|
|
||||||
public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheService cache)
|
public class SubscriptionService(
|
||||||
|
AppDatabase db,
|
||||||
|
PaymentService payment,
|
||||||
|
AccountService accounts,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ICacheService cache
|
||||||
|
)
|
||||||
{
|
{
|
||||||
public async Task<Subscription> CreateSubscriptionAsync(
|
public async Task<Subscription> CreateSubscriptionAsync(
|
||||||
Account.Account account,
|
Account.Account account,
|
||||||
@ -77,6 +85,85 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS
|
|||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Subscription> CreateSubscriptionFromOrder(ISubscriptionOrder order)
|
||||||
|
{
|
||||||
|
var cfgSection = configuration.GetSection("Payment:Subscriptions");
|
||||||
|
var provider = order.Provider;
|
||||||
|
|
||||||
|
var currency = "irl";
|
||||||
|
var subscriptionIdentifier = order.SubscriptionId;
|
||||||
|
switch (provider)
|
||||||
|
{
|
||||||
|
case "afdian":
|
||||||
|
var afdianPlans = cfgSection.GetValue<Dictionary<string, string>>("Afdian");
|
||||||
|
var afdianPlan = afdianPlans?.FirstOrDefault(p => p.Value == subscriptionIdentifier);
|
||||||
|
if (afdianPlan?.Key is not null) subscriptionIdentifier = afdianPlan.Value.Key;
|
||||||
|
currency = "cny";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscriptionTemplate = SubscriptionTypeData
|
||||||
|
.SubscriptionDict.TryGetValue(subscriptionIdentifier, out var template)
|
||||||
|
? template
|
||||||
|
: null;
|
||||||
|
if (subscriptionTemplate is null)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier), $@"Subscription {subscriptionIdentifier} was not found.");
|
||||||
|
|
||||||
|
Account.Account? account = null;
|
||||||
|
if (!string.IsNullOrEmpty(provider))
|
||||||
|
account = await accounts.LookupAccountByConnection(order.AccountId, provider);
|
||||||
|
else if (Guid.TryParse(order.AccountId, out var accountId))
|
||||||
|
account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId);
|
||||||
|
|
||||||
|
if (account is null)
|
||||||
|
throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}");
|
||||||
|
|
||||||
|
var cycleDuration = order.Duration;
|
||||||
|
|
||||||
|
var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionIdentifier);
|
||||||
|
if (existingSubscription is not null && existingSubscription.PaymentMethod != provider)
|
||||||
|
throw new InvalidOperationException($"Active subscription with identifier {subscriptionIdentifier} already exists.");
|
||||||
|
if (existingSubscription?.PaymentDetails.OrderId == order.Id)
|
||||||
|
return existingSubscription;
|
||||||
|
if (existingSubscription is not null)
|
||||||
|
{
|
||||||
|
// Same provider, but different order, renew the subscription
|
||||||
|
existingSubscription.PaymentDetails.OrderId = order.Id;
|
||||||
|
existingSubscription.EndedAt = order.BegunAt.Plus(cycleDuration);
|
||||||
|
existingSubscription.RenewalAt = order.BegunAt.Plus(cycleDuration);
|
||||||
|
existingSubscription.Status = SubscriptionStatus.Paid;
|
||||||
|
|
||||||
|
db.Update(existingSubscription);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return existingSubscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
BegunAt = order.BegunAt,
|
||||||
|
EndedAt = order.BegunAt.Plus(cycleDuration),
|
||||||
|
IsActive = true,
|
||||||
|
Status = SubscriptionStatus.Unpaid,
|
||||||
|
PaymentMethod = provider,
|
||||||
|
PaymentDetails = new PaymentDetails
|
||||||
|
{
|
||||||
|
Currency = currency,
|
||||||
|
OrderId = order.Id,
|
||||||
|
},
|
||||||
|
BasePrice = subscriptionTemplate.BasePrice,
|
||||||
|
RenewalAt = order.BegunAt.Plus(cycleDuration),
|
||||||
|
AccountId = account.Id,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.WalletSubscriptions.Add(subscription);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cancel the renewal of the current activated subscription.
|
/// Cancel the renewal of the current activated subscription.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -218,6 +305,8 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS
|
|||||||
|
|
||||||
private const string SubscriptionCacheKeyPrefix = "subscription:";
|
private const string SubscriptionCacheKeyPrefix = "subscription:";
|
||||||
|
|
||||||
|
public AccountService Accounts { get; } = accounts;
|
||||||
|
|
||||||
public async Task<Subscription?> GetSubscriptionAsync(Guid accountId, string identifier)
|
public async Task<Subscription?> GetSubscriptionAsync(Guid accountId, string identifier)
|
||||||
{
|
{
|
||||||
// Create a unique cache key for this subscription
|
// Create a unique cache key for this subscription
|
||||||
|
@ -101,6 +101,18 @@
|
|||||||
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
|
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Payment": {
|
||||||
|
"Auth": {
|
||||||
|
"Afdian": "<token here>"
|
||||||
|
},
|
||||||
|
"Subscriptions": {
|
||||||
|
"Afdian": {
|
||||||
|
"solian.stellar.primary": "7d17aae23c9611f0b5705254001e7c00",
|
||||||
|
"solian.stellar.nova": "7dfae4743c9611f0b3a55254001e7c00",
|
||||||
|
"solian.stellar.supernova": "141713ee3d6211f085b352540025c377"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"KnownProxies": [
|
"KnownProxies": [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"::1"
|
"::1"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user