diff --git a/OTSSignsOrchestrator/Api/CustomersApi.cs b/OTSSignsOrchestrator/Api/CustomersApi.cs index a5b5edf..4e3649e 100644 --- a/OTSSignsOrchestrator/Api/CustomersApi.cs +++ b/OTSSignsOrchestrator/Api/CustomersApi.cs @@ -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(), diff --git a/OTSSignsOrchestrator/Api/ProvisionApi.cs b/OTSSignsOrchestrator/Api/ProvisionApi.cs index d02cc23..27935a0 100644 --- a/OTSSignsOrchestrator/Api/ProvisionApi.cs +++ b/OTSSignsOrchestrator/Api/ProvisionApi.cs @@ -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(req.Plan, true, out var plan)) - return Results.BadRequest("Invalid plan. Must be Essentials or Pro."); + if (!Enum.TryParse(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(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(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, diff --git a/OTSSignsOrchestrator/ClientApp/src/api/customers.ts b/OTSSignsOrchestrator/ClientApp/src/api/customers.ts index 3f034e3..8dec57f 100644 --- a/OTSSignsOrchestrator/ClientApp/src/api/customers.ts +++ b/OTSSignsOrchestrator/ClientApp/src/api/customers.ts @@ -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; diff --git a/OTSSignsOrchestrator/ClientApp/src/api/provision.ts b/OTSSignsOrchestrator/ClientApp/src/api/provision.ts index d7cf634..7f2d6a0 100644 --- a/OTSSignsOrchestrator/ClientApp/src/api/provision.ts +++ b/OTSSignsOrchestrator/ClientApp/src/api/provision.ts @@ -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; diff --git a/OTSSignsOrchestrator/ClientApp/src/pages/CreateInstancePage.tsx b/OTSSignsOrchestrator/ClientApp/src/pages/CreateInstancePage.tsx index 21dfe50..744120d 100644 --- a/OTSSignsOrchestrator/ClientApp/src/pages/CreateInstancePage.tsx +++ b/OTSSignsOrchestrator/ClientApp/src/pages/CreateInstancePage.tsx @@ -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 */}
-

Plan & Identity

+

Instance Type & Plan

+
+ + +
- + = { Decommissioned: 'text-status-danger', }; +const typeBadgeVariant: Record = { + 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() {
{c.companyName} + {c.customerType && c.customerType !== 'Standard' && typeBadgeVariant[c.customerType] && ( + + {c.customerType} + + )} {c.failedPaymentCount > 0 && ( {c.failedPaymentCount} failed )} diff --git a/OTSSignsOrchestrator/Data/Entities/Customer.cs b/OTSSignsOrchestrator/Data/Entities/Customer.cs index cf461c2..06f5bb4 100644 --- a/OTSSignsOrchestrator/Data/Entities/Customer.cs +++ b/OTSSignsOrchestrator/Data/Entities/Customer.cs @@ -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; } diff --git a/OTSSignsOrchestrator/Migrations/20260326233733_AddCustomerType.Designer.cs b/OTSSignsOrchestrator/Migrations/20260326233733_AddCustomerType.Designer.cs new file mode 100644 index 0000000..8a1cf92 --- /dev/null +++ b/OTSSignsOrchestrator/Migrations/20260326233733_AddCustomerType.Designer.cs @@ -0,0 +1,811 @@ +// +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 + { + /// + 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("Key") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("key"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("category"); + + b.Property("IsSensitive") + .HasColumnType("boolean") + .HasColumnName("is_sensitive"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("actor"); + + b.Property("Detail") + .HasColumnType("text") + .HasColumnName("detail"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasColumnName("instance_id"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Outcome") + .HasColumnType("text") + .HasColumnName("outcome"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CheckedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("checked_at"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("LatencyMs") + .HasColumnType("integer") + .HasColumnName("latency_ms"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CertExpiry") + .HasColumnType("timestamp with time zone") + .HasColumnName("cert_expiry"); + + b.Property("CertPem") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cert_pem"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("EntityId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("entity_id"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasColumnName("instance_id"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("abbreviation"); + + b.Property("AdminEmail") + .IsRequired() + .HasColumnType("text") + .HasColumnName("admin_email"); + + b.Property("AdminFirstName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("admin_first_name"); + + b.Property("AdminLastName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("admin_last_name"); + + b.Property("CompanyName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("company_name"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CustomerType") + .HasColumnType("integer") + .HasColumnName("customer_type"); + + b.Property("FailedPaymentCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("failed_payment_count"); + + b.Property("FirstPaymentFailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_payment_failed_at"); + + b.Property("Plan") + .HasColumnType("text") + .HasColumnName("plan"); + + b.Property("ScreenCount") + .HasColumnType("integer") + .HasColumnName("screen_count"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("StripeCheckoutSessionId") + .HasColumnType("text") + .HasColumnName("stripe_checkout_session_id"); + + b.Property("StripeCustomerId") + .HasColumnType("text") + .HasColumnName("stripe_customer_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CheckName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("check_name"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasColumnName("instance_id"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Remediated") + .HasColumnType("boolean") + .HasColumnName("remediated"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthentikProviderId") + .HasColumnType("text") + .HasColumnName("authentik_provider_id"); + + b.Property("CmsAdminPassRef") + .HasColumnType("text") + .HasColumnName("cms_admin_pass_ref"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CustomerId") + .HasColumnType("uuid") + .HasColumnName("customer_id"); + + b.Property("DockerStackName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("docker_stack_name"); + + b.Property("HealthStatus") + .IsRequired() + .HasColumnType("text") + .HasColumnName("health_status"); + + b.Property("LastHealthCheck") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_health_check"); + + b.Property("MysqlDatabase") + .IsRequired() + .HasColumnType("text") + .HasColumnName("mysql_database"); + + b.Property("NfsPath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nfs_path"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CustomerId") + .HasColumnType("uuid") + .HasColumnName("customer_id"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("JobType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_type"); + + b.Property("Parameters") + .HasColumnType("text") + .HasColumnName("parameters"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("JobId") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.Property("LogOutput") + .HasColumnType("text") + .HasColumnName("log_output"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("Operation") + .IsRequired() + .HasColumnType("text") + .HasColumnName("operation"); + + b.Property("StackName") + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("stack_name"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasColumnName("instance_id"); + + b.Property("ScreenCount") + .HasColumnType("integer") + .HasColumnName("screen_count"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("host"); + + b.Property("IsLocal") + .HasColumnType("boolean") + .HasColumnName("is_local"); + + b.Property("KeyPassphrase") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("key_passphrase"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("LastTestSuccess") + .HasColumnType("boolean") + .HasColumnName("last_test_success"); + + b.Property("LastTestedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_tested_at"); + + b.Property("Password") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("password"); + + b.Property("Port") + .HasColumnType("integer") + .HasColumnName("port"); + + b.Property("PrivateKeyPath") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("private_key_path"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UseKeyAuth") + .HasColumnType("boolean") + .HasColumnName("use_key_auth"); + + b.Property("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("StripeEventId") + .HasColumnType("text") + .HasColumnName("stripe_event_id"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("event_type"); + + b.Property("Payload") + .HasColumnType("text") + .HasColumnName("payload"); + + b.Property("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 + } + } +} diff --git a/OTSSignsOrchestrator/Migrations/20260326233733_AddCustomerType.cs b/OTSSignsOrchestrator/Migrations/20260326233733_AddCustomerType.cs new file mode 100644 index 0000000..17e87fd --- /dev/null +++ b/OTSSignsOrchestrator/Migrations/20260326233733_AddCustomerType.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OTSSignsOrchestrator.Migrations +{ + /// + public partial class AddCustomerType : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "plan", + table: "customers", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AddColumn( + name: "customer_type", + table: "customers", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "customer_type", + table: "customers"); + + migrationBuilder.AlterColumn( + name: "plan", + table: "customers", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + } +} diff --git a/OTSSignsOrchestrator/Migrations/OrchestratorDbContextModelSnapshot.cs b/OTSSignsOrchestrator/Migrations/OrchestratorDbContextModelSnapshot.cs index b64d69c..383801c 100644 --- a/OTSSignsOrchestrator/Migrations/OrchestratorDbContextModelSnapshot.cs +++ b/OTSSignsOrchestrator/Migrations/OrchestratorDbContextModelSnapshot.cs @@ -225,6 +225,10 @@ namespace OTSSignsOrchestrator.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); + b.Property("CustomerType") + .HasColumnType("integer") + .HasColumnName("customer_type"); + b.Property("FailedPaymentCount") .ValueGeneratedOnAdd() .HasColumnType("integer") @@ -236,7 +240,6 @@ namespace OTSSignsOrchestrator.Migrations .HasColumnName("first_payment_failed_at"); b.Property("Plan") - .IsRequired() .HasColumnType("text") .HasColumnName("plan"); diff --git a/OTSSignsOrchestrator/Reports/BillingReportService.cs b/OTSSignsOrchestrator/Reports/BillingReportService.cs index d494288..814f114 100644 --- a/OTSSignsOrchestrator/Reports/BillingReportService.cs +++ b/OTSSignsOrchestrator/Reports/BillingReportService.cs @@ -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) diff --git a/OTSSignsOrchestrator/Webhooks/StripeWebhookHandler.cs b/OTSSignsOrchestrator/Webhooks/StripeWebhookHandler.cs index 610bda9..fb1aad4 100644 --- a/OTSSignsOrchestrator/Webhooks/StripeWebhookHandler.cs +++ b/OTSSignsOrchestrator/Webhooks/StripeWebhookHandler.cs @@ -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);