Skip to content

Commit

Permalink
trial on signup
Browse files Browse the repository at this point in the history
  • Loading branch information
goenning committed Mar 22, 2024
1 parent e9c5104 commit f6df5c8
Show file tree
Hide file tree
Showing 20 changed files with 253 additions and 69 deletions.
9 changes: 8 additions & 1 deletion src/Features/Authentication/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,15 @@ await _emailClient.SendEmailAsync(email, "Confirm your registration", "Register"

public async Task<UserAccount> CreateAccountAsync(string name, string email, CancellationToken cancellationToken)
{
var freeTrialEndsAt = DateTime.UtcNow.AddDays(_env.IsManagedCloud ? 30 : 9999).AddHours(12);

var userId = NanoId.New();
var cmd = new CommandDefinition("INSERT INTO users (id, name, email) VALUES (@userId, @name, @email)", new { userId, name, email = email.ToLower() }, cancellationToken: cancellationToken);
var cmd = new CommandDefinition(
"INSERT INTO users (id, name, email, free_trial_ends_at) VALUES (@userId, @name, @email, @freeTrialEndsAt)",
new { userId, name, email = email.ToLower(), freeTrialEndsAt },
cancellationToken: cancellationToken
);

await _db.Connection.ExecuteAsync(cmd);

return new UserAccount(new UserIdentity(userId, name, email));
Expand Down
35 changes: 9 additions & 26 deletions src/Features/Billing/BillingController.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
using System.Data;
using Aptabase.Features.Authentication;
using Aptabase.Features.Billing.LemonSqueezy;
using Microsoft.AspNetCore.Mvc;
using Aptabase.Data;
using Dapper;
using Aptabase.Features.Stats;

namespace Aptabase.Features.Billing;
Expand All @@ -25,12 +23,12 @@ public class BillingHistoricUsage
public class BillingController : Controller
{
private readonly IQueryClient _queryClient;
private readonly IDbContext _db;
private readonly IBillingQueries _billingQueries;
private readonly LemonSqueezyClient _lsClient;

public BillingController(IDbContext db, LemonSqueezyClient lsClient, IQueryClient queryClient)
public BillingController(IDbContext db, IBillingQueries billingQueries, LemonSqueezyClient lsClient, IQueryClient queryClient)
{
_db = db ?? throw new ArgumentNullException(nameof(db));
_billingQueries = billingQueries ?? throw new ArgumentNullException(nameof(billingQueries));
_lsClient = lsClient ?? throw new ArgumentNullException(nameof(lsClient));
_queryClient = queryClient ?? throw new ArgumentNullException(nameof(queryClient));
}
Expand All @@ -39,16 +37,17 @@ public BillingController(IDbContext db, LemonSqueezyClient lsClient, IQueryClien
public async Task<IActionResult> BillingState(CancellationToken cancellationToken)
{
var user = this.GetCurrentUserIdentity();
var appIds = await GetOwnedAppIds(user);
var appIds = await _billingQueries.GetOwnedAppIds(user);

var usage = await _queryClient.NamedQuerySingleAsync<BillingUsage>("get_billing_usage__v1", new {
app_ids = appIds,
}, cancellationToken);

var sub = await GetUserSubscription(user);
var sub = await _billingQueries.GetUserSubscription(user);
var plan = sub is null || sub.Status == "expired"
? SubscriptionPlan.AptabaseFree
? SubscriptionPlan.GetFreeVariant(await _billingQueries.GetUserFreeTierOrTrial(user))
: SubscriptionPlan.GetByVariantId(sub.VariantId);

var state = (usage?.Count ?? 0) < plan.MonthlyEvents ? "OK" : "OVERUSE";

return Ok(new {
Expand All @@ -68,7 +67,7 @@ public async Task<IActionResult> BillingState(CancellationToken cancellationToke
public async Task<IActionResult> HistoricalUsage(CancellationToken cancellationToken)
{
var user = this.GetCurrentUserIdentity();
var appIds = await GetOwnedAppIds(user);
var appIds = await _billingQueries.GetOwnedAppIds(user);

var rows = await _queryClient.NamedQueryAsync<BillingHistoricUsage>("billing_historical_usage__v1", new {
app_ids = appIds,
Expand All @@ -90,27 +89,11 @@ public async Task<IActionResult> GenerateCheckoutUrl(CancellationToken cancellat
public async Task<IActionResult> GeneratePortalUrl(CancellationToken cancellationToken)
{
var user = this.GetCurrentUserIdentity();
var sub = await GetUserSubscription(user);
var sub = await _billingQueries.GetUserSubscription(user);
if (sub is null)
return NotFound();

var url = await _lsClient.GetBillingPortalUrl(sub.Id, cancellationToken);
return Ok(new { url });
}

private async Task<Subscription?> GetUserSubscription(UserIdentity user)
{
return await _db.Connection.QueryFirstOrDefaultAsync<Subscription>(
@"SELECT * FROM subscriptions
WHERE owner_id = @userId
ORDER BY created_at DESC LIMIT 1",
new { userId = user.Id });
}

private async Task<string[]> GetOwnedAppIds(UserIdentity user)
{
var releaseAppIds = await _db.Connection.QueryAsync<string>(@"SELECT id FROM apps WHERE owner_id = @userId", new { userId = user.Id });
var debugAppIds = releaseAppIds.Select(id => $"{id}_DEBUG");
return releaseAppIds.Concat(debugAppIds).ToArray();
}
}
61 changes: 61 additions & 0 deletions src/Features/Billing/BillingQueries.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using Aptabase.Data;
using Aptabase.Features.Authentication;
using Dapper;

namespace Aptabase.Features.Billing;

public interface IBillingQueries
{
Task<UserIdentity[]> GetTrialsDueSoon();
Task<Subscription?> GetUserSubscription(UserIdentity user);
Task<FreeSubscription> GetUserFreeTierOrTrial(UserIdentity user);
Task<string[]> GetOwnedAppIds(UserIdentity user);
}

public class BillingQueries : IBillingQueries
{
private readonly IDbContext _db;

public BillingQueries(IDbContext db)
{
_db = db ?? throw new ArgumentNullException(nameof(db));
}

public async Task<Subscription?> GetUserSubscription(UserIdentity user)
{
return await _db.Connection.QueryFirstOrDefaultAsync<Subscription>(
@"SELECT * FROM subscriptions
WHERE owner_id = @userId
ORDER BY created_at DESC LIMIT 1",
new { userId = user.Id });
}

public async Task<FreeSubscription> GetUserFreeTierOrTrial(UserIdentity user)
{
return await _db.Connection.QueryFirstAsync<FreeSubscription>(
@"SELECT free_quota, free_trial_ends_at FROM users WHERE id = @userId",
new { userId = user.Id });
}

public async Task<string[]> GetOwnedAppIds(UserIdentity user)
{
var releaseAppIds = await _db.Connection.QueryAsync<string>(@"SELECT id FROM apps WHERE owner_id = @userId", new { userId = user.Id });
var debugAppIds = releaseAppIds.Select(id => $"{id}_DEBUG");
return releaseAppIds.Concat(debugAppIds).ToArray();
}

public async Task<UserIdentity[]> GetTrialsDueSoon()
{
var users = await _db.Connection.QueryAsync<UserIdentity>(
@"SELECT DISTINCT u.id, u.name, u.email
FROM users u
LEFT JOIN subscriptions s
ON s.owner_id = u.id
INNER JOIN apps a
ON a.owner_id = u.id
AND a.has_events = true
WHERE u.free_trial_ends_at = now() + INTERVAL '7 DAY'
AND s.id IS NULL");
return users.ToArray();
}
}
2 changes: 1 addition & 1 deletion src/Features/Billing/LemonSqueezy/LemonSqueezyClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public LemonSqueezyClient(IHttpClientFactory factory, EnvSettings env, ILogger<L
variant = new {
data = new {
type = "variants",
id = "103474"
id = _env.IsProduction ? "103474" : "85183"
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/Features/Billing/LemonSqueezyWebhookController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ private async Task<IActionResult> HandleSubscriptionCreatedOrUpdated([FromBody]
return BadRequest(new { message = "Missing 'user_id' on meta.custom_data" });
}

await _db.Connection.ExecuteAsync(@"UPDATE users SET lock_reason = null WHERE id = @ownerId", new { ownerId });
await _db.Connection.ExecuteAsync(
@"UPDATE users SET lock_reason = :lockReason WHERE id = @ownerId",
new { ownerId, lockReason = body.Status == "expired" ? "B" : null }
);

await _db.Connection.ExecuteAsync(@"INSERT INTO subscriptions
(id, owner_id, customer_id, product_id, variant_id, status, ends_at)
VALUES
Expand Down
57 changes: 38 additions & 19 deletions src/Features/Billing/Subscription.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@

namespace Aptabase.Features.Billing;

public class Subscription
{
public long Id { get; set; }
Expand All @@ -12,51 +14,68 @@ public class Subscription
public DateTime ModifiedAt { get; set; }
}

public class FreeSubscription
{
public long? FreeQuota { get; set; }
public DateTime? FreeTrialEndsAt { get; set; }
}

public class SubscriptionPlan
{
public string Name { get; }
public int MonthlyPrice { get; }
public int MonthlyEvents { get; }
public long VariantId { get; }
public DateTime? FreeTrialEndsAt { get; }

public SubscriptionPlan(string name, int monthlyEvents, int monthlyPrice, long variantId)
public SubscriptionPlan(string name, int monthlyEvents, int monthlyPrice, long variantId, DateTime? freeTrialEndsAt)
{
Name = name;
MonthlyEvents = monthlyEvents;
MonthlyPrice = monthlyPrice;
VariantId = variantId;
FreeTrialEndsAt = freeTrialEndsAt;
}

public static readonly SubscriptionPlan AptabaseFree = new SubscriptionPlan("Free", 20_000, 0, 0);
public static SubscriptionPlan GetFreeVariant(FreeSubscription sub)
{
if (sub.FreeQuota.HasValue)
return new SubscriptionPlan("Free Plan", (int)sub.FreeQuota.Value, 0, 0, null);

if (sub.FreeTrialEndsAt.HasValue)
return new SubscriptionPlan("Free Trial", 200_000, 0, 0, sub.FreeTrialEndsAt);

throw new InvalidOperationException("No free subscription found");
}

public static SubscriptionPlan GetByVariantId(long variantId)
{
return ProductionPlans.FirstOrDefault(plan => plan.VariantId == variantId)
?? DevelopmentPlans.FirstOrDefault(plan => plan.VariantId == variantId)
?? SubscriptionPlan.AptabaseFree;
?? throw new InvalidOperationException($"Subscription Variant not found for ID {variantId}");
}

private static readonly SubscriptionPlan[] DevelopmentPlans = new[]
{
new SubscriptionPlan("200k", 200_000, 10, 85183),
new SubscriptionPlan("1M", 1_000_000, 20, 85184),
new SubscriptionPlan("2M", 2_000_000, 40, 85185),
new SubscriptionPlan("5M", 5_000_000, 75, 85187),
new SubscriptionPlan("10M", 10_000_000, 140, 85188),
new SubscriptionPlan("20M", 20_000_000, 240, 85190),
new SubscriptionPlan("30M", 30_000_000, 300, 85192),
new SubscriptionPlan("50M", 50_000_000, 450, 85194),
new SubscriptionPlan("200k Plan", 200_000, 10, 85183, null),
new SubscriptionPlan("1M Plan", 1_000_000, 20, 85184, null),
new SubscriptionPlan("2M Plan", 2_000_000, 40, 85185, null),
new SubscriptionPlan("5M Plan", 5_000_000, 75, 85187, null),
new SubscriptionPlan("10M Plan", 10_000_000, 140, 85188, null),
new SubscriptionPlan("20M Plan", 20_000_000, 240, 85190, null),
new SubscriptionPlan("30M Plan", 30_000_000, 300, 85192, null),
new SubscriptionPlan("50M Plan", 50_000_000, 450, 85194, null),
};

private static readonly SubscriptionPlan[] ProductionPlans = new[]
{
new SubscriptionPlan("200k", 200_000, 10, 103474),
new SubscriptionPlan("1M", 1_000_000, 20, 103475),
new SubscriptionPlan("2M", 2_000_000, 40, 103476),
new SubscriptionPlan("5M", 5_000_000, 75, 103477),
new SubscriptionPlan("10M", 10_000_000, 140, 103478),
new SubscriptionPlan("20M", 20_000_000, 240, 103479),
new SubscriptionPlan("30M", 30_000_000, 300, 103480),
new SubscriptionPlan("50M", 50_000_000, 450, 103481),
new SubscriptionPlan("200k Plan", 200_000, 10, 103474, null),
new SubscriptionPlan("1M Plan", 1_000_000, 20, 103475, null),
new SubscriptionPlan("2M Plan", 2_000_000, 40, 103476, null),
new SubscriptionPlan("5M Plan", 5_000_000, 75, 103477, null),
new SubscriptionPlan("10M Plan", 10_000_000, 140, 103478, null),
new SubscriptionPlan("20M Plan", 20_000_000, 240, 103479, null),
new SubscriptionPlan("30M Plan", 30_000_000, 300, 103480, null),
new SubscriptionPlan("50M Plan", 50_000_000, 450, 103481, null),
};
}
51 changes: 51 additions & 0 deletions src/Features/Billing/TrialNotificationCronJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Aptabase.Features.Notification;
using Sgbj.Cron;

namespace Aptabase.Features.Billing;

public class TrialNotificationCronJob : BackgroundService
{
private readonly IBillingQueries _billingQueries;
private readonly IEmailClient _emailClient;
private readonly EnvSettings _env;
private readonly ILogger _logger;

public TrialNotificationCronJob(IBillingQueries billingQueries, IEmailClient emailClient, EnvSettings env, ILogger<TrialNotificationCronJob> logger)
{
_billingQueries = billingQueries ?? throw new ArgumentNullException(nameof(billingQueries));
_emailClient = emailClient ?? throw new ArgumentNullException(nameof(emailClient));
_env = env ?? throw new ArgumentNullException(nameof(env));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("TrialNotificationCronJob is starting.");

using var timer = new CronTimer("0 0 * * *", TimeZoneInfo.Utc);

while (await timer.WaitForNextTickAsync(cancellationToken))
{
_logger.LogInformation("Searching for users to notify about trial expiration.");
var users = await _billingQueries.GetTrialsDueSoon();
foreach (var user in users)
{
_logger.LogInformation("Sending trial notification to {name} ({user})", user.Name, user.Email);
await _emailClient.SendEmailAsync(user.Email, "Your Trial ends in 5 days", "TrialEndsSoon", new()
{
{ "name", user.Name },
{ "url", $"{_env.SelfBaseUrl}/billing" },
}, cancellationToken);
}

_logger.LogInformation("Sent trial notifications to {count} users", users.Length);
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("TrialNotificationCronJob stopped.");
}
}
}
2 changes: 2 additions & 0 deletions src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
using Aptabase.Features.Stats;
using Aptabase.Features.Apps;
using Aptabase.Features.Ingestion.Buffer;
using Aptabase.Features.Billing;

public partial class Program
{
Expand Down Expand Up @@ -124,6 +125,7 @@ public static void Main(string[] args)
builder.Services.AddHealthChecks();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddSingleton<IAppQueries, AppQueries>();
builder.Services.AddSingleton<IBillingQueries, BillingQueries>();
builder.Services.AddSingleton<IPrivacyQueries, PrivacyQueries>();
builder.Services.AddSingleton<IUserHasher, DailyUserHasher>();
builder.Services.AddSingleton<IAuthTokenManager, AuthTokenManager>();
Expand Down
11 changes: 11 additions & 0 deletions src/assets/Templates/TrialEndsSoon.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<p>Hi ##NAME##,</p>
<p>
Your free 30-day trial of Aptabase Analytics ends in 5 days. That means you'll lose access to the dashboard and we'll
stop processing events from your apps.
</p>
<p>If you want to continue using Aptabase for your app analytics, you can upgrade to one of the paid plans.</p>
<p>
<a target="_blank" rel="noopener noreferrer nofollow" href="##URL##">Account Billing</a>
</p>
<p>Thanks for trying Aptabase!</p>
<p><em>- Guilherme, Founder of Aptabase</em></p>
2 changes: 1 addition & 1 deletion src/webapp/components/Alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type AlertProps = {

const variants = {
default: "text-foreground",
warning: "bg-warning/5 border-warning [&>svg]:text-warning",
warning: "bg-warning/10 border-warning [&>svg]:text-warning",
};

const Alert = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & AlertProps>(
Expand Down
8 changes: 2 additions & 6 deletions src/webapp/features/analytics/locked/AppLockedContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,9 @@ export function AppLockedContent(props: Props) {
<>
<IconAlertTriangle className="text-warning h-5 w-5" />
<div>
<p className="text-center">
{props.reason === "B" ? "App is locked due to overuse" : "App is locked"}
</p>
<p className="text-center">{props.reason === "B" ? "App is locked due to billing" : "App is locked"}</p>
<p className="text-center text-muted-foreground">
{props.reason === "B"
? "Subscribe to a suitable plan to unlock"
: "Contact support to unlock"}
{props.reason === "B" ? "Subscribe to a suitable plan to unlock" : "Contact support to unlock"}
</p>
</div>
</>
Expand Down
2 changes: 1 addition & 1 deletion src/webapp/features/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function Component() {
<Page title="Sign up">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<Logo className="mx-auto h-12 w-auto text-primary" />
<h2 className="text-center text-3xl font-bold">Get started for free</h2>
<h2 className="text-center text-3xl font-bold">Sign up for an account</h2>
<DataResidency />
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
Expand Down

0 comments on commit f6df5c8

Please sign in to comment.