feat: Add customer type and update related logic for provisioning and customer management

This commit is contained in:
Matt Batchelder
2026-03-26 19:39:53 -04:00
parent fc510b9b20
commit 40e3be0e85
12 changed files with 970 additions and 18 deletions

View File

@@ -32,8 +32,9 @@ public static class CustomersApi
c.AdminEmail,
c.AdminFirstName,
c.AdminLastName,
Plan = c.Plan.ToString(),
Plan = c.Plan.HasValue ? c.Plan.Value.ToString() : null,
c.ScreenCount,
CustomerType = c.CustomerType.ToString(),
c.StripeCustomerId,
c.StripeSubscriptionId,
Status = c.Status.ToString(),
@@ -88,8 +89,9 @@ public static class CustomersApi
customer.AdminEmail,
customer.AdminFirstName,
customer.AdminLastName,
Plan = customer.Plan.ToString(),
Plan = customer.Plan.HasValue ? customer.Plan.Value.ToString() : null,
customer.ScreenCount,
CustomerType = customer.CustomerType.ToString(),
customer.StripeCustomerId,
customer.StripeSubscriptionId,
Status = customer.Status.ToString(),

View File

@@ -137,8 +137,24 @@ public static class ProvisionApi
if (await db.Customers.AnyAsync(c => c.Abbreviation == abbrev))
return Results.Conflict(new { message = $"Abbreviation '{abbrev}' is already in use." });
if (!Enum.TryParse<CustomerPlan>(req.Plan, true, out var plan))
return Results.BadRequest("Invalid plan. Must be Essentials or Pro.");
if (!Enum.TryParse<CustomerType>(req.CustomerType ?? "Standard", true, out var customerType))
return Results.BadRequest("Invalid customer type. Must be Standard, Internal, Demo, or Trial.");
CustomerPlan? plan = null;
if (customerType == CustomerType.Standard)
{
if (string.IsNullOrWhiteSpace(req.Plan))
return Results.BadRequest("Plan is required for Standard instances.");
if (!Enum.TryParse<CustomerPlan>(req.Plan, true, out var parsedPlan))
return Results.BadRequest("Invalid plan. Must be Essentials or Pro.");
plan = parsedPlan;
}
else if (!string.IsNullOrWhiteSpace(req.Plan))
{
if (!Enum.TryParse<CustomerPlan>(req.Plan, true, out var parsedPlan))
return Results.BadRequest("Invalid plan. Must be Essentials or Pro.");
plan = parsedPlan;
}
// Create Customer record
var customer = new Customer
@@ -150,7 +166,10 @@ public static class ProvisionApi
AdminLastName = req.AdminLastName?.Trim() ?? string.Empty,
Abbreviation = abbrev,
Plan = plan,
ScreenCount = Math.Max(1, req.ScreenCount),
ScreenCount = customerType == CustomerType.Standard
? Math.Max(1, req.ScreenCount ?? 1)
: req.ScreenCount ?? 0,
CustomerType = customerType,
Status = CustomerStatus.Provisioning,
CreatedAt = DateTime.UtcNow,
};
@@ -233,8 +252,9 @@ public record ManualProvisionRequest(
string? AdminFirstName,
string? AdminLastName,
string? Abbreviation,
string Plan,
int ScreenCount,
string? CustomerType,
string? Plan,
int? ScreenCount,
string? NewtId,
string? NewtSecret,
string? NfsServer,

View File

@@ -7,8 +7,9 @@ export interface CustomerListItem {
adminEmail: string;
adminFirstName: string;
adminLastName: string;
plan: string;
plan: string | null;
screenCount: number;
customerType: string;
stripeCustomerId: string | null;
stripeSubscriptionId: string | null;
status: string;

View File

@@ -19,8 +19,9 @@ export interface ManualProvisionRequest {
adminFirstName?: string;
adminLastName?: string;
abbreviation?: string;
plan: string;
screenCount: number;
customerType?: string;
plan?: string;
screenCount?: number;
newtId?: string;
newtSecret?: string;
nfsServer?: string;

View File

@@ -17,6 +17,7 @@ interface FormState {
adminFirstName: string;
adminLastName: string;
abbreviation: string;
customerType: string;
plan: string;
screenCount: number;
newtId: string;
@@ -33,6 +34,7 @@ const initialForm: FormState = {
adminFirstName: '',
adminLastName: '',
abbreviation: '',
customerType: 'Standard',
plan: 'Essentials',
screenCount: 1,
newtId: '',
@@ -62,14 +64,16 @@ export default function CreateInstancePage() {
const deployMut = useMutation({
mutationFn: () => {
const isStandard = form.customerType === 'Standard';
const req: ManualProvisionRequest = {
companyName: form.companyName,
adminEmail: form.adminEmail,
adminFirstName: form.adminFirstName || undefined,
adminLastName: form.adminLastName || undefined,
abbreviation: form.abbreviation || undefined,
plan: form.plan,
screenCount: form.screenCount,
customerType: form.customerType,
plan: isStandard || form.plan ? form.plan : undefined,
screenCount: isStandard ? form.screenCount : (form.screenCount > 0 ? form.screenCount : undefined),
newtId: form.newtId || undefined,
newtSecret: form.newtSecret || undefined,
nfsServer: form.nfsServer || undefined,
@@ -99,6 +103,7 @@ export default function CreateInstancePage() {
if (!form.adminEmail.trim()) e.adminEmail = 'Admin email is required';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.adminEmail)) e.adminEmail = 'Invalid email address';
if (form.abbreviation && !/^[a-z]{3}$/.test(form.abbreviation)) e.abbreviation = 'Must be exactly 3 lowercase letters';
if (form.customerType === 'Standard' && form.screenCount < 1) e.screenCount = 'Screen count must be at least 1';
setErrors(e);
return Object.keys(e).length === 0;
};
@@ -188,10 +193,26 @@ export default function CreateInstancePage() {
{/* Plan & Abbreviation */}
<section>
<h3 className="mb-2 text-sm font-medium text-gray-400">Plan &amp; Identity</h3>
<h3 className="mb-2 text-sm font-medium text-gray-400">Instance Type &amp; Plan</h3>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label htmlFor="cip-type" className="mb-1 block text-xs text-gray-400">Instance Type</label>
<select
id="cip-type"
value={form.customerType}
onChange={(e) => set('customerType', e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
>
<option value="Standard">Standard (billed customer)</option>
<option value="Internal">Internal (OTS staff)</option>
<option value="Demo">Demo (prospect showcase)</option>
<option value="Trial">Trial (evaluation, no payment)</option>
</select>
</div>
<div>
<label htmlFor="cip-plan" className="mb-1 block text-xs text-gray-400">Plan</label>
<label htmlFor="cip-plan" className="mb-1 block text-xs text-gray-400">
Plan{form.customerType !== 'Standard' && <span className="ml-1 text-gray-500">(optional)</span>}
</label>
<select
id="cip-plan"
value={form.plan}
@@ -203,7 +224,9 @@ export default function CreateInstancePage() {
</select>
</div>
<div>
<label htmlFor="cip-screens" className="mb-1 block text-xs text-gray-400">Screen Count</label>
<label htmlFor="cip-screens" className="mb-1 block text-xs text-gray-400">
Screen Count{form.customerType !== 'Standard' && <span className="ml-1 text-gray-500">(optional)</span>}
</label>
<input
id="cip-screens"
type="number"

View File

@@ -31,6 +31,12 @@ const statusText: Record<string, string> = {
Decommissioned: 'text-status-danger',
};
const typeBadgeVariant: Record<string, string> = {
Internal: 'bg-blue-900/60 text-blue-300 border-blue-700',
Demo: 'bg-purple-900/60 text-purple-300 border-purple-700',
Trial: 'bg-orange-900/60 text-orange-300 border-orange-700',
};
const statuses = ['Active', 'Provisioning', 'PendingPayment', 'Suspended', 'Decommissioned'] as const;
export default function CustomersPage() {
@@ -132,6 +138,11 @@ export default function CustomersPage() {
<TableCell>
<div>
<span className="font-medium">{c.companyName}</span>
{c.customerType && c.customerType !== 'Standard' && typeBadgeVariant[c.customerType] && (
<span className={`ml-2 inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium ${typeBadgeVariant[c.customerType]}`}>
{c.customerType}
</span>
)}
{c.failedPaymentCount > 0 && (
<Badge variant="destructive" className="ml-2">{c.failedPaymentCount} failed</Badge>
)}

View File

@@ -15,6 +15,14 @@ public enum CustomerStatus
Decommissioned
}
public enum CustomerType
{
Standard,
Internal,
Demo,
Trial
}
public class Customer
{
public Guid Id { get; set; }
@@ -23,8 +31,9 @@ public class Customer
public string AdminEmail { get; set; } = string.Empty;
public string AdminFirstName { get; set; } = string.Empty;
public string AdminLastName { get; set; } = string.Empty;
public CustomerPlan Plan { get; set; }
public CustomerPlan? Plan { get; set; }
public int ScreenCount { get; set; }
public CustomerType CustomerType { get; set; }
public string? StripeCustomerId { get; set; }
public string? StripeSubscriptionId { get; set; }
public string? StripeCheckoutSessionId { get; set; }

View File

@@ -0,0 +1,811 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using OTSSignsOrchestrator.Data;
#nullable disable
namespace OTSSignsOrchestrator.Migrations
{
[DbContext(typeof(OrchestratorDbContext))]
[Migration("20260326233733_AddCustomerType")]
partial class AddCustomerType
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("key");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("category");
b.Property<bool>("IsSensitive")
.HasColumnType("boolean")
.HasColumnName("is_sensitive");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("app_settings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("text")
.HasColumnName("action");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("text")
.HasColumnName("actor");
b.Property<string>("Detail")
.HasColumnType("text")
.HasColumnName("detail");
b.Property<Guid?>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<DateTime>("OccurredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at");
b.Property<string>("Outcome")
.HasColumnType("text")
.HasColumnName("outcome");
b.Property<string>("Target")
.IsRequired()
.HasColumnType("text")
.HasColumnName("target");
b.HasKey("Id")
.HasName("pk_audit_logs");
b.HasIndex("InstanceId");
b.HasIndex("OccurredAt");
b.ToTable("audit_logs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.AuthentikMetrics", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CheckedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("checked_at");
b.Property<string>("ErrorMessage")
.HasColumnType("text")
.HasColumnName("error_message");
b.Property<int>("LatencyMs")
.HasColumnType("integer")
.HasColumnName("latency_ms");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_authentik_metrics");
b.ToTable("authentik_metrics");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ByoiConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CertExpiry")
.HasColumnType("timestamp with time zone")
.HasColumnName("cert_expiry");
b.Property<string>("CertPem")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cert_pem");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.HasColumnType("boolean")
.HasColumnName("enabled");
b.Property<string>("EntityId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("entity_id");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("SsoUrl")
.IsRequired()
.HasColumnType("text")
.HasColumnName("sso_url");
b.HasKey("Id")
.HasName("pk_byoi_configs");
b.HasIndex("InstanceId")
.HasDatabaseName("ix_byoi_configs_instance_id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("byoi_configs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Abbreviation")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasColumnName("abbreviation");
b.Property<string>("AdminEmail")
.IsRequired()
.HasColumnType("text")
.HasColumnName("admin_email");
b.Property<string>("AdminFirstName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("admin_first_name");
b.Property<string>("AdminLastName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("admin_last_name");
b.Property<string>("CompanyName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("company_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("CustomerType")
.HasColumnType("integer")
.HasColumnName("customer_type");
b.Property<int>("FailedPaymentCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("failed_payment_count");
b.Property<DateTime?>("FirstPaymentFailedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_payment_failed_at");
b.Property<string>("Plan")
.HasColumnType("text")
.HasColumnName("plan");
b.Property<int>("ScreenCount")
.HasColumnType("integer")
.HasColumnName("screen_count");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<string>("StripeCheckoutSessionId")
.HasColumnType("text")
.HasColumnName("stripe_checkout_session_id");
b.Property<string>("StripeCustomerId")
.HasColumnType("text")
.HasColumnName("stripe_customer_id");
b.Property<string>("StripeSubscriptionId")
.HasColumnType("text")
.HasColumnName("stripe_subscription_id");
b.HasKey("Id")
.HasName("pk_customers");
b.HasIndex("Abbreviation")
.IsUnique();
b.HasIndex("StripeCustomerId")
.IsUnique();
b.ToTable("customers");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.HealthEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("CheckName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("check_name");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<string>("Message")
.HasColumnType("text")
.HasColumnName("message");
b.Property<DateTime>("OccurredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at");
b.Property<bool>("Remediated")
.HasColumnType("boolean")
.HasColumnName("remediated");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_health_events");
b.HasIndex("InstanceId")
.HasDatabaseName("ix_health_events_instance_id");
b.ToTable("health_events");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Instance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("AuthentikProviderId")
.HasColumnType("text")
.HasColumnName("authentik_provider_id");
b.Property<string>("CmsAdminPassRef")
.HasColumnType("text")
.HasColumnName("cms_admin_pass_ref");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid")
.HasColumnName("customer_id");
b.Property<string>("DockerStackName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("docker_stack_name");
b.Property<string>("HealthStatus")
.IsRequired()
.HasColumnType("text")
.HasColumnName("health_status");
b.Property<DateTime?>("LastHealthCheck")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_health_check");
b.Property<string>("MysqlDatabase")
.IsRequired()
.HasColumnType("text")
.HasColumnName("mysql_database");
b.Property<string>("NfsPath")
.IsRequired()
.HasColumnType("text")
.HasColumnName("nfs_path");
b.Property<string>("XiboUrl")
.IsRequired()
.HasColumnType("text")
.HasColumnName("xibo_url");
b.HasKey("Id")
.HasName("pk_instances");
b.HasIndex("CustomerId")
.HasDatabaseName("ix_instances_customer_id");
b.HasIndex("DockerStackName")
.IsUnique();
b.ToTable("instances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Job", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid")
.HasColumnName("customer_id");
b.Property<string>("ErrorMessage")
.HasColumnType("text")
.HasColumnName("error_message");
b.Property<string>("JobType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_type");
b.Property<string>("Parameters")
.HasColumnType("text")
.HasColumnName("parameters");
b.Property<DateTime?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<string>("TriggeredBy")
.HasColumnType("text")
.HasColumnName("triggered_by");
b.HasKey("Id")
.HasName("pk_jobs");
b.HasIndex("CustomerId")
.HasDatabaseName("ix_jobs_customer_id");
b.ToTable("jobs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.JobStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Guid>("JobId")
.HasColumnType("uuid")
.HasColumnName("job_id");
b.Property<string>("LogOutput")
.HasColumnType("text")
.HasColumnName("log_output");
b.Property<DateTime?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<string>("StepName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("step_name");
b.HasKey("Id")
.HasName("pk_job_steps");
b.HasIndex("JobId")
.HasDatabaseName("ix_job_steps_job_id");
b.ToTable("job_steps");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.OauthAppRegistry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.HasKey("Id")
.HasName("pk_oauth_app_registries");
b.HasIndex("ClientId")
.IsUnique();
b.HasIndex("InstanceId")
.HasDatabaseName("ix_oauth_app_registries_instance_id");
b.ToTable("oauth_app_registries");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<long?>("DurationMs")
.HasColumnType("bigint")
.HasColumnName("duration_ms");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("message");
b.Property<string>("Operation")
.IsRequired()
.HasColumnType("text")
.HasColumnName("operation");
b.Property<string>("StackName")
.HasMaxLength(150)
.HasColumnType("character varying(150)")
.HasColumnName("stack_name");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone")
.HasColumnName("timestamp");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_operation_logs");
b.HasIndex("Operation");
b.HasIndex("StackName");
b.HasIndex("Timestamp");
b.ToTable("operation_logs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ScreenSnapshot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<int>("ScreenCount")
.HasColumnType("integer")
.HasColumnName("screen_count");
b.Property<DateOnly>("SnapshotDate")
.HasColumnType("date")
.HasColumnName("snapshot_date");
b.HasKey("Id")
.HasName("pk_screen_snapshots");
b.HasIndex("InstanceId")
.HasDatabaseName("ix_screen_snapshots_instance_id");
b.ToTable("screen_snapshots");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("host");
b.Property<bool>("IsLocal")
.HasColumnType("boolean")
.HasColumnName("is_local");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("key_passphrase");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("label");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("boolean")
.HasColumnName("last_test_success");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_tested_at");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("password");
b.Property<int>("Port")
.HasColumnType("integer")
.HasColumnName("port");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("private_key_path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<bool>("UseKeyAuth")
.HasColumnType("boolean")
.HasColumnName("use_key_auth");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_ssh_hosts");
b.HasIndex("Label")
.IsUnique();
b.ToTable("ssh_hosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.StripeEvent", b =>
{
b.Property<string>("StripeEventId")
.HasColumnType("text")
.HasColumnName("stripe_event_id");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("event_type");
b.Property<string>("Payload")
.HasColumnType("text")
.HasColumnName("payload");
b.Property<DateTime>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.HasKey("StripeEventId")
.HasName("pk_stripe_events");
b.ToTable("stripe_events");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ByoiConfig", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
.WithMany("ByoiConfigs")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_byoi_configs__instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.HealthEvent", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
.WithMany("HealthEvents")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_health_events__instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Instance", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Customer", "Customer")
.WithMany("Instances")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_instances_customers_customer_id");
b.Navigation("Customer");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Job", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Customer", "Customer")
.WithMany("Jobs")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_jobs_customers_customer_id");
b.Navigation("Customer");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.JobStep", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Job", "Job")
.WithMany("Steps")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_job_steps_jobs_job_id");
b.Navigation("Job");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.OauthAppRegistry", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
.WithMany("OauthAppRegistries")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_oauth_app_registries_instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ScreenSnapshot", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
.WithMany("ScreenSnapshots")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_screen_snapshots_instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Customer", b =>
{
b.Navigation("Instances");
b.Navigation("Jobs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Instance", b =>
{
b.Navigation("ByoiConfigs");
b.Navigation("HealthEvents");
b.Navigation("OauthAppRegistries");
b.Navigation("ScreenSnapshots");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Job", b =>
{
b.Navigation("Steps");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Migrations
{
/// <inheritdoc />
public partial class AddCustomerType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "plan",
table: "customers",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AddColumn<int>(
name: "customer_type",
table: "customers",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "customer_type",
table: "customers");
migrationBuilder.AlterColumn<string>(
name: "plan",
table: "customers",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
}
}

View File

@@ -225,6 +225,10 @@ namespace OTSSignsOrchestrator.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("CustomerType")
.HasColumnType("integer")
.HasColumnName("customer_type");
b.Property<int>("FailedPaymentCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
@@ -236,7 +240,6 @@ namespace OTSSignsOrchestrator.Migrations
.HasColumnName("first_payment_failed_at");
b.Property<string>("Plan")
.IsRequired()
.HasColumnType("text")
.HasColumnName("plan");

View File

@@ -52,7 +52,7 @@ public class BillingReportService
CompanyName = g.First().CompanyName,
Date = g.Key.SnapshotDate,
ScreenCount = g.Sum(r => r.ScreenCount),
Plan = g.First().Plan.ToString(),
Plan = g.First().Plan.HasValue ? g.First().Plan!.Value.ToString() : string.Empty,
})
.OrderBy(r => r.CustomerAbbrev)
.ThenBy(r => r.Date)

View File

@@ -186,6 +186,14 @@ public static class StripeWebhookHandler
return;
}
if (customer.CustomerType != CustomerType.Standard)
{
logger.LogInformation(
"Skipping payment-failed billing logic for non-Standard customer {CustomerId} (type={Type})",
customer.Id, customer.CustomerType);
return;
}
customer.FailedPaymentCount++;
customer.FirstPaymentFailedAt ??= DateTime.UtcNow;
await db.SaveChangesAsync();
@@ -239,6 +247,14 @@ public static class StripeWebhookHandler
return;
}
if (customer.CustomerType != CustomerType.Standard)
{
logger.LogInformation(
"Skipping subscription-updated billing logic for non-Standard customer {CustomerId} (type={Type})",
customer.Id, customer.CustomerType);
return;
}
// Sync screen count from subscription quantity
var quantity = subscription.Items?.Data?.FirstOrDefault()?.Quantity;
if (quantity.HasValue && (int)quantity.Value != customer.ScreenCount)
@@ -311,6 +327,14 @@ public static class StripeWebhookHandler
return;
}
if (customer.CustomerType != CustomerType.Standard)
{
logger.LogInformation(
"Skipping subscription-deleted billing logic for non-Standard customer {CustomerId} (type={Type})",
customer.Id, customer.CustomerType);
return;
}
logger.LogInformation(
"Subscription deleted for customer {CustomerId} ({Company}) — creating decommission job",
customer.Id, customer.CompanyName);