feat: billing + tier enforcement + Stripe integration (#9)#10
feat: billing + tier enforcement + Stripe integration (#9)#10CIKR-Repos merged 2 commits intomainfrom
Conversation
Reviewer's GuideImplements Stripe-based billing, subscription and usage tracking with tier enforcement (Free/Pro/Enterprise), adds a billing UI and usage dashboard on the client, and refactors the pipeline builder into a block-based editor with richer configuration and preview. Sequence diagram for Stripe checkout and subscription activationsequenceDiagram
actor U as User
participant BP as BillingComponent
participant BS as BillingService_Angular
participant BC as BillingController
participant SS as StripeService
participant S as Stripe
participant SW as StripeWebhooks
participant BWC as BillingController_webhook
participant SS2 as StripeService_webhook
participant DB as PipeRagDbContext
U->>BP: Click Upgrade(Pro)
BP->>BS: upgrade("Pro")
BS->>BC: POST /api/billing/create-checkout-session
BC->>SS: CreateCheckoutSessionAsync(userId, Pro, successUrl, cancelUrl)
SS->>DB: Load User and Subscription
SS->>S: Create Customer and Checkout Session
S-->>SS: CheckoutSession(url)
SS-->>BC: url
BC-->>BS: { url }
BS-->>U: Redirect to Stripe Checkout
SW->>BWC: POST /api/billing/webhook
BWC->>SS2: HandleWebhookAsync(json, signature)
SS2->>S: Verify event using webhook secret
S-->>SS2: checkout.session.completed
SS2->>DB: Load or create Subscription
SS2->>DB: Update Subscription(Tier=Pro, Status=Active)
SS2->>DB: Update User.Tier = Pro
SS2-->>BWC: success
BWC-->>SW: 200 OK
Sequence diagram for tier enforcement on write operationssequenceDiagram
participant C as Client
participant API as ASP.NET_Core
participant TEM as TierEnforcementMiddleware
participant UTS as IUsageTrackingService
participant CTRL as TargetController
C->>API: POST /api/chat
API->>TEM: InvokeAsync(HttpContext)
TEM->>TEM: Check user authenticated and path
TEM->>UTS: CanPerformQueryAsync(userId)
UTS-->>TEM: true or false
alt limit not reached
TEM->>API: Call next middleware
API->>CTRL: Handle chat request
CTRL-->>C: 200 OK with response
else limit reached
TEM-->>C: 429 Too Many Requests
end
Entity relationship diagram for subscriptions, usage and widget configerDiagram
User ||--o| Subscription : has
User ||--o{ UsageRecord : has
User ||--o{ Project : owns
Project ||--o{ Document : has
Project ||--|| WidgetConfig : has
User {
guid Id
string Email
string Tier
}
Subscription {
guid Id
guid UserId
string StripeCustomerId
string StripeSubscriptionId
string StripePriceId
string Tier
string Status
datetime CurrentPeriodStart
datetime CurrentPeriodEnd
datetime CreatedAt
datetime UpdatedAt
datetime CancelledAt
}
UsageRecord {
guid Id
guid UserId
date Date
int QueryCount
int DocumentCount
int ProjectCount
long StorageBytes
datetime UpdatedAt
}
Project {
guid Id
guid OwnerId
}
Document {
guid Id
guid ProjectId
long FileSizeBytes
}
WidgetConfig {
guid Id
guid ProjectId
string PrimaryColor
string BackgroundColor
string TextColor
string Position
string AvatarUrl
string Title
string Subtitle
string PlaceholderText
string AllowedOrigins
bool IsActive
datetime CreatedAt
datetime UpdatedAt
}
Class diagram for billing, usage tracking and tier enforcementclassDiagram
class IBillingService {
<<interface>>
+Task~string~ CreateCheckoutSessionAsync(Guid userId, UserTier tier, string successUrl, string cancelUrl)
+Task~string~ CreatePortalSessionAsync(Guid userId, string returnUrl)
+Task HandleWebhookAsync(string json, string stripeSignature)
+Task~SubscriptionDto?~ GetSubscriptionAsync(Guid userId)
}
class IUsageTrackingService {
<<interface>>
+Task IncrementQueryCountAsync(Guid userId)
+Task~UsageDto~ GetUsageAsync(Guid userId)
+Task~bool~ CanPerformQueryAsync(Guid userId)
+Task~bool~ CanCreateDocumentAsync(Guid userId)
+Task~bool~ CanCreateProjectAsync(Guid userId)
+Task~bool~ CanUploadStorageAsync(Guid userId, long additionalBytes)
+Task RecalculateDocumentCountAsync(Guid userId)
+Task RecalculateProjectCountAsync(Guid userId)
+Task RecalculateStorageAsync(Guid userId)
}
class StripeService {
-PipeRagDbContext _db
-IConfiguration _config
-ILogger_StripeService_ _logger
+StripeService(PipeRagDbContext db, IConfiguration config, ILogger_StripeService_ logger)
+Task~string~ CreateCheckoutSessionAsync(Guid userId, UserTier tier, string successUrl, string cancelUrl)
+Task~string~ CreatePortalSessionAsync(Guid userId, string returnUrl)
+Task HandleWebhookAsync(string json, string stripeSignature)
+Task~SubscriptionDto?~ GetSubscriptionAsync(Guid userId)
-Task HandleCheckoutCompleted(Event stripeEvent)
-Task HandleSubscriptionUpdated(Event stripeEvent)
-Task HandleSubscriptionDeleted(Event stripeEvent)
}
class UsageTrackingService {
-PipeRagDbContext _db
+UsageTrackingService(PipeRagDbContext db)
+Task IncrementQueryCountAsync(Guid userId)
+Task~UsageDto~ GetUsageAsync(Guid userId)
+Task~bool~ CanPerformQueryAsync(Guid userId)
+Task~bool~ CanCreateDocumentAsync(Guid userId)
+Task~bool~ CanCreateProjectAsync(Guid userId)
+Task~bool~ CanUploadStorageAsync(Guid userId, long additionalBytes)
+Task RecalculateDocumentCountAsync(Guid userId)
+Task RecalculateProjectCountAsync(Guid userId)
+Task RecalculateStorageAsync(Guid userId)
-Task~UsageRecord~ GetOrCreateTodayRecord(Guid userId)
}
class TierEnforcementMiddleware {
-RequestDelegate _next
-ILogger_TierEnforcementMiddleware_ _logger
-static HashSet~string~ QueryPaths
-static HashSet~string~ DocumentPaths
-static HashSet~string~ ProjectPaths
+TierEnforcementMiddleware(RequestDelegate next, ILogger_TierEnforcementMiddleware_ logger)
+Task InvokeAsync(HttpContext context, IUsageTrackingService usageService)
}
class BillingController {
-IBillingService _billing
-IUsageTrackingService _usage
+BillingController(IBillingService billing, IUsageTrackingService usage)
+Task~ActionResult~ CreateCheckoutSession(CheckoutRequest request)
+Task~ActionResult~ CreatePortalSession(PortalRequest request)
+Task~ActionResult~ Webhook()
+Task~ActionResult~ GetSubscription()
+Task~ActionResult~ GetUsage()
-Guid GetUserId()
}
class WidgetController {
-PipeRagDbContext _db
+WidgetController(PipeRagDbContext db)
+Task~ActionResult~ Get(Guid projectId, CancellationToken ct)
+Task~ActionResult~ Upsert(Guid projectId, WidgetConfigRequest request, CancellationToken ct)
+Task~IActionResult~ Delete(Guid projectId, CancellationToken ct)
-Task~Project?~ GetAuthorizedProjectAsync(Guid projectId, CancellationToken ct)
-Guid GetUserId()
-WidgetConfigResponse MapToResponse(WidgetConfig c)
}
class SubscriptionDto {
+UserTier Tier
+string Status
+DateTime CurrentPeriodEnd
+string StripeCustomerId
}
class UsageDto {
+int QueriesUsed
+int QueriesLimit
+int DocumentsUsed
+int DocumentsLimit
+int ProjectsUsed
+int ProjectsLimit
+long StorageBytesUsed
+long StorageBytesLimit
+UserTier Tier
}
class Subscription {
+Guid Id
+Guid UserId
+string StripeCustomerId
+string StripeSubscriptionId
+string StripePriceId
+UserTier Tier
+SubscriptionStatus Status
+DateTime CurrentPeriodStart
+DateTime CurrentPeriodEnd
+DateTime CreatedAt
+DateTime UpdatedAt
+DateTime CancelledAt
}
class UsageRecord {
+Guid Id
+Guid UserId
+DateTime Date
+int QueryCount
+int DocumentCount
+int ProjectCount
+long StorageBytes
+DateTime UpdatedAt
}
class WidgetConfig {
+Guid Id
+Guid ProjectId
+string PrimaryColor
+string BackgroundColor
+string TextColor
+string Position
+string AvatarUrl
+string Title
+string Subtitle
+string PlaceholderText
+string AllowedOrigins
+bool IsActive
+DateTime CreatedAt
+DateTime UpdatedAt
}
class User {
+Guid Id
+string Email
+UserTier Tier
}
class TierLimits {
+static GetLimits(UserTier tier) (int,int,int,long)
}
IBillingService <|.. StripeService
IUsageTrackingService <|.. UsageTrackingService
BillingController --> IBillingService
BillingController --> IUsageTrackingService
TierEnforcementMiddleware --> IUsageTrackingService
StripeService --> Subscription
StripeService --> User
UsageTrackingService --> UsageRecord
UsageTrackingService --> User
UsageTrackingService --> Document
UsageTrackingService --> Project
WidgetController --> WidgetConfig
WidgetController --> Project
TierLimits ..> UsageTrackingService
TierLimits ..> IUsageTrackingService
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 6 issues, and left some high level feedback:
- In
PipelineComponentthe newonConfigChangeNum/onConfigChangeFloatimplementations always update the config withNaNon parse failures, whereas the previous versions guarded withisNaN; consider restoring the guards so invalid slider/number input doesn’t silently corrupt block config. - In
StripeService.HandleSubscriptionUpdated,CurrentPeriodStart/CurrentPeriodEndare set fromstripeSub.CreatedandstripeSub.EndedAt; Stripe subscriptions exposeCurrentPeriodStart/CurrentPeriodEndexplicitly, which are a better fit for billing cycles than creation/ended timestamps. - The
TierEnforcementMiddlewarecurrently only checksPOSTrequests and matches paths against static prefixes like/api/chatand/api/documents; verify the actual API routes (e.g. project-scoped document uploads) align with these prefixes, otherwise some tier limits may never be enforced.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `PipelineComponent` the new `onConfigChangeNum`/`onConfigChangeFloat` implementations always update the config with `NaN` on parse failures, whereas the previous versions guarded with `isNaN`; consider restoring the guards so invalid slider/number input doesn’t silently corrupt block config.
- In `StripeService.HandleSubscriptionUpdated`, `CurrentPeriodStart`/`CurrentPeriodEnd` are set from `stripeSub.Created` and `stripeSub.EndedAt`; Stripe subscriptions expose `CurrentPeriodStart`/`CurrentPeriodEnd` explicitly, which are a better fit for billing cycles than creation/ended timestamps.
- The `TierEnforcementMiddleware` currently only checks `POST` requests and matches paths against static prefixes like `/api/chat` and `/api/documents`; verify the actual API routes (e.g. project-scoped document uploads) align with these prefixes, otherwise some tier limits may never be enforced.
## Individual Comments
### Comment 1
<location> `client/src/app/pages/pipeline/pipeline.ts:36-38` </location>
<code_context>
- if (this.projectId) {
- this.svc.runPipeline(this.projectId);
- }
+ onConfigChange(blockId: string, key: string, event: Event): void {
+ const el = event.target as HTMLInputElement;
+ this.svc.updateBlockConfig(blockId, key, el.value);
+ }
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Restore broader element typing and NaN-guards for config change handlers to avoid runtime issues.
The handlers now assume `event.target` is always an `HTMLInputElement` and unconditionally write `el.value` (or parsed numbers) into `block.config`. This drops support for `HTMLSelectElement` / `HTMLTextAreaElement` and removes the previous `NaN` checks in numeric handlers, so `parseInt` / `parseFloat` results of `NaN` will still be written to config and can break code that expects valid numbers.
Please restore the broader element union type on `onConfigChange` and reintroduce `isNaN` guards in the numeric handlers so config is only updated with valid values.
</issue_to_address>
### Comment 2
<location> `client/src/app/pages/pipeline/pipeline.ts:88-90` </location>
<code_context>
}
blockSummary(block: PipelineBlock): string {
+ const c = block.config;
switch (block.type) {
- case 'source': return `${block.config['sourceType']} · ${block.config['fileTypes']}`;
- case 'chunking': return `${block.config['strategy']} · ${block.config['chunkSize']} tokens · ${block.config['chunkOverlap']} overlap`;
- case 'embedding': return `${block.config['model']} · ${block.config['dimensions']}d`;
- case 'retrieval': return `${block.config['strategy']} · top ${block.config['topK']} · threshold ${block.config['scoreThreshold']}`;
- case 'generation': return `${block.config['model']} · temp ${block.config['temperature']}`;
+ case 'source': return c['sourceType'] + ' · ' + c['fileTypes'];
+ case 'chunking': return c['strategy'] + ' · ' + c['chunkSize'] + ' chars';
+ case 'embedding': return c['model'] + ' · ' + c['dimensions'] + 'd';
+ case 'retrieval': return c['strategy'] + ' · top ' + c['topK'];
</code_context>
<issue_to_address>
**suggestion:** Align `blockSummary` units and content with the underlying configuration semantics.
Two regressions in `blockSummary`:
- `chunking` now labels `chunkSize` as `'chars'`, but elsewhere it’s treated as tokens (e.g. `chunkPreviews`, UI sliders). This misalignment can confuse users about what they’re tuning.
- `retrieval` no longer surfaces `scoreThreshold`, reducing the value of the summary.
Please keep the unit consistent with the actual backend semantics (likely tokens) and re-include `scoreThreshold` in the retrieval summary (e.g. `strategy · top {topK} · threshold {scoreThreshold}`).
```suggestion
case 'chunking': return c['strategy'] + ' · ' + c['chunkSize'] + ' tokens · ' + c['chunkOverlap'] + ' overlap';
case 'embedding': return c['model'] + ' · ' + c['dimensions'] + 'd';
case 'retrieval': return c['strategy'] + ' · top ' + c['topK'] + ' · threshold ' + c['scoreThreshold'];
```
</issue_to_address>
### Comment 3
<location> `src/PipeRAG.Infrastructure/Services/StripeService.cs:168-169` </location>
<code_context>
+ "trialing" => SubscriptionStatus.Trialing,
+ _ => SubscriptionStatus.Active
+ };
+ sub.CurrentPeriodStart = stripeSub.Created;
+ sub.CurrentPeriodEnd = stripeSub.EndedAt;
+ sub.UpdatedAt = DateTime.UtcNow;
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Use Stripe’s current period properties instead of `Created`/`EndedAt` for subscription periods.
In `HandleSubscriptionUpdated`, mapping period fields this way will persist incorrect data. `Created` is the subscription creation time, not the current billing period start, and `EndedAt` is only populated once the subscription actually ends (so it will usually be null for active subs). Please use `stripeSub.CurrentPeriodStart` and `stripeSub.CurrentPeriodEnd` instead so your stored period values are correct for both active and canceled subscriptions.
</issue_to_address>
### Comment 4
<location> `src/PipeRAG.Api/Middleware/TierEnforcementMiddleware.cs:26-7` </location>
<code_context>
+ var path = context.Request.Path.Value?.ToLower() ?? "";
+ var method = context.Request.Method;
+
+ if (method != "POST" || !context.User.Identity?.IsAuthenticated == true)
+ {
+ await _next(context);
+ return;
</code_context>
<issue_to_address>
**nitpick:** Simplify and clarify the authentication guard expression in the middleware.
The expression `!context.User.Identity?.IsAuthenticated == true` is hard to parse and depends on nullable-bool negation. To improve readability and avoid nullability pitfalls, consider:
```csharp
if (!HttpMethods.IsPost(method) || context.User.Identity?.IsAuthenticated != true)
{
await _next(context);
return;
}
```
This preserves the behavior while making the control flow clearer.
</issue_to_address>
### Comment 5
<location> `src/PipeRAG.Api/Controllers/BillingController.cs:22` </location>
<code_context>
+ _usage = usage;
+ }
+
+ private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
+
+ [Authorize]
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard against missing or malformed user ID claims to avoid 500s in billing endpoints.
`Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!)` will throw if the claim is missing or not a valid GUID, causing a 500 on billing requests.
Instead, consider:
- Using `Guid.TryParse` and returning `Unauthorized`/`Forbid` when parsing fails, or
- Calling a shared helper that already does robust user ID extraction and error handling.
That way billing endpoints fail gracefully on bad or unexpected tokens rather than erroring.
</issue_to_address>
### Comment 6
<location> `docs/features/pr-009-billing-tier-enforcement.md:41-44` </location>
<code_context>
+- `customer.subscription.deleted` — Downgrade to Free tier on cancellation
+
+## Configuration
+Required in `appsettings.json`:
+```json
+{
+ "Stripe": {
+ "SecretKey": "sk_...",
+ "WebhookSecret": "whsec_...",
</code_context>
<issue_to_address>
**🚨 suggestion (security):** Clarify that Stripe keys should be sourced from secure configuration rather than committed directly in appsettings.json.
Consider adding a brief note that `SecretKey`, `WebhookSecret`, and price IDs should be supplied via secure configuration (e.g., environment variables, user secrets, key vault) rather than committed in `appsettings.json`, to avoid accidental exposure of real Stripe secrets in source control.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| onConfigChange(blockId: string, key: string, event: Event): void { | ||
| const el = event.target as HTMLInputElement; | ||
| this.svc.updateBlockConfig(blockId, key, el.value); |
There was a problem hiding this comment.
issue (bug_risk): Restore broader element typing and NaN-guards for config change handlers to avoid runtime issues.
The handlers now assume event.target is always an HTMLInputElement and unconditionally write el.value (or parsed numbers) into block.config. This drops support for HTMLSelectElement / HTMLTextAreaElement and removes the previous NaN checks in numeric handlers, so parseInt / parseFloat results of NaN will still be written to config and can break code that expects valid numbers.
Please restore the broader element union type on onConfigChange and reintroduce isNaN guards in the numeric handlers so config is only updated with valid values.
| case 'chunking': return c['strategy'] + ' · ' + c['chunkSize'] + ' chars'; | ||
| case 'embedding': return c['model'] + ' · ' + c['dimensions'] + 'd'; | ||
| case 'retrieval': return c['strategy'] + ' · top ' + c['topK']; |
There was a problem hiding this comment.
suggestion: Align blockSummary units and content with the underlying configuration semantics.
Two regressions in blockSummary:
chunkingnow labelschunkSizeas'chars', but elsewhere it’s treated as tokens (e.g.chunkPreviews, UI sliders). This misalignment can confuse users about what they’re tuning.retrievalno longer surfacesscoreThreshold, reducing the value of the summary.
Please keep the unit consistent with the actual backend semantics (likely tokens) and re-includescoreThresholdin the retrieval summary (e.g.strategy · top {topK} · threshold {scoreThreshold}).
| case 'chunking': return c['strategy'] + ' · ' + c['chunkSize'] + ' chars'; | |
| case 'embedding': return c['model'] + ' · ' + c['dimensions'] + 'd'; | |
| case 'retrieval': return c['strategy'] + ' · top ' + c['topK']; | |
| case 'chunking': return c['strategy'] + ' · ' + c['chunkSize'] + ' tokens · ' + c['chunkOverlap'] + ' overlap'; | |
| case 'embedding': return c['model'] + ' · ' + c['dimensions'] + 'd'; | |
| case 'retrieval': return c['strategy'] + ' · top ' + c['topK'] + ' · threshold ' + c['scoreThreshold']; |
| sub.CurrentPeriodStart = stripeSub.Created; | ||
| sub.CurrentPeriodEnd = stripeSub.EndedAt; |
There was a problem hiding this comment.
issue (bug_risk): Use Stripe’s current period properties instead of Created/EndedAt for subscription periods.
In HandleSubscriptionUpdated, mapping period fields this way will persist incorrect data. Created is the subscription creation time, not the current billing period start, and EndedAt is only populated once the subscription actually ends (so it will usually be null for active subs). Please use stripeSub.CurrentPeriodStart and stripeSub.CurrentPeriodEnd instead so your stored period values are correct for both active and canceled subscriptions.
| namespace PipeRAG.Api.Middleware; | ||
|
|
||
| public class TierEnforcementMiddleware | ||
| { |
There was a problem hiding this comment.
nitpick: Simplify and clarify the authentication guard expression in the middleware.
The expression !context.User.Identity?.IsAuthenticated == true is hard to parse and depends on nullable-bool negation. To improve readability and avoid nullability pitfalls, consider:
if (!HttpMethods.IsPost(method) || context.User.Identity?.IsAuthenticated != true)
{
await _next(context);
return;
}This preserves the behavior while making the control flow clearer.
| _usage = usage; | ||
| } | ||
|
|
||
| private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); |
There was a problem hiding this comment.
issue (bug_risk): Guard against missing or malformed user ID claims to avoid 500s in billing endpoints.
Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!) will throw if the claim is missing or not a valid GUID, causing a 500 on billing requests.
Instead, consider:
- Using
Guid.TryParseand returningUnauthorized/Forbidwhen parsing fails, or - Calling a shared helper that already does robust user ID extraction and error handling.
That way billing endpoints fail gracefully on bad or unexpected tokens rather than erroring.
| Required in `appsettings.json`: | ||
| ```json | ||
| { | ||
| "Stripe": { |
There was a problem hiding this comment.
🚨 suggestion (security): Clarify that Stripe keys should be sourced from secure configuration rather than committed directly in appsettings.json.
Consider adding a brief note that SecretKey, WebhookSecret, and price IDs should be supplied via secure configuration (e.g., environment variables, user secrets, key vault) rather than committed in appsettings.json, to avoid accidental exposure of real Stripe secrets in source control.
feat: deploy + CI/CD pipeline (#10)
Stripe checkout/portal, usage tracking, tier limits (Free/Pro/Enterprise), billing UI
Summary by Sourcery
Introduce Stripe-powered billing, subscription management, and tier-based usage enforcement across the app, alongside a revamped pipeline builder UI and a new billing dashboard.
New Features:
Bug Fixes:
Enhancements:
Documentation:
Tests: