From 97e824e3ce898693445006a292984b57893f9386 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 22 Jun 2026 22:12:08 -0500 Subject: [PATCH 1/3] RE1-T117 Trying to fix timeouts --- Core/Resgrid.Services/SubscriptionsService.cs | 297 +++++++++++------- 1 file changed, 183 insertions(+), 114 deletions(-) diff --git a/Core/Resgrid.Services/SubscriptionsService.cs b/Core/Resgrid.Services/SubscriptionsService.cs index fc2c00d75..325b1262c 100644 --- a/Core/Resgrid.Services/SubscriptionsService.cs +++ b/Core/Resgrid.Services/SubscriptionsService.cs @@ -27,6 +27,11 @@ public class SubscriptionsService : ISubscriptionsService private const int FreePlanId = 1; + // Billing API calls must fail fast. A slow/unresponsive Billing API previously blocked the + // request thread for up to 200s (e.g. the Subscription and User Dashboard pages would hang + // for ~3 minutes). Keep every Billing call on this short, shared timeout. + private const int BillingApiTimeoutMs = 5000; + private readonly IPlansRepository _plansRepository; private readonly IPaymentRepository _paymentsRepository; private readonly ICacheProvider _cacheProvider; @@ -60,7 +65,7 @@ public SubscriptionsService(IPlansRepository plansRepository, IPaymentRepository { var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) { - MaxTimeout = 5000 // ms — fail fast so callers (e.g. Twilio webhooks) don't exceed their own timeouts + MaxTimeout = BillingApiTimeoutMs // ms — fail fast so callers (e.g. Twilio webhooks) don't exceed their own timeouts }; var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); @@ -93,26 +98,34 @@ public async Task GetPlanCountsForDepartmentAsync(int depar { if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) { - var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + try { - MaxTimeout = 200000 // ms - }; - var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); + var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + { + MaxTimeout = BillingApiTimeoutMs // ms + }; + var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); - var request = new RestRequest($"/api/Billing/GetPlanCountsForDepartment", Method.Get); - request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); - request.AddHeader("Content-Type", "application/json"); - request.AddParameter("departmentId", departmentId, ParameterType.QueryString); + var request = new RestRequest($"/api/Billing/GetPlanCountsForDepartment", Method.Get); + request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); + request.AddHeader("Content-Type", "application/json"); + request.AddParameter("departmentId", departmentId, ParameterType.QueryString); - var response = await client.ExecuteAsync(request); + var response = await client.ExecuteAsync(request); - if (response.StatusCode == HttpStatusCode.NotFound) - return new DepartmentPlanCount(); + if (response.StatusCode == HttpStatusCode.NotFound) + return new DepartmentPlanCount(); - if (response.Data == null || response.Data.Data == null) - return new DepartmentPlanCount(); + if (response.Data == null || response.Data.Data == null) + return new DepartmentPlanCount(); - return response.Data.Data; + return response.Data.Data; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return new DepartmentPlanCount(); + } } return new DepartmentPlanCount(); @@ -122,26 +135,34 @@ public async Task GetCurrentPaymentForDepartmentAsync(int departmentId, { if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) { - var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + try { - MaxTimeout = 200000 // ms - }; + var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + { + MaxTimeout = BillingApiTimeoutMs // ms + }; - var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); - var request = new RestRequest($"/api/Billing/GetCurrentPaymentForDepartment", Method.Get); - request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); - request.AddHeader("Content-Type", "application/json"); - request.AddParameter("departmentId", departmentId, ParameterType.QueryString); + var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); + var request = new RestRequest($"/api/Billing/GetCurrentPaymentForDepartment", Method.Get); + request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); + request.AddHeader("Content-Type", "application/json"); + request.AddParameter("departmentId", departmentId, ParameterType.QueryString); - var response = await client.ExecuteAsync(request); + var response = await client.ExecuteAsync(request); - if (response.StatusCode == HttpStatusCode.NotFound) - return null; + if (response.StatusCode == HttpStatusCode.NotFound) + return null; - if (response.Data == null) - return null; + if (response.Data == null) + return null; - return response.Data.Data; + return response.Data.Data; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return null; + } } async Task getPayment() @@ -172,27 +193,35 @@ public async Task GetPreviousNonFreePaymentForDepartmentAsync(int depar { if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) { - var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + try { - MaxTimeout = 200000 // ms - }; + var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + { + MaxTimeout = BillingApiTimeoutMs // ms + }; - var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); - var request = new RestRequest($"/api/Billing/GetPreviousNonFreePaymentForDepartment", Method.Get); - request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); - request.AddHeader("Content-Type", "application/json"); - request.AddParameter("departmentId", departmentId, ParameterType.QueryString); - request.AddParameter("paymentId", paymentId, ParameterType.QueryString); + var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); + var request = new RestRequest($"/api/Billing/GetPreviousNonFreePaymentForDepartment", Method.Get); + request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); + request.AddHeader("Content-Type", "application/json"); + request.AddParameter("departmentId", departmentId, ParameterType.QueryString); + request.AddParameter("paymentId", paymentId, ParameterType.QueryString); - var response = await client.ExecuteAsync(request); + var response = await client.ExecuteAsync(request); - if (response.StatusCode == HttpStatusCode.NotFound) - return null; + if (response.StatusCode == HttpStatusCode.NotFound) + return null; - if (response.Data == null) - return null; + if (response.Data == null) + return null; - return response.Data.Data; + return response.Data.Data; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return null; + } } // I went with amount here as there could be preview payments, demo payments, etc in the system, no just Plans.FreePaymentId. @@ -208,26 +237,34 @@ public async Task GetUpcomingPaymentForDepartmentAsync(int departmentId { if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) { - var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + try { - MaxTimeout = 200000 // ms - }; + var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + { + MaxTimeout = BillingApiTimeoutMs // ms + }; - var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); - var request = new RestRequest($"/api/Billing/GetUpcomingPaymentForDepartment", Method.Get); - request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); - request.AddHeader("Content-Type", "application/json"); - request.AddParameter("departmentId", departmentId, ParameterType.QueryString); + var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); + var request = new RestRequest($"/api/Billing/GetUpcomingPaymentForDepartment", Method.Get); + request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); + request.AddHeader("Content-Type", "application/json"); + request.AddParameter("departmentId", departmentId, ParameterType.QueryString); - var response = await client.ExecuteAsync(request); + var response = await client.ExecuteAsync(request); - if (response.StatusCode == HttpStatusCode.NotFound) - return null; + if (response.StatusCode == HttpStatusCode.NotFound) + return null; - if (response.Data == null) - return null; + if (response.Data == null) + return null; - return response.Data.Data; + return response.Data.Data; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return null; + } } var payment = (from p in await _paymentsRepository.GetAllAsync() @@ -242,26 +279,34 @@ public async Task GetPaymentByTransactionIdAsync(string transactionId) { if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) { - var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + try { - MaxTimeout = 200000 // ms - }; + var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + { + MaxTimeout = BillingApiTimeoutMs // ms + }; - var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); - var request = new RestRequest($"/api/Billing/GetPaymentByTransactionId", Method.Get); - request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); - request.AddHeader("Content-Type", "application/json"); - request.AddParameter("transactionId", transactionId, ParameterType.QueryString); + var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); + var request = new RestRequest($"/api/Billing/GetPaymentByTransactionId", Method.Get); + request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); + request.AddHeader("Content-Type", "application/json"); + request.AddParameter("transactionId", transactionId, ParameterType.QueryString); - var response = await client.ExecuteAsync(request); + var response = await client.ExecuteAsync(request); - if (response.StatusCode == HttpStatusCode.NotFound) - return null; + if (response.StatusCode == HttpStatusCode.NotFound) + return null; - if (response.Data == null) - return null; + if (response.Data == null) + return null; - return response.Data.Data; + return response.Data.Data; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return null; + } } return await _paymentsRepository.GetPaymentByTransactionIdAsync(transactionId); @@ -271,26 +316,34 @@ public async Task GetPaymentByTransactionIdAsync(string transactionId) { if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) { - var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + try { - MaxTimeout = 200000 // ms - }; + var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + { + MaxTimeout = BillingApiTimeoutMs // ms + }; - var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); - var request = new RestRequest($"/api/Billing/GetPlanById", Method.Get); - request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); - request.AddHeader("Content-Type", "application/json"); - request.AddParameter("planId", planId, ParameterType.QueryString); + var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); + var request = new RestRequest($"/api/Billing/GetPlanById", Method.Get); + request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); + request.AddHeader("Content-Type", "application/json"); + request.AddParameter("planId", planId, ParameterType.QueryString); - var response = await client.ExecuteAsync(request); + var response = await client.ExecuteAsync(request); - if (response.StatusCode == HttpStatusCode.NotFound) - return null; + if (response.StatusCode == HttpStatusCode.NotFound) + return null; - if (response.Data == null) - return null; + if (response.Data == null) + return null; - return response.Data.Data; + return response.Data.Data; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return null; + } } async Task getPlan() @@ -310,26 +363,34 @@ public async Task GetPaymentByTransactionIdAsync(string transactionId) { if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) { - var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + try { - MaxTimeout = 200000 // ms - }; + var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + { + MaxTimeout = BillingApiTimeoutMs // ms + }; - var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); - var request = new RestRequest($"/api/Billing/GetPlanByExternalId", Method.Get); - request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); - request.AddHeader("Content-Type", "application/json"); - request.AddParameter("externalId", externalId, ParameterType.QueryString); + var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); + var request = new RestRequest($"/api/Billing/GetPlanByExternalId", Method.Get); + request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); + request.AddHeader("Content-Type", "application/json"); + request.AddParameter("externalId", externalId, ParameterType.QueryString); + + var response = await client.ExecuteAsync(request); - var response = await client.ExecuteAsync(request); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; - if (response.StatusCode == HttpStatusCode.NotFound) - return null; + if (response.Data == null) + return null; - if (response.Data == null) + return response.Data.Data; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); return null; - - return response.Data.Data; + } } async Task getPlan() @@ -351,26 +412,34 @@ public async Task GetPaymentByIdAsync(int paymentId) { if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) { - var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + try { - MaxTimeout = 200000 // ms - }; + var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) + { + MaxTimeout = BillingApiTimeoutMs // ms + }; - var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); - var request = new RestRequest($"/api/Billing/GetPaymentById", Method.Get); - request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); - request.AddHeader("Content-Type", "application/json"); - request.AddParameter("paymentId", paymentId, ParameterType.QueryString); + var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); + var request = new RestRequest($"/api/Billing/GetPaymentById", Method.Get); + request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); + request.AddHeader("Content-Type", "application/json"); + request.AddParameter("paymentId", paymentId, ParameterType.QueryString); - var response = await client.ExecuteAsync(request); + var response = await client.ExecuteAsync(request); - if (response.StatusCode == HttpStatusCode.NotFound) - return null; + if (response.StatusCode == HttpStatusCode.NotFound) + return null; - if (response.Data == null) - return null; + if (response.Data == null) + return null; - return response.Data.Data; + return response.Data.Data; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return null; + } } var payment = await _paymentsRepository.GetPaymentByIdIdAsync(paymentId); From 8c7da0e167decbb7df5e9c6293ff7d0f3d107f45 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 23 Jun 2026 08:04:26 -0500 Subject: [PATCH 2/3] RE1-T117 PR#415 fixes --- Core/Resgrid.Services/SubscriptionsService.cs | 17 ++++++++++++++-- .../Controllers/SubscriptionController.cs | 20 ++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Core/Resgrid.Services/SubscriptionsService.cs b/Core/Resgrid.Services/SubscriptionsService.cs index 325b1262c..05656b59d 100644 --- a/Core/Resgrid.Services/SubscriptionsService.cs +++ b/Core/Resgrid.Services/SubscriptionsService.cs @@ -116,15 +116,21 @@ public async Task GetPlanCountsForDepartmentAsync(int depar if (response.StatusCode == HttpStatusCode.NotFound) return new DepartmentPlanCount(); + // No usable payload — a timeout/empty response (RestSharp returns TimedOut with Data == null, + // it does not throw) or a success envelope with null data. Return null to fail closed + // (counts unavailable) rather than treating it as zero usage, which would fail open. if (response.Data == null || response.Data.Data == null) - return new DepartmentPlanCount(); + return null; return response.Data.Data; } catch (Exception ex) { Framework.Logging.LogException(ex); - return new DepartmentPlanCount(); + // Billing API faulted (not merely empty): return null to signal "counts unavailable" so + // callers fail closed (deny) instead of treating it as zero usage. LimitsService guards on + // null (== null => deny); a zero-count object would slip past those guards and fail open. + return null; } } @@ -614,6 +620,13 @@ public async Task GetAdjustedUpgradePriceAsync(int paymentId, int planId var payment = await GetPaymentByIdAsync(paymentId); var plan = await GetPlanByIdAsync(planId); + // GetPaymentByIdAsync/GetPlanByIdAsync return null when the Billing API is unavailable or the id + // isn't found. Every branch below dereferences payment, payment.Plan and plan and returns a price; + // silently returning 0 would mean a free upgrade, so fail loud rather than bill an outage-derived $0. + if (payment?.Plan == null || plan == null) + throw new InvalidOperationException( + $"Cannot compute upgrade price: missing billing data (paymentId={paymentId}, planId={planId})."); + // Both the original payment and the new plan have the same billing frequency, i.e. yearly/monthly. if (payment.Plan.Frequency == plan.Frequency) { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs index 660e85813..9ddca2628 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs @@ -445,6 +445,11 @@ public async Task Cancel() CancelView model = new CancelView(); model.Payment = await _subscriptionsService.GetCurrentPaymentForDepartmentAsync((await _departmentsService.GetDepartmentByUserIdAsync(UserId)).DepartmentId); + // GetCurrentPaymentForDepartmentAsync returns null when the Billing API is unavailable; without a + // current payment there is nothing to cancel and model.Payment.PlanId would NRE. + if (model.Payment == null) + return RedirectToAction("CancelFailure", "Subscription", new { Area = "User" }); + model.Plan = await _subscriptionsService.GetPlanByIdAsync(model.Payment.PlanId); return View(model); @@ -571,6 +576,11 @@ public async Task Cancel(CancelView model, CancellationToken canc } model.Payment = await _subscriptionsService.GetCurrentPaymentForDepartmentAsync((await _departmentsService.GetDepartmentByUserIdAsync(UserId)).DepartmentId); + // GetCurrentPaymentForDepartmentAsync returns null when the Billing API is unavailable; without a + // current payment there is nothing to cancel and model.Payment.PlanId would NRE. + if (model.Payment == null) + return RedirectToAction("CancelFailure", "Subscription", new { Area = "User" }); + model.Plan = await _subscriptionsService.GetPlanByIdAsync(model.Payment.PlanId); return View(model); @@ -595,7 +605,10 @@ public async Task BuyAddon(string planAddonId) if (model.PlanAddon.PlanId.HasValue) { var plan = await _subscriptionsService.GetPlanByIdAsync(model.PlanAddon.PlanId.Value); - model.Frequency = ((PlanFrequency)plan.Frequency).ToString(); + // GetPlanByIdAsync returns null when the Billing API is unavailable / the plan isn't found. + // Guard before dereferencing so a billing outage can't NRE this page. + if (plan != null) + model.Frequency = ((PlanFrequency)plan.Frequency).ToString(); } return View(model); @@ -753,6 +766,11 @@ public async Task GetStripeSession(int id, int count, string disc return BadRequest("Invalid entity pack count."); var plan = await _subscriptionsService.GetPlanByIdAsync(id); + // GetPlanByIdAsync returns null when the Billing API is unavailable / the plan isn't found. + // Fail with a clear error instead of NRE'ing on plan.GetExternalKey()/plan.PlanId below. + if (plan == null) + return StatusCode(StatusCodes.Status500InternalServerError, "Unable to load the selected plan. Please try again."); + var stripeCustomerId = await _departmentSettingsService.GetStripeCustomerIdForDepartmentAsync(DepartmentId); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); var user = _usersService.GetUserById(UserId); From 5d85535fe2356ffe1729422c819d1f736a102360 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 23 Jun 2026 08:24:10 -0500 Subject: [PATCH 3/3] RE1-T117 PR#415 fixes --- Core/Resgrid.Services/SubscriptionsService.cs | 6 ++++++ .../User/Controllers/SubscriptionController.cs | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/Core/Resgrid.Services/SubscriptionsService.cs b/Core/Resgrid.Services/SubscriptionsService.cs index 05656b59d..7db9f1fef 100644 --- a/Core/Resgrid.Services/SubscriptionsService.cs +++ b/Core/Resgrid.Services/SubscriptionsService.cs @@ -620,6 +620,12 @@ public async Task GetAdjustedUpgradePriceAsync(int paymentId, int planId var payment = await GetPaymentByIdAsync(paymentId); var plan = await GetPlanByIdAsync(planId); + // Sometimes the payment comes back without its Plan navigation populated; hydrate it from PlanId + // before giving up, mirroring GetCurrentPaymentForDepartmentAsync, so a valid payment with a + // missing navigation can still be priced rather than failing immediately. + if (payment != null && payment.Plan == null) + payment.Plan = await GetPlanByIdAsync(payment.PlanId); + // GetPaymentByIdAsync/GetPlanByIdAsync return null when the Billing API is unavailable or the id // isn't found. Every branch below dereferences payment, payment.Plan and plan and returns a price; // silently returning 0 would mean a free upgrade, so fail loud rather than bill an outage-derived $0. diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs index 9ddca2628..913b582af 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs @@ -592,6 +592,11 @@ public async Task BuyAddon(string planAddonId) { var model = new BuyAddonView(); model.PlanAddon = await _subscriptionsService.GetPlanAddonByIdAsync(planAddonId); + // GetPlanAddonByIdAsync returns null when the add-on id isn't found (or the Billing API is + // unavailable). Bail before dereferencing model.PlanAddon below (PlanAddonId / AddonType / PlanId). + if (model.PlanAddon == null) + return NotFound(); + model.PlanAddonId = model.PlanAddon.PlanAddonId; model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); var addonTypes = await _subscriptionsService.GetAllAddonPlansAsync(); @@ -620,6 +625,12 @@ public async Task ManagePTTAddon() { var model = new BuyAddonView(); model.PlanAddon = await _subscriptionsService.GetPlanAddonByIdAsync("6f4c5f8b-584d-4291-8a7d-29bf97ae6aa9"); + // GetPlanAddonByIdAsync returns null when the Billing API is unavailable / the add-on isn't found. + // The id here is hardcoded (a known product), so a null means a server/billing problem, not a bad + // request — surface a 500 instead of NRE'ing on model.PlanAddon below. + if (model.PlanAddon == null) + return StatusCode(StatusCodes.Status500InternalServerError, "Unable to load the PTT add-on. Please try again."); + model.PlanAddonId = model.PlanAddon.PlanAddonId; model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); @@ -775,6 +786,11 @@ public async Task GetStripeSession(int id, int count, string disc var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); var user = _usersService.GetUserById(UserId); var session = await _subscriptionsService.CreateStripeSessionForSub(DepartmentId, stripeCustomerId, plan.GetExternalKey(), plan.PlanId, user.Email, department.Name, count, discountCode); + // CreateStripeSessionForSub returns null when the Billing API is unavailable / Stripe session + // creation fails. Fail with a clear error instead of NRE'ing on session.CustomerId/SessionId below. + if (session == null) + return StatusCode(StatusCodes.Status500InternalServerError, "Unable to start the checkout session. Please try again."); + var subscription = await _subscriptionsService.GetActiveStripeSubscriptionAsync(session.CustomerId); bool hasActiveSub = false;