Compare commits

...

1 Commits

Author SHA1 Message Date
Matt Batchelder
40e3be0e85 feat: Add customer type and update related logic for provisioning and customer management 2026-03-26 19:39:53 -04:00
12 changed files with 970 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ interface FormState {
adminFirstName: string; adminFirstName: string;
adminLastName: string; adminLastName: string;
abbreviation: string; abbreviation: string;
customerType: string;
plan: string; plan: string;
screenCount: number; screenCount: number;
newtId: string; newtId: string;
@@ -33,6 +34,7 @@ const initialForm: FormState = {
adminFirstName: '', adminFirstName: '',
adminLastName: '', adminLastName: '',
abbreviation: '', abbreviation: '',
customerType: 'Standard',
plan: 'Essentials', plan: 'Essentials',
screenCount: 1, screenCount: 1,
newtId: '', newtId: '',
@@ -62,14 +64,16 @@ export default function CreateInstancePage() {
const deployMut = useMutation({ const deployMut = useMutation({
mutationFn: () => { mutationFn: () => {
const isStandard = form.customerType === 'Standard';
const req: ManualProvisionRequest = { const req: ManualProvisionRequest = {
companyName: form.companyName, companyName: form.companyName,
adminEmail: form.adminEmail, adminEmail: form.adminEmail,
adminFirstName: form.adminFirstName || undefined, adminFirstName: form.adminFirstName || undefined,
adminLastName: form.adminLastName || undefined, adminLastName: form.adminLastName || undefined,
abbreviation: form.abbreviation || undefined, abbreviation: form.abbreviation || undefined,
plan: form.plan, customerType: form.customerType,
screenCount: form.screenCount, plan: isStandard || form.plan ? form.plan : undefined,
screenCount: isStandard ? form.screenCount : (form.screenCount > 0 ? form.screenCount : undefined),
newtId: form.newtId || undefined, newtId: form.newtId || undefined,
newtSecret: form.newtSecret || undefined, newtSecret: form.newtSecret || undefined,
nfsServer: form.nfsServer || undefined, nfsServer: form.nfsServer || undefined,
@@ -99,6 +103,7 @@ export default function CreateInstancePage() {
if (!form.adminEmail.trim()) e.adminEmail = 'Admin email is required'; if (!form.adminEmail.trim()) e.adminEmail = 'Admin email is required';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.adminEmail)) e.adminEmail = 'Invalid email address'; 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.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); setErrors(e);
return Object.keys(e).length === 0; return Object.keys(e).length === 0;
}; };
@@ -188,10 +193,26 @@ export default function CreateInstancePage() {
{/* Plan & Abbreviation */} {/* Plan & Abbreviation */}
<section> <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="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> <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 <select
id="cip-plan" id="cip-plan"
value={form.plan} value={form.plan}
@@ -203,7 +224,9 @@ export default function CreateInstancePage() {
</select> </select>
</div> </div>
<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 <input
id="cip-screens" id="cip-screens"
type="number" type="number"

View File

@@ -31,6 +31,12 @@ const statusText: Record<string, string> = {
Decommissioned: 'text-status-danger', 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; const statuses = ['Active', 'Provisioning', 'PendingPayment', 'Suspended', 'Decommissioned'] as const;
export default function CustomersPage() { export default function CustomersPage() {
@@ -132,6 +138,11 @@ export default function CustomersPage() {
<TableCell> <TableCell>
<div> <div>
<span className="font-medium">{c.companyName}</span> <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 && ( {c.failedPaymentCount > 0 && (
<Badge variant="destructive" className="ml-2">{c.failedPaymentCount} failed</Badge> <Badge variant="destructive" className="ml-2">{c.failedPaymentCount} failed</Badge>
)} )}

View File

@@ -15,6 +15,14 @@ public enum CustomerStatus
Decommissioned Decommissioned
} }
public enum CustomerType
{
Standard,
Internal,
Demo,
Trial
}
public class Customer public class Customer
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
@@ -23,8 +31,9 @@ public class Customer
public string AdminEmail { get; set; } = string.Empty; public string AdminEmail { get; set; } = string.Empty;
public string AdminFirstName { get; set; } = string.Empty; public string AdminFirstName { get; set; } = string.Empty;
public string AdminLastName { 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 int ScreenCount { get; set; }
public CustomerType CustomerType { get; set; }
public string? StripeCustomerId { get; set; } public string? StripeCustomerId { get; set; }
public string? StripeSubscriptionId { get; set; } public string? StripeSubscriptionId { get; set; }
public string? StripeCheckoutSessionId { 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") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
b.Property<int>("CustomerType")
.HasColumnType("integer")
.HasColumnName("customer_type");
b.Property<int>("FailedPaymentCount") b.Property<int>("FailedPaymentCount")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("integer") .HasColumnType("integer")
@@ -236,7 +240,6 @@ namespace OTSSignsOrchestrator.Migrations
.HasColumnName("first_payment_failed_at"); .HasColumnName("first_payment_failed_at");
b.Property<string>("Plan") b.Property<string>("Plan")
.IsRequired()
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("plan"); .HasColumnName("plan");

View File

@@ -52,7 +52,7 @@ public class BillingReportService
CompanyName = g.First().CompanyName, CompanyName = g.First().CompanyName,
Date = g.Key.SnapshotDate, Date = g.Key.SnapshotDate,
ScreenCount = g.Sum(r => r.ScreenCount), 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) .OrderBy(r => r.CustomerAbbrev)
.ThenBy(r => r.Date) .ThenBy(r => r.Date)

View File

@@ -186,6 +186,14 @@ public static class StripeWebhookHandler
return; 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.FailedPaymentCount++;
customer.FirstPaymentFailedAt ??= DateTime.UtcNow; customer.FirstPaymentFailedAt ??= DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -239,6 +247,14 @@ public static class StripeWebhookHandler
return; 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 // Sync screen count from subscription quantity
var quantity = subscription.Items?.Data?.FirstOrDefault()?.Quantity; var quantity = subscription.Items?.Data?.FirstOrDefault()?.Quantity;
if (quantity.HasValue && (int)quantity.Value != customer.ScreenCount) if (quantity.HasValue && (int)quantity.Value != customer.ScreenCount)
@@ -311,6 +327,14 @@ public static class StripeWebhookHandler
return; 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( logger.LogInformation(
"Subscription deleted for customer {CustomerId} ({Company}) — creating decommission job", "Subscription deleted for customer {CustomerId} ({Company}) — creating decommission job",
customer.Id, customer.CompanyName); customer.Id, customer.CompanyName);