Skip to main content

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

ProblemDetails
Scattered workspace flagsIsEnterprise, IsTrial, TrialStartDate, TrialEndDate, StripeActiveSubscriptionId, WorkspacePlan -- 6 plan-related columns on workspace
1:1 enterprise planOnly one enterprise_plan record per workspace. Renewal overwrites dates with no history
Invoices disconnected from plansworkspace_invoice links to workspace. stripe_invoice links to Stripe subscription. Neither links to a billing period
No trial managementTrial dates live on workspace, not on the plan. No admin-created trials
Two billing period sourcesStripe uses current_period_start/end. Enterprise uses BillingPeriodStart/End. Dashboard has priority logic to pick one
No automatic deactivationOnly auto-renew handles expiry. No system verifies non-renewing plans expire correctly
Cancel to meaningless defaultWhen Stripe cancels, workspace gets plan=default with DateTime.UtcNow as both period start AND end
Redundant flagsIsEnterprise 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 source field 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_invoice as an audit mirror
  • workspace.ActiveWorkspacePlan remains as a denormalized plan tier for fast limit lookups
  • workspace.ActiveSubscriptionId provides 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_plan with its own limits

Proposed Data Model

Workspace Entity Changes

ColumnActionReason
IsEnterpriseRemoveDerived: ActiveSubscription.Source == "enterprise"
IsTrialRemoveDerived: ActiveSubscription.Status == "trialing"
TrialStartDateRemoveLives on workspace_subscription.period_start
TrialEndDateRemoveLives on workspace_subscription.period_end
StripeActiveSubscriptionIdRemoveDerived: ActiveSubscription.StripeSubscriptionId
WorkspacePlanRenameActiveWorkspacePlan -- denormalized current tier for fast limit lookups
StripeCustomerIdKeepNeeded for Stripe API calls
ActiveSubscriptionIdAddGUID FK to workspace_subscription -- one-hop access

Workspace Plan Changes

ColumnActionReason
stripe_product_idAddNullable varchar. Links to Stripe product for plan mapping
cancel_at_period_endN/AThis goes on workspace_subscription, not plan
All limit columnsKeepUnchanged

Note: monthly_price is NOT stored on the table. Pricing is fetched from the Stripe API at runtime for upgrade/downgrade decisions.

Plan Tiers

NameTypeMonthly PriceNotes
defaultFixed0Free tier limits. Every workspace starts here
trialFixed0Trial experience limits. Used by both Stripe and admin trials
basicFixedFrom StripeStandard tier
proFixedFrom StripeStandard tier
businessFixedFrom StripeStandard tier
business_proFixedFrom StripeStandard tier
enterprise_*CustomAdmin-setCustom 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:

ReasonWhen
upgradePlan changed to a higher-priced tier
downgradePlan changed to a lower-priced tier
plan_changePlan changed to same-priced tier
cancelledUser or admin cancelled
expiredPeriod ended without renewal
renewedAuto-renew created a new period
replacedDifferent source took over (e.g., Stripe replaced enterprise)
trial_endedTrial expired or converted to paid
NULLStill 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

TableReplaced By
enterprise_planworkspace_subscription where source=enterprise
workspace_plan_historyDeactivated subscription records with deactivation_reason
workspace_metrics_recordworkspace_subscription.metrics_data (live on active, frozen on deactivated)
workspace_invoicesubscription_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 Statemetrics_data Behavior
ActiveMetricsComputationCron writes every 5 min. Overwritten with fresh computation. Always current
DeactivatedFrozen 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):

ConcernPeriod
Limit enforcement (max executions)Subscription period. Resets on renewal
Execution and transaction countersSubscription period
Charts (sessions over time, errors)Subscription period. Dashboard provides time filters (7d, 30d, this period)
Sessions and logsCurrent subscription period start to now
Snapshot at deactivationFull 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_subscriptions ordered by created_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

RuleDetail
Trial is a plan tierRow in workspace_plan with specific limits
No auto-renewTrials always have auto_renew=false. They do not renew into another trial
One active subscriptionExisting active/trialing subscription is deactivated before creating trial
Multiple trials allowedSystem does not prevent re-trials. Admin discretion. Stripe eligibility uses history check
Dashboard displayPurple/distinct bar, Trial badge, days remaining, Upgrade Now button

Trial Edge Cases

ScenarioBehavior
User on admin trial does Stripe checkoutsubscription.created handler deactivates admin trial (reason=replaced). Stripe takes over
Admin creates enterprise for workspace with Stripe subUI warns that Stripe subscription is active. Admin should cancel in Stripe first
Stripe upgrade during trialStripe sends subscription.updated with TrialEnd=now. Handler deactivates trial, creates active subscription
Trial on a workspace that already trialedStripe: 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

