✨ Subscription gifts
This commit is contained in:
@@ -58,6 +58,185 @@ public record class SubscriptionTypeData(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a gifted subscription that can be claimed by another user.
|
||||
/// Support both direct gifts (to specific users) and open gifts (anyone can redeem via link/code).
|
||||
/// </summary>
|
||||
[Index(nameof(GiftCode))]
|
||||
[Index(nameof(GifterId))]
|
||||
[Index(nameof(RecipientId))]
|
||||
public class SnWalletGift : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The user who purchased/gave the gift.
|
||||
/// </summary>
|
||||
public Guid GifterId { get; set; }
|
||||
public SnAccount Gifter { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The intended recipient. Null for open gifts that anyone can redeem.
|
||||
/// </summary>
|
||||
public Guid? RecipientId { get; set; }
|
||||
public SnAccount? Recipient { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique redemption code/link identifier for the gift.
|
||||
/// </summary>
|
||||
[MaxLength(128)]
|
||||
public string GiftCode { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom message from the gifter.
|
||||
/// </summary>
|
||||
[MaxLength(1000)]
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The subscription type being gifted.
|
||||
/// </summary>
|
||||
[MaxLength(4096)]
|
||||
public string SubscriptionIdentifier { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The original price before any discounts.
|
||||
/// </summary>
|
||||
public decimal BasePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The final price paid after discounts.
|
||||
/// </summary>
|
||||
public decimal FinalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status of the gift.
|
||||
/// </summary>
|
||||
public GiftStatus Status { get; set; } = GiftStatus.Created;
|
||||
|
||||
/// <summary>
|
||||
/// When the gift was redeemed. Null if not yet redeemed.
|
||||
/// </summary>
|
||||
public Instant? RedeemedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The user who redeemed the gift (if different from recipient).
|
||||
/// </summary>
|
||||
public Guid? RedeemerId { get; set; }
|
||||
public SnAccount? Redeemer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The subscription created when the gift is redeemed.
|
||||
/// </summary>
|
||||
public SnWalletSubscription? Subscription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the gift expires and can no longer be redeemed.
|
||||
/// </summary>
|
||||
public Instant ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this gift can be redeemed by anyone (open gift) or only the specified recipient.
|
||||
/// </summary>
|
||||
public bool IsOpenGift { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Payment method used by the gifter.
|
||||
/// </summary>
|
||||
[MaxLength(4096)]
|
||||
public string PaymentMethod { get; set; } = null!;
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public SnPaymentDetails PaymentDetails { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Coupon used for the gift purchase.
|
||||
/// </summary>
|
||||
public Guid? CouponId { get; set; }
|
||||
public SnWalletCoupon? Coupon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the gift can still be redeemed.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public bool IsRedeemable
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Status != GiftStatus.Sent) return false;
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
return now <= ExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the gift has expired.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public bool IsExpired
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Status == GiftStatus.Redeemed || Status == GiftStatus.Cancelled) return false;
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
return now > ExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Uncomment once protobuf files are regenerated
|
||||
/*
|
||||
public Proto.Gift ToProtoValue() => new()
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
GifterId = GifterId.ToString(),
|
||||
RecipientId = RecipientId?.ToString(),
|
||||
GiftCode = GiftCode,
|
||||
Message = Message,
|
||||
SubscriptionIdentifier = SubscriptionIdentifier,
|
||||
BasePrice = BasePrice.ToString(CultureInfo.InvariantCulture),
|
||||
FinalPrice = FinalPrice.ToString(CultureInfo.InvariantCulture),
|
||||
Status = (Proto.GiftStatus)Status,
|
||||
RedeemedAt = RedeemedAt?.ToTimestamp(),
|
||||
RedeemerId = RedeemerId?.ToString(),
|
||||
SubscriptionId = SubscriptionId?.ToString(),
|
||||
ExpiresAt = ExpiresAt.ToTimestamp(),
|
||||
IsOpenGift = IsOpenGift,
|
||||
PaymentMethod = PaymentMethod,
|
||||
PaymentDetails = PaymentDetails.ToProtoValue(),
|
||||
CouponId = CouponId?.ToString(),
|
||||
Coupon = Coupon?.ToProtoValue(),
|
||||
IsRedeemable = IsRedeemable,
|
||||
IsExpired = IsExpired,
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
|
||||
public static SnWalletGift FromProtoValue(Proto.Gift proto) => new()
|
||||
{
|
||||
Id = Guid.Parse(proto.Id),
|
||||
GifterId = Guid.Parse(proto.GifterId),
|
||||
RecipientId = proto.HasRecipientId ? Guid.Parse(proto.RecipientId) : null,
|
||||
GiftCode = proto.GiftCode,
|
||||
Message = proto.Message,
|
||||
SubscriptionIdentifier = proto.SubscriptionIdentifier,
|
||||
BasePrice = decimal.Parse(proto.BasePrice),
|
||||
FinalPrice = decimal.Parse(proto.FinalPrice),
|
||||
Status = (GiftStatus)proto.Status,
|
||||
RedeemedAt = proto.RedeemedAt?.ToInstant(),
|
||||
RedeemerId = proto.HasRedeemerId ? Guid.Parse(proto.RedeemerId) : null,
|
||||
SubscriptionId = proto.HasSubscriptionId ? Guid.Parse(proto.SubscriptionId) : null,
|
||||
ExpiresAt = proto.ExpiresAt.ToInstant(),
|
||||
IsOpenGift = proto.IsOpenGift,
|
||||
PaymentMethod = proto.PaymentMethod,
|
||||
PaymentDetails = SnPaymentDetails.FromProtoValue(proto.PaymentDetails),
|
||||
CouponId = proto.HasCouponId ? Guid.Parse(proto.CouponId) : null,
|
||||
Coupon = proto.Coupon is not null ? SnWalletCoupon.FromProtoValue(proto.Coupon) : null,
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||
};
|
||||
*/
|
||||
}
|
||||
|
||||
public abstract class SubscriptionType
|
||||
{
|
||||
/// <summary>
|
||||
@@ -99,11 +278,24 @@ public enum SubscriptionStatus
|
||||
Cancelled
|
||||
}
|
||||
|
||||
public enum GiftStatus
|
||||
{
|
||||
Created = 0,
|
||||
Sent = 1,
|
||||
Redeemed = 2,
|
||||
Expired = 3,
|
||||
Cancelled = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The subscription is for the Stellar Program in most cases.
|
||||
/// The paid subscription in another word.
|
||||
/// </summary>
|
||||
[Index(nameof(Identifier))]
|
||||
[Index(nameof(AccountId))]
|
||||
[Index(nameof(Status))]
|
||||
[Index(nameof(AccountId), nameof(Identifier))]
|
||||
[Index(nameof(AccountId), nameof(IsActive))]
|
||||
public class SnWalletSubscription : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
@@ -142,40 +334,51 @@ public class SnWalletSubscription : ModelBase
|
||||
public Guid AccountId { get; set; }
|
||||
public SnAccount Account { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// If this subscription was redeemed from a gift, this references the gift record.
|
||||
/// </summary>
|
||||
public Guid? GiftId { get; set; }
|
||||
public SnWalletGift? Gift { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public bool IsAvailable
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!IsActive) return false;
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
if (BegunAt > now) return false;
|
||||
if (EndedAt.HasValue && now > EndedAt.Value) return false;
|
||||
if (RenewalAt.HasValue && now > RenewalAt.Value) return false;
|
||||
if (Status != SubscriptionStatus.Active) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
get => IsAvailableAt(SystemClock.Instance.GetCurrentInstant());
|
||||
}
|
||||
|
||||
[NotMapped]
|
||||
public decimal FinalPrice
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsFreeTrial) return 0;
|
||||
if (Coupon == null) return BasePrice;
|
||||
get => CalculateFinalPriceAt(SystemClock.Instance.GetCurrentInstant());
|
||||
}
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (Coupon.AffectedAt.HasValue && now < Coupon.AffectedAt.Value ||
|
||||
Coupon.ExpiredAt.HasValue && now > Coupon.ExpiredAt.Value) return BasePrice;
|
||||
/// <summary>
|
||||
/// Optimized method to check availability at a specific instant (avoids repeated SystemClock calls).
|
||||
/// </summary>
|
||||
public bool IsAvailableAt(Instant currentInstant)
|
||||
{
|
||||
if (!IsActive) return false;
|
||||
if (BegunAt > currentInstant) return false;
|
||||
if (EndedAt.HasValue && currentInstant > EndedAt.Value) return false;
|
||||
if (RenewalAt.HasValue && currentInstant > RenewalAt.Value) return false;
|
||||
if (Status != SubscriptionStatus.Active) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Coupon.DiscountAmount.HasValue) return BasePrice - Coupon.DiscountAmount.Value;
|
||||
if (Coupon.DiscountRate.HasValue) return BasePrice * (decimal)(1 - Coupon.DiscountRate.Value);
|
||||
return BasePrice;
|
||||
}
|
||||
/// <summary>
|
||||
/// Optimized method to calculate final price at a specific instant (avoids repeated SystemClock calls).
|
||||
/// </summary>
|
||||
public decimal CalculateFinalPriceAt(Instant currentInstant)
|
||||
{
|
||||
if (IsFreeTrial) return 0;
|
||||
if (Coupon == null) return BasePrice;
|
||||
|
||||
if (Coupon.AffectedAt.HasValue && currentInstant < Coupon.AffectedAt.Value ||
|
||||
Coupon.ExpiredAt.HasValue && currentInstant > Coupon.ExpiredAt.Value) return BasePrice;
|
||||
|
||||
if (Coupon.DiscountAmount.HasValue) return BasePrice - Coupon.DiscountAmount.Value;
|
||||
if (Coupon.DiscountRate.HasValue) return BasePrice * (decimal)(1 - Coupon.DiscountRate.Value);
|
||||
return BasePrice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -184,6 +387,9 @@ public class SnWalletSubscription : ModelBase
|
||||
/// </summary>
|
||||
public SnSubscriptionReferenceObject ToReference()
|
||||
{
|
||||
// Cache the current instant once to avoid multiple SystemClock calls
|
||||
var currentInstant = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
return new SnSubscriptionReferenceObject
|
||||
{
|
||||
Id = Id,
|
||||
@@ -191,11 +397,11 @@ public class SnWalletSubscription : ModelBase
|
||||
BegunAt = BegunAt,
|
||||
EndedAt = EndedAt,
|
||||
IsActive = IsActive,
|
||||
IsAvailable = IsAvailable,
|
||||
IsAvailable = IsAvailableAt(currentInstant),
|
||||
IsFreeTrial = IsFreeTrial,
|
||||
Status = Status,
|
||||
BasePrice = BasePrice,
|
||||
FinalPrice = FinalPrice,
|
||||
FinalPrice = CalculateFinalPriceAt(currentInstant),
|
||||
RenewalAt = RenewalAt,
|
||||
AccountId = AccountId
|
||||
};
|
||||
@@ -263,11 +469,13 @@ public class SnSubscriptionReferenceObject : ModelBase
|
||||
public Instant? RenewalAt { get; set; }
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
private string? _displayName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable name of the subscription type if available.
|
||||
/// Gets the human-readable name of the subscription type if available (cached for performance).
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public string? DisplayName => SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(Identifier, out var name)
|
||||
public string? DisplayName => _displayName ??= SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(Identifier, out var name)
|
||||
? name
|
||||
: null;
|
||||
|
||||
@@ -281,8 +489,8 @@ public class SnSubscriptionReferenceObject : ModelBase
|
||||
IsAvailable = IsAvailable,
|
||||
IsFreeTrial = IsFreeTrial,
|
||||
Status = (Proto.SubscriptionStatus)Status,
|
||||
BasePrice = BasePrice.ToString(CultureInfo.CurrentCulture),
|
||||
FinalPrice = FinalPrice.ToString(CultureInfo.CurrentCulture),
|
||||
BasePrice = BasePrice.ToString(CultureInfo.InvariantCulture),
|
||||
FinalPrice = FinalPrice.ToString(CultureInfo.InvariantCulture),
|
||||
RenewalAt = RenewalAt?.ToTimestamp(),
|
||||
AccountId = AccountId.ToString(),
|
||||
DisplayName = DisplayName,
|
||||
@@ -401,4 +609,4 @@ public class SnWalletCoupon : ModelBase
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -31,6 +31,16 @@ enum SubscriptionStatus {
|
||||
SUBSCRIPTION_STATUS_CANCELLED = 4;
|
||||
}
|
||||
|
||||
enum GiftStatus {
|
||||
// Using proto3 enum naming convention
|
||||
GIFT_STATUS_UNSPECIFIED = 0;
|
||||
GIFT_STATUS_CREATED = 1;
|
||||
GIFT_STATUS_SENT = 2;
|
||||
GIFT_STATUS_REDEEMED = 3;
|
||||
GIFT_STATUS_EXPIRED = 4;
|
||||
GIFT_STATUS_CANCELLED = 5;
|
||||
}
|
||||
|
||||
message Subscription {
|
||||
string id = 1;
|
||||
google.protobuf.Timestamp begun_at = 2;
|
||||
@@ -93,6 +103,31 @@ message Coupon {
|
||||
google.protobuf.Timestamp updated_at = 10;
|
||||
}
|
||||
|
||||
message Gift {
|
||||
string id = 1;
|
||||
string gifter_id = 2;
|
||||
optional string recipient_id = 3;
|
||||
string gift_code = 4;
|
||||
optional string message = 5;
|
||||
string subscription_identifier = 6;
|
||||
string base_price = 7;
|
||||
string final_price = 8;
|
||||
GiftStatus status = 9;
|
||||
optional google.protobuf.Timestamp redeemed_at = 10;
|
||||
optional string redeemer_id = 11;
|
||||
optional string subscription_id = 12;
|
||||
google.protobuf.Timestamp expires_at = 13;
|
||||
bool is_open_gift = 14;
|
||||
string payment_method = 15;
|
||||
PaymentDetails payment_details = 16;
|
||||
optional string coupon_id = 17;
|
||||
optional Coupon coupon = 18;
|
||||
bool is_redeemable = 19;
|
||||
bool is_expired = 20;
|
||||
google.protobuf.Timestamp created_at = 21;
|
||||
google.protobuf.Timestamp updated_at = 22;
|
||||
}
|
||||
|
||||
service WalletService {
|
||||
rpc GetWallet(GetWalletRequest) returns (Wallet);
|
||||
rpc CreateWallet(CreateWalletRequest) returns (Wallet);
|
||||
|
Reference in New Issue
Block a user