Unified Billing Architecture Proposal
This document proposes a redesign of the billing data model to unify Stripe and Enterprise billing into a single, maintainable system with full subscription lifecycle tracking.
Problem Statement
The current architecture has two parallel billing systems with different data models, scattered workspace flags, and no clear plan lifecycle.
Current Issues
| Problem | Details |
|---|---|
| Scattered workspace flags | IsEnterprise, IsTrial, TrialStartDate, TrialEndDate, StripeActiveSubscriptionId, WorkspacePlan -- 6 plan-related columns on workspace |
| 1:1 enterprise plan | Only one enterprise_plan record per workspace. Renewal overwrites dates with no history |
| Invoices disconnected from plans | workspace_invoice links to workspace. stripe_invoice links to Stripe subscription. Neither links to a billing period |
| No trial management | Trial dates live on workspace, not on the plan. No admin-created trials |
| Two billing period sources | Stripe uses current_period_start/end. Enterprise uses BillingPeriodStart/End. Dashboard has priority logic to pick one |
| No automatic deactivation | Only auto-renew handles expiry. No system verifies non-renewing plans expire correctly |
| Cancel to meaningless default | When Stripe cancels, workspace gets plan=default with DateTime.UtcNow as both period start AND end |
| Redundant flags | IsEnterprise derivable from plan source. IsTrial derivable from plan status |
Current Data Model
Proposed Architecture
Core Concept
One unified workspace_subscription table replaces enterprise_plan, workspace_plan_history, and workspace_metrics_record. It tracks the full lifecycle of every billing period from any source: Stripe, Enterprise, or System.
Key principles:
- A workspace has many subscriptions over time (1:N) but only one active at a time
- The
sourcefield indicates who manages billing (stripe, enterprise, system) - Invoices belong to the subscription (billing period), not the workspace
- Raw Stripe data stays in
stripe_subscription/stripe_invoiceas an audit mirror workspace.ActiveWorkspacePlanremains as a denormalized plan tier for fast limit lookupsworkspace.ActiveSubscriptionIdprovides one-hop access to the current subscription- Metrics are stored directly on the subscription record (live for active, frozen at deactivation)
- Default plan is a real auto-renewing system subscription (30-day cycle)
- Trial is a plan tier in
workspace_planwith its own limits
Proposed Data Model
Workspace Entity Changes
| Column | Action | Reason |
|---|---|---|
IsEnterprise | Remove | Derived: ActiveSubscription.Source == "enterprise" |
IsTrial | Remove | Derived: ActiveSubscription.Status == "trialing" |
TrialStartDate | Remove | Lives on workspace_subscription.period_start |
TrialEndDate | Remove | Lives on workspace_subscription.period_end |
StripeActiveSubscriptionId | Remove | Derived: ActiveSubscription.StripeSubscriptionId |
WorkspacePlan | Rename | ActiveWorkspacePlan -- denormalized current tier for fast limit lookups |
StripeCustomerId | Keep | Needed for Stripe API calls |
ActiveSubscriptionId | Add | GUID FK to workspace_subscription -- one-hop access |
Workspace Plan Changes
| Column | Action | Reason |
|---|---|---|
stripe_product_id | Add | Nullable varchar. Links to Stripe product for plan mapping |
cancel_at_period_end | N/A | This goes on workspace_subscription, not plan |
| All limit columns | Keep | Unchanged |
Note: monthly_price is NOT stored on the table. Pricing is fetched from the Stripe API at runtime for upgrade/downgrade decisions.
Plan Tiers
| Name | Type | Monthly Price | Notes |
|---|---|---|---|
default | Fixed | 0 | Free tier limits. Every workspace starts here |
trial | Fixed | 0 | Trial experience limits. Used by both Stripe and admin trials |
basic | Fixed | From Stripe | Standard tier |
pro | Fixed | From Stripe | Standard tier |
business | Fixed | From Stripe | Standard tier |
business_pro | Fixed | From Stripe | Standard tier |
enterprise_* | Custom | Admin-set | Custom per-customer limits. Admin creates rows in workspace_plan |
Upgrade/Downgrade Logic
Replace the hardcoded PlanTier dictionary. Pricing is fetched from Stripe API, not stored locally.
Stripe plans: The StripeController already has IsUpgrade in the change-plan request. The client compares prices fetched from Stripe at checkout time. The server validates by fetching prices from Stripe API.
Enterprise plans: Admin always applies changes immediately. The admin UI doesn't need upgrade/downgrade distinction — every enterprise plan change is immediate. The deactivation_reason is set to plan_change.
// Stripe: client sends IsUpgrade flag, server validates via Stripe API
public async Task<string> ResolveStripeTransitionType(string oldPriceId, string newPriceId, CancellationToken ct)
{
var priceService = new PriceService();
var oldPrice = await priceService.GetAsync(oldPriceId, cancellationToken: ct);
var newPrice = await priceService.GetAsync(newPriceId, cancellationToken: ct);
return newPrice.UnitAmount > oldPrice.UnitAmount ? "upgrade"
: newPrice.UnitAmount < oldPrice.UnitAmount ? "downgrade"
: "plan_change";
}
// Enterprise: always immediate, reason=plan_change
Deactivation Reasons
When a subscription is deactivated, deactivation_reason records why:
| Reason | When |
|---|---|
upgrade | Plan changed to a higher-priced tier |
downgrade | Plan changed to a lower-priced tier |
plan_change | Plan changed to same-priced tier |
cancelled | User or admin cancelled |
expired | Period ended without renewal |
renewed | Auto-renew created a new period |
replaced | Different source took over (e.g., Stripe replaced enterprise) |
trial_ended | Trial expired or converted to paid |
| NULL | Still active |
Reading Subscription History
Query all subscriptions for a workspace ordered by created_at to see the full story:
Sub 1: plan=default, source=system, status=expired, reason=replaced, Jan 1 - Jan 31
Sub 2: plan=trial, source=enterprise, status=expired, reason=trial_ended, Feb 1 - Feb 14
Sub 3: plan=enterprise_acme, source=enterprise, status=expired, reason=renewed, Feb 14 - Mar 14
Sub 4: plan=enterprise_acme, source=enterprise, status=expired, reason=renewed, Mar 14 - Apr 14
Sub 5: plan=enterprise_acme, source=enterprise, status=cancelled, reason=cancelled, Apr 14 - Apr 20
Sub 6: plan=default, source=system, status=active, reason=NULL, Apr 20 - May 20
Tables Removed
| Table | Replaced By |
|---|---|
enterprise_plan | workspace_subscription where source=enterprise |
workspace_plan_history | Deactivated subscription records with deactivation_reason |
workspace_metrics_record | workspace_subscription.metrics_data (live on active, frozen on deactivated) |
workspace_invoice | subscription_invoice (going forward only) |
Subscription Lifecycle
Status Transitions
Activation Rule
When a new subscription becomes active or trialing, all other active/trialing subscriptions for that workspace are deactivated. This single rule prevents conflicts.
Metrics Design
Metrics on Subscription
The metrics_data blob on workspace_subscription replaces the separate workspace_metrics_record table:
| Subscription State | metrics_data Behavior |
|---|---|
| Active | MetricsComputationCron writes every 5 min. Overwritten with fresh computation. Always current |
| Deactivated | Frozen snapshot from the last cron run before deactivation. Never updated again. Read-only historical view |
Metrics Period
Metrics are computed within the subscription period (period_start to period_end):
| Concern | Period |
|---|---|
| Limit enforcement (max executions) | Subscription period. Resets on renewal |
| Execution and transaction counters | Subscription period |
| Charts (sessions over time, errors) | Subscription period. Dashboard provides time filters (7d, 30d, this period) |
| Sessions and logs | Current subscription period start to now |
| Snapshot at deactivation | Full subscription period metrics |
Dashboard Period Navigator
Users can browse their subscription history to see metrics for each period:
[< Previous] Feb 14 - Mar 14, 2026 (Enterprise Acme) [Next >]
- Current (active) subscription: live metrics from cron
- Old (deactivated) subscriptions: frozen metrics_data snapshot, read-only
- Navigation queries
workspace_subscriptionsordered bycreated_at
Default Plan Metrics
Default subscriptions (source=system) auto-renew every 30 days. This means:
- Free-tier users also accumulate monthly metric snapshots
- They can navigate back to see last month usage
- No special-casing for no plan. There is always a subscription
Trial Management
Trial Plan Tier
trial is a row in the workspace_plan table with defined limits:
workspace_plan:
name: trial
monthly_price: 0
max_executions: 500
max_processes: 10
max_collaborators: 5
...
All trials use the same limits regardless of source or what plan the user will eventually subscribe to.
Stripe Trial
Trigger
User checks out on pricing page. Currently only "EW-FLOW Basic" plan gets a 7-day trial (configured via Stripe:TrailPlan in appsettings). Trial eligibility is checked by querying subscription history instead of the IsTrial workspace flag.
Trial Eligibility Check
// Replaces workspace.IsTrial flag
var hasTrialed = await context.WorkspaceSubscriptions
.AnyAsync(s => s.WorkspaceId == workspaceId
&& s.Source == "stripe"
&& (s.Status == "trialing" || s.DeactivationReason == "trial_ended"), ct);
Webhook Flow
Admin Trial
Trigger
Admin creates trial for an enterprise prospect via the Manage dialog. No Stripe involvement.
Admin Flow
Trial Rules
| Rule | Detail |
|---|---|
| Trial is a plan tier | Row in workspace_plan with specific limits |
| No auto-renew | Trials always have auto_renew=false. They do not renew into another trial |
| One active subscription | Existing active/trialing subscription is deactivated before creating trial |
| Multiple trials allowed | System does not prevent re-trials. Admin discretion. Stripe eligibility uses history check |
| Dashboard display | Purple/distinct bar, Trial badge, days remaining, Upgrade Now button |
Trial Edge Cases
| Scenario | Behavior |
|---|---|
| User on admin trial does Stripe checkout | subscription.created handler deactivates admin trial (reason=replaced). Stripe takes over |
| Admin creates enterprise for workspace with Stripe sub | UI warns that Stripe subscription is active. Admin should cancel in Stripe first |
| Stripe upgrade during trial | Stripe sends subscription.updated with TrialEnd=now. Handler deactivates trial, creates active subscription |
| Trial on a workspace that already trialed | Stripe: eligibility check prevents. Admin: allowed (admin decision) |
Flow: Stripe Checkout (No Trial)
Flow: Stripe Period Renewal
When Stripe renews a subscription (new period via webhook), a new workspace_subscription record is created per cycle for full history.
Flow: Stripe Cancellation
Stripe Deactivation Rules
When a workspace_subscription with source=stripe is deactivated locally, we may need to also cancel it in the Stripe API. The rule: if deactivation is NOT triggered by a Stripe webhook, and stripe_subscription_id is not null, cancel in Stripe.
Deactivation Scenarios
| # | Trigger | Initiator | Stripe Sub State | Stripe API Call | Cancel Mode |
|---|---|---|---|---|---|
| 1 | subscription.deleted webhook | Stripe | Already cancelled | No (Stripe did it) | N/A |
| 2 | subscription.updated webhook (new period) | Stripe | Still active (new cycle) | No (sub continues) | N/A |
| 3 | subscription.updated webhook (trial to active) | Stripe | Still active (converted) | No (sub continues) | N/A |
| 4 | Admin switches to Enterprise | Admin | Still active, charging | Yes | Immediate |
| 5 | Admin creates trial replacing Stripe | Admin | Still active or trialing | Yes | Immediate |
| 6 | Admin locks/disables workspace | Admin | Still active | Yes | Immediate |
| 7 | Cron: Stripe sub expired, no webhook | System | Unknown/stale | Yes (safety) | Immediate |
| 8 | User self-cancels via billing portal | User | Still active | Yes | At period end |
Cancel Modes
| Mode | Stripe API Call | When Used |
|---|---|---|
| Immediate | SubscriptionService.CancelAsync(subId) | Admin replaces Stripe with enterprise. Stop charging now |
| At period end | SubscriptionService.UpdateAsync(subId, CancelAtPeriodEnd=true) | User self-cancels. Let them use what they paid for |
Deactivation Origin
The deactivation method accepts an origin parameter to determine Stripe API behavior:
public enum DeactivationOrigin
{
StripeWebhook, // Stripe already handled it. No API call
AdminAction, // Admin did it. Cancel in Stripe immediately
CronExpiry, // Safety net. Cancel in Stripe immediately
UserCancel // User cancelled. Cancel at period end
}
Deactivation Flow
Handling Delayed Webhooks
After we cancel in Stripe (scenario 4-7), Stripe will eventually send a customer.subscription.deleted webhook. The webhook handler must handle this gracefully:
Flow: Admin Switches Stripe to Enterprise
This is the most common Stripe deactivation scenario. The admin enables enterprise for a workspace that has an active Stripe subscription:
Admin UI Warning
When admin opens the Manage dialog for a workspace with an active Stripe subscription, the UI should show a warning:
This workspace has an active Stripe subscription (Pro plan, renews Mar 28).
Enabling enterprise will IMMEDIATELY cancel the Stripe subscription.
The customer will not be charged again by Stripe.
[Confirm and Enable Enterprise] [Cancel]
Flow: Admin Enterprise Management
Flow: Auto-Renewal (Lifecycle Cron)
Flow: Expiry Check (Lifecycle Cron)
Verifies that non-renewing plans are properly deactivated when they expire. This is a new capability.
Dashboard Display
With the unified model, the dashboard reads from the active subscription without priority logic:
Convenience Properties (C# Entity)
public partial class Workspace
{
// Stored columns
public Guid? ActiveSubscriptionId { get; set; }
public string ActiveWorkspacePlan { get; set; } = "default";
public string? StripeCustomerId { get; set; }
// Navigation
public virtual WorkspaceSubscription? ActiveSubscription { get; set; }
public virtual WorkspacePlan PlanLimits { get; set; } = null!;
public virtual ICollection<WorkspaceSubscription> WorkspaceSubscriptions { get; set; } = new List<WorkspaceSubscription>();
// Computed (not stored)
[NotMapped] public bool IsEnterprise => ActiveSubscription?.Source == "enterprise";
[NotMapped] public bool IsTrial => ActiveSubscription?.Status == "trialing";
[NotMapped] public string? StripeActiveSubscriptionId => ActiveSubscription?.StripeSubscriptionId;
}
Admin Dashboard Enhancement
The admin enterprise page shows subscription health for proactive management:
Cron Architecture
The current single BillingPeriodMetricsCronService is split into two focused cron jobs:
| Cron Job | Frequency | Responsibility |
|---|---|---|
MetricsComputationCron | Every 5 min | Compute and write metrics_data to the active subscription for each workspace |
SubscriptionLifecycleCron | Hourly | Auto-renewal, expiry check, trial expiry. Does not compute metrics |
Why Split?
- Metrics need near-real-time updates (dashboard displays live data)
- Lifecycle events (renewal, expiry) don't need 5-min precision. Hourly is sufficient
- Reduces DB query load: lifecycle cron only queries subscriptions near expiry, not all workspaces
- Each cron is smaller, testable, and has a single responsibility
Registration
// Program.cs
scheduler.Schedule<MetricsComputationCron>()
.EveryFiveMinutes();
scheduler.Schedule<SubscriptionLifecycleCron>()
.Hourly();
scheduler.Schedule<DataRetentionCronService>()
.DailyAtHour(3);
Cancel at Period End
When a user cancels their Stripe subscription via the billing portal, they keep access until the period ends. This uses the cancel_at_period_end field on workspace_subscription.
Flow
Webhook Update
When subscription.updated is received with cancel_at_period_end=true (Stripe sends this), update the field on the local subscription:
// In StripeWebhookController, customer.subscription.updated handler:
if (updatedSub.CancelAtPeriodEnd)
{
localSub.CancelAtPeriodEnd = true;
// Do NOT deactivate. Status stays active.
}
Workspace Creation — Default Subscription
When a new workspace is created, the creation endpoint also creates the initial default subscription. There is never a workspace without a subscription.
// In workspace creation endpoint:
var defaultSub = new WorkspaceSubscription
{
Id = Guid.NewGuid(),
WorkspaceId = workspace.Id,
PlanName = "default",
Source = "system",
Status = "active",
PeriodStart = DateTime.UtcNow,
PeriodEnd = DateTime.UtcNow.AddDays(30),
CycleDays = 30,
AutoRenew = true,
CreatedAt = DateTime.UtcNow
};
context.WorkspaceSubscriptions.Add(defaultSub);
workspace.ActiveSubscriptionId = defaultSub.Id;
workspace.ActiveWorkspacePlan = "default";
Concurrency — Preventing Duplicate Active Subscriptions
To prevent race conditions (e.g., Stripe webhook and admin action hitting simultaneously), a DB-level unique partial index ensures only one active/trialing subscription per workspace:
CREATE UNIQUE INDEX IX_workspace_subscription_active
ON workspace_subscription (workspace_id)
WHERE status IN ('active', 'trialing');
If a concurrent write attempts to create a second active subscription, the DB rejects it. The application catches the constraint violation and handles it:
try
{
await context.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// Another process already created an active subscription.
// Reload and skip or retry.
logger.LogWarning("Concurrent subscription creation for workspace {Id}. Skipping.", workspaceId);
}
Stripe Downgrade via Webhook
Stripe downgrades are handled by the existing SubscriptionSchedule code in StripeController. The proposal does NOT add scheduled_plan_name fields to workspace_subscription. Instead:
- User requests downgrade in billing UI
StripeControllercreates aSubscriptionSchedulein Stripe (current plan until period end, then new plan)- Stripe manages the schedule. No local tracking of the pending change
- At period end, Stripe applies the new plan and sends
customer.subscription.updatedwith the new price/product - Webhook handler: deactivate old subscription (reason=downgrade), create new subscription with lower plan
The downgrade is fully managed by Stripe. Our system reacts to the webhook event.
Enterprise downgrades are always immediate — admin changes the plan and it takes effect now. Deactivate old (reason=plan_change), create new.
API Endpoints
New Endpoints
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
GET | /api/subscriptions/history | WorkspaceMember | List all subscriptions for the current workspace (period navigator) |
GET | /api/subscriptions/{id}/metrics | WorkspaceMember | Get metrics for a specific subscription (current or historical) |
GET | /api/workspace-plans | Administrator | List all plan tiers (for admin dynamic dropdown) |
POST | /api/workspace-plans | Administrator | Create custom enterprise plan tier |
PUT | /api/workspace-plans/{name} | Administrator | Edit plan tier limits |
Updated Endpoints
| Method | Endpoint | Change |
|---|---|---|
GET | /api/metrics/billing-period | Read from ActiveSubscription.metrics_data instead of workspace_metrics_record |
POST | /api/admin/enterprise/save-workspace-settings | Rewrite to use workspace_subscription instead of enterprise_plan |
POST | /api/stripe/cancel-subscription/{id} | Set cancel_at_period_end=true on workspace_subscription |
POST | /api/stripe/change-plan | Use Stripe API for price comparison. Create/deactivate subscriptions on webhook |
Removed Endpoints (replaced by unified subscription management)
| Endpoint | Replaced By |
|---|---|
/api/admin/enterprise/change-plan | save-workspace-settings |
/api/admin/enterprise/renew-plan | Auto-renewal via cron |
/api/admin/enterprise/dismiss-plan | save-workspace-settings (disable enterprise) |
/api/admin/enterprise/toggle-enterprise | save-workspace-settings |
Notification Service
A dedicated SubscriptionNotificationService receives all subscription lifecycle triggers. Each trigger maps to a specific method that handles the notification logic.
Service Interface
public interface ISubscriptionNotificationService
{
Task OnTrialExpiringAsync(Guid workspaceId, DateTime expiresAt, CancellationToken ct);
Task OnTrialExpiredAsync(Guid workspaceId, CancellationToken ct);
Task OnSubscriptionCancelledAsync(Guid workspaceId, string planName, CancellationToken ct);
Task OnAutoRenewalFailedAsync(Guid workspaceId, string planName, string error, CancellationToken ct);
Task OnPlanChangedAsync(Guid workspaceId, string oldPlan, string newPlan, string changeType, CancellationToken ct);
Task OnSubscriptionExpiringAsync(Guid workspaceId, DateTime expiresAt, CancellationToken ct);
Task OnCancelAtPeriodEndAsync(Guid workspaceId, DateTime periodEnd, CancellationToken ct);
Task OnPaymentFailedAsync(Guid workspaceId, int attemptNumber, CancellationToken ct);
}
Trigger Points
| Event | Trigger Location | Method |
|---|---|---|
| Trial expiring in 3 days | SubscriptionLifecycleCron | OnTrialExpiringAsync |
| Trial expired | SubscriptionLifecycleCron | OnTrialExpiredAsync |
| Subscription cancelled | DeactivateSubscriptionAsync | OnSubscriptionCancelledAsync |
| Auto-renewal failed | SubscriptionLifecycleCron | OnAutoRenewalFailedAsync |
| Plan upgraded/downgraded/changed | BillingService or AdminController | OnPlanChangedAsync |
| Subscription expiring in 7 days | SubscriptionLifecycleCron | OnSubscriptionExpiringAsync |
| User cancels at period end | StripeController | OnCancelAtPeriodEndAsync |
| Stripe payment failed | StripeWebhookController (invoice.payment_failed) | OnPaymentFailedAsync |
| Stripe trial ending in 3 days | StripeWebhookController (trial_will_end) | OnTrialExpiringAsync |
Implementation (Phase 1)
Initial implementation logs all events. Actual email/in-app delivery is a follow-up:
public class SubscriptionNotificationService(
ILogger<SubscriptionNotificationService> logger,
EmailSender emailSender,
WilsonCoreContext context) : ISubscriptionNotificationService
{
public async Task OnTrialExpiringAsync(Guid workspaceId, DateTime expiresAt, CancellationToken ct)
{
var workspace = await context.Workspaces
.Include(w => w.WorkspaceUserRoles)
.FirstOrDefaultAsync(w => w.Id == workspaceId, ct);
var owner = workspace?.WorkspaceUserRoles
.FirstOrDefault(r => r.Role == "owner");
logger.LogInformation(
"Trial expiring for workspace {Id} on {Date}. Owner: {Owner}",
workspaceId, expiresAt, owner?.UserId);
// TODO: Send email to workspace owner
// TODO: In-app notification
}
// ... similar pattern for each method
}
A-Cube E-Invoicing & Smart Receipts
The platform uses the A-Cube Stripe App for Italian e-invoicing compliance. A-Cube reads customer data from the Stripe Customer object and automatically generates e-invoices or Smart Receipts based on the available fields.
A-Cube Prerequisites
| Setting | Value | Purpose |
|---|---|---|
| Allow B2C invoices | ON | Enables e-invoices for B2C customers who provide fiscal_code |
| Smart Receipts | Activated | Fallback for B2C customers without fiscal_code (requires appointment + enable in settings) |
Customer Classification & Invoice Routing
| Case | Country | Tax ID | Fiscal Code | A-Cube Output |
|---|---|---|---|---|
| IT B2B | IT | ✅ VAT provided | Not needed | e-Invoice |
| IT B2C (with CF) | IT | ❌ | ✅ Provided (optional) | B2C e-Invoice |
| IT B2C (without CF) | IT | ❌ | ❌ Not provided | Smart Receipt (corrispettivo) |
| Intl B2B | non-IT | ✅ Tax ID provided | Not needed | Cross-border e-Invoice |
| Intl B2C (with CF) | non-IT | ❌ | ✅ Provided (optional) | e-Invoice |
| Intl B2C (without CF) | non-IT | ❌ | ❌ Not provided | Smart Receipt |
Note:
split_paymentis not implemented — no Italian public administration (PA) customers expected.
Stripe Customer Fields (A-Cube Requirements)
A-Cube reads the following from the Stripe Customer object:
| Field | Source | Required | Notes |
|---|---|---|---|
customer.name | Checkout (CustomerUpdate.Name = "auto") | ✅ Always | Auto-saved from checkout |
customer.address | Checkout (BillingAddressCollection = "required") | ✅ Always | address.country determines IT vs non-IT routing |
customer.tax_ids | Checkout (TaxIdCollection.Enabled = true) | B2B only | VAT/Tax ID collected at checkout |
customer.metadata.fiscal_code | Checkout custom field → webhook | Optional | Saved on checkout.session.completed |
customer.metadata.codice_destinatario | Checkout custom field → webhook | Optional | IT default: 0000000, non-IT default: XXXXXXX |
Checkout Session Configuration
The checkout session collects all required data for A-Cube compliance:
var options = new SessionCreateOptions
{
// ... line items, mode, customer ...
// ✅ Address + Name → auto-saved to Customer
BillingAddressCollection = "required",
CustomerUpdate = new SessionCustomerUpdateOptions
{
Address = "auto",
Name = "auto",
},
// ✅ B2B: VAT/Tax ID collection
TaxIdCollection = new SessionTaxIdCollectionOptions { Enabled = true },
AutomaticTax = new SessionAutomaticTaxOptions { Enabled = true },
// ✅ B2C: Fiscal code + Codice Destinatario (both optional)
CustomFields = new List<SessionCustomFieldOptions>
{
new SessionCustomFieldOptions
{
Key = "fiscal_code",
Label = new SessionCustomFieldLabelOptions
{
Type = "custom",
Custom = "Codice Fiscale (for Italian invoices)"
},
Type = "text",
Text = new SessionCustomFieldTextOptions
{
MinimumLength = 11,
MaximumLength = 16
},
Optional = true
},
new SessionCustomFieldOptions
{
Key = "codice_destinatario",
Label = new SessionCustomFieldLabelOptions
{
Type = "custom",
Custom = "Codice Destinatario / SDI (optional, for Italian businesses)"
},
Type = "text",
Text = new SessionCustomFieldTextOptions
{
MinimumLength = 7,
MaximumLength = 7
},
Optional = true
}
},
};
Webhook: Save Custom Fields to Customer Metadata
On checkout.session.completed, read custom fields and save to Stripe Customer metadata so A-Cube can access them on every future invoice:
private async Task HandleCheckoutCompleted(Stripe.Checkout.Session session)
{
var sessionService = new Stripe.Checkout.SessionService();
var fullSession = await sessionService.GetAsync(session.Id);
var fiscalCode = fullSession.CustomFields?
.FirstOrDefault(f => f.Key == "fiscal_code")?.Text?.Value;
var codiceDestinatario = fullSession.CustomFields?
.FirstOrDefault(f => f.Key == "codice_destinatario")?.Text?.Value;
var metadata = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(fiscalCode))
metadata["fiscal_code"] = fiscalCode;
if (!string.IsNullOrEmpty(codiceDestinatario))
metadata["codice_destinatario"] = codiceDestinatario;
if (metadata.Count > 0)
{
var customerService = new CustomerService();
await customerService.UpdateAsync(session.CustomerId,
new CustomerUpdateOptions { Metadata = metadata });
}
}
Current Implementation vs Required Changes
| Area | Current State | Required Change |
|---|---|---|
fiscal_code custom field | ✅ Already at checkout (optional) | No change |
codice_destinatario custom field | ❌ Not collected | Add as optional custom field (7 chars) |
checkout.session.completed handler | ✅ Saves fiscal_code to metadata | Update: also save codice_destinatario |
| A-Cube "Allow B2C invoices" | Unknown | Verify setting is ON |
| A-Cube Smart Receipts | Unknown | Activate (appointment + settings) |
split_payment | ❌ Not needed | Skip — no PA customers |
| Billing portal | ✅ Stripe portal for updates | No change (tax_id and address editable there) |
Timing: Customer Data Before First Invoice
For charge_automatically subscriptions, Stripe finalizes the first invoice at checkout. The CustomerUpdate = { Address = "auto", Name = "auto" } option ensures address and name are written to the Customer object during the checkout flow, before the invoice is finalized. This satisfies A-Cube's requirement that customer data is present when the invoice is processed.
Custom field metadata (fiscal_code, codice_destinatario) is saved via webhook after checkout. Since A-Cube processes invoices asynchronously (not at finalization time), the metadata is available by the time A-Cube reads the invoice.
Stripe Integration Gaps
1. Payment Failure Recovery / Dunning (Critical)
Problem: When invoice.payment_failed fires, the handler only updates the invoice status locally. No user notification, no dashboard warning, no grace period, no degradation after repeated failures.
Proposed Solution:
Stripe-Side Configuration
Configure Stripe's built-in retry logic (Smart Retries) in the Stripe Dashboard:
- Retry schedule: 3 attempts over 7 days (Stripe default)
- After all retries fail: Cancel subscription (Stripe setting:
subscription_status_after_all_retries = canceled)
Webhook Handling
Add payment_failed_count and last_payment_failed_at to workspace_subscription:
Dashboard Warning
When payment_failed_count > 0 and status is still active:
⚠️ Payment failed — please update your payment method to avoid service interruption.
[Update Payment Method] → Stripe Billing Portal
New Fields on workspace_subscription
| Field | Type | Purpose |
|---|---|---|
payment_failed_count | int | Number of consecutive failed payment attempts |
last_payment_failed_at | DateTime? | Timestamp of last failure |
2. Webhook Idempotency (Critical)
Problem: If Stripe retries a webhook (timeout, 5xx), the handler could create duplicate StripeSubscription, StripeInvoice, or StripePayment records.
Proposed Solution:
Store processed Stripe event IDs and check before processing:
private async Task<IActionResult> Post(CancellationToken ct)
{
// ... signature verification ...
// Idempotency check
var eventId = stripeEvent.Id;
var alreadyProcessed = await context.StripeWebhookEvents
.AnyAsync(e => e.StripeEventId == eventId, ct);
if (alreadyProcessed)
{
logger.LogInformation("Skipping duplicate Stripe event {EventId}", eventId);
return Ok(); // Return 200 so Stripe doesn't retry
}
try
{
await HandleStripeEventAsync(stripeEvent, ct);
// Record processed event
context.StripeWebhookEvents.Add(new StripeWebhookEvent
{
StripeEventId = eventId,
EventType = stripeEvent.Type,
ProcessedAt = DateTime.UtcNow
});
await context.SaveChangesAsync(ct);
return Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed processing Stripe event {EventId} ({EventType})",
eventId, stripeEvent.Type);
return StatusCode(500); // Stripe will retry
}
}
New Table: stripe_webhook_event
| Column | Type | Notes |
|---|---|---|
stripe_event_id | VARCHAR(255) PK | Stripe event ID (e.g., evt_1234) |
event_type | VARCHAR(100) | e.g., invoice.payment_failed |
processed_at | DATETIME | When we processed it |
Cleanup:
DataRetentionCronServicecan purge events older than 30 days (Stripe won't retry after ~72 hours).
3. Trial Will End Webhook (High)
Problem: No handler for customer.subscription.trial_will_end. Stripe sends this 3 days before trial ends. Users get no warning.
Proposed Solution:
Add webhook handler that triggers the existing ISubscriptionNotificationService:
case "customer.subscription.trial_will_end":
if (stripeEvent.Data.Object is Subscription trialEndingSub)
{
var workspace = await context.Workspaces
.FirstOrDefaultAsync(w => w.StripeActiveSubscriptionId == trialEndingSub.Id, ct);
if (workspace != null && trialEndingSub.TrialEnd.HasValue)
{
await notificationService.OnTrialExpiringAsync(
workspace.Id, trialEndingSub.TrialEnd.Value, ct);
}
}
break;
This integrates directly with the ISubscriptionNotificationService.OnTrialExpiringAsync already designed in the Notification Service section.
Note: This is in addition to the cron-based trial expiry check. The webhook gives a Stripe-triggered notification, while the cron handles enterprise/system trials.
4. Refund Handling (High)
Problem: No charge.refunded webhook handler. Refunds issued from Stripe Dashboard aren't reflected locally. Also, bug on line 487 where AmountRefunded uses AmountCaptured instead of AmountRefunded.
Proposed Solution:
Bug Fix
// StripeController.cs line 487 — BUG
AmountRefunded = Math.Round(request.AmountCaptured / 100m, 2), // ❌ WRONG
// Fix:
AmountRefunded = Math.Round(request.AmountRefunded / 100m, 2), // ✅ CORRECT
New Webhook Handler
case "charge.refunded":
if (stripeEvent.Data.Object is Charge refundedCharge)
{
var payment = await context.StripePayments
.FirstOrDefaultAsync(p => p.StripeChargeId == refundedCharge.Id, ct);
if (payment != null)
{
payment.AmountRefunded = Math.Round(refundedCharge.AmountRefunded / 100m, 2);
payment.Refunded = refundedCharge.Refunded; // true if fully refunded
payment.Status = refundedCharge.Status; // "succeeded" still, check Refunded flag
payment.UpdatedAt = DateTime.UtcNow;
payment.StripeResponse = JsonSerializer.Serialize(refundedCharge);
await context.SaveChangesAsync(ct);
logger.LogInformation("Processed refund for charge {ChargeId}. " +
"Refunded: {Amount}", refundedCharge.Id, refundedCharge.AmountRefunded);
}
}
break;
Invoice Status Update
If the charge relates to an invoice, also update the invoice status:
if (!string.IsNullOrEmpty(refundedCharge.InvoiceId))
{
var invoice = await context.StripeInvoices
.FirstOrDefaultAsync(i => i.StripeInvoiceId == refundedCharge.InvoiceId, ct);
if (invoice != null)
{
// Re-fetch from Stripe to get updated amounts
var invoiceService = new InvoiceService();
var freshInvoice = await invoiceService.GetAsync(refundedCharge.InvoiceId);
invoice.AmountPaid = Math.Round(freshInvoice.AmountPaid / 100m, 2);
invoice.AmountRemaining = Math.Round(freshInvoice.AmountRemaining / 100m, 2);
invoice.Status = freshInvoice.Status;
invoice.UpdatedAt = DateTime.UtcNow;
await context.SaveChangesAsync(ct);
}
}
5. Subscription Reactivation Endpoint (High)
Problem: If a user cancels (cancel_at_period_end = true) and then changes their mind, there's no way to undo the cancellation.
Proposed Solution:
New endpoint in StripeController:
[HttpPost("reactivate-subscription/{subscriptionId}")]
public async Task<IActionResult> ReactivateSubscription(
string subscriptionId, CancellationToken cancellationToken = default)
{
try
{
var subService = new SubscriptionService();
var sub = await subService.GetAsync(subscriptionId, cancellationToken: cancellationToken);
if (!sub.CancelAtPeriodEnd)
return BadRequest("Subscription is not pending cancellation.");
var updated = await subService.UpdateAsync(subscriptionId,
new SubscriptionUpdateOptions { CancelAtPeriodEnd = false },
cancellationToken: cancellationToken);
return Ok(new
{
SubscriptionId = updated.Id,
Status = updated.Status,
CancelAtPeriodEnd = updated.CancelAtPeriodEnd
});
}
catch (StripeException ex)
{
return BadRequest(new { error = ex.Message });
}
}
Frontend Change (Invoices.razor)
When isPlanCancelRequested == true, show a "Reactivate" button:
@if (isPlanCancelRequested)
{
<SfButton CssClass="rounded-1 btn fw-medium text-sm py-8 px-8"
OnClick="@(async () => await ReactivateSubscription(subscriptionId))"
Content="Undo Cancellation — Keep My Plan" />
<p class="text-body-secondary text-xs mt-1">
Your subscription is scheduled to cancel at the end of the billing period.
</p>
}
Webhook Sync
The customer.subscription.updated webhook will automatically fire when cancel_at_period_end flips back to false, updating the local workspace_subscription.cancel_at_period_end field.
6. Dead Code Cleanup (High)
Problem: 5 methods in StripeController have no HTTP route attribute and are unreachable via HTTP. They duplicate logic now handled by BillingService (called from webhooks).
Methods to Remove:
| Method | Line | Reason |
|---|---|---|
CreateStripeCustomer | 437 | Duplicate of webhook-based customer handling |
CreateStripeCharge | 475 | Replaced by BillingService.CreateStripeChargeAsync |
UpdateStripeCharge | 519 | Replaced by BillingService.UpdateStripeChargeAsync |
CreateSubscription | 559 | Replaced by BillingService.CreateSubscriptionAsync |
CreateInvoice | 655 | Replaced by BillingService.CreateInvoiceAsync |
Also has unreachable UpdateSubscription (line 619) and UpdateInvoice (line 697) with [HttpPut] but no specific route — these conflict with UpdateCustomer which also uses [HttpPut]. Only UpdateCustomer is actually called from the frontend.
Action: Remove all 7 dead methods during Phase 3 backend refactoring. All their functionality lives in BillingService.
Updated Webhook Events (Complete List)
After implementing the above gaps, the full webhook handler will cover:
| Event | Handler | Status |
|---|---|---|
charge.succeeded | BillingService.CreateStripeChargeAsync | ✅ Existing |
charge.refunded | Update StripePayment + StripeInvoice | 🆕 New |
checkout.session.completed | Save fiscal_code + codice_destinatario to metadata | ✅ Existing (update) |
customer.updated | BillingService.UpdateCustomerAsync | ✅ Existing |
customer.subscription.created | BillingService.CreateSubscriptionAsync | ✅ Existing |
customer.subscription.updated | Plan transition + cancel_at_period_end sync | ✅ Existing |
customer.subscription.deleted | Deactivate → default subscription | ✅ Existing |
customer.subscription.trial_will_end | NotificationService.OnTrialExpiringAsync | 🆕 New |
invoice.created | BillingService.CreateInvoiceAsync | ✅ Existing |
invoice.payment_succeeded | BillingService.UpdateInvoiceAsync + reset failed count | ✅ Existing (update) |
invoice.payment_failed | Update invoice + increment failed count + notify | ✅ Existing (update) |
invoice_payment.paid | Link charge to invoice/subscription | ✅ Existing |
Migration Plan
Phase 1: Database Schema
- Add
stripe_product_idcolumn toworkspace_plan - Add
trialrow toworkspace_plan - Create
workspace_subscriptiontable (includescancel_at_period_end,payment_failed_count,last_payment_failed_atfields) - Create
subscription_invoicetable (includesbilling_reasonfield) - Create
stripe_webhook_eventtable (idempotency tracking) - Add
active_subscription_idcolumn toworkspace(nullable) - Rename
workspace_planFK column on workspace toactive_workspace_plan - Add unique partial index
IX_workspace_subscription_activefor concurrency safety
Phase 2: Data Migration
- For each workspace with an active
enterprise_plan: createworkspace_subscriptionwithsource=enterprise - For each workspace with an active Stripe subscription: create
workspace_subscriptionwithsource=stripe - For all other workspaces: create
workspace_subscriptionwithsource=system,plan=default, 30-day auto-renew - Set
active_subscription_idon each workspace to point to the created subscription - Copy
workspace_metrics_record.metrics_datato each active subscriptionmetrics_data - Migrate
workspace_invoicerecords tosubscription_invoice(link to correct subscription)
Phase 3: Backend Code
- Create
WorkspaceSubscriptionandSubscriptionInvoiceEF entities - Add EF mappings in
WilsonCoreContext - Add convenience properties to
Workspaceentity - Update
BillingServiceto createworkspace_subscriptionon Stripe events - Split
BillingPeriodMetricsCronServiceinto:MetricsComputationCron(every 5 min): write metrics toActiveSubscription.metrics_dataSubscriptionLifecycleCron(hourly): auto-renewal, expiry check, trial expiry, notification triggers
- Update
WorkspacePlanService: use Stripe API for price-based transition type resolution - Update
AdminEnterpriseControllerto useworkspace_subscription - Update
StripeWebhookController:- Find workspace by subscription link instead of
StripeActiveSubscriptionId - Handle
cancel_at_period_endonsubscription.updated - Handle delayed webhooks (skip if already deactivated locally)
- Find workspace by subscription link instead of
- Create
ISubscriptionNotificationServicewith trigger methods - Create
DeactivateSubscriptionAsyncwithDeactivationOriginenum for Stripe API cancellation logic - Update workspace creation to create initial default subscription
- Add new API endpoints: subscription history, plan management
- Update
CreateCheckoutSession: addcodice_destinatariocustom field - Update
HandleCheckoutCompleted: save bothfiscal_codeandcodice_destinatarioto customer metadata - Add webhook idempotency: check
stripe_webhook_eventbefore processing, record after - Add
charge.refundedwebhook handler: updateStripePayment+StripeInvoice - Add
customer.subscription.trial_will_endwebhook handler →NotificationService.OnTrialExpiringAsync - Update
invoice.payment_failedhandler: incrementpayment_failed_count, triggerOnPaymentFailedAsync - Update
invoice.payment_succeededhandler: resetpayment_failed_countto 0 - Add
reactivate-subscription/{id}endpoint: setcancel_at_period_end = false - Fix bug:
AmountRefundedusesAmountCaptured(StripeController line 487) - Add
OnPaymentFailedAsyncmethod toISubscriptionNotificationService
Phase 4: Frontend
- Update
Dashboard.razor:- Read from
ActiveSubscriptioninstead of priority logic - Add period navigator (Previous / Next)
- Add time filter (7d / 30d / This Period)
- Read from
- Update
AdminEnterprise.razor:- Plan dropdown reads from
workspace_plantable (dynamic, includes custom tiers) - Add trial creation (status=trialing)
- Add subscription health alerts panel
- Plan dropdown reads from
- Remove all
IsEnterprise/IsTrialflag checks -- use source/status instead - Add payment failure warning banner on dashboard when
payment_failed_count > 0 - Add "Undo Cancellation" / reactivation button on
Invoices.razorwhencancel_at_period_end = true
Phase 5: Cleanup
- Remove
IsEnterprise,IsTrial,TrialStartDate,TrialEndDate,StripeActiveSubscriptionIdfrom workspace entity and database - Drop
enterprise_plantable - Drop
workspace_plan_historytable - Drop
workspace_metrics_recordtable - Drop
workspace_invoicetable (replaced bysubscription_invoice) - Remove
PlanMaphardcoded dictionaries fromBillingServiceandStripeWebhookController(useworkspace_plan.stripe_product_idmapping) - Remove
PlanTierdictionary fromWorkspacePlanService(replaced by Stripe API price lookup) - Remove old endpoints:
change-plan,renew-plan,dismiss-plan,toggle-enterprise - Remove
FrontEndStatsCronService(if still present, commented out) - Remove
Stripe:TrailPlanconfig key from appsettings - Remove 7 dead methods from
StripeController(CreateStripeCustomer, CreateStripeCharge, UpdateStripeCharge, CreateSubscription, UpdateSubscription, CreateInvoice, UpdateInvoice)
File Impact Summary
| File | Change |
|---|---|
WorkflowEngineLibrary/Core/Models/Workspace.cs | Remove 5 columns, rename WorkspacePlan, add ActiveSubscriptionId, add computed properties |
WorkflowEngineLibrary/Core/Models/WorkspaceSubscription.cs | New entity (includes cancel_at_period_end) |
WorkflowEngineLibrary/Core/Models/SubscriptionInvoice.cs | New entity (includes billing_reason) |
WorkflowEngineLibrary/Core/Models/WorkspacePlan.cs | Add stripe_product_id |
WorkflowDatabase/WilsonCoreContext.cs | Add new entity mappings, update Workspace mapping, unique partial index |
Server/Services/Implementations/BillingService.cs | Create workspace_subscription on Stripe events, DeactivateSubscriptionAsync |
Server/Services/Implementations/MetricsComputationCron.cs | New — extracted from BillingPeriodMetricsCronService (every 5 min) |
Server/Services/Implementations/SubscriptionLifecycleCron.cs | New — renewal, expiry, trial expiry, notification triggers (hourly) |
Server/Services/Implementations/SubscriptionNotificationService.cs | New — handles all subscription lifecycle notifications |
Server/Services/Implementations/WorkspacePlanService.cs | Use Stripe API for price comparison, remove PlanTier dictionary |
Server/Controllers/AdminEnterpriseController.cs | Rewrite to use workspace_subscription |
WorkflowEngineLibrary/Core/Models/StripeWebhookEvent.cs | New entity for webhook idempotency tracking |
Server/Controllers/StripeWebhookController.cs | Idempotency check, find workspace via subscription link, handle cancel_at_period_end, delayed webhooks, codice_destinatario, charge.refunded, trial_will_end, payment failure tracking |
Server/Controllers/Workspace/StripeController.cs | Trial eligibility, cancel_at_period_end, codice_destinatario, reactivate-subscription endpoint, remove 7 dead methods, fix AmountRefunded bug |
Server/Controllers/Workspace/SubscriptionController.cs | New — subscription history and metrics endpoints |
Server/Controllers/Admin/WorkspacePlanController.cs | New — CRUD for workspace_plan tiers |
Client/Pages/Dashboard.razor | Read from ActiveSubscription, cancel_at_period_end badge, period nav, time filter, payment failure warning |
Client/Pages/Invoices.razor | Add reactivation button when cancel_at_period_end = true |
Client/Pages/AdminEnterprise.razor | Dynamic plan dropdown, trial creation, health alerts |
Shared/Models/EnterpriseModels.cs | Rename to SubscriptionModels, update DTOs |
Shared/Models/BillingPeriodMetrics.cs | Simplify: single SubscriptionInfo |
Server/Program.cs | Register new crons, notification service |