#TriggerInitiatorStripe Sub StateStripe API CallCancel Mode
1subscription.deleted webhookStripeAlready cancelledNo (Stripe did it)N/A
2subscription.updated webhook (new period)StripeStill active (new cycle)No (sub continues)N/A
3subscription.updated webhook (trial to active)StripeStill active (converted)No (sub continues)N/A
4Admin switches to EnterpriseAdminStill active, chargingYesImmediate
5Admin creates trial replacing StripeAdminStill active or trialingYesImmediate
6Admin locks/disables workspaceAdminStill activeYesImmediate
7Cron: Stripe sub expired, no webhookSystemUnknown/staleYes (safety)Immediate
8User self-cancels via billing portalUserStill activeYesAt period end

Cancel Modes

ModeStripe API CallWhen Used
ImmediateSubscriptionService.CancelAsync(subId)Admin replaces Stripe with enterprise. Stop charging now
At period endSubscriptionService.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 JobFrequencyResponsibility
MetricsComputationCronEvery 5 minCompute and write metrics_data to the active subscription for each workspace
SubscriptionLifecycleCronHourlyAuto-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:

  1. User requests downgrade in billing UI
  2. StripeController creates a SubscriptionSchedule in Stripe (current plan until period end, then new plan)
  3. Stripe manages the schedule. No local tracking of the pending change
  4. At period end, Stripe applies the new plan and sends customer.subscription.updated with the new price/product
  5. 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

MethodEndpointAuthPurpose
GET/api/subscriptions/historyWorkspaceMemberList all subscriptions for the current workspace (period navigator)
GET/api/subscriptions/{id}/metricsWorkspaceMemberGet metrics for a specific subscription (current or historical)
GET/api/workspace-plansAdministratorList all plan tiers (for admin dynamic dropdown)
POST/api/workspace-plansAdministratorCreate custom enterprise plan tier
PUT/api/workspace-plans/{name}AdministratorEdit plan tier limits

Updated Endpoints

MethodEndpointChange
GET/api/metrics/billing-periodRead from ActiveSubscription.metrics_data instead of workspace_metrics_record
POST/api/admin/enterprise/save-workspace-settingsRewrite 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-planUse Stripe API for price comparison. Create/deactivate subscriptions on webhook

Removed Endpoints (replaced by unified subscription management)

EndpointReplaced By
/api/admin/enterprise/change-plansave-workspace-settings
/api/admin/enterprise/renew-planAuto-renewal via cron
/api/admin/enterprise/dismiss-plansave-workspace-settings (disable enterprise)
/api/admin/enterprise/toggle-enterprisesave-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

