Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
158 commits
Select commit Hold shift + click to select a range
5e87c6e
Add back-office tenant overview API with queries, repositories, and t…
tjementum May 3, 2026
ec5cb85
Add back-office accounts list, side pane, and detail pages
tjementum May 3, 2026
bcacbab
Polish back-office accounts pages with design fixes and new components
tjementum May 3, 2026
29eb872
Add back-office tenant subscription tracking and blob proxy
tjementum May 3, 2026
d97d0f3
Polish back-office tenant overview list and detail
tjementum May 3, 2026
e08595a
Cover back-office tenant overview list and detail flow
tjementum May 3, 2026
c579d59
Expose HasEverSubscribed on tenant detail response
tjementum May 3, 2026
28bc40e
Polish back-office accounts toolbar filters, side pane status, and cu…
tjementum May 3, 2026
4c0699d
Update back button assertion in back-office accounts e2e
tjementum May 3, 2026
50214b2
Add multi-select plan and status filters to back-office tenants list
tjementum May 3, 2026
e703de4
Add back-office endpoints for Users search and detail
tjementum May 3, 2026
33cdeb7
Extend back-office Users queries with cross-tenant sessions and tenan…
tjementum May 4, 2026
f611eb2
Add back-office dashboard KPIs and trends endpoints
tjementum May 4, 2026
702d91b
Add back-office dashboard with KPI cards and trend charts
tjementum May 4, 2026
ae24b2a
Add back-office dashboard and users e2e smoke coverage
tjementum May 4, 2026
0e283c1
Drop redundant py-4 class on user KPI cards
tjementum May 4, 2026
1b2eb04
Rebuild back-office dashboard with full mockup layout and Stripe events
tjementum May 4, 2026
af5f626
Add period-over-period comparison overlay to dashboard trend charts
tjementum May 4, 2026
44d279a
Polish back-office dashboard: account terminology, legend padding, an…
tjementum May 4, 2026
70498de
Wrap recharts chart types in shared component with accessibilityLayer…
tjementum May 4, 2026
26db15c
Add LinkCard shared component and use it in dashboard KPI tiles and a…
tjementum May 4, 2026
80c0d0f
Consolidate Table styling into shared component
tjementum May 4, 2026
ce6a289
Use shared Card primitives in dashboard card shell
tjementum May 5, 2026
51c8066
Fix back-office dashboard e2e to match CardTitle div instead of headi…
tjementum May 5, 2026
f727577
Add append-only BillingEvent log written from Stripe sync
tjementum May 6, 2026
ae01508
Read recent Stripe events from BillingEvent log and add back-office b…
tjementum May 6, 2026
9566c09
Enrich PaymentTransaction with active plan from Stripe price catalog
tjementum May 6, 2026
15d958c
Add /billing-events back-office page and wire dashboard card to it
tjementum May 6, 2026
ca7f3e4
Add @smoke e2e test for back-office /billing-events flow
tjementum May 6, 2026
9871b87
Add Sync with Stripe admin action on tenant detail page
tjementum May 6, 2026
3e521e4
Add inline billing drift detection with Subscription flags and BackOf…
tjementum May 6, 2026
4ba60d4
Polish billing events surfaces and add Billing tab on tenant detail
tjementum May 6, 2026
fbfaa0f
Reorder billing sections and add view-all links on tenant detail
tjementum May 6, 2026
7451680
Polish back-office tenant detail UI and complete Danish translations
tjementum May 7, 2026
58586d0
Add back-office tenant overview with Stripe billing reconciliation, b…
tjementum May 7, 2026
797ebef
List all back-office users newest-first by default with pagination
tjementum May 7, 2026
db11a14
Rewrite MRR trend using BillingEvent log and add data-quality banners
tjementum May 7, 2026
06a679f
Move Sync with Stripe into a kebab menu and align detail header avatars
tjementum May 7, 2026
4170f87
Polish detail headers with responsive layout and align Current plan h…
tjementum May 8, 2026
4010f9c
Restrict Sync with Stripe to back-office admins
tjementum May 8, 2026
694f965
Add kiosk mode toggle to back-office dashboard
tjementum May 8, 2026
158f0ff
Hide mobile floating sidebar trigger in kiosk mode and center dashboa…
tjementum May 8, 2026
05ee4a0
Convert dashboard recent-events and recent-signups cards to tables
tjementum May 8, 2026
358a3e3
Fix Recent billing events nav target and update e2e tests for back-of…
tjementum May 8, 2026
2770703
Collapse five back-office migrations into one
tjementum May 8, 2026
70cf72e
Display tenant and user IDs on detail surfaces and polish dashboard c…
tjementum May 8, 2026
b743b0e
Replace Created with Signed up for accounts in back-office surfaces
tjementum May 9, 2026
56ac3c3
Make billing events 1:1 with Stripe events and add unsynced and drift…
tjementum May 9, 2026
10d686f
Add multi-source reconciliation to billing event ledger
tjementum May 9, 2026
970e544
Address ultrareview findings on back-office tenant overview branch
tjementum May 10, 2026
076f2c5
Filter account owners list to owner role only
tjementum May 10, 2026
7e36816
Add Open in Stripe deep link to account actions menu
tjementum May 10, 2026
03ff3cf
Group billing events under Billing and drop coming soon section
tjementum May 10, 2026
b918706
Treat post-payment credit notes as Succeeded not Refunded
tjementum May 10, 2026
1548aae
Sort back-office users by last seen with never-seen at the bottom
tjementum May 10, 2026
bec0d6b
Default account list sort to recently modified first
tjementum May 10, 2026
e4ea3c9
Treat refunded payments as previously subscribed when classifying ten…
tjementum May 10, 2026
a46ca4a
Route View all events link to billing-events tab
tjementum May 10, 2026
7cdfb7c
Stop clipping descenders on account and user detail headings
tjementum May 10, 2026
c854c0f
Sort accounts by CreatedAt when navigating from Recent signups
tjementum May 10, 2026
fc1e82d
Add cross-tenant invoices page to back-office
tjementum May 10, 2026
ae5e011
Show relative date and drop download links from invoices list
tjementum May 10, 2026
7667c7f
Show Amount, VAT, Total on compact invoices and drop download links
tjementum May 10, 2026
ac6c744
Show relative date with absolute fallback on back-office tables
tjementum May 10, 2026
30a2fe1
Show first owner name under account name in accounts list
tjementum May 10, 2026
45b8894
Restore ascending toggle on accounts table column sort
tjementum May 10, 2026
c74180b
Link Blended MRR KPI to active and downgrading accounts
tjementum May 10, 2026
9bcc5a1
Include same-pass pending events in drift coverage check
tjementum May 10, 2026
8e6f575
Add Recent payments and Recent logins cards to dashboard
tjementum May 10, 2026
7f65b94
Add customer name, VAT, card brand and country flag to current plan card
tjementum May 10, 2026
855a60b
Persist drift discrepancy enums as strings in JSONB
tjementum May 10, 2026
661b655
Reset subscribed_since on every Stripe sync and add MRR tolerance
tjementum May 10, 2026
dbb9cd9
Stamp BillingEvent currency from Stripe event payload
tjementum May 10, 2026
358accb
Enforce DKK-only currency at Stripe sync boundary and DB CHECK
tjementum May 10, 2026
cda3919
Drop SVG logo uploads and add nosniff header to back-office proxy
tjementum May 10, 2026
4b7d510
Replace orphaned BackfillLegacyBillingEventsAsync comment with curren…
tjementum May 10, 2026
6feb0d8
Seed payment_transactions test JSON with valid tax breakdown
tjementum May 10, 2026
64fd6d1
Add authorization test coverage for AcknowledgeBillingDrift admin end…
tjementum May 10, 2026
fdfd18a
Detect stale denormalized BillingEvent fields via new drift discrepancy
tjementum May 10, 2026
6a29c44
Clamp AmountExcludingTax to non-negative and tighten tax-breakdown CHECK
tjementum May 10, 2026
a62e3ff
Classify subscription deletion via cancel-at-period-end state and can…
tjementum May 10, 2026
4db80d5
Prefer payload unit_amount over live catalog when replaying subscript…
tjementum May 10, 2026
c05ff47
Drive BillingEvent emission from Stripe events.list and rename Sync t…
tjementum May 11, 2026
1eb041e
Persist Stripe Created timestamp on stripe_events and use it for repl…
tjementum May 11, 2026
000c530
Stub back-office billing-events response in e2e to seed table render
tjementum May 11, 2026
6774994
Reconcile scheduled price from catalog on every sync to fix BLENDED M…
tjementum May 11, 2026
8d1b439
Re-model SubscribedSince as MIN(SubscriptionCreated.occurred_at) per …
tjementum May 11, 2026
14fb082
Make stripe_event api_version, stripe_created_at, and payload_hash no…
tjementum May 11, 2026
8c8b065
Replace StripeSyncSweeper with detect-only BillingDriftWorker scannin…
tjementum May 11, 2026
f13db64
Apply project conventions across back-office-tenant-overview branch
tjementum May 11, 2026
ffffbde
Serve SPA public assets via composite file provider in local dev
tjementum May 11, 2026
ed56c61
Use "Back Office" spelling consistently in user-facing strings
tjementum May 11, 2026
5447022
Document deliberate back-office read/write authorization split
tjementum May 11, 2026
d9e9a51
Guard SPA public directory lookup behind IsRunningInAzure to unblock …
tjementum May 11, 2026
3b2c1eb
Skip scheduled-price write when catalog lookup misses to keep webhook…
tjementum May 11, 2026
991dad5
Skip drift status update when Stripe view is unavailable so DriftChec…
tjementum May 11, 2026
d07b23b
Derive platform currency from Stripe at startup and remove hardcoded …
tjementum May 11, 2026
479b4a6
Seed ReplayState from persisted history and add back-office archive r…
tjementum May 11, 2026
d09635d
Polish back-office a11y, copy, and timeout safety from ultra-review b…
tjementum May 11, 2026
14b607a
Preserve subscription and billing event history across tenant soft-de…
tjementum May 11, 2026
42e2cf2
Tighten stripe_events column nullability and add JSONB currency forma…
tjementum May 11, 2026
c60fc24
Read subscription without row lock in detect-mode drift check to unbl…
tjementum May 11, 2026
e8bae61
Restructure back-office e2e suite and add MrrCalculator and BillingDr…
tjementum May 11, 2026
aae226c
Leave pending Stripe events pending when Apply-mode Stripe view is un…
tjementum May 11, 2026
3a74b1f
Fix ledger correctness gaps in events.list anchor, heal-sync drift, a…
tjementum May 11, 2026
c66807b
Apply ultra-review Low-priority cleanup pass
tjementum May 11, 2026
0e41d7b
Add missing using directives to BillingDriftWorkerTests
tjementum May 11, 2026
95d2358
Remove hardcoded DKK guard from BillingEvent and fix M2+M4+M17 lint f…
tjementum May 11, 2026
39089ab
Aggregate main API in OpenAPI and exclude back-office endpoints from …
tjementum May 11, 2026
f99bcbf
Make stripe_events payload-derived columns nullable and INSERT-only
tjementum May 11, 2026
cab8ed2
Enforce ex-VAT amount convention and align MockStripeClient timeline
tjementum May 11, 2026
ac42565
Redesign Current plan card with two-column layout and card brand logos
tjementum May 11, 2026
caaf3b0
Decouple MockStripeClient and tests from hardcoded DKK currency and V…
tjementum May 11, 2026
e081d87
Gate back-office billing surfaces behind PUBLIC_SUBSCRIPTION_ENABLED …
tjementum May 11, 2026
f8778f1
Pass Stripe configuration env vars to account-workers so the BillingD…
tjementum May 11, 2026
3593e11
Skip BillingDriftWorker pass when Stripe is unconfigured to avoid per…
tjementum May 11, 2026
c5c3d9b
Emit BillingEvent for customer-lifecycle events without requiring a S…
tjementum May 11, 2026
6f0587a
Parallelize BillingDriftWorker iterations to cut detect pass from ten…
tjementum May 11, 2026
335847f
Delete cross-store CheckResourceCoverage drift check that false-posit…
tjementum May 12, 2026
3156d70
Stop reading stripe_events.payload on the webhook hot path
tjementum May 12, 2026
bb2eb79
Add disaster-recovery red-alert dialog after Reconcile-with-Stripe drift
tjementum May 12, 2026
6f9c8eb
Seed replayer from live Subscription so cancel-of-scheduled-downgrade…
tjementum May 12, 2026
bc33167
Skip boundary-event row in staleness loop so Reconcile clears drift
tjementum May 12, 2026
663038e
Exclude archive events Stripe already returned from disaster recovery…
tjementum May 12, 2026
69b10e6
Pass PUBLIC_SUBSCRIPTION_ENABLED and PUBLIC_GOOGLE_OAUTH_ENABLED to a…
tjementum May 12, 2026
5708d80
Skip all same-second sibling rows in staleness loop, not just the see…
tjementum May 12, 2026
e651d28
Stop using email tail for Stripe Link last4 and skip card-style field…
tjementum May 12, 2026
4f1913e
Flag cancelled customers with payment transactions but no billing events
tjementum May 12, 2026
3cab019
Use subscription start date on Lifetime Value tile instead of tenant …
tjementum May 12, 2026
6ac172c
Add InvoiceTotal and AmountFromCredit to PaymentTransaction for credi…
tjementum May 12, 2026
cca289b
Sum AmountExcludingTax not InvoiceTotal for Lifetime Value
tjementum May 12, 2026
0dcf0fc
Add Total Revenue KPI tile to back-office dashboard
tjementum May 12, 2026
3be0e81
Add behavioral guidelines section to AGENTS.md
tjementum May 12, 2026
3528a21
Add Revenue timeline chart to back-office dashboard
tjementum May 12, 2026
508cc05
Switch Revenue chart to daily buckets and dip on refund date
tjementum May 12, 2026
0511805
Cumulate Revenue chart per period and overlay prior-period line
tjementum May 12, 2026
41e1842
Revenue chart shows full historical cumulative not period-reset cumul…
tjementum May 12, 2026
787b427
Show per-point dates and delta in current vs prior period chart tooltips
tjementum May 12, 2026
c7f70c6
Rename side pane Renewal date label to Expires for consistency with d…
tjementum May 12, 2026
125ac07
Compute deltas as end-vs-prior-end and color positive green negative red
tjementum May 12, 2026
a4a93cb
Record CreditNotedAt timestamp on PaymentTransaction for credit note …
tjementum May 12, 2026
562fb63
Group Billing Events filters by impact and always show from to plan
tjementum May 12, 2026
51df034
Split invoices and refunds and show credit note date on refunded rows
tjementum May 12, 2026
faa80b7
Show each Billing Events filter type under a single group heading
tjementum May 12, 2026
42b24f3
Render Cancelled plan transitions as the cancelled plan to Basis
tjementum May 12, 2026
9bc4a9b
Use toggle pill filters on billing events and invoices
tjementum May 12, 2026
7e9869a
Render credit notes as their own dated row on invoices surfaces
tjementum May 12, 2026
f415d5a
Project credit-note row when CreditNoteUrl or RefundedAt is set
tjementum May 12, 2026
e4f3f8b
Show Expired with last active date when subscription is canceled
tjementum May 12, 2026
fd5d360
Render invoice and credit note rows with correct status, dates, and R…
tjementum May 12, 2026
14dfd08
Re-query billing events count before drift detection so stale Missing…
tjementum May 12, 2026
8e68992
Exclude credit-noted and refunded transactions from total revenue and…
tjementum May 12, 2026
7be9e59
Fall back to RefundedAt when credit-note timestamp is missing in reve…
tjementum May 12, 2026
0f2310b
Allow billing event pills to overlap and move refunds out of MRR impact
tjementum May 12, 2026
09dc362
Link Total revenue KPI tile to invoices route
tjementum May 12, 2026
66f98ef
Move VAT number under payment method and reserve Expires label for ca…
tjementum May 12, 2026
32c48b7
Add back office demo gif and short captions to README
tjementum May 12, 2026
de545cf
Fold InvoiceTotal and AmountFromCredit columns into the BillingEvents…
tjementum May 12, 2026
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
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
## Behavioral Guidelines

