Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion architecture.html

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2690,7 +2690,7 @@
"Challenges": 15,
"Chat": 80,
"ChecklistTemplates": 5,
"Common": 34,
"Common": 35,
"Gamification": 28,
"Goals": 33,
"Habits": 74,
Expand Down Expand Up @@ -2895,7 +2895,7 @@
"Challenges": 14,
"Chat": 61,
"ChecklistTemplates": 9,
"Common": 168,
"Common": 169,
"Gamification": 36,
"Goals": 38,
"Habits": 83,
Expand Down Expand Up @@ -4796,6 +4796,11 @@
"file": "tests/Orbit.Application.Tests/Common/StripeSettingsTests.cs",
"references": []
},
{
"testClass": "SubscriptionLapseReasonExtensionsTests",
"file": "tests/Orbit.Application.Tests/Common/SubscriptionLapseReasonExtensionsTests.cs",
"references": []
},
{
"testClass": "TimeFormatResolverTests",
"file": "tests/Orbit.Application.Tests/Common/TimeFormatResolverTests.cs",
Expand Down
14 changes: 14 additions & 0 deletions src/Orbit.Application/Common/SubscriptionLapseReasonExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Orbit.Domain.Enums;

namespace Orbit.Application.Common;

public static class SubscriptionLapseReasonExtensions
{
public static string? ToApiValue(this SubscriptionLapseReason? reason) => reason switch
{
SubscriptionLapseReason.Canceled => "canceled",
SubscriptionLapseReason.PaymentFailed => "payment_failed",
SubscriptionLapseReason.Expired => "expired",
_ => null,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ public async Task<Result> Handle(HandlePlayNotificationCommand request, Cancella
return Result.Success();

var grantsPro = state.GrantsOrbitPro(_settings);
var consumedCouponId = ApplySubscriptionState(user, state, notification.PurchaseToken, grantsPro);
var consumedCouponId = ApplySubscriptionState(
user, state, notification.PurchaseToken, notification.NotificationType, grantsPro);

if (!string.IsNullOrEmpty(decoded.MessageId))
await processedNotificationRepository.AddAsync(ProcessedPlayNotification.Create(decoded.MessageId), cancellationToken);
Expand Down Expand Up @@ -127,11 +128,11 @@ public async Task<Result> Handle(HandlePlayNotificationCommand request, Cancella
}

private string? ApplySubscriptionState(
User user, PlaySubscriptionState state, string purchaseToken, bool grantsPro)
User user, PlaySubscriptionState state, string purchaseToken, int notificationType, bool grantsPro)
{
if (!grantsPro)
{
user.CancelPlaySubscription();
user.CancelPlaySubscription(GetPlayLapseReason(notificationType, user.SubscriptionLapseReason));
return null;
}

Expand All @@ -146,9 +147,24 @@ public async Task<Result> Handle(HandlePlayNotificationCommand request, Cancella
user.SetPlaySubscription(purchaseToken, state.ExpiresAt, state.Interval);
}

if (notificationType == 3)
user.RecordSubscriptionLapseReason(
SubscriptionSource.GooglePlay, SubscriptionLapseReason.Canceled);

return consumedCouponId;
}

private static SubscriptionLapseReason GetPlayLapseReason(
int notificationType, SubscriptionLapseReason? existingReason) => notificationType switch
{
5 => SubscriptionLapseReason.PaymentFailed,
3 => existingReason == SubscriptionLapseReason.PaymentFailed
? SubscriptionLapseReason.PaymentFailed
: SubscriptionLapseReason.Canceled,
13 => existingReason ?? SubscriptionLapseReason.Expired,
_ => SubscriptionLapseReason.Expired,
};

private static bool StripeCoversLaterPeriod(User user, PlaySubscriptionState state) =>
user.SubscriptionSource == SubscriptionSource.Stripe
&& user.IsPro
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ public async Task<Result> Handle(HandleWebhookCommand request, CancellationToken
await HandleInvoicePaid(stripeEvent, cancellationToken);
break;

case "invoice.payment_failed":
await HandleInvoicePaymentFailed(stripeEvent, cancellationToken);
break;

case "customer.subscription.deleted":
await HandleSubscriptionDeleted(stripeEvent, cancellationToken);
break;
Expand Down Expand Up @@ -127,7 +131,7 @@ private async Task HandleCheckoutSessionCompleted(Event stripeEvent, Cancellatio
var periodEnd = GetPeriodEnd(subscription, interval);

user.SetStripeCustomerId(session.CustomerId ?? session.Customer?.Id ?? "");
user.SetStripeSubscription(subscriptionId, periodEnd, interval);
user.SetStripeSubscription(subscriptionId, periodEnd, interval, stripeEvent.Created);

if (!string.IsNullOrEmpty(user.ReferralCouponId))
{
Expand Down Expand Up @@ -158,7 +162,7 @@ private async Task HandleInvoicePaid(Event stripeEvent, CancellationToken ct)
var subscription = await subscriptionService.GetAsync(invoiceSubId, cancellationToken: ct);
var interval = GetSubscriptionInterval(subscription);
var periodEnd = GetPeriodEnd(subscription, interval);
user.SetStripeSubscription(invoiceSubId, periodEnd, interval);
user.SetStripeSubscription(invoiceSubId, periodEnd, interval, stripeEvent.Created);
await unitOfWork.SaveChangesAsync(ct);
LogSubscriptionRenewed(logger, user.Id, periodEnd);
AnalyticsCapture.SafeCaptureUserEvent(productAnalytics, logger, user, "subscription_renewed");
Expand All @@ -178,7 +182,8 @@ private async Task HandleSubscriptionDeleted(Event stripeEvent, CancellationToke
if (user is null)
return;

user.CancelStripeSubscription();
user.CancelStripeSubscription(
GetDeletedSubscriptionLapseReason(subscription, user), stripeEvent.Created);
await unitOfWork.SaveChangesAsync(ct);
LogUserDowngraded(logger, user.Id);
AnalyticsCapture.SafeCaptureUserEvent(productAnalytics, logger, user, "subscription_canceled");
Expand All @@ -202,17 +207,57 @@ private async Task HandleSubscriptionUpdated(Event stripeEvent, CancellationToke
{
var interval = GetSubscriptionInterval(subscription);
var periodEnd = GetPeriodEnd(subscription, interval);
user.SetStripeSubscription(subscription.Id, periodEnd, interval);
user.SetStripeSubscription(subscription.Id, periodEnd, interval, stripeEvent.Created);
}
else if (subscription.Status == "canceled")
{
user.CancelStripeSubscription(
GetCanceledSubscriptionLapseReason(subscription), stripeEvent.Created);
}
else if (subscription.Status is "canceled" or "unpaid")
else if (subscription.Status == "unpaid")
{
user.CancelStripeSubscription();
user.CancelStripeSubscription(
SubscriptionLapseReason.PaymentFailed, stripeEvent.Created);
}

await unitOfWork.SaveChangesAsync(ct);
AnalyticsCapture.SafeCaptureUserEvent(productAnalytics, logger, user, "subscription_updated");
}

private async Task HandleInvoicePaymentFailed(Event stripeEvent, CancellationToken ct)
{
var invoice = stripeEvent.Data.Object as Invoice;
var subscriptionId = invoice?.Parent?.SubscriptionDetails?.SubscriptionId;

if (subscriptionId is null)
return;

var user = await userRepository.FindOneTrackedIgnoringFiltersAsync(
u => u.StripeSubscriptionId == subscriptionId, ct);

if (user is null)
return;

user.RecordSubscriptionLapseReason(
SubscriptionSource.Stripe,
SubscriptionLapseReason.PaymentFailed,
stripeEvent.Created);
await unitOfWork.SaveChangesAsync(ct);
}

private static SubscriptionLapseReason GetCanceledSubscriptionLapseReason(Subscription subscription) =>
subscription.CancellationDetails?.Reason is "payment_failed" or "payment_disputed"
? SubscriptionLapseReason.PaymentFailed
: SubscriptionLapseReason.Canceled;

private static SubscriptionLapseReason GetDeletedSubscriptionLapseReason(Subscription subscription, User user) =>
subscription.CancellationDetails?.Reason switch
{
"payment_failed" or "payment_disputed" => SubscriptionLapseReason.PaymentFailed,
"cancellation_requested" => SubscriptionLapseReason.Canceled,
_ => user.SubscriptionLapseReason ?? SubscriptionLapseReason.Expired,
};

private static DateTime GetPeriodEnd(Subscription subscription, SubscriptionInterval interval)
{
var item = subscription.Items?.Data?.FirstOrDefault();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public async Task<Result<SubscriptionStatusResponse>> Handle(GetSubscriptionStat
await payGate.GetAiMessageLimit(user.Id, cancellationToken),
user.IsLifetimePro,
user.SubscriptionInterval?.ToString().ToLowerInvariant(),
user.SubscriptionSource.ToApiValue()));
user.SubscriptionSource.ToApiValue(),
user.SubscriptionLapseReason.ToApiValue(),
user.SubscriptionEndedAtUtc));
}
}
4 changes: 3 additions & 1 deletion src/Orbit.Application/Subscriptions/SubscriptionDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ public record SubscriptionStatusResponse(
int AiMessagesLimit,
bool IsLifetimePro,
string? SubscriptionInterval,
string? Source);
string? Source,
string? LapseReason = null,
DateTime? SubscriptionEndedAtUtc = null);

public record PlayVerifyResponse(
bool HasProAccess,
Expand Down
83 changes: 77 additions & 6 deletions src/Orbit.Domain/Entities/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public partial class User : Entity
public DateTime? AiMessagesResetAt { get; private set; }
public SubscriptionInterval? SubscriptionInterval { get; private set; }
public SubscriptionSource? SubscriptionSource { get; private set; }
public SubscriptionLapseReason? SubscriptionLapseReason { get; private set; }
public DateTime? SubscriptionEndedAtUtc { get; private set; }
public DateTime? StripeSubscriptionEventCreatedAtUtc { get; private set; }
public string? PlayPurchaseToken { get; private set; }
public DateTime CreatedAtUtc { get; private set; }
public bool HasImportedCalendar { get; private set; } = false;
Expand Down Expand Up @@ -223,12 +226,21 @@ public Result SetColorScheme(string? colorScheme)

public void SetStripeCustomerId(string customerId) => StripeCustomerId = customerId;

public void SetStripeSubscription(string subscriptionId, DateTime expiresAt, SubscriptionInterval? interval = null)
public void SetStripeSubscription(
string subscriptionId,
DateTime expiresAt,
SubscriptionInterval? interval = null,
DateTime? eventCreatedAtUtc = null)
{
if (!TryAcceptStripeEvent(eventCreatedAtUtc, acceptEqualTimestamp: true))
return;

StripeSubscriptionId = subscriptionId;
PlanExpiresAt = expiresAt;
Plan = UserPlan.Pro;
SubscriptionSource = Enums.SubscriptionSource.Stripe;
SubscriptionLapseReason = null;
SubscriptionEndedAtUtc = null;
PlayPurchaseToken = null;
if (interval.HasValue)
SubscriptionInterval = interval.Value;
Expand All @@ -240,32 +252,91 @@ public void SetPlaySubscription(string purchaseToken, DateTime expiresAt, Subscr
PlanExpiresAt = expiresAt;
Plan = UserPlan.Pro;
SubscriptionSource = Enums.SubscriptionSource.GooglePlay;
SubscriptionLapseReason = null;
SubscriptionEndedAtUtc = null;
if (interval.HasValue)
SubscriptionInterval = interval.Value;
}

public void LinkPlayPurchaseToken(string purchaseToken) => PlayPurchaseToken = purchaseToken;

public void CancelStripeSubscription()
public void CancelStripeSubscription(
SubscriptionLapseReason reason,
DateTime? eventCreatedAtUtc = null)
{
EnsureValidLapseReason(reason);
if (!TryAcceptStripeEvent(eventCreatedAtUtc, acceptEqualTimestamp: true))
return;

StripeSubscriptionId = null;
if (SubscriptionSource == Enums.SubscriptionSource.Stripe)
ClearEntitlement();
ClearEntitlement(reason);
}

public void CancelPlaySubscription()
public void CancelPlaySubscription(SubscriptionLapseReason reason)
{
EnsureValidLapseReason(reason);
PlayPurchaseToken = null;
if (SubscriptionSource == Enums.SubscriptionSource.GooglePlay)
ClearEntitlement();
ClearEntitlement(reason);
}

private void ClearEntitlement()
public void RecordSubscriptionLapseReason(
SubscriptionSource source,
SubscriptionLapseReason reason,
DateTime? eventCreatedAtUtc = null)
{
EnsureValidSubscriptionSource(source);
EnsureValidLapseReason(reason);

if (SubscriptionSource != source)
return;

if (source == Enums.SubscriptionSource.Stripe
&& !TryAcceptStripeEvent(eventCreatedAtUtc, acceptEqualTimestamp: false))
return;

SubscriptionLapseReason = reason;
}

private void ClearEntitlement(SubscriptionLapseReason reason)
{
Plan = UserPlan.Free;
PlanExpiresAt = null;
SubscriptionInterval = null;
SubscriptionSource = null;
SubscriptionLapseReason = reason;
SubscriptionEndedAtUtc = DateTime.UtcNow;
}

private static void EnsureValidLapseReason(SubscriptionLapseReason reason)
{
if (!Enum.IsDefined(reason))
throw new ArgumentOutOfRangeException(nameof(reason));
}

private static void EnsureValidSubscriptionSource(SubscriptionSource source)
{
if (!Enum.IsDefined(source))
throw new ArgumentOutOfRangeException(nameof(source));
}

private bool TryAcceptStripeEvent(DateTime? eventCreatedAtUtc, bool acceptEqualTimestamp)
{
if (!eventCreatedAtUtc.HasValue)
return true;

if (eventCreatedAtUtc.Value.Kind != DateTimeKind.Utc)
throw new ArgumentException("Stripe event time must be UTC.", nameof(eventCreatedAtUtc));

if (StripeSubscriptionEventCreatedAtUtc.HasValue
&& (acceptEqualTimestamp
? eventCreatedAtUtc.Value < StripeSubscriptionEventCreatedAtUtc.Value
: eventCreatedAtUtc.Value <= StripeSubscriptionEventCreatedAtUtc.Value))
return false;

StripeSubscriptionEventCreatedAtUtc = eventCreatedAtUtc.Value;
return true;
}

public void StartTrial(DateTime endsAt) => TrialEndsAt = endsAt;
Expand Down
8 changes: 8 additions & 0 deletions src/Orbit.Domain/Enums/SubscriptionLapseReason.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Orbit.Domain.Enums;

public enum SubscriptionLapseReason
{
Canceled = 0,
PaymentFailed = 1,
Expired = 2
}
Loading
Loading