EventTrigger LocationMethod
Trial expiring in 3 daysSubscriptionLifecycleCronOnTrialExpiringAsync
Trial expiredSubscriptionLifecycleCronOnTrialExpiredAsync
Subscription cancelledDeactivateSubscriptionAsyncOnSubscriptionCancelledAsync
Auto-renewal failedSubscriptionLifecycleCronOnAutoRenewalFailedAsync
Plan upgraded/downgraded/changedBillingService or AdminControllerOnPlanChangedAsync
Subscription expiring in 7 daysSubscriptionLifecycleCronOnSubscriptionExpiringAsync
User cancels at period endStripeControllerOnCancelAtPeriodEndAsync
Stripe payment failedStripeWebhookController (invoice.payment_failed)OnPaymentFailedAsync
Stripe trial ending in 3 daysStripeWebhookController (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

SettingValuePurpose
Allow B2C invoicesONEnables e-invoices for B2C customers who provide fiscal_code
Smart ReceiptsActivatedFallback for B2C customers without fiscal_code (requires appointment + enable in settings)

Customer Classification & Invoice Routing

CaseCountryTax IDFiscal CodeA-Cube Output
IT B2BIT✅ VAT providedNot needede-Invoice
IT B2C (with CF)IT✅ Provided (optional)B2C e-Invoice
IT B2C (without CF)IT❌ Not providedSmart Receipt (corrispettivo)
Intl B2Bnon-IT✅ Tax ID providedNot neededCross-border e-Invoice
Intl B2C (with CF)non-IT✅ Provided (optional)e-Invoice
Intl B2C (without CF)non-IT❌ Not providedSmart Receipt

Note: split_payment is 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:

FieldSourceRequiredNotes
customer.nameCheckout (CustomerUpdate.Name = "auto")✅ AlwaysAuto-saved from checkout
customer.addressCheckout (BillingAddressCollection = "required")✅ Alwaysaddress.country determines IT vs non-IT routing
customer.tax_idsCheckout (TaxIdCollection.Enabled = true)B2B onlyVAT/Tax ID collected at checkout
customer.metadata.fiscal_codeCheckout custom field → webhookOptionalSaved on checkout.session.completed
customer.metadata.codice_destinatarioCheckout custom field → webhookOptionalIT 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

AreaCurrent StateRequired Change
fiscal_code custom field✅ Already at checkout (optional)No change
codice_destinatario custom field❌ Not collectedAdd as optional custom field (7 chars)
checkout.session.completed handler✅ Saves fiscal_code to metadataUpdate: also save codice_destinatario
A-Cube "Allow B2C invoices"UnknownVerify setting is ON
A-Cube Smart ReceiptsUnknownActivate (appointment + settings)
split_payment❌ Not neededSkip — no PA customers
Billing portal✅ Stripe portal for updatesNo 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

FieldTypePurpose
payment_failed_countintNumber of consecutive failed payment attempts
last_payment_failed_atDateTime?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

ColumnTypeNotes
stripe_event_idVARCHAR(255) PKStripe event ID (e.g., evt_1234)
event_typeVARCHAR(100)e.g., invoice.payment_failed
processed_atDATETIMEWhen we processed it

Cleanup: DataRetentionCronService can 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:

MethodLineReason
CreateStripeCustomer437Duplicate of webhook-based customer handling
CreateStripeCharge475Replaced by BillingService.CreateStripeChargeAsync
UpdateStripeCharge519Replaced by BillingService.UpdateStripeChargeAsync
CreateSubscription559Replaced by BillingService.CreateSubscriptionAsync
CreateInvoice655Replaced 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:

EventHandlerStatus
charge.succeededBillingService.CreateStripeChargeAsync✅ Existing
charge.refundedUpdate StripePayment + StripeInvoice🆕 New
checkout.session.completedSave fiscal_code + codice_destinatario to metadata✅ Existing (update)
customer.updatedBillingService.UpdateCustomerAsync✅ Existing
customer.subscription.createdBillingService.CreateSubscriptionAsync✅ Existing
customer.subscription.updatedPlan transition + cancel_at_period_end sync✅ Existing
customer.subscription.deletedDeactivate → default subscription✅ Existing
customer.subscription.trial_will_endNotificationService.OnTrialExpiringAsync🆕 New
invoice.createdBillingService.CreateInvoiceAsync✅ Existing
invoice.payment_succeededBillingService.UpdateInvoiceAsync + reset failed count✅ Existing (update)
invoice.payment_failedUpdate invoice + increment failed count + notify✅ Existing (update)
invoice_payment.paidLink charge to invoice/subscription✅ Existing

Migration Plan

Phase 1: Database Schema

  1. Add stripe_product_id column to workspace_plan
  2. Add trial row to workspace_plan
  3. Create workspace_subscription table (includes cancel_at_period_end, payment_failed_count, last_payment_failed_at fields)
  4. Create subscription_invoice table (includes billing_reason field)
  5. Create stripe_webhook_event table (idempotency tracking)
  6. Add active_subscription_id column to workspace (nullable)
  7. Rename workspace_plan FK column on workspace to active_workspace_plan
  8. Add unique partial index IX_workspace_subscription_active for concurrency safety

Phase 2: Data Migration

  1. For each workspace with an active enterprise_plan: create workspace_subscription with source=enterprise
  2. For each workspace with an active Stripe subscription: create workspace_subscription with source=stripe
  3. For all other workspaces: create workspace_subscription with source=system, plan=default, 30-day auto-renew
  4. Set active_subscription_id on each workspace to point to the created subscription
  5. Copy workspace_metrics_record.metrics_data to each active subscription metrics_data
  6. Migrate workspace_invoice records to subscription_invoice (link to correct subscription)

Phase 3: Backend Code

  1. Create WorkspaceSubscription and SubscriptionInvoice EF entities
  2. Add EF mappings in WilsonCoreContext
  3. Add convenience properties to Workspace entity
  4. Update BillingService to create workspace_subscription on Stripe events
  5. Split BillingPeriodMetricsCronService into:
    • MetricsComputationCron (every 5 min): write metrics to ActiveSubscription.metrics_data
    • SubscriptionLifecycleCron (hourly): auto-renewal, expiry check, trial expiry, notification triggers
  6. Update WorkspacePlanService: use Stripe API for price-based transition type resolution
  7. Update AdminEnterpriseController to use workspace_subscription
  8. Update StripeWebhookController:
    • Find workspace by subscription link instead of StripeActiveSubscriptionId
    • Handle cancel_at_period_end on subscription.updated
    • Handle delayed webhooks (skip if already deactivated locally)
  9. Create ISubscriptionNotificationService with trigger methods
  10. Create DeactivateSubscriptionAsync with DeactivationOrigin enum for Stripe API cancellation logic
  11. Update workspace creation to create initial default subscription
  12. Add new API endpoints: subscription history, plan management
  13. Update CreateCheckoutSession: add codice_destinatario custom field
  14. Update HandleCheckoutCompleted: save both fiscal_code and codice_destinatario to customer metadata
  15. Add webhook idempotency: check stripe_webhook_event before processing, record after
  16. Add charge.refunded webhook handler: update StripePayment + StripeInvoice
  17. Add customer.subscription.trial_will_end webhook handler → NotificationService.OnTrialExpiringAsync
  18. Update invoice.payment_failed handler: increment payment_failed_count, trigger OnPaymentFailedAsync
  19. Update invoice.payment_succeeded handler: reset payment_failed_count to 0
  20. Add reactivate-subscription/{id} endpoint: set cancel_at_period_end = false
  21. Fix bug: AmountRefunded uses AmountCaptured (StripeController line 487)
  22. Add OnPaymentFailedAsync method to ISubscriptionNotificationService

Phase 4: Frontend

  1. Update Dashboard.razor:
    • Read from ActiveSubscription instead of priority logic
    • Add period navigator (Previous / Next)
    • Add time filter (7d / 30d / This Period)
  2. Update AdminEnterprise.razor:
    • Plan dropdown reads from workspace_plan table (dynamic, includes custom tiers)
    • Add trial creation (status=trialing)
    • Add subscription health alerts panel
  3. Remove all IsEnterprise / IsTrial flag checks -- use source/status instead
  4. Add payment failure warning banner on dashboard when payment_failed_count > 0
  5. Add "Undo Cancellation" / reactivation button on Invoices.razor when cancel_at_period_end = true

Phase 5: Cleanup

  1. Remove IsEnterprise, IsTrial, TrialStartDate, TrialEndDate, StripeActiveSubscriptionId from workspace entity and database
  2. Drop enterprise_plan table
  3. Drop workspace_plan_history table
  4. Drop workspace_metrics_record table
  5. Drop workspace_invoice table (replaced by subscription_invoice)
  6. Remove PlanMap hardcoded dictionaries from BillingService and StripeWebhookController (use workspace_plan.stripe_product_id mapping)
  7. Remove PlanTier dictionary from WorkspacePlanService (replaced by Stripe API price lookup)
  8. Remove old endpoints: change-plan, renew-plan, dismiss-plan, toggle-enterprise
  9. Remove FrontEndStatsCronService (if still present, commented out)
  10. Remove Stripe:TrailPlan config key from appsettings
  11. Remove 7 dead methods from StripeController (CreateStripeCustomer, CreateStripeCharge, UpdateStripeCharge, CreateSubscription, UpdateSubscription, CreateInvoice, UpdateInvoice)

File Impact Summary

FileChange
WorkflowEngineLibrary/Core/Models/Workspace.csRemove 5 columns, rename WorkspacePlan, add ActiveSubscriptionId, add computed properties
WorkflowEngineLibrary/Core/Models/WorkspaceSubscription.csNew entity (includes cancel_at_period_end)
WorkflowEngineLibrary/Core/Models/SubscriptionInvoice.csNew entity (includes billing_reason)
WorkflowEngineLibrary/Core/Models/WorkspacePlan.csAdd stripe_product_id
WorkflowDatabase/WilsonCoreContext.csAdd new entity mappings, update Workspace mapping, unique partial index
Server/Services/Implementations/BillingService.csCreate workspace_subscription on Stripe events, DeactivateSubscriptionAsync
Server/Services/Implementations/MetricsComputationCron.csNew — extracted from BillingPeriodMetricsCronService (every 5 min)
Server/Services/Implementations/SubscriptionLifecycleCron.csNew — renewal, expiry, trial expiry, notification triggers (hourly)
Server/Services/Implementations/SubscriptionNotificationService.csNew — handles all subscription lifecycle notifications
Server/Services/Implementations/WorkspacePlanService.csUse Stripe API for price comparison, remove PlanTier dictionary
Server/Controllers/AdminEnterpriseController.csRewrite to use workspace_subscription
WorkflowEngineLibrary/Core/Models/StripeWebhookEvent.csNew entity for webhook idempotency tracking
Server/Controllers/StripeWebhookController.csIdempotency 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.csTrial eligibility, cancel_at_period_end, codice_destinatario, reactivate-subscription endpoint, remove 7 dead methods, fix AmountRefunded bug
Server/Controllers/Workspace/SubscriptionController.csNew — subscription history and metrics endpoints
Server/Controllers/Admin/WorkspacePlanController.csNew — CRUD for workspace_plan tiers
Client/Pages/Dashboard.razorRead from ActiveSubscription, cancel_at_period_end badge, period nav, time filter, payment failure warning
Client/Pages/Invoices.razorAdd reactivation button when cancel_at_period_end = true
Client/Pages/AdminEnterprise.razorDynamic plan dropdown, trial creation, health alerts
Shared/Models/EnterpriseModels.csRename to SubscriptionModels, update DTOs
Shared/Models/BillingPeriodMetrics.csSimplify: single SubscriptionInfo
Server/Program.csRegister new crons, notification service