1. **Think before coding.** State assumptions explicitly. If uncertain, ask rather than guess. When multiple interpretations exist, present them - don't pick silently.

2. **Goal-driven execution.** Define success criteria before iterating. Loop until verified. Strong success criteria let you loop independently; "make it work" requires constant clarification.

3. **Read before you write.** Before adding code in a file, read the file's exports, the immediate caller, and obvious shared utilities. "Looks orthogonal to me" is the most dangerous phrase in this codebase.

4. **Checkpoint significant steps.** After each step in a multi-step task, summarize what was done, what's verified, and what's left. Don't continue from a state you can't describe back.

5. **Fail loud.** Surface uncertainty, don't hide it. "Completed" is wrong if anything was skipped silently. "Tests pass" is wrong if any were skipped. "Feature works" is wrong if the edge case wasn't verified.

## Build, Test, and Format

Use the developer CLI skills (`build`, `test`, `format`, `lint`, `e2e`, `aspire-restart`, `team-interrupt`) for all code workflows. They invoke `dotnet run --project developer-cli -- <command>` directly. Never run `dotnet`, `npm`, or `npx` directly - the pre-tool-use Bash hook blocks them.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ Follow our [up-to-date roadmap](https://github.com/orgs/PlatformPlatform/project

Show your support for our project - give us a star on GitHub! It truly means a lot! ⭐

### Back office

Operate the platform: manage account signups, users, and logins, and monitor revenue, MRR, churn, invoices, and Stripe drift.

<img src="https://platformplatformgithub.blob.core.windows.net/BackOffice.gif" alt="PlatformPlatform Back Office" title="PlatformPlatform Back Office" />

### Product demo

End-user flows: tenant signup, account settings, Google login, welcome flow, accessibility and localization, and Stripe-powered subscription signup and management.

<img src="https://platformplatformgithub.blob.core.windows.net/$root/PlatformPlatformDemo.gif" alt="PlatformPlatform Demo" title="PlatformPlatform Demo" />

# Getting Started
Expand Down
173 changes: 173 additions & 0 deletions application/AppGateway.Tests/ApiAggregationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using System.Net;
using System.Text;
using AppGateway.ApiAggregation;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Primitives;
using SharedKernel.Configuration;
using Xunit;
using Yarp.ReverseProxy.Configuration;

namespace AppGateway.Tests;

public sealed class ApiAggregationServiceTests
{
private const string AccountOpenApiJson = """
{
"openapi": "3.0.0",
"info": { "title": "PlatformPlatform Account API", "version": "v1" },
"paths": {
"/api/account/users": { "get": { "responses": { "200": { "description": "OK" } } } },
"/internal-api/account/probe": { "get": { "responses": { "200": { "description": "OK" } } } }
}
}
""";

private const string MainOpenApiJson = """
{
"openapi": "3.0.0",
"info": { "title": "PlatformPlatform Main API", "version": "v1" },
"paths": {
"/api/main/health": { "get": { "responses": { "200": { "description": "OK" } } } }
}
}
""";

// Simulates a future endpoint that accidentally leaks into the account document with a
// /api/back-office prefix; the aggregator's belt-and-braces filter must drop it.
private const string AccountOpenApiJsonWithLeakedBackOfficePath = """
{
"openapi": "3.0.0",
"info": { "title": "PlatformPlatform Account API", "version": "v1" },
"paths": {
"/api/account/users": { "get": { "responses": { "200": { "description": "OK" } } } },
"/api/back-office/leaked": { "get": { "responses": { "200": { "description": "OK" } } } }
}
}
""";

[Fact]
public async Task GetAggregatedOpenApiJson_ShouldIncludeAccountApiPaths()
{
// Arrange
var service = CreateService(AccountOpenApiJson, MainOpenApiJson);

// Act
var aggregated = await service.GetAggregatedOpenApiJson();

// Assert
aggregated.Should().Contain("\"/api/account/users\"");
}

[Fact]
public async Task GetAggregatedOpenApiJson_ShouldIncludeMainApiPaths()
{
// Arrange
var service = CreateService(AccountOpenApiJson, MainOpenApiJson);

// Act
var aggregated = await service.GetAggregatedOpenApiJson();

// Assert
aggregated.Should().Contain("\"/api/main/health\"");
}

[Fact]
public async Task GetAggregatedOpenApiJson_ShouldExcludeBackOfficePaths()
{
// Arrange
var service = CreateService(AccountOpenApiJsonWithLeakedBackOfficePath, MainOpenApiJson);

// Act
var aggregated = await service.GetAggregatedOpenApiJson();

// Assert
aggregated.Should().NotContain("/api/back-office/");
aggregated.Should().Contain("\"/api/account/users\"");
}

[Fact]
public async Task GetAggregatedOpenApiJson_ShouldExcludeInternalApiPaths()
{
// Arrange
var service = CreateService(AccountOpenApiJson, MainOpenApiJson);

// Act
var aggregated = await service.GetAggregatedOpenApiJson();

// Assert
aggregated.Should().NotContain("/internal-api/");
}

private static ApiAggregationService CreateService(string accountDocumentJson, string mainDocumentJson)
{
var handler = new StubHttpMessageHandler(request =>
{
var requestUrl = request.RequestUri!.ToString();
if (requestUrl.EndsWith("/openapi/account.json", StringComparison.OrdinalIgnoreCase))
{
return BuildJsonResponse(accountDocumentJson);
}

if (requestUrl.EndsWith("/openapi/v1.json", StringComparison.OrdinalIgnoreCase))
{
return BuildJsonResponse(mainDocumentJson);
}

return new HttpResponseMessage(HttpStatusCode.NotFound) { ReasonPhrase = $"Unstubbed URL: {requestUrl}" };
}
);

var clusters = new ClusterConfig[]
{
new() { ClusterId = "account-api", Destinations = new Dictionary<string, DestinationConfig> { ["destination"] = new() { Address = "https://placeholder.invalid" } } },
new() { ClusterId = "main-api", Destinations = new Dictionary<string, DestinationConfig> { ["destination"] = new() { Address = "https://placeholder.invalid" } } }
};

var proxyConfigProvider = new StubProxyConfigProvider(new StubProxyConfig(clusters));
var httpClientFactory = new StubHttpClientFactory(handler);
var ports = new PortAllocation(9000);
return new ApiAggregationService(NullLogger<ApiAggregationService>.Instance, proxyConfigProvider, httpClientFactory, ports);
}

private static HttpResponseMessage BuildJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}

private sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(responder(request));
}
}

