Skip to content

Commit d340d87

Browse files
committed
Add plan to tenant to avoid exposing subscription entity to non-owners
1 parent 9e71281 commit d340d87

File tree

11 files changed

+75
-21
lines changed

11 files changed

+75
-21
lines changed

application/account/Core/Database/Migrations/20260303023200_Initial.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ protected override void Up(MigrationBuilder migrationBuilder)
1919
deleted_at = table.Column<DateTimeOffset>("timestamptz", nullable: true),
2020
name = table.Column<string>("text", nullable: false),
2121
state = table.Column<string>("text", nullable: false),
22-
logo = table.Column<string>("jsonb", nullable: false, defaultValue: "{}"),
22+
plan = table.Column<string>("text", nullable: false, defaultValue: "Basis"),
2323
suspension_reason = table.Column<string>("text", nullable: true),
24-
suspended_at = table.Column<DateTimeOffset>("timestamptz", nullable: true)
24+
suspended_at = table.Column<DateTimeOffset>("timestamptz", nullable: true),
25+
logo = table.Column<string>("jsonb", nullable: false, defaultValue: "{}")
2526
},
2627
constraints: table => { table.PrimaryKey("pk_tenants", x => x.id); }
2728
);

application/account/Core/Features/Billing/Queries/GetPaymentHistory.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using Account.Features.Subscriptions.Domain;
2+
using Account.Features.Users.Domain;
23
using JetBrains.Annotations;
34
using SharedKernel.Cqrs;
5+
using SharedKernel.ExecutionContext;
46

57
namespace Account.Features.Billing.Queries;
68

@@ -21,11 +23,16 @@ public sealed record PaymentTransactionResponse(
2123
string? CreditNoteUrl
2224
);
2325

24-
public sealed class GetPaymentHistoryHandler(ISubscriptionRepository subscriptionRepository)
26+
public sealed class GetPaymentHistoryHandler(ISubscriptionRepository subscriptionRepository, IExecutionContext executionContext)
2527
: IRequestHandler<GetPaymentHistoryQuery, Result<PaymentHistoryResponse>>
2628
{
2729
public async Task<Result<PaymentHistoryResponse>> Handle(GetPaymentHistoryQuery query, CancellationToken cancellationToken)
2830
{
31+
if (executionContext.UserInfo.Role != nameof(UserRole.Owner))
32+
{
33+
return Result<PaymentHistoryResponse>.Forbidden("Only owners can view payment history.");
34+
}
35+
2936
var subscription = await subscriptionRepository.GetCurrentAsync(cancellationToken);
3037

3138
var allTransactions = subscription.PaymentTransactions

application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription,
7777
if (customerResult.IsCustomerDeleted)
7878
{
7979
subscription.ResetToFreePlan();
80+
tenant.UpdatePlan(SubscriptionPlan.Basis);
8081
tenant.Suspend(SuspensionReason.CustomerDeleted, timeProvider.GetUtcNow());
8182
tenantRepository.Update(tenant);
8283
subscriptionRepository.Update(subscription);
@@ -112,6 +113,7 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription,
112113
if (stripeState is not null)
113114
{
114115
subscription.SetStripeSubscription(stripeState.StripeSubscriptionId, stripeState.Plan, stripeState.CurrentPriceAmount, stripeState.CurrentPriceCurrency, stripeState.CurrentPeriodEnd, stripeState.PaymentMethod);
116+
tenant.UpdatePlan(stripeState.Plan);
115117
}
116118

117119
// Always sync payment transactions from Stripe (via subscription when active, via invoices when cancelled)
@@ -201,18 +203,21 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription,
201203
if (subscriptionExpired)
202204
{
203205
subscription.ResetToFreePlan();
206+
tenant.UpdatePlan(SubscriptionPlan.Basis);
204207
events.CollectEvent(new SubscriptionExpired(subscription.Id, previousPlan, daysOnCurrentPlan, previousPriceAmount!.Value, -previousPriceAmount.Value, previousPriceCurrency!));
205208
}
206209

207210
if (subscriptionImmediatelyCancelled)
208211
{
209212
subscription.ResetToFreePlan();
213+
tenant.UpdatePlan(SubscriptionPlan.Basis);
210214
events.CollectEvent(new SubscriptionCancelled(subscription.Id, previousPlan, CancellationReason.CancelledByAdmin, 0, daysOnCurrentPlan, previousPriceAmount!.Value, -previousPriceAmount.Value, previousPriceCurrency!));
211215
}
212216

213217
if (subscriptionSuspended)
214218
{
215219
subscription.ResetToFreePlan();
220+
tenant.UpdatePlan(SubscriptionPlan.Basis);
216221
tenant.Suspend(SuspensionReason.PaymentFailed, timeProvider.GetUtcNow());
217222
events.CollectEvent(new SubscriptionSuspended(subscription.Id, previousPlan, SuspensionReason.PaymentFailed, previousPriceAmount!.Value, -previousPriceAmount.Value, previousPriceCurrency!));
218223
}
@@ -240,7 +245,8 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription,
240245
}
241246

242247
// Persist all aggregate mutations and mark pending events as processed
243-
if (subscriptionCreated || subscriptionSuspended)
248+
var tenantChanged = stripeState is not null || subscriptionCreated || subscriptionExpired || subscriptionImmediatelyCancelled || subscriptionSuspended;
249+
if (tenantChanged)
244250
{
245251
tenantRepository.Update(tenant);
246252
}

application/account/Core/Features/Tenants/Domain/Tenant.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Account.Features.Subscriptions.Domain;
12
using SharedKernel.Domain;
23

34
namespace Account.Features.Tenants.Domain;
@@ -7,13 +8,16 @@ public sealed class Tenant : SoftDeletableAggregateRoot<TenantId>
78
private Tenant() : base(TenantId.NewId())
89
{
910
State = TenantState.Active;
11+
Plan = SubscriptionPlan.Basis;
1012
Logo = new Logo();
1113
}
1214

1315
public string Name { get; private set; } = string.Empty;
1416

1517
public TenantState State { get; private set; }
1618

19+
public SubscriptionPlan Plan { get; private set; }
20+
1721
public SuspensionReason? SuspensionReason { get; private set; }
1822

1923
public DateTimeOffset? SuspendedAt { get; private set; }
@@ -55,6 +59,11 @@ public void RemoveLogo()
5559
{
5660
Logo = new Logo(Version: Logo.Version);
5761
}
62+
63+
public void UpdatePlan(SubscriptionPlan plan)
64+
{
65+
Plan = plan;
66+
}
5867
}
5968

6069
public sealed record Logo(string? Url = null, int Version = 0);

application/account/Tests/Authentication/GetUserSessionsTests.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Account.Database;
33
using Account.Features.Authentication.Domain;
44
using Account.Features.Authentication.Queries;
5+
using Account.Features.Subscriptions.Domain;
56
using FluentAssertions;
67
using SharedKernel.Authentication.TokenGeneration;
78
using SharedKernel.Domain;
@@ -134,7 +135,8 @@ private long InsertTenant(string name)
134135
("ModifiedAt", null),
135136
("Name", name),
136137
("State", "Active"),
137-
("Logo", """{"Url":null,"Version":0}""")
138+
("Logo", """{"Url":null,"Version":0}"""),
139+
("Plan", nameof(SubscriptionPlan.Basis))
138140
]
139141
);
140142