private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name)
{
return new HttpClient(handler, false);
}
}

private sealed class StubProxyConfigProvider(IProxyConfig config) : IProxyConfigProvider
{
public IProxyConfig GetConfig()
{
return config;
}
}

private sealed class StubProxyConfig(IReadOnlyList<ClusterConfig> clusters) : IProxyConfig
{
public IReadOnlyList<RouteConfig> Routes => Array.Empty<RouteConfig>();

public IReadOnlyList<ClusterConfig> Clusters => clusters;

public IChangeToken ChangeToken => new CancellationChangeToken(CancellationToken.None);
}
}
34 changes: 31 additions & 3 deletions application/AppGateway/ApiAggregation/ApiAggregationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

namespace AppGateway.ApiAggregation;

public class ApiAggregationService(
public sealed class ApiAggregationService(
ILogger<ApiAggregationService> logger,
IProxyConfigProvider proxyConfigProvider,
IHttpClientFactory httpClientFactory,
Expand Down Expand Up @@ -36,16 +36,27 @@
var proxyConfiguration = proxyConfigProvider.GetConfig();

// account-api emits two OpenAPI documents (account, back-office) post-consolidation; the
// user-facing aggregator only surfaces 'account' since back-office endpoints don't appear
// in the user-facing contract.
// user-facing aggregator only surfaces 'account' since back-office endpoints are admin-only
// and don't appear in the public contract.
var accountCluster = proxyConfiguration.Clusters.FirstOrDefault(c => c.ClusterId == "account-api");
if (accountCluster is not null)
{
var accountDocument = await FetchOpenApiDocument(accountCluster, "account");
CombineOpenApiDocuments(aggregatedOpenApiDocument, accountDocument);
}

// main-api emits a single OpenAPI document at /openapi/v1.json (ApiDocumentLayout.Single).
// It must be aggregated so future routes added to main appear in the unified public contract
// without further wiring here.
var mainCluster = proxyConfiguration.Clusters.FirstOrDefault(c => c.ClusterId == "main-api");
if (mainCluster is not null)
{
var mainDocument = await FetchOpenApiDocument(mainCluster, "v1");
CombineOpenApiDocuments(aggregatedOpenApiDocument, mainDocument);
}

FilterInternalEndpoints(aggregatedOpenApiDocument);
FilterBackOfficeEndpoints(aggregatedOpenApiDocument);

return aggregatedOpenApiDocument;
}
Expand Down Expand Up @@ -73,7 +84,7 @@
private void CombineOpenApiDocuments(OpenApiDocument aggregatedOpenApiDocument, OpenApiDocument openApiDocument)
{
// Merge paths
foreach (var path in openApiDocument.Paths)

Check warning on line 87 in application/AppGateway/ApiAggregation/ApiAggregationService.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Loops should be simplified using the "Where" LINQ method
{
if (!aggregatedOpenApiDocument.Paths.ContainsKey(path.Key))
{
Expand Down Expand Up @@ -116,4 +127,21 @@
openApiDocument.Paths.Remove(path);
}
}

// Belt-and-braces guard: account-api's 'account' document already excludes back-office endpoints
// because they're grouped under a separate ApiExplorer document, but if a future endpoint
// accidentally leaks into the account group with a /api/back-office/ prefix we still drop it
// from the public contract.
private static void FilterBackOfficeEndpoints(OpenApiDocument openApiDocument)
{
var backOfficePaths = openApiDocument.Paths
.Where(p => p.Key.StartsWith("/api/back-office/"))
.Select(p => p.Key)
.ToArray();

foreach (var path in backOfficePaths)
{
openApiDocument.Paths.Remove(path);
}
}
}
12 changes: 12 additions & 0 deletions application/AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@
.WithEnvironment("KESTREL_PORT", ports.AccountWorkers.ToString())
.WithReference(accountDatabase)
.WithReference(azureStorage)
// The BillingDriftWorker resolves StripeClientFactory which reads these. Without them the worker
// process sees UnconfiguredStripeClient even when Stripe is configured at the API level, and every
// stale subscription logs a warn + fail line through ProcessPendingStripeEvents on every worker start.
.WithEnvironment("Stripe__SubscriptionEnabled", stripeFullyConfigured ? "true" : "false")
.WithEnvironment("Stripe__ApiKey", stripeApiKey)
.WithEnvironment("Stripe__WebhookSecret", stripeWebhookSecret)
.WithEnvironment("Stripe__PublishableKey", stripePublishableKey)
.WithEnvironment("Stripe__AllowMockProvider", "true")
.WaitFor(accountDatabase);

var accountApi = builder
Expand Down Expand Up @@ -114,6 +122,10 @@
.WithEnvironment("Stripe__WebhookSecret", stripeWebhookSecret)
.WithEnvironment("Stripe__PublishableKey", stripePublishableKey)
.WithEnvironment("Stripe__AllowMockProvider", "true")
.WithEnvironment("PUBLIC_GOOGLE_OAUTH_ENABLED", googleOAuthConfigured ? "true" : "false")
// Force-on so newcomers see the back-office billing UI without Stripe configured. Set to "false" (or
// change back to `stripeFullyConfigured ? "true" : "false"`) to hide all billing/revenue/Stripe data.
.WithEnvironment("PUBLIC_SUBSCRIPTION_ENABLED", "true")
.WaitFor(accountWorkers);

var mainDatabase = postgres
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
using SharedKernel.Endpoints;
using SharedKernel.OpenApi;

namespace Account.Api.Endpoints;
namespace Account.Api.BackOffice;

public sealed class BackOfficeEndpoints : IEndpoints
{
Expand All @@ -14,7 +14,7 @@ public sealed class BackOfficeEndpoints : IEndpoints
public void MapEndpoints(IEndpointRouteBuilder routes)
{
// BackOffice:Host is required (validated at startup via ValidateOnStart in
// ApiDependencyConfiguration.AddBackOfficeHostOptions). PP-1149 must keep that validation in place
// ApiDependencyConfiguration.AddBackOfficeHostOptions). The startup validation must stay in place
// so a missing/blank value fails loudly rather than silently 404-ing back-office endpoints.
var backOfficeHost = routes.ServiceProvider.GetRequiredService<IOptions<BackOfficeHostOptions>>().Value.Host;

Expand Down
37 changes: 37 additions & 0 deletions application/account/Api/BackOffice/BillingDriftEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Account.Features.BackOffice.BillingDrift.Queries;
using Microsoft.Extensions.Options;
using SharedKernel.ApiResults;
using SharedKernel.Authentication.BackOfficeIdentity;
using SharedKernel.Endpoints;
using SharedKernel.OpenApi;

namespace Account.Api.BackOffice;

public sealed class BillingDriftEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/back-office/billing-drift";

public void MapEndpoints(IEndpointRouteBuilder routes)
{
var backOfficeHost = routes.ServiceProvider.GetRequiredService<IOptions<BackOfficeHostOptions>>().Value.Host;

var group = routes.MapGroup(RoutesPrefix)
.WithTags("BackOfficeBillingDrift")
.WithGroupName(OpenApiDocumentNames.BackOffice)
.RequireHost(backOfficeHost)
.RequireAuthorization(BackOfficeIdentityDefaults.PolicyName)
.ProducesValidationProblem();

group.MapGet("/summary", async Task<ApiResult<BillingDriftSummaryResponse>> ([AsParameters] GetBillingDriftSummaryQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<BillingDriftSummaryResponse>();

group.MapGet("/unsynced-summary", async Task<ApiResult<UnsyncedSubscriptionsSummaryResponse>> ([AsParameters] GetUnsyncedSubscriptionsSummaryQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<UnsyncedSubscriptionsSummaryResponse>();

group.MapGet("/mrr-consistency-summary", async Task<ApiResult<DashboardMrrConsistencySummaryResponse>> ([AsParameters] GetDashboardMrrConsistencySummaryQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<DashboardMrrConsistencySummaryResponse>();
}
}
29 changes: 29 additions & 0 deletions application/account/Api/BackOffice/BillingEventsEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Account.Features.BackOffice.BillingEvents.Queries;
using Microsoft.Extensions.Options;
using SharedKernel.ApiResults;
using SharedKernel.Authentication.BackOfficeIdentity;
using SharedKernel.Endpoints;
using SharedKernel.OpenApi;

namespace Account.Api.BackOffice;

public sealed class BillingEventsEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/back-office/billing-events";

public void MapEndpoints(IEndpointRouteBuilder routes)
{
var backOfficeHost = routes.ServiceProvider.GetRequiredService<IOptions<BackOfficeHostOptions>>().Value.Host;

var group = routes.MapGroup(RoutesPrefix)
.WithTags("BackOfficeBillingEvents")
.WithGroupName(OpenApiDocumentNames.BackOffice)
.RequireHost(backOfficeHost)
.RequireAuthorization(BackOfficeIdentityDefaults.PolicyName)
.ProducesValidationProblem();

group.MapGet("/", async Task<ApiResult<BillingEventsResponse>> ([AsParameters] GetBackOfficeBillingEventsQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<BillingEventsResponse>();
}
}
Loading
Loading