application/account/Tests/Authentication/SwitchTenantTests.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf
3030
("ModifiedAt", null),
3131
("Name", tenant2Name),
3232
("State", nameof(TenantState.Active)),
33-
("Logo", """{"Url":null,"Version":0}""")
33+
("Logo", """{"Url":null,"Version":0}"""),
34+
("Plan", nameof(SubscriptionPlan.Basis))
3435
]
3536
);
3637

@@ -110,7 +111,8 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo
110111
("ModifiedAt", null),
111112
("Name", Faker.Company.CompanyName()),
112113
("State", nameof(TenantState.Active)),
113-
("Logo", """{"Url":null,"Version":0}""")
114+
("Logo", """{"Url":null,"Version":0}"""),
115+
("Plan", nameof(SubscriptionPlan.Basis))
114116
]
115117
);
116118

@@ -170,7 +172,8 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail()
170172
("ModifiedAt", null),
171173
("Name", tenant2Name),
172174
("State", nameof(TenantState.Active)),
173-
("Logo", """{"Url":null,"Version":0}""")
175+
("Logo", """{"Url":null,"Version":0}"""),
176+
("Plan", nameof(SubscriptionPlan.Basis))
174177
]
175178
);
176179

@@ -240,7 +243,8 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData()
240243
("ModifiedAt", null),
241244
("Name", tenant2Name),
242245
("State", nameof(TenantState.Active)),
243-
("Logo", """{"Url":null,"Version":0}""")
246+
("Logo", """{"Url":null,"Version":0}"""),
247+
("Plan", nameof(SubscriptionPlan.Basis))
244248
]
245249
);
246250

@@ -318,7 +322,8 @@ public async Task SwitchTenant_WhenSessionAlreadyRevoked_ShouldReturnUnauthorize
318322
("ModifiedAt", null),
319323
("Name", Faker.Company.CompanyName()),
320324
("State", nameof(TenantState.Active)),
321-
("Logo", """{"Url":null,"Version":0}""")
325+
("Logo", """{"Url":null,"Version":0}"""),
326+
("Plan", nameof(SubscriptionPlan.Basis))
322327
]
323328
);
324329

application/account/Tests/Billing/GetPaymentHistoryTests.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Net;
12
using System.Net.Http.Json;
23
using Account.Database;
34
using Account.Features.Billing.Queries;
@@ -38,4 +39,14 @@ public async Task GetPaymentHistory_WhenTransactionsExist_ShouldReturnPaginatedH
3839
result.Transactions[0].Currency.Should().Be("usd");
3940
result.Transactions[0].Status.Should().Be(PaymentTransactionStatus.Succeeded);
4041
}
42+
43+
[Fact]
44+
public async Task GetPaymentHistory_WhenNotOwner_ShouldReturnForbidden()
45+
{
46+
// Act
47+
var response = await AuthenticatedMemberHttpClient.GetAsync("/api/account/billing/payment-history");
48+
49+
// Assert
50+
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
51+
}
4152
}

application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,8 @@ public async Task CompleteEmailLogin_WithValidPreferredTenant_ShouldLoginToPrefe
218218
("ModifiedAt", null),
219219
("Name", Faker.Company.CompanyName()),
220220
("State", nameof(TenantState.Active)),
221-
("Logo", """{"Url":null,"Version":0}""")
221+
("Logo", """{"Url":null,"Version":0}"""),
222+
("Plan", nameof(SubscriptionPlan.Basis))
222223
]
223224
);
224225

@@ -316,7 +317,8 @@ public async Task CompleteEmailLogin_WithPreferredTenantUserDoesNotHaveAccess_Sh
316317
("ModifiedAt", null),
317318
("Name", Faker.Company.CompanyName()),
318319
("State", nameof(TenantState.Active)),
319-
("Logo", """{"Url":null,"Version":0}""")
320+
("Logo", """{"Url":null,"Version":0}"""),
321+
("Plan", nameof(SubscriptionPlan.Basis))
320322
]
321323
);
322324

application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,8 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr
407407
("ModifiedAt", null),
408408
("Name", Faker.Company.CompanyName()),
409409
("State", nameof(TenantState.Active)),
410-
("Logo", """{"Url":null,"Version":0}""")
410+
("Logo", """{"Url":null,"Version":0}"""),
411+
("Plan", nameof(SubscriptionPlan.Basis))
411412
]
412413
);
413414

@@ -584,7 +585,8 @@ public async Task CompleteExternalLogin_WithPreferredTenantUserDoesNotHaveAccess
584585
("ModifiedAt", null),
585586
("Name", Faker.Company.CompanyName()),
586587
("State", nameof(TenantState.Active)),
587-
("Logo", """{"Url":null,"Version":0}""")
588+
("Logo", """{"Url":null,"Version":0}"""),
589+
("Plan", nameof(SubscriptionPlan.Basis))
588590
]
589591
);
590592

application/account/Tests/Tenants/GetTenantsForUserTests.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Net.Http.Json;
33
using System.Text.Json;
44
using Account.Database;
5+
using Account.Features.Subscriptions.Domain;
56
using Account.Features.Tenants.Domain;
67
using Account.Features.Tenants.Queries;
78
using Account.Features.Users.Domain;
@@ -29,7 +30,8 @@ public async Task GetTenants_UserWithMultipleTenants_ReturnsAllTenants()
2930
("ModifiedAt", null),
3031
("Name", tenant2Name),
3132
("State", nameof(TenantState.Active)),
32-
("Logo", """{"Url":null,"Version":0}""")
33+
("Logo", """{"Url":null,"Version":0}"""),
34+
("Plan", nameof(SubscriptionPlan.Basis))
3335
]
3436
);
3537

@@ -103,7 +105,8 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse
103105
("ModifiedAt", null),
104106
("Name", "Other Tenant"),
105107
("State", nameof(TenantState.Active)),
106-
("Logo", """{"Url":null,"Version":0}""")
108+
("Logo", """{"Url":null,"Version":0}"""),
109+
("Plan", nameof(SubscriptionPlan.Basis))
107110
]
108111
);
109112

@@ -148,7 +151,8 @@ public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsers
148151
("ModifiedAt", null),
149152
("Name", "Other User Tenant"),
150153
("State", nameof(TenantState.Active)),
151-
("Logo", """{"Url":null,"Version":0}""")
154+
("Logo", """{"Url":null,"Version":0}"""),
155+
("Plan", nameof(SubscriptionPlan.Basis))
152156
]
153157
);
154158

@@ -194,7 +198,8 @@ public async Task GetTenants_UserWithUnconfirmedEmail_ShowsAsNewTenant()
194198
("ModifiedAt", null),
195199
("Name", tenant2Name),
196200
("State", nameof(TenantState.Active)),
197-
("Logo", """{"Url":null,"Version":0}""")
201+
("Logo", """{"Url":null,"Version":0}"""),
202+
("Plan", nameof(SubscriptionPlan.Basis))
198203
]
199204
);
200205

0 commit comments

Comments
 (0)