From 89a41dd229c14367ade27b75dfec90e9a801a330 Mon Sep 17 00:00:00 2001 From: webserver Date: Thu, 24 Sep 2026 11:04:32 -0700 Subject: [PATCH 1/8] Show only the internet services we can sell at an address The Internet page looked up FCC broadband data by census block, so every address in a block was shown the best provider in that block, including addresses Ziply won't install at. It also streamed 100-400MB CSVs per request on a 1GB server and only understood WA, OR and CA. Replace it with an address-level lookup against Ziply's building lists: - ServiceAddresses table and WFI 300 Mbps ($75) / 1 Gbps ($115) services - Streaming importer for the Ziply .xlsx lists (~20MB peak memory) - /Internet/Availability matches house number, street and ZIP, falling back to the nearest listed building within 30m - WFI is sellable only when both serviceable and product flags are set; buildings with one flag ask the customer to contact us, EIA on-net buildings offer a quote Co-Authored-By: Claude Opus 5.5 --- .../Models/ServiceAddress.cs | 117 +++++++++ NumberSearch.DataAccess/ServiceAddresses.sql | 40 +++ .../Ziply/import_ziply_building_list.py | 118 +++++++++ NumberSearch.Mvc/Controllers/Endpoints.cs | 148 ++++------- NumberSearch.Mvc/Startup.cs | 6 +- .../Views/Services/Internet.cshtml | 239 ++++++------------ NumberSearch.Tests/Unit.cs | 20 ++ 7 files changed, 422 insertions(+), 266 deletions(-) create mode 100644 NumberSearch.DataAccess/Models/ServiceAddress.cs create mode 100644 NumberSearch.DataAccess/ServiceAddresses.sql create mode 100644 NumberSearch.DataAccess/Ziply/import_ziply_building_list.py diff --git a/NumberSearch.DataAccess/Models/ServiceAddress.cs b/NumberSearch.DataAccess/Models/ServiceAddress.cs new file mode 100644 index 00000000..eaeec5e7 --- /dev/null +++ b/NumberSearch.DataAccess/Models/ServiceAddress.cs @@ -0,0 +1,117 @@ +using Dapper; + +using Npgsql; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace NumberSearch.DataAccess +{ + /// + /// An address where a provider has told us a product can be sold, loaded from their building lists by Ziply/import_ziply_building_list.py. + /// + public class ServiceAddress + { + public long ServiceAddressId { get; set; } + public string Provider { get; set; } = string.Empty; + public string Product { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string HouseNumber { get; set; } = string.Empty; + public string StreetKey { get; set; } = string.Empty; + public string StreetAddress { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public string Postal { get; set; } = string.Empty; + public double Latitude { get; set; } + public double Longitude { get; set; } + public string MaxSpeed { get; set; } = string.Empty; + public string SourceFile { get; set; } = string.Empty; + public DateTime DateIngested { get; set; } + + private const string Columns = "\"ServiceAddressId\", \"Provider\", \"Product\", \"Status\", \"HouseNumber\", \"StreetKey\", \"StreetAddress\", \"City\", \"State\", \"Postal\", \"Latitude\", \"Longitude\", \"MaxSpeed\", \"SourceFile\", \"DateIngested\""; + + /// + /// How far a geocoded point may be from a listed building and still count as that building, when the street address doesn't match. + /// + public const double MaxDistanceMeters = 30; + + /// + /// Lowercase the street name and drop everything but letters and digits, ex. "Pine Cliff" -> "pinecliff". Must match street_key() in the import script. + /// + public static string ToStreetKey(string streetName) + { + var key = new StringBuilder(streetName.Length); + foreach (var c in streetName) + { + if (char.IsAsciiLetterOrDigit(c)) + { + key.Append(char.ToLowerInvariant(c)); + } + } + return key.ToString(); + } + + /// + /// Find the listed addresses matching a street address, or failing that the nearest listed building within MaxDistanceMeters of the point. + /// + public static async Task> LookupAsync(string houseNumber, string streetName, string postal, double latitude, double longitude, string connectionString) + { + await using var connection = new NpgsqlConnection(connectionString); + + var streetKey = ToStreetKey(streetName); + if (!string.IsNullOrWhiteSpace(houseNumber) && !string.IsNullOrWhiteSpace(streetKey) && !string.IsNullOrWhiteSpace(postal)) + { + var matches = await connection + .QueryAsync($"SELECT {Columns} FROM public.\"ServiceAddresses\" " + + "WHERE \"Postal\" = @postal AND \"HouseNumber\" = @houseNumber AND \"StreetKey\" = @streetKey", + new { postal = postal.Trim(), houseNumber = houseNumber.Trim(), streetKey }) + .ConfigureAwait(false); + + if (matches.Any()) + { + return matches; + } + } + + if (latitude is 0 && longitude is 0) + { + return []; + } + + // Search a small box around the point using the index, then keep only the closest building's rows. + var latDelta = MaxDistanceMeters / 111_320d; + var lonDelta = MaxDistanceMeters / (111_320d * Math.Cos(latitude * Math.PI / 180)); + var nearby = await connection + .QueryAsync($"SELECT {Columns} FROM public.\"ServiceAddresses\" " + + "WHERE \"Latitude\" BETWEEN @minLat AND @maxLat AND \"Longitude\" BETWEEN @minLon AND @maxLon", + new { minLat = latitude - latDelta, maxLat = latitude + latDelta, minLon = longitude - lonDelta, maxLon = longitude + lonDelta }) + .ConfigureAwait(false); + + var closest = nearby + .Select(x => (Address: x, Meters: DistanceMeters(latitude, longitude, x.Latitude, x.Longitude))) + .Where(x => x.Meters <= MaxDistanceMeters) + .OrderBy(x => x.Meters) + .FirstOrDefault(); + + if (closest.Address is null) + { + return []; + } + + return nearby.Where(x => x.Latitude == closest.Address.Latitude && x.Longitude == closest.Address.Longitude); + } + + /// + /// Equirectangular approximation, accurate to well under a meter at these distances. + /// + public static double DistanceMeters(double lat1, double lon1, double lat2, double lon2) + { + var x = (lon2 - lon1) * Math.PI / 180 * Math.Cos((lat1 + lat2) / 2 * Math.PI / 180); + var y = (lat2 - lat1) * Math.PI / 180; + return Math.Sqrt(x * x + y * y) * 6_371_000; + } + } +} diff --git a/NumberSearch.DataAccess/ServiceAddresses.sql b/NumberSearch.DataAccess/ServiceAddresses.sql new file mode 100644 index 00000000..adee8276 --- /dev/null +++ b/NumberSearch.DataAccess/ServiceAddresses.sql @@ -0,0 +1,40 @@ +-- Address-level service availability, in support of https://acceleratenetworks.com/Internet. +-- Replaces the FCC census block lookup, which showed providers for every address in a block rather than the addresses we can actually sell. +-- Run this against the PostgresqlProd database before deploying, then load the building lists with Ziply/import_ziply_building_list.py. + +CREATE TABLE IF NOT EXISTS public."ServiceAddresses" +( + "ServiceAddressId" bigserial NOT NULL, + "Provider" text NOT NULL DEFAULT '', + -- WFI (wholesale fiber internet) or EIA (ethernet internet access) + "Product" text NOT NULL DEFAULT '', + -- Sellable: both of the provider's serviceability flags agree, quote the listed price. + -- Confirm: only one flag is set, ask the customer to contact us before quoting. + -- Quote: on-net for a custom priced product like EIA. + "Status" text NOT NULL DEFAULT '', + "HouseNumber" text NOT NULL DEFAULT '', + -- Street name lowercased with everything but letters and digits removed, ex. "Pine Cliff" -> "pinecliff" + "StreetKey" text NOT NULL DEFAULT '', + "StreetAddress" text NOT NULL DEFAULT '', + "City" text NOT NULL DEFAULT '', + "State" text NOT NULL DEFAULT '', + "Postal" text NOT NULL DEFAULT '', + "Latitude" double precision NOT NULL DEFAULT 0, + "Longitude" double precision NOT NULL DEFAULT 0, + "MaxSpeed" text NOT NULL DEFAULT '', + "BuildingKey" text NOT NULL DEFAULT '', + "SourceFile" text NOT NULL DEFAULT '', + "DateIngested" timestamp without time zone NOT NULL DEFAULT now(), + CONSTRAINT "ServiceAddresses_pkey" PRIMARY KEY ("ServiceAddressId") +); + +CREATE INDEX IF NOT EXISTS "ServiceAddresses_Address_idx" ON public."ServiceAddresses" ("Postal", "HouseNumber"); +CREATE INDEX IF NOT EXISTS "ServiceAddresses_Location_idx" ON public."ServiceAddresses" ("Latitude", "Longitude"); + +ALTER TABLE public."ServiceAddresses" OWNER TO "numberSearch"; + +-- The fiber internet tiers sold at WFI Sellable addresses. $15 off when bundled with any phone service, on a 2, 3 or 5 year term. +INSERT INTO public."Services" ("ServiceId", "Name", "Price", "Description") +VALUES ('cbcf5128-5164-40de-8dff-e71d0f152cab', 'Fiber Internet 300 Mbps', 75, '300/300 Mbps fiber internet on a 2, 3 or 5 year term. $15 off when bundled with phone service.'), + ('708c3885-6dab-4a60-9e42-05cf13530076', 'Fiber Internet 1 Gbps', 115, '1/1 Gbps fiber internet on a 2, 3 or 5 year term. $15 off when bundled with phone service.') +ON CONFLICT ("ServiceId") DO NOTHING; diff --git a/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py b/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py new file mode 100644 index 00000000..ff548139 --- /dev/null +++ b/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Load a Ziply Fiber building list (.xlsx) into public."ServiceAddresses". + +Usage: import_ziply_building_list.py WFI|EIA ".xlsx" [psql connection args...] + ex. import_ziply_building_list.py WFI "/WFI 24635_20260920050445.xlsx" -d numberSearch + +The lists are 500MB+ of inline string XML once unzipped, so rows are streamed and cleared as they +are read rather than loaded with openpyxl. Peak memory stays around 20MB. Only the rows we can sell +or quote are kept, and the previous rows for the same product are replaced in a single transaction. +Uses only the standard library and psql. +""" +import csv +import os +import re +import subprocess +import sys +import tempfile +import zipfile +from xml.etree.ElementTree import iterparse + +NS = '{http://schemas.openxmlformats.org/spreadsheetml/2006/main}' +COLUMNS = ['Provider', 'Product', 'Status', 'HouseNumber', 'StreetKey', 'StreetAddress', 'City', 'State', + 'Postal', 'Latitude', 'Longitude', 'MaxSpeed', 'BuildingKey', 'SourceFile'] + + +def street_key(name): + # Must match ServiceAddress.ToStreetKey in NumberSearch.DataAccess. + return re.sub(r'[^a-z0-9]', '', name.lower()) + + +def column_index(ref): + n = 0 + for ch in re.match(r'[A-Z]+', ref).group(): + n = n * 26 + ord(ch) - 64 + return n - 1 + + +def rows(path): + """Yield each row after the header as a dict keyed by column name.""" + header = None + with zipfile.ZipFile(path) as z, z.open('xl/worksheets/sheet1.xml') as sheet: + for _, el in iterparse(sheet, events=('end',)): + if el.tag != NS + 'row': + continue + values = {} + for c in el.findall(NS + 'c'): + text = ''.join(t.text or '' for t in c.iter(NS + 't')) + if not text: + v = c.find(NS + 'v') + text = v.text or '' if v is not None else '' + values[column_index(c.get('r'))] = text.strip() + el.clear() + if header is None: + if values.get(0) == 'Building Name': + header = values + continue + if values: + yield {name: values.get(i, '') for i, name in header.items()} + + +def status(product, row): + if product == 'WFI': + serviceable = row.get('BFI/WFI serviceable') == 'Y' + offered = row.get('PRODUCT') == 'BFI/WFI' + if serviceable and offered: + return 'Sellable' + if serviceable or offered: + return 'Confirm' + return None + return 'Quote' if row.get('EIA On-Net') == 'Y' else None + + +def main(): + if len(sys.argv) < 3 or sys.argv[1] not in ('WFI', 'EIA'): + sys.exit(__doc__) + product, path, psql_args = sys.argv[1], sys.argv[2], sys.argv[3:] + source = os.path.basename(path) + kept = skipped = 0 + + with tempfile.NamedTemporaryFile('w', newline='', suffix='.csv', delete=False) as out: + # Quote everything so empty values load as empty strings rather than NULL. + writer = csv.writer(out, quoting=csv.QUOTE_ALL) + for row in rows(path): + s = status(product, row) + if s is None: + skipped += 1 + continue + try: + lat, lon = float(row.get('Latitude') or 0), float(row.get('Longitude') or 0) + except ValueError: + lat = lon = 0.0 + writer.writerow([row.get('Provider') or 'Ziply Fiber', product, s, row.get('Primary Number', ''), + street_key(row.get('Street Name', '')), row.get('Street Address', ''), + row.get('City', ''), row.get('State', ''), row.get('Postal', '')[:5], lat, lon, + row.get('BFI Max Serviceable Speed', ''), row.get('C2F Building Key', ''), source]) + kept += 1 + + if kept == 0: + os.unlink(out.name) + sys.exit(f'No {product} rows found in {path}, leaving the existing rows in place.') + + columns = ', '.join(f'"{c}"' for c in COLUMNS) + script = ( + 'BEGIN;\n' + f"DELETE FROM public.\"ServiceAddresses\" WHERE \"Product\" = '{product}';\n" + f"\\copy public.\"ServiceAddresses\" ({columns}) FROM '{out.name}' WITH (FORMAT csv)\n" + 'COMMIT;\n' + 'ANALYZE public."ServiceAddresses";\n' + ) + try: + subprocess.run(['psql', '-v', 'ON_ERROR_STOP=1', *psql_args], input=script, text=True, check=True) + finally: + os.unlink(out.name) + print(f'Loaded {kept} {product} addresses from {source}, skipped {skipped} not serviceable.') + + +if __name__ == '__main__': + main() diff --git a/NumberSearch.Mvc/Controllers/Endpoints.cs b/NumberSearch.Mvc/Controllers/Endpoints.cs index 40f9802b..35105e84 100644 --- a/NumberSearch.Mvc/Controllers/Endpoints.cs +++ b/NumberSearch.Mvc/Controllers/Endpoints.cs @@ -1,10 +1,7 @@ ๏ปฟusing Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; -using nietras.SeparatedValues; - using NumberSearch.DataAccess; -using NumberSearch.DataAccess.FCC; using NumberSearch.DataAccess.FusionPBX; using NumberSearch.Mvc.Models; @@ -12,7 +9,6 @@ using System.Collections.Concurrent; using System.ComponentModel.DataAnnotations; -using System.IO.Compression; using ZLinq; @@ -205,122 +201,70 @@ await Parallel.ForEachAsync(relatedNumbers, async (number, token) => } /// - /// Represents the speeds for a provider in a specific geographic area. + /// A service we can sell at an address. /// - /// - /// - /// - /// - /// - /// - /// - /// - public readonly record struct ProviderGeoSpeeds(string geoid, string frn, string provider, string technology, string techdesc, decimal down, decimal up, Guid serviceId); + /// WFI (fiber internet) or EIA (ethernet internet access). + /// Sellable (add to cart at the listed price), Confirm (contact us to confirm availability), or Quote (custom priced, request a quote). + /// The name of the service. + /// The symmetrical speed of the service. + /// The monthly price in dollars, or 0 when the price must be quoted. + /// The ServiceId to add to the cart, or empty when it can't be added to the cart directly. + /// Terms and details to show alongside the service. + public readonly record struct InternetOffer(string product, string status, string name, string speed, int price, Guid serviceId, string note); /// - /// Represents the technical description for a specific technology. + /// The services available at an address. /// - /// The code for the technology. - /// The name of the technology. - /// The description of the technology. - public readonly record struct TechDesc(string code, string name, string description); + /// Whether any service is available at the address. + /// The address from the provider's building list that matched the query. + /// The services available at the address. + public readonly record struct InternetAvailability(bool serviceable, string matchedAddress, InternetOffer[] offers); + + public static readonly Guid FiberInternet300ServiceId = new("cbcf5128-5164-40de-8dff-e71d0f152cab"); + public static readonly Guid FiberInternet1GServiceId = new("708c3885-6dab-4a60-9e42-05cf13530076"); + private const string FiberTerms = "2, 3 or 5 year term. $15/mo off when bundled with any phone service."; /// - /// Looks up geographic information for a specific state. + /// Look up the internet services we can sell at an address, using the building lists from our providers. /// - /// The state for which to look up geographic information. - /// The geographic identifier within that state. + /// The house or building number, ex. 1250 + /// The street name without the direction or suffix, ex. 1st + /// The 5 digit ZIP code, ex. 98134 + /// The latitude of the address, used when the street address doesn't match exactly. + /// The longitude of the address, used when the street address doesn't match exactly. /// The configuration for the application. - /// An array of ProviderGeoSpeeds. - public static async Task, BadRequest>> FCCStateGeoIdLookup([Required] string state, string geoid, [FromServices] MvcConfiguration mvcConfiguration) + /// The services available at the address. + public static async Task, BadRequest>> InternetAvailabilityAsync(string? houseNumber, string? street, string? postal, double? latitude, double? longitude, [FromServices] MvcConfiguration mvcConfiguration) { - if (string.IsNullOrWhiteSpace(state)) + if ((string.IsNullOrWhiteSpace(houseNumber) || string.IsNullOrWhiteSpace(street) || string.IsNullOrWhiteSpace(postal)) && (latitude is null || longitude is null)) { - return TypedResults.BadRequest("No state provided (ex: Washington). Please try a different query. ๐Ÿฅบ๐Ÿ‘‰๐Ÿ‘ˆ"); + return TypedResults.BadRequest("Provide a houseNumber, street, and postal code or a latitude and longitude (ex: houseNumber=1250&street=1st&postal=98134). Please try a different query. ๐Ÿฅบ๐Ÿ‘‰๐Ÿ‘ˆ"); } - // Add portable numbers to cart in bulk - if (!string.IsNullOrWhiteSpace(geoid)) - { - var canidates = new List(); - - var techDesc = new List() { - new("10", "Copper", "Fixed wireline service using copper wire (e.g., Asymmetric or Symmetric DSL, ethernet over copper, T-1, etc.)."), - new("40","Cable","Fixed wireline service using coaxial cable or hybrid fiber-coaxial (e.g., DOCSISx)."), - new("50","Fiber to the Premises","Fixed wireline service using fiber to the home or business end user, but does not include \"fiber to the curb\"."), - new("70","Unlicensed Fixed Wireless","Fixed terrestrial wireless service using entirely unlicensed spectrum, including services provided over WiFi as a fixed solution."), - new("71","Licensed Fixed Wireless","Fixed wireless service using entirely licensed spectrum (including priority access licenses in the 3.5 GHz band) or a hybrid of licensed, unlicensed, and licensed-by-rule spectrum to make last-mile connections to fixed locations. This includes service provided over a 4G LTE or 5G-NR mobile network but sold as a fixed solution."), - new("72","LBR Fixed Wireless","Fixed wireless services using entirely licensed-by-rule spectrum or a hybrid of licensed-by-rule and unlicensed spectrum to make last-mile connections to fixed locations. Licensed-by-rule spectrum users include operators providing last-mile connections through general authorized access (GAA) in the 3.5 GHz Citizens Broadband Radio Service (CBRS) band."),}; - - var result = await ListAsOfDates.GetAsync(mvcConfiguration.FCCUsername.AsMemory(), mvcConfiguration.FCCAPIToken.AsMemory()); - var date = result.data.AsValueEnumerable().OrderByDescending(x => x.as_of_date).Where(x => x.data_type is "availability").FirstOrDefault(); - var listing = await ListAvailabilityData.GetAsync(date.as_of_date.AsMemory(), mvcConfiguration.FCCUsername.AsMemory(), mvcConfiguration.FCCAPIToken.AsMemory()); - var toGet = listing.data.Where(x => x.state_name.Equals(state, StringComparison.InvariantCultureIgnoreCase)).Where(x => x.technology_code is not "60" && x.technology_code is not "61"); - string downloadsPath = Path.GetTempPath(); - var services = await Service.GetAllAsync(mvcConfiguration.PostgresqlProd); - var toLoop = toGet.ToArray(); - foreach (var item in toLoop) - { - var files = Directory.GetFiles(downloadsPath); - var file = files.FirstOrDefault(x => x.Contains(item.file_name) && x.EndsWith(".csv")); - - // Download and unzip, if required. - if (string.IsNullOrWhiteSpace(file)) - { - string filePath = await item.DownloadFileAsync(downloadsPath, mvcConfiguration.FCCUsername.AsMemory(), mvcConfiguration.FCCAPIToken.AsMemory()); - await ZipFile.ExtractToDirectoryAsync(filePath, downloadsPath); - System.IO.File.Delete(filePath); - files = Directory.GetFiles(downloadsPath); - file = files.FirstOrDefault(x => x.Contains(item.file_name) && x.EndsWith(".csv")); - } - - if (!string.IsNullOrWhiteSpace(file)) - { - using var reader = Sep.Reader().FromFile(file); - foreach (var readRow in reader) - { - if (MemoryExtensions.Equals(readRow["block_geoid"].Span, geoid.AsSpan(), StringComparison.Ordinal)) - { - var id = readRow["block_geoid"].ToString(); - var frn = readRow["frn"].ToString(); - var provider = readRow["brand_name"].ToString(); - var down = readRow["max_advertised_download_speed"].Parse(); - var up = readRow["max_advertised_upload_speed"].Parse(); - var desc = techDesc.FirstOrDefault(x => x.code == item.technology_code); - var service = services.FirstOrDefault(x => x.Name == item.technology_code_desc); - canidates.Add(new ProviderGeoSpeeds(id, frn, provider, item.technology_code_desc, desc.description, down, up, service.ServiceId)); - } - } - } - } - - var providers = canidates.AsValueEnumerable().Select(x => x.provider).Distinct(); - var results = new List(); - var quantumPresent = providers.Any(x => x is "Quantum Fiber"); - foreach (var p in providers) - { - var winner = canidates.AsValueEnumerable().Where(x => x.provider == p).MaxBy(x => x.up); - if (!(winner.provider is "CenturyLink" && quantumPresent)) - { - results.Add(winner); - } - } - - var techs = results.AsValueEnumerable().Select(x => x.technology).Distinct(); - var singlePerTech = new List(); + var addresses = (await ServiceAddress.LookupAsync(houseNumber ?? string.Empty, street ?? string.Empty, postal ?? string.Empty, latitude ?? 0, longitude ?? 0, mvcConfiguration.PostgresqlProd)).ToArray(); + var offers = new List(); - foreach (var p in techs) + var wfi = addresses.AsValueEnumerable().Where(x => x.Product is "WFI").ToArray(); + if (wfi.AsValueEnumerable().Any(x => x.Status is "Sellable")) + { + offers.Add(new("WFI", "Sellable", "Fiber Internet 300 Mbps", "300/300 Mbps", 75, FiberInternet300ServiceId, FiberTerms)); + if (wfi.AsValueEnumerable().Any(x => x.Status is "Sellable" && x.MaxSpeed.StartsWith("1.0G"))) { - var speedWinner = canidates.AsValueEnumerable().Where(x => x.technology == p).MaxBy(x => x.up); - singlePerTech.Add(speedWinner); + offers.Add(new("WFI", "Sellable", "Fiber Internet 1 Gbps", "1/1 Gbps", 115, FiberInternet1GServiceId, FiberTerms)); } - - return TypedResults.Ok(singlePerTech.AsValueEnumerable().Where(x => x.up > 0).OrderByDescending(x => x.down).ToArray()); } - else + else if (wfi.Length > 0) + { + offers.Add(new("WFI", "Confirm", "Fiber Internet", "Up to 1/1 Gbps", 0, Guid.Empty, "Fiber may be available here. Contact us to confirm before ordering.")); + } + + if (addresses.AsValueEnumerable().Any(x => x.Product is "EIA" && x.Status is "Quote")) { - return TypedResults.BadRequest("No geoid provided (ex: 530330060001014). Please try a different query. ๐Ÿฅบ๐Ÿ‘‰๐Ÿ‘ˆ"); + offers.Add(new("EIA", "Quote", "Ethernet Internet Access", "Dedicated, custom speeds", 0, Guid.Empty, "Dedicated fiber with an SLA. Contact us for a quote.")); } + + var matched = addresses.AsValueEnumerable().Select(x => $"{x.StreetAddress.Trim()}, {x.City}, {x.State} {x.Postal}").FirstOrDefault() ?? string.Empty; + return TypedResults.Ok(new InternetAvailability(offers.Count > 0, matched, offers.ToArray())); } /// diff --git a/NumberSearch.Mvc/Startup.cs b/NumberSearch.Mvc/Startup.cs index be1939a6..fea20a87 100644 --- a/NumberSearch.Mvc/Startup.cs +++ b/NumberSearch.Mvc/Startup.cs @@ -272,11 +272,11 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) return Task.CompletedTask; }); - endpoints.MapGet("/Internet/Providers/Availabilityโ€‹", Endpoints.FCCStateGeoIdLookup) + endpoints.MapGet("/Internet/Availability", Endpoints.InternetAvailabilityAsync) .AddOpenApiOperationTransformer((operation, context, ct) => { - operation.Summary = "Lookup internet provider availability by state and geo ID"; - operation.Description = "Get information about internet provider availability in a specific state and geographic area."; + operation.Summary = "Lookup the internet services available at an address"; + operation.Description = "Get the fiber and ethernet internet services Accelerate Networks can sell at a specific address, from our providers' building lists. Match by house number, street name, and ZIP code, or by latitude and longitude."; return Task.CompletedTask; }).CacheOutput(); diff --git a/NumberSearch.Mvc/Views/Services/Internet.cshtml b/NumberSearch.Mvc/Views/Services/Internet.cshtml index 7b404bd4..0acb5ae4 100644 --- a/NumberSearch.Mvc/Views/Services/Internet.cshtml +++ b/NumberSearch.Mvc/Views/Services/Internet.cshtml @@ -6,163 +6,81 @@ } @@ -172,7 +90,7 @@

The best internet

The best internet

-

With the FCC's help we'll scour the county and find you the best internet possible for your business.

+

Symmetrical fiber internet for your business across Washington, Oregon, Idaho, and Montana.

@@ -182,20 +100,19 @@

๐Ÿชจ Your internet connection is the bedrock of your business.

-

๐Ÿ†• Let us accelerate your business with the latest Fiber to the Premises technology or tried and tested Copper and Cable connectivity.

-

๐ŸŽบ With 5G networks we can also offer you Licensed or Unlicensed Fixed Wireless alternatives to suit your needs.

+

๐Ÿ†• Let us accelerate your business with symmetrical fiber internet at 300 Mbps for $75/mo or 1 Gbps for $115/mo on a 2, 3 or 5 year term.

+

๐Ÿ“ž Save $15/mo on fiber internet when you bundle it with any of our phone services.

+

๐Ÿข Need dedicated bandwidth with an SLA? Ethernet internet access is available at many business addresses, contact us for a quote.

๐Ÿค Whatever technology you choose, rest assured that Accelerate Networks will handle all the details with our Seattle-based support and installation teams.

Accelerate Your Business

    -
  • ๐ŸŒพ Fiber to the Premises
  • -
  • โ›ท๏ธ Cable
  • -
  • ๐Ÿ’จ Copper
  • -
  • ๐Ÿ“ถ Licensed Fixed Wireless
  • -
  • ๐Ÿ›œ Unlicensed Fixed Wireless
  • -
  • ๐Ÿ”ฎ LBR Fixed Wireless
  • +
  • ๐ŸŒพ Fiber Internet 300 Mbps - $75/mo
  • +
  • ๐Ÿš€ Fiber Internet 1 Gbps - $115/mo
  • +
  • ๐Ÿ“ž $15/mo off when bundled with phone service
  • +
  • ๐Ÿข Ethernet Internet Access - quoted
@@ -229,7 +146,7 @@ @if (Model?.Cart?.Order?.OrderId != Guid.Empty && Model?.Cart?.Order?.DateSubmitted > DateTime.MinValue) {
- + Enter the service address or, the address of the installation location (ex. 1250 1st Ave S, Seattle, WA, 98134, USA) @@ -239,7 +156,7 @@ else {
- + @@ -253,9 +170,9 @@ - - - + + + diff --git a/NumberSearch.Tests/Unit.cs b/NumberSearch.Tests/Unit.cs index d0e05bdb..0367338c 100644 --- a/NumberSearch.Tests/Unit.cs +++ b/NumberSearch.Tests/Unit.cs @@ -26,5 +26,25 @@ public void PortPinTest() Assert.True(pin > 0); Assert.True(pin > 100000); } + + [Theory] + [InlineData("Pine Cliff", "pinecliff")] + [InlineData("1st", "1st")] + [InlineData("Bothell-Everett ", "bothelleverett")] + [InlineData("O'Neil", "oneil")] + [InlineData("", "")] + public void ServiceAddressStreetKeyTest(string streetName, string expected) + { + Assert.Equal(expected, NumberSearch.DataAccess.ServiceAddress.ToStreetKey(streetName)); + } + + [Fact] + public void ServiceAddressDistanceTest() + { + // 0.0001 degrees of latitude is about 11 meters. + var meters = NumberSearch.DataAccess.ServiceAddress.DistanceMeters(46.8993, -121.0128, 46.8994, -121.0128); + Assert.InRange(meters, 10.5, 11.5); + Assert.Equal(0, NumberSearch.DataAccess.ServiceAddress.DistanceMeters(46.8993, -121.0128, 46.8993, -121.0128)); + } } } From 4344f7cc69a24a99fb75e82fe9ef1cc864db4eda Mon Sep 17 00:00:00 2001 From: webserver Date: Thu, 24 Sep 2026 11:16:48 -0700 Subject: [PATCH 2/8] Apply the fiber bundle discount and record the contract term Fiber internet is $15/mo less per connection when the order also has phone service (Standard Lines or Concurrent Seats) or the Partner coupon. The discount is applied once per connection, never stacked, by a single InternetBundle helper shared by checkout, the order sidebar and the Ops invoice regeneration so the three can't drift apart. Checkout now requires a 2, 3 or 5 year term when fiber is in the cart. It is saved to Orders.InternetTermYears and shown on the fiber invoice line. Ops reads the column but never writes it, so editing an order there can't clear the customer's choice. Co-Authored-By: Claude Opus 5.5 --- NumberSearch.DataAccess/InternetBundle.sql | 11 +++ .../Models/InternetBundle.cs | 69 +++++++++++++++++++ NumberSearch.DataAccess/Models/Order.cs | 24 ++++--- .../Controllers/CartController.cs | 30 +++++++- NumberSearch.Mvc/Controllers/Endpoints.cs | 6 +- NumberSearch.Mvc/Views/Cart/Order.cshtml | 42 ++++++++++- .../Controllers/OrdersController.cs | 17 ++++- NumberSearch.Ops/PostgreSQL/Order.cs | 4 ++ .../PostgreSQL/numberSearchContext.cs | 4 ++ NumberSearch.Tests/Unit.cs | 26 +++++++ 10 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 NumberSearch.DataAccess/InternetBundle.sql create mode 100644 NumberSearch.DataAccess/Models/InternetBundle.cs diff --git a/NumberSearch.DataAccess/InternetBundle.sql b/NumberSearch.DataAccess/InternetBundle.sql new file mode 100644 index 00000000..b0a1d99f --- /dev/null +++ b/NumberSearch.DataAccess/InternetBundle.sql @@ -0,0 +1,11 @@ +-- Fiber internet contract terms and the phone bundle discount, in support of https://acceleratenetworks.com/Internet. +-- Run this against the PostgresqlProd database before deploying. + +-- The contract term (2, 3 or 5 years) chosen for fiber internet on an order, 0 when the order has no fiber internet. +ALTER TABLE public."Orders" + ADD COLUMN IF NOT EXISTS "InternetTermYears" integer NOT NULL DEFAULT 0; + +-- The Partner coupon now also takes $15/mo off each fiber internet connection, see InternetBundle.cs. +UPDATE public."Coupons" +SET "Description" = '5G service and fiber internet at partner pricing.' +WHERE "CouponId" = '245e1c8a-0208-4016-acd1-a36f45315e88'; diff --git a/NumberSearch.DataAccess/Models/InternetBundle.cs b/NumberSearch.DataAccess/Models/InternetBundle.cs new file mode 100644 index 00000000..d997695e --- /dev/null +++ b/NumberSearch.DataAccess/Models/InternetBundle.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NumberSearch.DataAccess +{ + /// + /// Fiber internet is $15/mo less per connection when it's bundled with phone service, or when the Partner coupon is applied. + /// + public static class InternetBundle + { + public static readonly Guid FiberInternet300ServiceId = new("cbcf5128-5164-40de-8dff-e71d0f152cab"); + public static readonly Guid FiberInternet1GServiceId = new("708c3885-6dab-4a60-9e42-05cf13530076"); + public static readonly Guid StandardLinesServiceId = new("16e2c639-445b-4ae6-9925-07300318206b"); + public static readonly Guid ConcurrentSeatsServiceId = new("48eb4627-8692-4a3b-8be1-be64bbeea534"); + public static readonly Guid PartnerCouponId = new("245e1c8a-0208-4016-acd1-a36f45315e88"); + + public const int DiscountPerConnection = 15; + public const string Name = "Fiber + Phone Bundle"; + public const string Description = "$15/mo off fiber internet when bundled with phone service."; + public const string PartnerDescription = "$15/mo off fiber internet at partner pricing."; + + /// + /// The contract terms fiber internet can be sold on, in years. + /// + public static readonly int[] TermYears = [2, 3, 5]; + + public static bool IsFiberInternet(Guid serviceId) => serviceId == FiberInternet300ServiceId || serviceId == FiberInternet1GServiceId; + + public static bool IsPhoneService(Guid serviceId) => serviceId == StandardLinesServiceId || serviceId == ConcurrentSeatsServiceId; + + /// + /// The parts of a product order the bundle depends on, so the Ops site's own ProductOrder model can use the same rules. + /// + public readonly record struct Line(Guid ServiceId, long Quantity, Guid? CouponId); + + public static Line[] ToLines(IEnumerable productOrders) => [.. productOrders.Select(x => new Line(x.ServiceId, x.Quantity, x.CouponId))]; + + /// + /// The number of fiber internet connections in the cart. + /// + public static int FiberConnections(IEnumerable productOrders) => FiberConnections(ToLines(productOrders)); + + public static int FiberConnections(IEnumerable lines) => + (int)lines.Where(x => IsFiberInternet(x.ServiceId)).Sum(x => Math.Max(x.Quantity, 0)); + + /// + /// Whether the bundle discount comes from the Partner coupon rather than phone service. + /// + public static bool IsPartnerOnly(IEnumerable productOrders) => IsPartnerOnly(ToLines(productOrders)); + + public static bool IsPartnerOnly(IEnumerable lines) => !HasPhoneService(lines) && HasPartnerCoupon(lines); + + /// + /// The total monthly bundle discount in dollars, as a positive number. Applied once per fiber connection no matter how many phone services or coupons qualify it. + /// + public static int Discount(IEnumerable productOrders) => Discount(ToLines(productOrders)); + + public static int Discount(IEnumerable lines) + { + var all = lines as ICollection ?? [.. lines]; + return HasPhoneService(all) || HasPartnerCoupon(all) ? FiberConnections(all) * DiscountPerConnection : 0; + } + + private static bool HasPhoneService(IEnumerable lines) => lines.Any(x => IsPhoneService(x.ServiceId) && x.Quantity > 0); + + private static bool HasPartnerCoupon(IEnumerable lines) => lines.Any(x => x.CouponId == PartnerCouponId); + } +} diff --git a/NumberSearch.DataAccess/Models/Order.cs b/NumberSearch.DataAccess/Models/Order.cs index 5f7f31f1..b999f3b0 100644 --- a/NumberSearch.DataAccess/Models/Order.cs +++ b/NumberSearch.DataAccess/Models/Order.cs @@ -52,13 +52,17 @@ public record Order public string QuoteStatus { get; set; } = string.Empty; public bool VoiceProductAcknowledged { get; set; } public DateTime? VoiceProductAcknowledgedUtc { get; set; } = null; + /// + /// The contract term in years for fiber internet on this order, or 0 when the order has no fiber internet. + /// + public int InternetTermYears { get; set; } public static async Task GetByIdAsync(Guid orderId, string connectionString) { await using var connection = new NpgsqlConnection(connectionString); var result = await connection - .QueryFirstOrDefaultAsync("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\" FROM public.\"Orders\" " + + .QueryFirstOrDefaultAsync("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\" FROM public.\"Orders\" " + "WHERE \"OrderId\" = @orderId " + "ORDER BY \"DateSubmitted\" DESC", new { orderId }) @@ -72,7 +76,7 @@ public static async Task> GetByEmailAsync(string email, strin await using var connection = new NpgsqlConnection(connectionString); var result = await connection - .QueryAsync("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\" FROM public.\"Orders\" " + + .QueryAsync("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\" FROM public.\"Orders\" " + "WHERE \"Email\" = @email " + "ORDER BY \"DateSubmitted\" DESC", new { email }) @@ -87,7 +91,7 @@ public static async Task> GetAllAsync(string connectionString var result = await connection .QueryAsync - ("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\" " + + ("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\" " + "FROM public.\"Orders\" " + "ORDER BY \"DateSubmitted\" DESC") .ConfigureAwait(false); @@ -101,7 +105,7 @@ public static async Task> GetAllQuotesAsync(string connection var result = await connection .QueryAsync - ("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\" " + + ("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\" " + "FROM public.\"Orders\" " + "WHERE \"Quote\" = true " + "ORDER BY \"DateSubmitted\" DESC") @@ -116,7 +120,7 @@ public static async Task> GetByBackGroundworkNotCompletedAsyn var result = await connection .QueryAsync - ("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\" " + + ("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\" " + "FROM public.\"Orders\" " + "WHERE \"BackgroundWorkCompleted\" = false " + "ORDER BY \"DateSubmitted\" DESC") @@ -130,9 +134,9 @@ public async Task PostAsync(string connectionString) await using var connection = new NpgsqlConnection(connectionString); var result = await connection - .ExecuteAsync("INSERT INTO public.\"Orders\"(\"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\" ) " + - "VALUES(@OrderId, @FirstName, @LastName, @Email, @Address, @Address2, @City, @State, @Zip, @DateSubmitted, @BusinessName, @CustomerNotes, @BillingClientId, @BillingInvoiceId, @Quote, @BillingInvoiceReoccuringId, @SalesEmail, @BackgroundWorkCompleted, @Completed, @InstallDate, @UpfrontInvoiceLink, @ReoccuringInvoiceLink, @OnsiteInstallation, @AddressUnitType, @AddressUnitNumber, @UnparsedAddress, @MergedOrderId, @E911ServiceNumber, @DateConvertedFromQuote, @DateCompleted, @ContactPhoneNumber, @DateUpfrontInvoicePaid, @InternalNotes, @QuoteStatus, @VoiceProductAcknowledged, @VoiceProductAcknowledgedUtc )", - new { OrderId, FirstName, LastName, Email, Address, Address2, City, State, Zip, DateSubmitted, BusinessName, CustomerNotes, BillingClientId, BillingInvoiceId, Quote, BillingInvoiceReoccuringId, SalesEmail, BackgroundWorkCompleted, Completed, InstallDate, UpfrontInvoiceLink, ReoccuringInvoiceLink, OnsiteInstallation, AddressUnitType, AddressUnitNumber, UnparsedAddress, MergedOrderId, E911ServiceNumber, DateConvertedFromQuote, DateCompleted, ContactPhoneNumber, DateUpfrontInvoicePaid, InternalNotes, QuoteStatus, VoiceProductAcknowledged, VoiceProductAcknowledgedUtc }) + .ExecuteAsync("INSERT INTO public.\"Orders\"(\"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\" ) " + + "VALUES(@OrderId, @FirstName, @LastName, @Email, @Address, @Address2, @City, @State, @Zip, @DateSubmitted, @BusinessName, @CustomerNotes, @BillingClientId, @BillingInvoiceId, @Quote, @BillingInvoiceReoccuringId, @SalesEmail, @BackgroundWorkCompleted, @Completed, @InstallDate, @UpfrontInvoiceLink, @ReoccuringInvoiceLink, @OnsiteInstallation, @AddressUnitType, @AddressUnitNumber, @UnparsedAddress, @MergedOrderId, @E911ServiceNumber, @DateConvertedFromQuote, @DateCompleted, @ContactPhoneNumber, @DateUpfrontInvoicePaid, @InternalNotes, @QuoteStatus, @VoiceProductAcknowledged, @VoiceProductAcknowledgedUtc, @InternetTermYears )", + new { OrderId, FirstName, LastName, Email, Address, Address2, City, State, Zip, DateSubmitted, BusinessName, CustomerNotes, BillingClientId, BillingInvoiceId, Quote, BillingInvoiceReoccuringId, SalesEmail, BackgroundWorkCompleted, Completed, InstallDate, UpfrontInvoiceLink, ReoccuringInvoiceLink, OnsiteInstallation, AddressUnitType, AddressUnitNumber, UnparsedAddress, MergedOrderId, E911ServiceNumber, DateConvertedFromQuote, DateCompleted, ContactPhoneNumber, DateUpfrontInvoicePaid, InternalNotes, QuoteStatus, VoiceProductAcknowledged, VoiceProductAcknowledgedUtc, InternetTermYears }) .ConfigureAwait(false); if (result == 1) @@ -151,9 +155,9 @@ public async Task PutAsync(string connectionString) var result = await connection .ExecuteAsync("UPDATE public.\"Orders\" " + - "SET \"FirstName\" = @FirstName, \"LastName\" = @LastName, \"Email\" = @Email, \"Address\" = @Address, \"Address2\" = @Address2, \"City\" = @City, \"State\" = @State, \"Zip\" = @Zip, \"DateSubmitted\" = @DateSubmitted, \"BusinessName\" = @BusinessName, \"CustomerNotes\" = @CustomerNotes, \"BillingClientId\" = @BillingClientId, \"BillingInvoiceId\" = @BillingInvoiceId, \"Quote\" = @Quote, \"BillingInvoiceReoccuringId\" = @BillingInvoiceReoccuringId, \"SalesEmail\" = @SalesEmail, \"BackgroundWorkCompleted\" = @BackgroundWorkCompleted, \"Completed\" = @Completed, \"InstallDate\" = @InstallDate, \"UpfrontInvoiceLink\" = @UpfrontInvoiceLink, \"ReoccuringInvoiceLink\" = @ReoccuringInvoiceLink, \"OnsiteInstallation\" = @OnsiteInstallation, \"AddressUnitType\" = @AddressUnitType, \"AddressUnitNumber\" = @AddressUnitNumber, \"UnparsedAddress\" = @UnparsedAddress, \"MergedOrderId\" = @MergedOrderId, \"E911ServiceNumber\" = @E911ServiceNumber, \"DateConvertedFromQuote\" = @DateConvertedFromQuote, \"DateCompleted\" = @DateCompleted, \"ContactPhoneNumber\" = @ContactPhoneNumber, \"DateUpfrontInvoicePaid\" = @DateUpfrontInvoicePaid, \"InternalNotes\" = @InternalNotes, \"QuoteStatus\" = @QuoteStatus, \"VoiceProductAcknowledged\" = @VoiceProductAcknowledged, \"VoiceProductAcknowledgedUtc\" = @VoiceProductAcknowledgedUtc " + + "SET \"FirstName\" = @FirstName, \"LastName\" = @LastName, \"Email\" = @Email, \"Address\" = @Address, \"Address2\" = @Address2, \"City\" = @City, \"State\" = @State, \"Zip\" = @Zip, \"DateSubmitted\" = @DateSubmitted, \"BusinessName\" = @BusinessName, \"CustomerNotes\" = @CustomerNotes, \"BillingClientId\" = @BillingClientId, \"BillingInvoiceId\" = @BillingInvoiceId, \"Quote\" = @Quote, \"BillingInvoiceReoccuringId\" = @BillingInvoiceReoccuringId, \"SalesEmail\" = @SalesEmail, \"BackgroundWorkCompleted\" = @BackgroundWorkCompleted, \"Completed\" = @Completed, \"InstallDate\" = @InstallDate, \"UpfrontInvoiceLink\" = @UpfrontInvoiceLink, \"ReoccuringInvoiceLink\" = @ReoccuringInvoiceLink, \"OnsiteInstallation\" = @OnsiteInstallation, \"AddressUnitType\" = @AddressUnitType, \"AddressUnitNumber\" = @AddressUnitNumber, \"UnparsedAddress\" = @UnparsedAddress, \"MergedOrderId\" = @MergedOrderId, \"E911ServiceNumber\" = @E911ServiceNumber, \"DateConvertedFromQuote\" = @DateConvertedFromQuote, \"DateCompleted\" = @DateCompleted, \"ContactPhoneNumber\" = @ContactPhoneNumber, \"DateUpfrontInvoicePaid\" = @DateUpfrontInvoicePaid, \"InternalNotes\" = @InternalNotes, \"QuoteStatus\" = @QuoteStatus, \"VoiceProductAcknowledged\" = @VoiceProductAcknowledged, \"VoiceProductAcknowledgedUtc\" = @VoiceProductAcknowledgedUtc, \"InternetTermYears\" = @InternetTermYears " + "WHERE \"OrderId\" = @OrderId", - new { FirstName, LastName, Email, Address, Address2, City, State, Zip, DateSubmitted, BusinessName, CustomerNotes, BillingClientId, BillingInvoiceId, Quote, BillingInvoiceReoccuringId, SalesEmail, BackgroundWorkCompleted, Completed, InstallDate, UpfrontInvoiceLink, ReoccuringInvoiceLink, OnsiteInstallation, AddressUnitType, AddressUnitNumber, UnparsedAddress, MergedOrderId, E911ServiceNumber, DateConvertedFromQuote, DateCompleted, ContactPhoneNumber, DateUpfrontInvoicePaid, InternalNotes, QuoteStatus, VoiceProductAcknowledged, VoiceProductAcknowledgedUtc, OrderId }) + new { FirstName, LastName, Email, Address, Address2, City, State, Zip, DateSubmitted, BusinessName, CustomerNotes, BillingClientId, BillingInvoiceId, Quote, BillingInvoiceReoccuringId, SalesEmail, BackgroundWorkCompleted, Completed, InstallDate, UpfrontInvoiceLink, ReoccuringInvoiceLink, OnsiteInstallation, AddressUnitType, AddressUnitNumber, UnparsedAddress, MergedOrderId, E911ServiceNumber, DateConvertedFromQuote, DateCompleted, ContactPhoneNumber, DateUpfrontInvoicePaid, InternalNotes, QuoteStatus, VoiceProductAcknowledged, VoiceProductAcknowledgedUtc, InternetTermYears, OrderId }) .ConfigureAwait(false); if (result == 1) diff --git a/NumberSearch.Mvc/Controllers/CartController.cs b/NumberSearch.Mvc/Controllers/CartController.cs index b2390989..f66949fd 100644 --- a/NumberSearch.Mvc/Controllers/CartController.cs +++ b/NumberSearch.Mvc/Controllers/CartController.cs @@ -828,6 +828,21 @@ public async Task SubmitAsync(CartResult input) order.VoiceProductAcknowledgedUtc = DateTime.Now; } + if (InternetBundle.FiberConnections(cart.ProductOrders) > 0) + { + if (!InternetBundle.TermYears.Contains(order.InternetTermYears)) + { + _ = cart.SetToSession(HttpContext.Session); + Log.Error("[Checkout] No contract term was selected for an order containing fiber internet."); + var message = "๐Ÿ’€ Please select a 2, 3 or 5 year term for your fiber internet before submitting your order."; + return View("Order", new CartResult { Message = message, Cart = cart }); + } + } + else + { + order.InternetTermYears = 0; + } + order.DateSubmitted = DateTime.Now; if (order.OrderId != Guid.Empty) @@ -1233,7 +1248,7 @@ public static ProductOrderSummary GetInvoiceItemsFromProductOrders(ref Order ord reoccuringItems.Add(new Line_Items { product_key = service.Name, - notes = $"{service.Description}", + notes = InternetBundle.IsFiberInternet(service.ServiceId) && order.InternetTermYears > 0 ? $"{order.InternetTermYears} year term. {service.Description}" : $"{service.Description}", cost = service.Price, quantity = productOrder.Quantity }); @@ -1353,6 +1368,19 @@ public static ProductOrderSummary GetInvoiceItemsFromProductOrders(ref Order ord } } + var bundleDiscount = InternetBundle.Discount(cart.ProductOrders); + if (bundleDiscount > 0) + { + totalCost -= bundleDiscount; + reoccuringItems.Add(new Line_Items + { + product_key = InternetBundle.Name, + notes = InternetBundle.IsPartnerOnly(cart.ProductOrders) ? InternetBundle.PartnerDescription : InternetBundle.Description, + cost = InternetBundle.DiscountPerConnection * -1, + quantity = InternetBundle.FiberConnections(cart.ProductOrders) + }); + } + return new ProductOrderSummary(emailSubject ?? string.Empty, totalCost, totalPortingCost, totalNumberPurchasingCost); } diff --git a/NumberSearch.Mvc/Controllers/Endpoints.cs b/NumberSearch.Mvc/Controllers/Endpoints.cs index 35105e84..fef1ef9d 100644 --- a/NumberSearch.Mvc/Controllers/Endpoints.cs +++ b/NumberSearch.Mvc/Controllers/Endpoints.cs @@ -220,8 +220,6 @@ await Parallel.ForEachAsync(relatedNumbers, async (number, token) => /// The services available at the address. public readonly record struct InternetAvailability(bool serviceable, string matchedAddress, InternetOffer[] offers); - public static readonly Guid FiberInternet300ServiceId = new("cbcf5128-5164-40de-8dff-e71d0f152cab"); - public static readonly Guid FiberInternet1GServiceId = new("708c3885-6dab-4a60-9e42-05cf13530076"); private const string FiberTerms = "2, 3 or 5 year term. $15/mo off when bundled with any phone service."; /// @@ -247,10 +245,10 @@ public static async Task, BadRequest>> var wfi = addresses.AsValueEnumerable().Where(x => x.Product is "WFI").ToArray(); if (wfi.AsValueEnumerable().Any(x => x.Status is "Sellable")) { - offers.Add(new("WFI", "Sellable", "Fiber Internet 300 Mbps", "300/300 Mbps", 75, FiberInternet300ServiceId, FiberTerms)); + offers.Add(new("WFI", "Sellable", "Fiber Internet 300 Mbps", "300/300 Mbps", 75, InternetBundle.FiberInternet300ServiceId, FiberTerms)); if (wfi.AsValueEnumerable().Any(x => x.Status is "Sellable" && x.MaxSpeed.StartsWith("1.0G"))) { - offers.Add(new("WFI", "Sellable", "Fiber Internet 1 Gbps", "1/1 Gbps", 115, FiberInternet1GServiceId, FiberTerms)); + offers.Add(new("WFI", "Sellable", "Fiber Internet 1 Gbps", "1/1 Gbps", 115, InternetBundle.FiberInternet1GServiceId, FiberTerms)); } } else if (wfi.Length > 0) diff --git a/NumberSearch.Mvc/Views/Cart/Order.cshtml b/NumberSearch.Mvc/Views/Cart/Order.cshtml index be32abef..feb4a73c 100644 --- a/NumberSearch.Mvc/Views/Cart/Order.cshtml +++ b/NumberSearch.Mvc/Views/Cart/Order.cshtml @@ -1,4 +1,5 @@ -๏ปฟ@using PhoneNumbersNA +๏ปฟ@using NumberSearch.DataAccess +@using PhoneNumbersNA @using System @model NumberSearch.Mvc.CartResult; @@ -14,6 +15,9 @@ var verified = Model?.Cart?.VerifiedPhoneNumbers?.OrderByDescending(x => x.VerifiedDialedNumber).ToArray(); var coupons = Model?.Cart?.Coupons?.ToArray(); var productOrders = Model?.Cart?.ProductOrdersToDictionary(); + var productOrderList = Model?.Cart?.ProductOrders ?? []; + var fiberConnections = InternetBundle.FiberConnections(productOrderList); + var bundleDiscount = InternetBundle.Discount(productOrderList); var stdSeatId = new Guid("16e2c639-445b-4ae6-9925-07300318206b"); var concurrentSeatId = new Guid("48eb4627-8692-4a3b-8be1-be64bbeea534"); @@ -547,6 +551,24 @@ } } + + if (bundleDiscount > 0) + { + couponTotal += bundleDiscount * -1; +
  • +
    +
    + @InternetBundle.Name + @if (fiberConnections > 1) + { + x @fiberConnections + } +
    + @(InternetBundle.IsPartnerOnly(productOrderList) ? InternetBundle.PartnerDescription : InternetBundle.Description) +
    + $@bundleDiscount +
  • + } }
  • @@ -656,6 +678,24 @@
    + @if (fiberConnections > 0) + { + var selectedTerm = Model?.Cart?.Order?.InternetTermYears ?? 0; +
    + + + + Fiber internet is billed monthly on a 2, 3 or 5 year term + +
    + } +
    diff --git a/NumberSearch.Ops/Controllers/OrdersController.cs b/NumberSearch.Ops/Controllers/OrdersController.cs index 9993df74..9bd673f2 100644 --- a/NumberSearch.Ops/Controllers/OrdersController.cs +++ b/NumberSearch.Ops/Controllers/OrdersController.cs @@ -1526,7 +1526,7 @@ public async Task OrderNewInvoicesAsync(Guid orderId) reoccurringItems.Add(new Line_Items { product_key = service.Name ?? string.Empty, - notes = $"{service.Description}", + notes = NumberSearch.DataAccess.InternetBundle.IsFiberInternet(service.ServiceId) && order.InternetTermYears > 0 ? $"{order.InternetTermYears} year term. {service.Description}" : $"{service.Description}", cost = price, quantity = Convert.ToInt32(productOrder.Quantity) }); @@ -1617,6 +1617,21 @@ public async Task OrderNewInvoicesAsync(Guid orderId) } } + // Fiber internet is discounted once per connection when bundled with phone service or the Partner coupon. + var bundleLines = cart.ProductOrders.Select(x => new NumberSearch.DataAccess.InternetBundle.Line(x.ServiceId ?? Guid.Empty, x.Quantity, x.CouponId)).ToArray(); + var bundleDiscount = NumberSearch.DataAccess.InternetBundle.Discount(bundleLines); + if (bundleDiscount > 0) + { + totalCost -= bundleDiscount; + reoccurringItems.Add(new Line_Items + { + product_key = NumberSearch.DataAccess.InternetBundle.Name, + notes = NumberSearch.DataAccess.InternetBundle.IsPartnerOnly(bundleLines) ? NumberSearch.DataAccess.InternetBundle.PartnerDescription : NumberSearch.DataAccess.InternetBundle.Description, + cost = NumberSearch.DataAccess.InternetBundle.DiscountPerConnection * -1, + quantity = NumberSearch.DataAccess.InternetBundle.FiberConnections(bundleLines) + }); + } + // Handle hardware installation scenarios, if hardware is in the order. if (cart is not null && cart.Products.Count != 0) { diff --git a/NumberSearch.Ops/PostgreSQL/Order.cs b/NumberSearch.Ops/PostgreSQL/Order.cs index 3b96535c..99ccb82d 100644 --- a/NumberSearch.Ops/PostgreSQL/Order.cs +++ b/NumberSearch.Ops/PostgreSQL/Order.cs @@ -38,5 +38,9 @@ public partial class Order public DateTime? DateUpfrontInvoicePaid { get; set; } public string? InternalNotes { get; set; } public string? QuoteStatus { get; set; } + /// + /// Chosen by the customer at checkout, read only here so editing an order can't clear it. + /// + public int InternetTermYears { get; set; } } } diff --git a/NumberSearch.Ops/PostgreSQL/numberSearchContext.cs b/NumberSearch.Ops/PostgreSQL/numberSearchContext.cs index 560587e5..f52c0381 100644 --- a/NumberSearch.Ops/PostgreSQL/numberSearchContext.cs +++ b/NumberSearch.Ops/PostgreSQL/numberSearchContext.cs @@ -438,6 +438,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.InternalNotes).HasColumnType("character varying"); entity.Property(e => e.QuoteStatus).HasColumnType("character varying"); + + // Set by the customer at checkout, never written from here. + entity.Property(e => e.InternetTermYears).Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); + entity.Property(e => e.InternetTermYears).Metadata.SetBeforeSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); }); modelBuilder.Entity(entity => diff --git a/NumberSearch.Tests/Unit.cs b/NumberSearch.Tests/Unit.cs index 0367338c..8f2fc12f 100644 --- a/NumberSearch.Tests/Unit.cs +++ b/NumberSearch.Tests/Unit.cs @@ -38,6 +38,32 @@ public void ServiceAddressStreetKeyTest(string streetName, string expected) Assert.Equal(expected, NumberSearch.DataAccess.ServiceAddress.ToStreetKey(streetName)); } + [Fact] + public void InternetBundleDiscountTest() + { + var fiber = new NumberSearch.DataAccess.ProductOrder { ServiceId = NumberSearch.DataAccess.InternetBundle.FiberInternet1GServiceId, Quantity = 1 }; + var twoFiber = new NumberSearch.DataAccess.ProductOrder { ServiceId = NumberSearch.DataAccess.InternetBundle.FiberInternet300ServiceId, Quantity = 2 }; + var lines = new NumberSearch.DataAccess.ProductOrder { ServiceId = NumberSearch.DataAccess.InternetBundle.StandardLinesServiceId, Quantity = 3 }; + var seats = new NumberSearch.DataAccess.ProductOrder { ServiceId = NumberSearch.DataAccess.InternetBundle.ConcurrentSeatsServiceId, Quantity = 1 }; + var noLines = new NumberSearch.DataAccess.ProductOrder { ServiceId = NumberSearch.DataAccess.InternetBundle.StandardLinesServiceId, Quantity = 0 }; + var partner = new NumberSearch.DataAccess.ProductOrder { CouponId = NumberSearch.DataAccess.InternetBundle.PartnerCouponId, Quantity = 1 }; + var otherCoupon = new NumberSearch.DataAccess.ProductOrder { CouponId = Guid.NewGuid(), Quantity = 1 }; + + // Fiber on its own, or with a coupon that isn't Partner, pays full price. + Assert.Equal(0, NumberSearch.DataAccess.InternetBundle.Discount([fiber])); + Assert.Equal(0, NumberSearch.DataAccess.InternetBundle.Discount([fiber, otherCoupon, noLines])); + // Phone service or the Partner coupon take $15 off each fiber connection, never more. + Assert.Equal(15, NumberSearch.DataAccess.InternetBundle.Discount([fiber, lines])); + Assert.Equal(15, NumberSearch.DataAccess.InternetBundle.Discount([fiber, lines, seats, partner])); + Assert.Equal(15, NumberSearch.DataAccess.InternetBundle.Discount([fiber, partner])); + Assert.Equal(45, NumberSearch.DataAccess.InternetBundle.Discount([fiber, twoFiber, seats])); + // Phone service without fiber has nothing to discount. + Assert.Equal(0, NumberSearch.DataAccess.InternetBundle.Discount([lines, partner])); + + Assert.True(NumberSearch.DataAccess.InternetBundle.IsPartnerOnly([fiber, partner])); + Assert.False(NumberSearch.DataAccess.InternetBundle.IsPartnerOnly([fiber, lines, partner])); + } + [Fact] public void ServiceAddressDistanceTest() { From a8a9584255a793a3179b03c61f050fad9ee66791 Mon Sep 17 00:00:00 2001 From: Dan Ryan Date: Thu, 24 Sep 2026 13:00:39 -0700 Subject: [PATCH 3/8] Fixed Integration test (Yealink T54W deprecated) --- NumberSearch.Tests/Integration.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NumberSearch.Tests/Integration.cs b/NumberSearch.Tests/Integration.cs index 9232d06f..b3b20512 100644 --- a/NumberSearch.Tests/Integration.cs +++ b/NumberSearch.Tests/Integration.cs @@ -969,7 +969,7 @@ public async Task BulkVSRESTValidatePortabilityAsync() public async Task TeleDynamicsProductCheckQuantityAsync() { // Arrange - var partNumber = "yea-sip-t54w"; + var partNumber = "yea-sip-t87w"; // Act var results = await VendorProduct.GetAsync(partNumber, _teleDynamicsUsername, _teleDynamicsPassword); @@ -2420,4 +2420,4 @@ public void ParseAddressForOrder() // Assert.False(string.IsNullOrWhiteSpace(result.EmailSubject)); //} } -} \ No newline at end of file +} From d9595f8b53e7520ead0fe3a8309b211365d77a65 Mon Sep 17 00:00:00 2001 From: webserver Date: Thu, 24 Sep 2026 13:28:37 -0700 Subject: [PATCH 4/8] Address review feedback on Ziply address availability - Parse MaxSpeed into Mbps and only offer a fiber tier the building is listed at or above, logging buildings we can't read - Only sell at the listed price on an exact address match. A match on the building number alone or the nearest building within 30m now asks the customer to confirm, and the page says which building we checked - Fall back to the building number without suffixes or fractions when the exact house number misses, ex. "512 1/2" -> "512" - Record the qualified service address and Ziply building key on the order. Cart/Add only accepts fiber with a Sellable serviceAddressId, checkout rejects fiber without one, and the address is on the invoice - Importer reads shared string workbooks, cells without a reference, and says when the header is missing rather than blaming the data - Fix the two new CS8602 warnings, stop adding to the unread totalCost, hide the $0 Partner line when there's no 5G, and document why the Partner coupon has no quantity gate Co-Authored-By: Claude Opus 5.5 --- NumberSearch.DataAccess/InternetBundle.sql | 7 +- .../Models/InternetBundle.cs | 22 +++++ NumberSearch.DataAccess/Models/Order.cs | 28 +++--- .../Models/ServiceAddress.cs | 75 +++++++++++++--- NumberSearch.DataAccess/ServiceAddresses.sql | 1 + .../Ziply/import_ziply_building_list.py | 89 ++++++++++++++----- .../Controllers/CartAPIController.cs | 22 ++++- .../Controllers/CartController.cs | 40 ++++++--- NumberSearch.Mvc/Controllers/Endpoints.cs | 41 ++++++--- NumberSearch.Mvc/Views/Cart/Order.cshtml | 10 +++ .../Views/Services/Internet.cshtml | 8 +- NumberSearch.Mvc/wwwroot/js/site.js | 5 +- .../Controllers/OrdersController.cs | 8 +- NumberSearch.Ops/PostgreSQL/Order.cs | 2 + .../PostgreSQL/numberSearchContext.cs | 4 + NumberSearch.Tests/Unit.cs | 61 +++++++++++++ 16 files changed, 344 insertions(+), 79 deletions(-) diff --git a/NumberSearch.DataAccess/InternetBundle.sql b/NumberSearch.DataAccess/InternetBundle.sql index b0a1d99f..5acdbd05 100644 --- a/NumberSearch.DataAccess/InternetBundle.sql +++ b/NumberSearch.DataAccess/InternetBundle.sql @@ -1,9 +1,12 @@ -- Fiber internet contract terms and the phone bundle discount, in support of https://acceleratenetworks.com/Internet. --- Run this against the PostgresqlProd database before deploying. +-- Run this against the PostgresqlProd database before deploying. Safe to run again. -- The contract term (2, 3 or 5 years) chosen for fiber internet on an order, 0 when the order has no fiber internet. ALTER TABLE public."Orders" - ADD COLUMN IF NOT EXISTS "InternetTermYears" integer NOT NULL DEFAULT 0; + ADD COLUMN IF NOT EXISTS "InternetTermYears" integer NOT NULL DEFAULT 0, + -- The listed service address the fiber was qualified at, and the provider's building key, which survives re-importing the building lists. + ADD COLUMN IF NOT EXISTS "InternetServiceAddress" text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS "InternetBuildingKey" text NOT NULL DEFAULT ''; -- The Partner coupon now also takes $15/mo off each fiber internet connection, see InternetBundle.cs. UPDATE public."Coupons" diff --git a/NumberSearch.DataAccess/Models/InternetBundle.cs b/NumberSearch.DataAccess/Models/InternetBundle.cs index d997695e..cea73db0 100644 --- a/NumberSearch.DataAccess/Models/InternetBundle.cs +++ b/NumberSearch.DataAccess/Models/InternetBundle.cs @@ -27,8 +27,29 @@ public static class InternetBundle public static bool IsFiberInternet(Guid serviceId) => serviceId == FiberInternet300ServiceId || serviceId == FiberInternet1GServiceId; + /// + /// The speed in Mbps a building must be listed at to sell this fiber tier, or 0 when the service isn't fiber internet. + /// + public static int RequiredMbps(Guid serviceId) => serviceId == FiberInternet1GServiceId ? 1000 : serviceId == FiberInternet300ServiceId ? 300 : 0; + + /// + /// Whether a fiber tier can be sold at a listed address: only WFI Sellable buildings listed at or above the tier's speed. + /// + public static bool CanSellAt(Guid serviceId, ServiceAddress address) => + IsFiberInternet(serviceId) && address.Product is "WFI" && address.Status is "Sellable" && ServiceAddress.ParseMbps(address.MaxSpeed) >= RequiredMbps(serviceId); + public static bool IsPhoneService(Guid serviceId) => serviceId == StandardLinesServiceId || serviceId == ConcurrentSeatsServiceId; + /// + /// The invoice notes for a fiber internet line, with the contract term and the address it was qualified at. + /// + public static string FiberNotes(int termYears, string serviceAddress, string description) + { + var term = termYears > 0 ? $"{termYears} year term. " : string.Empty; + var address = string.IsNullOrWhiteSpace(serviceAddress) ? string.Empty : $"Service address: {serviceAddress}. "; + return $"{term}{address}{description}"; + } + /// /// The parts of a product order the bundle depends on, so the Ops site's own ProductOrder model can use the same rules. /// @@ -64,6 +85,7 @@ public static int Discount(IEnumerable lines) private static bool HasPhoneService(IEnumerable lines) => lines.Any(x => IsPhoneService(x.ServiceId) && x.Quantity > 0); + // Coupons don't carry a meaningful quantity, so any Partner coupon line qualifies, unlike phone service which must have at least one line or seat. private static bool HasPartnerCoupon(IEnumerable lines) => lines.Any(x => x.CouponId == PartnerCouponId); } } diff --git a/NumberSearch.DataAccess/Models/Order.cs b/NumberSearch.DataAccess/Models/Order.cs index b999f3b0..d55c6012 100644 --- a/NumberSearch.DataAccess/Models/Order.cs +++ b/NumberSearch.DataAccess/Models/Order.cs @@ -56,13 +56,21 @@ public record Order /// The contract term in years for fiber internet on this order, or 0 when the order has no fiber internet. ///
  • public int InternetTermYears { get; set; } + /// + /// The listed service address the fiber internet on this order was qualified at, from the provider's building list. + /// + public string InternetServiceAddress { get; set; } = string.Empty; + /// + /// The provider's key for that building, which stays the same when a new building list is imported. + /// + public string InternetBuildingKey { get; set; } = string.Empty; public static async Task GetByIdAsync(Guid orderId, string connectionString) { await using var connection = new NpgsqlConnection(connectionString); var result = await connection - .QueryFirstOrDefaultAsync("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\" FROM public.\"Orders\" " + + .QueryFirstOrDefaultAsync("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\", \"InternetServiceAddress\", \"InternetBuildingKey\" FROM public.\"Orders\" " + "WHERE \"OrderId\" = @orderId " + "ORDER BY \"DateSubmitted\" DESC", new { orderId }) @@ -76,7 +84,7 @@ public static async Task> GetByEmailAsync(string email, strin await using var connection = new NpgsqlConnection(connectionString); var result = await connection - .QueryAsync("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\" FROM public.\"Orders\" " + + .QueryAsync("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\", \"InternetServiceAddress\", \"InternetBuildingKey\" FROM public.\"Orders\" " + "WHERE \"Email\" = @email " + "ORDER BY \"DateSubmitted\" DESC", new { email }) @@ -91,7 +99,7 @@ public static async Task> GetAllAsync(string connectionString var result = await connection .QueryAsync - ("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\" " + + ("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\", \"InternetServiceAddress\", \"InternetBuildingKey\" " + "FROM public.\"Orders\" " + "ORDER BY \"DateSubmitted\" DESC") .ConfigureAwait(false); @@ -105,7 +113,7 @@ public static async Task> GetAllQuotesAsync(string connection var result = await connection .QueryAsync - ("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\" " + + ("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\", \"InternetServiceAddress\", \"InternetBuildingKey\" " + "FROM public.\"Orders\" " + "WHERE \"Quote\" = true " + "ORDER BY \"DateSubmitted\" DESC") @@ -120,7 +128,7 @@ public static async Task> GetByBackGroundworkNotCompletedAsyn var result = await connection .QueryAsync - ("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\" " + + ("SELECT \"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\", \"InternetServiceAddress\", \"InternetBuildingKey\" " + "FROM public.\"Orders\" " + "WHERE \"BackgroundWorkCompleted\" = false " + "ORDER BY \"DateSubmitted\" DESC") @@ -134,9 +142,9 @@ public async Task PostAsync(string connectionString) await using var connection = new NpgsqlConnection(connectionString); var result = await connection - .ExecuteAsync("INSERT INTO public.\"Orders\"(\"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\" ) " + - "VALUES(@OrderId, @FirstName, @LastName, @Email, @Address, @Address2, @City, @State, @Zip, @DateSubmitted, @BusinessName, @CustomerNotes, @BillingClientId, @BillingInvoiceId, @Quote, @BillingInvoiceReoccuringId, @SalesEmail, @BackgroundWorkCompleted, @Completed, @InstallDate, @UpfrontInvoiceLink, @ReoccuringInvoiceLink, @OnsiteInstallation, @AddressUnitType, @AddressUnitNumber, @UnparsedAddress, @MergedOrderId, @E911ServiceNumber, @DateConvertedFromQuote, @DateCompleted, @ContactPhoneNumber, @DateUpfrontInvoicePaid, @InternalNotes, @QuoteStatus, @VoiceProductAcknowledged, @VoiceProductAcknowledgedUtc, @InternetTermYears )", - new { OrderId, FirstName, LastName, Email, Address, Address2, City, State, Zip, DateSubmitted, BusinessName, CustomerNotes, BillingClientId, BillingInvoiceId, Quote, BillingInvoiceReoccuringId, SalesEmail, BackgroundWorkCompleted, Completed, InstallDate, UpfrontInvoiceLink, ReoccuringInvoiceLink, OnsiteInstallation, AddressUnitType, AddressUnitNumber, UnparsedAddress, MergedOrderId, E911ServiceNumber, DateConvertedFromQuote, DateCompleted, ContactPhoneNumber, DateUpfrontInvoicePaid, InternalNotes, QuoteStatus, VoiceProductAcknowledged, VoiceProductAcknowledgedUtc, InternetTermYears }) + .ExecuteAsync("INSERT INTO public.\"Orders\"(\"OrderId\", \"FirstName\", \"LastName\", \"Email\", \"Address\", \"Address2\", \"City\", \"State\", \"Zip\", \"DateSubmitted\", \"BusinessName\", \"CustomerNotes\", \"BillingClientId\", \"BillingInvoiceId\", \"Quote\", \"BillingInvoiceReoccuringId\", \"SalesEmail\", \"BackgroundWorkCompleted\", \"Completed\", \"InstallDate\", \"UpfrontInvoiceLink\", \"ReoccuringInvoiceLink\", \"OnsiteInstallation\", \"AddressUnitType\", \"AddressUnitNumber\", \"UnparsedAddress\", \"MergedOrderId\", \"E911ServiceNumber\", \"DateConvertedFromQuote\", \"DateCompleted\", \"ContactPhoneNumber\", \"DateUpfrontInvoicePaid\", \"InternalNotes\", \"QuoteStatus\", \"VoiceProductAcknowledged\", \"VoiceProductAcknowledgedUtc\", \"InternetTermYears\", \"InternetServiceAddress\", \"InternetBuildingKey\" ) " + + "VALUES(@OrderId, @FirstName, @LastName, @Email, @Address, @Address2, @City, @State, @Zip, @DateSubmitted, @BusinessName, @CustomerNotes, @BillingClientId, @BillingInvoiceId, @Quote, @BillingInvoiceReoccuringId, @SalesEmail, @BackgroundWorkCompleted, @Completed, @InstallDate, @UpfrontInvoiceLink, @ReoccuringInvoiceLink, @OnsiteInstallation, @AddressUnitType, @AddressUnitNumber, @UnparsedAddress, @MergedOrderId, @E911ServiceNumber, @DateConvertedFromQuote, @DateCompleted, @ContactPhoneNumber, @DateUpfrontInvoicePaid, @InternalNotes, @QuoteStatus, @VoiceProductAcknowledged, @VoiceProductAcknowledgedUtc, @InternetTermYears, @InternetServiceAddress, @InternetBuildingKey )", + new { OrderId, FirstName, LastName, Email, Address, Address2, City, State, Zip, DateSubmitted, BusinessName, CustomerNotes, BillingClientId, BillingInvoiceId, Quote, BillingInvoiceReoccuringId, SalesEmail, BackgroundWorkCompleted, Completed, InstallDate, UpfrontInvoiceLink, ReoccuringInvoiceLink, OnsiteInstallation, AddressUnitType, AddressUnitNumber, UnparsedAddress, MergedOrderId, E911ServiceNumber, DateConvertedFromQuote, DateCompleted, ContactPhoneNumber, DateUpfrontInvoicePaid, InternalNotes, QuoteStatus, VoiceProductAcknowledged, VoiceProductAcknowledgedUtc, InternetTermYears, InternetServiceAddress, InternetBuildingKey }) .ConfigureAwait(false); if (result == 1) @@ -155,9 +163,9 @@ public async Task PutAsync(string connectionString) var result = await connection .ExecuteAsync("UPDATE public.\"Orders\" " + - "SET \"FirstName\" = @FirstName, \"LastName\" = @LastName, \"Email\" = @Email, \"Address\" = @Address, \"Address2\" = @Address2, \"City\" = @City, \"State\" = @State, \"Zip\" = @Zip, \"DateSubmitted\" = @DateSubmitted, \"BusinessName\" = @BusinessName, \"CustomerNotes\" = @CustomerNotes, \"BillingClientId\" = @BillingClientId, \"BillingInvoiceId\" = @BillingInvoiceId, \"Quote\" = @Quote, \"BillingInvoiceReoccuringId\" = @BillingInvoiceReoccuringId, \"SalesEmail\" = @SalesEmail, \"BackgroundWorkCompleted\" = @BackgroundWorkCompleted, \"Completed\" = @Completed, \"InstallDate\" = @InstallDate, \"UpfrontInvoiceLink\" = @UpfrontInvoiceLink, \"ReoccuringInvoiceLink\" = @ReoccuringInvoiceLink, \"OnsiteInstallation\" = @OnsiteInstallation, \"AddressUnitType\" = @AddressUnitType, \"AddressUnitNumber\" = @AddressUnitNumber, \"UnparsedAddress\" = @UnparsedAddress, \"MergedOrderId\" = @MergedOrderId, \"E911ServiceNumber\" = @E911ServiceNumber, \"DateConvertedFromQuote\" = @DateConvertedFromQuote, \"DateCompleted\" = @DateCompleted, \"ContactPhoneNumber\" = @ContactPhoneNumber, \"DateUpfrontInvoicePaid\" = @DateUpfrontInvoicePaid, \"InternalNotes\" = @InternalNotes, \"QuoteStatus\" = @QuoteStatus, \"VoiceProductAcknowledged\" = @VoiceProductAcknowledged, \"VoiceProductAcknowledgedUtc\" = @VoiceProductAcknowledgedUtc, \"InternetTermYears\" = @InternetTermYears " + + "SET \"FirstName\" = @FirstName, \"LastName\" = @LastName, \"Email\" = @Email, \"Address\" = @Address, \"Address2\" = @Address2, \"City\" = @City, \"State\" = @State, \"Zip\" = @Zip, \"DateSubmitted\" = @DateSubmitted, \"BusinessName\" = @BusinessName, \"CustomerNotes\" = @CustomerNotes, \"BillingClientId\" = @BillingClientId, \"BillingInvoiceId\" = @BillingInvoiceId, \"Quote\" = @Quote, \"BillingInvoiceReoccuringId\" = @BillingInvoiceReoccuringId, \"SalesEmail\" = @SalesEmail, \"BackgroundWorkCompleted\" = @BackgroundWorkCompleted, \"Completed\" = @Completed, \"InstallDate\" = @InstallDate, \"UpfrontInvoiceLink\" = @UpfrontInvoiceLink, \"ReoccuringInvoiceLink\" = @ReoccuringInvoiceLink, \"OnsiteInstallation\" = @OnsiteInstallation, \"AddressUnitType\" = @AddressUnitType, \"AddressUnitNumber\" = @AddressUnitNumber, \"UnparsedAddress\" = @UnparsedAddress, \"MergedOrderId\" = @MergedOrderId, \"E911ServiceNumber\" = @E911ServiceNumber, \"DateConvertedFromQuote\" = @DateConvertedFromQuote, \"DateCompleted\" = @DateCompleted, \"ContactPhoneNumber\" = @ContactPhoneNumber, \"DateUpfrontInvoicePaid\" = @DateUpfrontInvoicePaid, \"InternalNotes\" = @InternalNotes, \"QuoteStatus\" = @QuoteStatus, \"VoiceProductAcknowledged\" = @VoiceProductAcknowledged, \"VoiceProductAcknowledgedUtc\" = @VoiceProductAcknowledgedUtc, \"InternetTermYears\" = @InternetTermYears, \"InternetServiceAddress\" = @InternetServiceAddress, \"InternetBuildingKey\" = @InternetBuildingKey " + "WHERE \"OrderId\" = @OrderId", - new { FirstName, LastName, Email, Address, Address2, City, State, Zip, DateSubmitted, BusinessName, CustomerNotes, BillingClientId, BillingInvoiceId, Quote, BillingInvoiceReoccuringId, SalesEmail, BackgroundWorkCompleted, Completed, InstallDate, UpfrontInvoiceLink, ReoccuringInvoiceLink, OnsiteInstallation, AddressUnitType, AddressUnitNumber, UnparsedAddress, MergedOrderId, E911ServiceNumber, DateConvertedFromQuote, DateCompleted, ContactPhoneNumber, DateUpfrontInvoicePaid, InternalNotes, QuoteStatus, VoiceProductAcknowledged, VoiceProductAcknowledgedUtc, InternetTermYears, OrderId }) + new { FirstName, LastName, Email, Address, Address2, City, State, Zip, DateSubmitted, BusinessName, CustomerNotes, BillingClientId, BillingInvoiceId, Quote, BillingInvoiceReoccuringId, SalesEmail, BackgroundWorkCompleted, Completed, InstallDate, UpfrontInvoiceLink, ReoccuringInvoiceLink, OnsiteInstallation, AddressUnitType, AddressUnitNumber, UnparsedAddress, MergedOrderId, E911ServiceNumber, DateConvertedFromQuote, DateCompleted, ContactPhoneNumber, DateUpfrontInvoicePaid, InternalNotes, QuoteStatus, VoiceProductAcknowledged, VoiceProductAcknowledgedUtc, InternetTermYears, InternetServiceAddress, InternetBuildingKey, OrderId }) .ConfigureAwait(false); if (result == 1) diff --git a/NumberSearch.DataAccess/Models/ServiceAddress.cs b/NumberSearch.DataAccess/Models/ServiceAddress.cs index eaeec5e7..5fe9f135 100644 --- a/NumberSearch.DataAccess/Models/ServiceAddress.cs +++ b/NumberSearch.DataAccess/Models/ServiceAddress.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; namespace NumberSearch.DataAccess @@ -28,10 +29,11 @@ public class ServiceAddress public double Latitude { get; set; } public double Longitude { get; set; } public string MaxSpeed { get; set; } = string.Empty; + public string BuildingKey { get; set; } = string.Empty; public string SourceFile { get; set; } = string.Empty; public DateTime DateIngested { get; set; } - private const string Columns = "\"ServiceAddressId\", \"Provider\", \"Product\", \"Status\", \"HouseNumber\", \"StreetKey\", \"StreetAddress\", \"City\", \"State\", \"Postal\", \"Latitude\", \"Longitude\", \"MaxSpeed\", \"SourceFile\", \"DateIngested\""; + private const string Columns = "\"ServiceAddressId\", \"Provider\", \"Product\", \"Status\", \"HouseNumber\", \"StreetKey\", \"StreetAddress\", \"City\", \"State\", \"Postal\", \"Latitude\", \"Longitude\", \"MaxSpeed\", \"BuildingKey\", \"SourceFile\", \"DateIngested\""; /// /// How far a geocoded point may be from a listed building and still count as that building, when the street address doesn't match. @@ -55,30 +57,79 @@ public static string ToStreetKey(string streetName) } /// - /// Find the listed addresses matching a street address, or failing that the nearest listed building within MaxDistanceMeters of the point. + /// The leading building number of a house number, without leading zeros, ex. "0512" -> "512", "512 1/2" -> "512", "1250A" -> "1250". + /// Only used when the exact house number doesn't match, because it can't tell 512 from 512 1/2. /// - public static async Task> LookupAsync(string houseNumber, string streetName, string postal, double latitude, double longitude, string connectionString) + public static string ToHouseKey(string houseNumber) + { + var digits = Regex.Match(houseNumber.Trim(), @"^\d+").Value.TrimStart('0'); + return digits.Length > 0 ? digits : string.Empty; + } + + /// + /// The download speed in Mbps from a building list speed like "1.0G/1.0G" or "300.0M/300.0M", or 0 when it can't be read. + /// + public static int ParseMbps(string maxSpeed) + { + var match = Regex.Match(maxSpeed, @"^\s*(\d+(?:\.\d+)?)\s*([GM])", RegexOptions.IgnoreCase); + if (!match.Success || !decimal.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var value)) + { + return 0; + } + return (int)(char.ToUpperInvariant(match.Groups[2].Value[0]) is 'G' ? value * 1000 : value); + } + + /// + /// How a lookup matched the listed addresses. Only an Exact match is precise enough to sell at the listed price. + /// + public enum MatchType { None, Exact, HouseNumber, Nearby } + + public readonly record struct LookupResult(MatchType Match, ServiceAddress[] Addresses); + + public static async Task GetByIdAsync(long serviceAddressId, string connectionString) + { + await using var connection = new NpgsqlConnection(connectionString); + + return await connection + .QueryFirstOrDefaultAsync($"SELECT {Columns} FROM public.\"ServiceAddresses\" WHERE \"ServiceAddressId\" = @serviceAddressId", + new { serviceAddressId }) + .ConfigureAwait(false); + } + + /// + /// Find the listed addresses matching a street address. Failing an exact house number, try the building number without suffixes or fractions, + /// and failing that the nearest listed building within MaxDistanceMeters of the point. + /// + public static async Task LookupAsync(string houseNumber, string streetName, string postal, double latitude, double longitude, string connectionString) { await using var connection = new NpgsqlConnection(connectionString); var streetKey = ToStreetKey(streetName); if (!string.IsNullOrWhiteSpace(houseNumber) && !string.IsNullOrWhiteSpace(streetKey) && !string.IsNullOrWhiteSpace(postal)) { - var matches = await connection + var street = (await connection .QueryAsync($"SELECT {Columns} FROM public.\"ServiceAddresses\" " + - "WHERE \"Postal\" = @postal AND \"HouseNumber\" = @houseNumber AND \"StreetKey\" = @streetKey", - new { postal = postal.Trim(), houseNumber = houseNumber.Trim(), streetKey }) - .ConfigureAwait(false); + "WHERE \"Postal\" = @postal AND \"StreetKey\" = @streetKey", + new { postal = postal.Trim(), streetKey }) + .ConfigureAwait(false)).ToArray(); + + var exact = street.Where(x => string.Equals(x.HouseNumber.Trim(), houseNumber.Trim(), StringComparison.OrdinalIgnoreCase)).ToArray(); + if (exact.Length > 0) + { + return new(MatchType.Exact, exact); + } - if (matches.Any()) + var houseKey = ToHouseKey(houseNumber); + var sameBuilding = houseKey.Length > 0 ? street.Where(x => ToHouseKey(x.HouseNumber) == houseKey).ToArray() : []; + if (sameBuilding.Length > 0) { - return matches; + return new(MatchType.HouseNumber, sameBuilding); } } if (latitude is 0 && longitude is 0) { - return []; + return new(MatchType.None, []); } // Search a small box around the point using the index, then keep only the closest building's rows. @@ -98,10 +149,10 @@ public static async Task> LookupAsync(string houseNu if (closest.Address is null) { - return []; + return new(MatchType.None, []); } - return nearby.Where(x => x.Latitude == closest.Address.Latitude && x.Longitude == closest.Address.Longitude); + return new(MatchType.Nearby, [.. nearby.Where(x => x.Latitude == closest.Address.Latitude && x.Longitude == closest.Address.Longitude)]); } /// diff --git a/NumberSearch.DataAccess/ServiceAddresses.sql b/NumberSearch.DataAccess/ServiceAddresses.sql index adee8276..82bfe88d 100644 --- a/NumberSearch.DataAccess/ServiceAddresses.sql +++ b/NumberSearch.DataAccess/ServiceAddresses.sql @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS public."ServiceAddresses" ); CREATE INDEX IF NOT EXISTS "ServiceAddresses_Address_idx" ON public."ServiceAddresses" ("Postal", "HouseNumber"); +CREATE INDEX IF NOT EXISTS "ServiceAddresses_Street_idx" ON public."ServiceAddresses" ("Postal", "StreetKey"); CREATE INDEX IF NOT EXISTS "ServiceAddresses_Location_idx" ON public."ServiceAddresses" ("Latitude", "Longitude"); ALTER TABLE public."ServiceAddresses" OWNER TO "numberSearch"; diff --git a/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py b/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py index ff548139..b32b444e 100644 --- a/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py +++ b/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py @@ -35,27 +35,71 @@ def column_index(ref): return n - 1 +def shared_strings(z): + """The workbook's shared string table. Ziply's exports use inline strings, but a list re-saved in Excel uses this.""" + if 'xl/sharedStrings.xml' not in z.namelist(): + return [] + strings = [] + with z.open('xl/sharedStrings.xml') as f: + for _, el in iterparse(f, events=('end',)): + if el.tag == NS + 'si': + strings.append(''.join(t.text or '' for t in el.iter(NS + 't'))) + el.clear() + return strings + + +def cell_text(c, strings): + if c.get('t') == 's': + v = c.find(NS + 'v') + return strings[int(v.text)] if v is not None and v.text else '' + text = ''.join(t.text or '' for t in c.iter(NS + 't')) + if not text: + v = c.find(NS + 'v') + text = v.text or '' if v is not None else '' + return text + + +class NoHeader(Exception): + pass + + def rows(path): """Yield each row after the header as a dict keyed by column name.""" header = None - with zipfile.ZipFile(path) as z, z.open('xl/worksheets/sheet1.xml') as sheet: - for _, el in iterparse(sheet, events=('end',)): - if el.tag != NS + 'row': - continue - values = {} - for c in el.findall(NS + 'c'): - text = ''.join(t.text or '' for t in c.iter(NS + 't')) - if not text: - v = c.find(NS + 'v') - text = v.text or '' if v is not None else '' - values[column_index(c.get('r'))] = text.strip() - el.clear() - if header is None: - if values.get(0) == 'Building Name': - header = values - continue - if values: - yield {name: values.get(i, '') for i, name in header.items()} + with zipfile.ZipFile(path) as z: + strings = shared_strings(z) + with z.open('xl/worksheets/sheet1.xml') as sheet: + for _, el in iterparse(sheet, events=('end',)): + if el.tag != NS + 'row': + continue + values = {} + for position, c in enumerate(el.findall(NS + 'c')): + # The cell reference is optional, in which case cells are in column order. + ref = c.get('r') + values[column_index(ref) if ref else position] = cell_text(c, strings).strip() + el.clear() + if header is None: + if 'Building Name' in values.values() and 'Street Address' in values.values(): + header = values + continue + if values: + yield {name: values.get(i, '') for i, name in header.items()} + if header is None: + raise NoHeader(f'No header row with "Building Name" and "Street Address" found in {path}. Is this a Ziply building list?') + + +def list_rows(path): + """rows(), but reading the header before any row is written so a missing header fails before the database is touched.""" + it = rows(path) + first = next(it, None) + if first is None: + return iter(()) + return _chain(first, it) + + +def _chain(first, it): + yield first + yield from it def status(product, row): @@ -80,7 +124,12 @@ def main(): with tempfile.NamedTemporaryFile('w', newline='', suffix='.csv', delete=False) as out: # Quote everything so empty values load as empty strings rather than NULL. writer = csv.writer(out, quoting=csv.QUOTE_ALL) - for row in rows(path): + try: + listed = list_rows(path) + except NoHeader as e: + os.unlink(out.name) + sys.exit(f'{e} Leaving the existing rows in place.') + for row in listed: s = status(product, row) if s is None: skipped += 1 @@ -97,7 +146,7 @@ def main(): if kept == 0: os.unlink(out.name) - sys.exit(f'No {product} rows found in {path}, leaving the existing rows in place.') + sys.exit(f'Found the header in {path} but no {product} rows we can sell or quote, leaving the existing rows in place.') columns = ', '.join(f'"{c}"' for c in COLUMNS) script = ( diff --git a/NumberSearch.Mvc/Controllers/CartAPIController.cs b/NumberSearch.Mvc/Controllers/CartAPIController.cs index c6b4b1d3..35ae01f8 100644 --- a/NumberSearch.Mvc/Controllers/CartAPIController.cs +++ b/NumberSearch.Mvc/Controllers/CartAPIController.cs @@ -441,7 +441,7 @@ public async Task RemoveNewClientPhoneMenuOptionAsync([FromRoute] [ApiExplorerSettings(IgnoreApi = true)] [HttpGet("Cart/Add/{type}/{id}/{quantity}")] [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] - public async Task AddToCartAsync([FromRoute] string type, [FromRoute] string id, [FromRoute] int quantity) + public async Task AddToCartAsync([FromRoute] string type, [FromRoute] string id, [FromRoute] int quantity, [FromQuery] long? serviceAddressId) { if (!ModelState.IsValid && !string.IsNullOrWhiteSpace(type) && !string.IsNullOrWhiteSpace(id)) { @@ -478,7 +478,17 @@ public async Task AddToCartAsync([FromRoute] string type, [FromRo break; case "Service": var checkService = Guid.TryParse(id, out var serviceId); - if (checkService) + if (checkService && InternetBundle.IsFiberInternet(serviceId)) + { + // Fiber can only be bought at an address the Internet page qualified, so we know which building we're installing at. + var qualified = serviceAddressId is > 0 ? await ServiceAddress.GetByIdAsync(serviceAddressId.Value, mvcConfiguration.PostgresqlProd) : null; + if (qualified is null || !InternetBundle.CanSellAt(serviceId, qualified)) + { + return BadRequest("Check your address on the Internet page before adding fiber internet to your cart."); + } + result = await cart.BuyServiceAsync(serviceId, quantity, qualified); + } + else if (checkService) { result = await cart.BuyServiceAsync(serviceId, quantity); } @@ -907,7 +917,7 @@ public async Task BuyProductAsync(Guid productId, int Quantity) return BadRequest($"Failed to purchase product {productId}."); } - public async Task BuyServiceAsync(Guid serviceId, int Quantity) + public async Task BuyServiceAsync(Guid serviceId, int Quantity, ServiceAddress? qualifiedAt = null) { if (!ModelState.IsValid) { @@ -928,6 +938,12 @@ public async Task BuyServiceAsync(Guid serviceId, int Quantity) var cart = Cart.GetFromSession(httpContext.Session); var checkAdd = cart.AddService(ref service, ref productOrder); + if (qualifiedAt is not null) + { + cart.Order.InternetServiceAddress = $"{qualifiedAt.StreetAddress.Trim()}, {qualifiedAt.City}, {qualifiedAt.State} {qualifiedAt.Postal}"; + cart.Order.InternetBuildingKey = qualifiedAt.BuildingKey; + } + var stdSeat = new Guid("16e2c639-445b-4ae6-9925-07300318206b"); var concurrentSeat = new Guid("48eb4627-8692-4a3b-8be1-be64bbeea534"); diff --git a/NumberSearch.Mvc/Controllers/CartController.cs b/NumberSearch.Mvc/Controllers/CartController.cs index f66949fd..a7c8c66a 100644 --- a/NumberSearch.Mvc/Controllers/CartController.cs +++ b/NumberSearch.Mvc/Controllers/CartController.cs @@ -688,6 +688,9 @@ public async Task SubmitAsync(CartResult input) await HttpContext.Session.LoadAsync(); Cart cart = Cart.GetFromSession(HttpContext.Session); + // The qualified fiber address is set by Cart/Add, never by the form, so carry it over from the session. + order.InternetServiceAddress = cart.Order?.InternetServiceAddress ?? string.Empty; + order.InternetBuildingKey = cart.Order?.InternetBuildingKey ?? string.Empty; cart.Order = order; // This is purely so that we can isolate the state of this call when it fails out. @@ -830,6 +833,14 @@ public async Task SubmitAsync(CartResult input) if (InternetBundle.FiberConnections(cart.ProductOrders) > 0) { + if (string.IsNullOrWhiteSpace(order.InternetServiceAddress)) + { + _ = cart.SetToSession(HttpContext.Session); + Log.Error("[Checkout] Fiber internet is in the cart without a qualified service address."); + var message = "๐Ÿ’€ Please check your address on the Internet page before ordering fiber internet."; + return View("Order", new CartResult { Message = message, Cart = cart }); + } + if (!InternetBundle.TermYears.Contains(order.InternetTermYears)) { _ = cart.SetToSession(HttpContext.Session); @@ -841,6 +852,8 @@ public async Task SubmitAsync(CartResult input) else { order.InternetTermYears = 0; + order.InternetServiceAddress = string.Empty; + order.InternetBuildingKey = string.Empty; } order.DateSubmitted = DateTime.Now; @@ -1248,7 +1261,7 @@ public static ProductOrderSummary GetInvoiceItemsFromProductOrders(ref Order ord reoccuringItems.Add(new Line_Items { product_key = service.Name, - notes = InternetBundle.IsFiberInternet(service.ServiceId) && order.InternetTermYears > 0 ? $"{order.InternetTermYears} year term. {service.Description}" : $"{service.Description}", + notes = InternetBundle.IsFiberInternet(service.ServiceId) ? InternetBundle.FiberNotes(order.InternetTermYears, order.InternetServiceAddress, service.Description) : $"{service.Description}", cost = service.Price, quantity = productOrder.Quantity }); @@ -1343,13 +1356,17 @@ public static ProductOrderSummary GetInvoiceItemsFromProductOrders(ref Order ord } } totalCost -= partnerDiscount; - reoccuringItems.Add(new Line_Items + // With no 5G in the order the Partner coupon only qualifies fiber for the bundle discount, which has its own line. + if (partnerDiscount > 0) { - product_key = coupon.Name, - notes = coupon.Description, - cost = partnerDiscount * -1, - quantity = 1 - }); + reoccuringItems.Add(new Line_Items + { + product_key = coupon.Name, + notes = coupon.Description, + cost = partnerDiscount * -1, + quantity = 1 + }); + } } } else @@ -1368,16 +1385,15 @@ public static ProductOrderSummary GetInvoiceItemsFromProductOrders(ref Order ord } } - var bundleDiscount = InternetBundle.Discount(cart.ProductOrders); - if (bundleDiscount > 0) + var bundleOrders = cart?.ProductOrders ?? []; + if (InternetBundle.Discount(bundleOrders) > 0) { - totalCost -= bundleDiscount; reoccuringItems.Add(new Line_Items { product_key = InternetBundle.Name, - notes = InternetBundle.IsPartnerOnly(cart.ProductOrders) ? InternetBundle.PartnerDescription : InternetBundle.Description, + notes = InternetBundle.IsPartnerOnly(bundleOrders) ? InternetBundle.PartnerDescription : InternetBundle.Description, cost = InternetBundle.DiscountPerConnection * -1, - quantity = InternetBundle.FiberConnections(cart.ProductOrders) + quantity = InternetBundle.FiberConnections(bundleOrders) }); } diff --git a/NumberSearch.Mvc/Controllers/Endpoints.cs b/NumberSearch.Mvc/Controllers/Endpoints.cs index fef1ef9d..d4524032 100644 --- a/NumberSearch.Mvc/Controllers/Endpoints.cs +++ b/NumberSearch.Mvc/Controllers/Endpoints.cs @@ -7,6 +7,8 @@ using PhoneNumbersNA; +using Serilog; + using System.Collections.Concurrent; using System.ComponentModel.DataAnnotations; @@ -216,9 +218,11 @@ await Parallel.ForEachAsync(relatedNumbers, async (number, token) => /// The services available at an address. /// /// Whether any service is available at the address. - /// The address from the provider's building list that matched the query. + /// The address from the provider's building list that matched the query, to show the customer what we checked. + /// Whether the matched address is the one searched for, rather than the same building number or the nearest listed building. + /// The listed address to pass to Cart/Add when adding a Sellable fiber tier, or 0. /// The services available at the address. - public readonly record struct InternetAvailability(bool serviceable, string matchedAddress, InternetOffer[] offers); + public readonly record struct InternetAvailability(bool serviceable, string matchedAddress, bool exactMatch, long serviceAddressId, InternetOffer[] offers); private const string FiberTerms = "2, 3 or 5 year term. $15/mo off when bundled with any phone service."; @@ -239,30 +243,41 @@ public static async Task, BadRequest>> return TypedResults.BadRequest("Provide a houseNumber, street, and postal code or a latitude and longitude (ex: houseNumber=1250&street=1st&postal=98134). Please try a different query. ๐Ÿฅบ๐Ÿ‘‰๐Ÿ‘ˆ"); } - var addresses = (await ServiceAddress.LookupAsync(houseNumber ?? string.Empty, street ?? string.Empty, postal ?? string.Empty, latitude ?? 0, longitude ?? 0, mvcConfiguration.PostgresqlProd)).ToArray(); + var lookup = await ServiceAddress.LookupAsync(houseNumber ?? string.Empty, street ?? string.Empty, postal ?? string.Empty, latitude ?? 0, longitude ?? 0, mvcConfiguration.PostgresqlProd); + var exact = lookup.Match is ServiceAddress.MatchType.Exact; var offers = new List(); - var wfi = addresses.AsValueEnumerable().Where(x => x.Product is "WFI").ToArray(); - if (wfi.AsValueEnumerable().Any(x => x.Status is "Sellable")) + var wfi = lookup.Addresses.AsValueEnumerable().Where(x => x.Product is "WFI").ToArray(); + // Only an exact address match is sold at the listed price. The same building number or a nearby building might be the building next door. + var sellable = exact ? wfi.AsValueEnumerable().Where(x => x.Status is "Sellable").OrderByDescending(x => ServiceAddress.ParseMbps(x.MaxSpeed)).FirstOrDefault() : null; + var tiers = sellable is null ? [] : new[] { - offers.Add(new("WFI", "Sellable", "Fiber Internet 300 Mbps", "300/300 Mbps", 75, InternetBundle.FiberInternet300ServiceId, FiberTerms)); - if (wfi.AsValueEnumerable().Any(x => x.Status is "Sellable" && x.MaxSpeed.StartsWith("1.0G"))) - { - offers.Add(new("WFI", "Sellable", "Fiber Internet 1 Gbps", "1/1 Gbps", 115, InternetBundle.FiberInternet1GServiceId, FiberTerms)); - } + new InternetOffer("WFI", "Sellable", "Fiber Internet 300 Mbps", "300/300 Mbps", 75, InternetBundle.FiberInternet300ServiceId, FiberTerms), + new InternetOffer("WFI", "Sellable", "Fiber Internet 1 Gbps", "1/1 Gbps", 115, InternetBundle.FiberInternet1GServiceId, FiberTerms), + }.AsValueEnumerable().Where(x => InternetBundle.CanSellAt(x.serviceId, sellable)).ToArray(); + + if (tiers.Length > 0) + { + offers.AddRange(tiers); } else if (wfi.Length > 0) { + if (sellable is not null) + { + Log.Warning("[Internet] Sellable building {BuildingKey} is listed at {MaxSpeed}, which is below every fiber tier or can't be read.", sellable.BuildingKey, sellable.MaxSpeed); + } offers.Add(new("WFI", "Confirm", "Fiber Internet", "Up to 1/1 Gbps", 0, Guid.Empty, "Fiber may be available here. Contact us to confirm before ordering.")); } - if (addresses.AsValueEnumerable().Any(x => x.Product is "EIA" && x.Status is "Quote")) + if (lookup.Addresses.AsValueEnumerable().Any(x => x.Product is "EIA" && x.Status is "Quote")) { offers.Add(new("EIA", "Quote", "Ethernet Internet Access", "Dedicated, custom speeds", 0, Guid.Empty, "Dedicated fiber with an SLA. Contact us for a quote.")); } - var matched = addresses.AsValueEnumerable().Select(x => $"{x.StreetAddress.Trim()}, {x.City}, {x.State} {x.Postal}").FirstOrDefault() ?? string.Empty; - return TypedResults.Ok(new InternetAvailability(offers.Count > 0, matched, offers.ToArray())); + // Name the building the fiber offers came from, so the address shown always matches the offers. + var shown = tiers.Length > 0 ? sellable : wfi.AsValueEnumerable().FirstOrDefault() ?? lookup.Addresses.AsValueEnumerable().FirstOrDefault(); + var matched = shown is null ? string.Empty : $"{shown.StreetAddress.Trim()}, {shown.City}, {shown.State} {shown.Postal}"; + return TypedResults.Ok(new InternetAvailability(offers.Count > 0, matched, exact, tiers.Length > 0 ? sellable!.ServiceAddressId : 0, offers.ToArray())); } /// diff --git a/NumberSearch.Mvc/Views/Cart/Order.cshtml b/NumberSearch.Mvc/Views/Cart/Order.cshtml index feb4a73c..1e456d42 100644 --- a/NumberSearch.Mvc/Views/Cart/Order.cshtml +++ b/NumberSearch.Mvc/Views/Cart/Order.cshtml @@ -540,6 +540,12 @@ localTotal = coupon.Value; couponTotal += localTotal * -1; } + + // With no 5G in the order the Partner coupon only qualifies fiber for the bundle discount, which has its own line. + if (coupon.Type == "Service" && localTotal == 0) + { + continue; + }
  • @@ -693,6 +699,10 @@ Fiber internet is billed monthly on a 2, 3 or 5 year term + @if (!string.IsNullOrWhiteSpace(Model?.Cart?.Order?.InternetServiceAddress)) + { +
    Service address: @Model?.Cart?.Order?.InternetServiceAddress
    + }
    } diff --git a/NumberSearch.Mvc/Views/Services/Internet.cshtml b/NumberSearch.Mvc/Views/Services/Internet.cshtml index 0acb5ae4..6469ad2d 100644 --- a/NumberSearch.Mvc/Views/Services/Internet.cshtml +++ b/NumberSearch.Mvc/Views/Services/Internet.cshtml @@ -65,13 +65,19 @@ showMessage(`We don't have internet service listed at this address yet. Please ${contact} and we'll check for you.`); return; } + const qualified = `serviceAddressId=${encodeURIComponent(availability.serviceAddressId)}`; const rows = availability.offers.map(offer => { const price = offer.price > 0 ? `$${offer.price}/mo` : 'Quote'; const action = offer.status == 'Sellable' - ? `` + ? `` : `${offer.status == 'Quote' ? 'Request a Quote' : 'Contact Us'}`; return `
  • `; }); + // Say which listed building we checked, since a near match might not be the address that was typed. + const checked = availability.exactMatch + ? `Showing services for ${escapeHtml(availability.matchedAddress)}.` + : `We couldn't find this exact address, the closest listed building is ${escapeHtml(availability.matchedAddress)}. Please ${contact} to confirm before ordering.`; + rows.unshift(``); document.getElementById("resultstablebody").innerHTML = rows.join(''); document.getElementById("spinner").hidden = true; }); diff --git a/NumberSearch.Mvc/wwwroot/js/site.js b/NumberSearch.Mvc/wwwroot/js/site.js index 896c01e0..e4b96d87 100644 --- a/NumberSearch.Mvc/wwwroot/js/site.js +++ b/NumberSearch.Mvc/wwwroot/js/site.js @@ -34,7 +34,7 @@ function escapeHtml(value) { .replace(/'/g, '''); } -function AddToCart(type, id, quantity, element) { +function AddToCart(type, id, quantity, element, query) { // Default to 1 unit if the "Add to Cart" button is pressed. const quantityDisplay = document.getElementById(id); if (quantityDisplay == null || quantityDisplay.value == null || quantityDisplay.value.length == 0) { @@ -56,6 +56,9 @@ function AddToCart(type, id, quantity, element) { let spinner = $(element).find('span'); spinner.removeClass('d-none'); let route = `/Cart/Add/${type}/${id}/${quantity}`; + if (query) { + route += `?${query}`; + } fetch(route) .then(response => { if (!response.ok) { diff --git a/NumberSearch.Ops/Controllers/OrdersController.cs b/NumberSearch.Ops/Controllers/OrdersController.cs index 9bd673f2..d8b42a4d 100644 --- a/NumberSearch.Ops/Controllers/OrdersController.cs +++ b/NumberSearch.Ops/Controllers/OrdersController.cs @@ -1526,7 +1526,7 @@ public async Task OrderNewInvoicesAsync(Guid orderId) reoccurringItems.Add(new Line_Items { product_key = service.Name ?? string.Empty, - notes = NumberSearch.DataAccess.InternetBundle.IsFiberInternet(service.ServiceId) && order.InternetTermYears > 0 ? $"{order.InternetTermYears} year term. {service.Description}" : $"{service.Description}", + notes = NumberSearch.DataAccess.InternetBundle.IsFiberInternet(service.ServiceId) ? NumberSearch.DataAccess.InternetBundle.FiberNotes(order.InternetTermYears, order.InternetServiceAddress, service.Description ?? string.Empty) : $"{service.Description}", cost = price, quantity = Convert.ToInt32(productOrder.Quantity) }); @@ -1618,11 +1618,9 @@ public async Task OrderNewInvoicesAsync(Guid orderId) } // Fiber internet is discounted once per connection when bundled with phone service or the Partner coupon. - var bundleLines = cart.ProductOrders.Select(x => new NumberSearch.DataAccess.InternetBundle.Line(x.ServiceId ?? Guid.Empty, x.Quantity, x.CouponId)).ToArray(); - var bundleDiscount = NumberSearch.DataAccess.InternetBundle.Discount(bundleLines); - if (bundleDiscount > 0) + var bundleLines = (cart?.ProductOrders ?? []).Select(x => new NumberSearch.DataAccess.InternetBundle.Line(x.ServiceId ?? Guid.Empty, x.Quantity, x.CouponId)).ToArray(); + if (NumberSearch.DataAccess.InternetBundle.Discount(bundleLines) > 0) { - totalCost -= bundleDiscount; reoccurringItems.Add(new Line_Items { product_key = NumberSearch.DataAccess.InternetBundle.Name, diff --git a/NumberSearch.Ops/PostgreSQL/Order.cs b/NumberSearch.Ops/PostgreSQL/Order.cs index 99ccb82d..71b4be77 100644 --- a/NumberSearch.Ops/PostgreSQL/Order.cs +++ b/NumberSearch.Ops/PostgreSQL/Order.cs @@ -42,5 +42,7 @@ public partial class Order /// Chosen by the customer at checkout, read only here so editing an order can't clear it. /// public int InternetTermYears { get; set; } + public string InternetServiceAddress { get; set; } = string.Empty; + public string InternetBuildingKey { get; set; } = string.Empty; } } diff --git a/NumberSearch.Ops/PostgreSQL/numberSearchContext.cs b/NumberSearch.Ops/PostgreSQL/numberSearchContext.cs index f52c0381..c5c23ddd 100644 --- a/NumberSearch.Ops/PostgreSQL/numberSearchContext.cs +++ b/NumberSearch.Ops/PostgreSQL/numberSearchContext.cs @@ -442,6 +442,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Set by the customer at checkout, never written from here. entity.Property(e => e.InternetTermYears).Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); entity.Property(e => e.InternetTermYears).Metadata.SetBeforeSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); + entity.Property(e => e.InternetServiceAddress).Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); + entity.Property(e => e.InternetServiceAddress).Metadata.SetBeforeSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); + entity.Property(e => e.InternetBuildingKey).Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); + entity.Property(e => e.InternetBuildingKey).Metadata.SetBeforeSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); }); modelBuilder.Entity(entity => diff --git a/NumberSearch.Tests/Unit.cs b/NumberSearch.Tests/Unit.cs index 8f2fc12f..6a8171d8 100644 --- a/NumberSearch.Tests/Unit.cs +++ b/NumberSearch.Tests/Unit.cs @@ -60,10 +60,71 @@ public void InternetBundleDiscountTest() // Phone service without fiber has nothing to discount. Assert.Equal(0, NumberSearch.DataAccess.InternetBundle.Discount([lines, partner])); + // Coupons don't carry a meaningful quantity, so a Partner line qualifies whatever its quantity. + var partnerNoQuantity = new NumberSearch.DataAccess.ProductOrder { CouponId = NumberSearch.DataAccess.InternetBundle.PartnerCouponId, Quantity = 0 }; + Assert.Equal(15, NumberSearch.DataAccess.InternetBundle.Discount([fiber, partnerNoQuantity])); + Assert.True(NumberSearch.DataAccess.InternetBundle.IsPartnerOnly([fiber, partner])); Assert.False(NumberSearch.DataAccess.InternetBundle.IsPartnerOnly([fiber, lines, partner])); } + [Theory] + [InlineData("1.0G/1.0G", 1000)] + [InlineData("300.0M/300.0M", 300)] + [InlineData("2.0G/2.0G", 2000)] + [InlineData("10G", 10000)] + [InlineData("1000M", 1000)] + [InlineData("50.0M/50.0M", 50)] + [InlineData("1 Gbps", 1000)] + [InlineData("", 0)] + [InlineData("fast", 0)] + public void ServiceAddressParseMbpsTest(string maxSpeed, int expected) + { + Assert.Equal(expected, NumberSearch.DataAccess.ServiceAddress.ParseMbps(maxSpeed)); + } + + [Theory] + [InlineData("512", "512")] + [InlineData("0512", "512")] + [InlineData("512 1/2", "512")] + [InlineData("1250A", "1250")] + [InlineData("N123", "")] + [InlineData("", "")] + public void ServiceAddressHouseKeyTest(string houseNumber, string expected) + { + Assert.Equal(expected, NumberSearch.DataAccess.ServiceAddress.ToHouseKey(houseNumber)); + } + + [Fact] + public void InternetBundleCanSellAtTest() + { + var gig = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "1.0G/1.0G" }; + var slow = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "300.0M/300.0M" }; + var slower = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "100.0M/100.0M" }; + var unreadable = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "" }; + var confirm = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Confirm", MaxSpeed = "1.0G/1.0G" }; + var eia = new NumberSearch.DataAccess.ServiceAddress { Product = "EIA", Status = "Quote", MaxSpeed = "1.0G/1.0G" }; + var t300 = NumberSearch.DataAccess.InternetBundle.FiberInternet300ServiceId; + var t1g = NumberSearch.DataAccess.InternetBundle.FiberInternet1GServiceId; + + Assert.True(NumberSearch.DataAccess.InternetBundle.CanSellAt(t300, gig)); + Assert.True(NumberSearch.DataAccess.InternetBundle.CanSellAt(t1g, gig)); + Assert.True(NumberSearch.DataAccess.InternetBundle.CanSellAt(t300, slow)); + Assert.False(NumberSearch.DataAccess.InternetBundle.CanSellAt(t1g, slow)); + Assert.False(NumberSearch.DataAccess.InternetBundle.CanSellAt(t300, slower)); + Assert.False(NumberSearch.DataAccess.InternetBundle.CanSellAt(t300, unreadable)); + Assert.False(NumberSearch.DataAccess.InternetBundle.CanSellAt(t300, confirm)); + Assert.False(NumberSearch.DataAccess.InternetBundle.CanSellAt(t300, eia)); + Assert.False(NumberSearch.DataAccess.InternetBundle.CanSellAt(NumberSearch.DataAccess.InternetBundle.StandardLinesServiceId, gig)); + } + + [Fact] + public void InternetBundleFiberNotesTest() + { + Assert.Equal("3 year term. Service address: 71 Pine Cliff Dr, Naches, Washington 98937. Fiber.", NumberSearch.DataAccess.InternetBundle.FiberNotes(3, "71 Pine Cliff Dr, Naches, Washington 98937", "Fiber.")); + Assert.Equal("Fiber.", NumberSearch.DataAccess.InternetBundle.FiberNotes(0, "", "Fiber.")); + } + [Fact] public void ServiceAddressDistanceTest() { From 11ccce0bc2ff1e4f82dfaeafb8ca2b306f12fead Mon Sep 17 00:00:00 2001 From: webserver Date: Thu, 24 Sep 2026 13:58:49 -0700 Subject: [PATCH 5/8] Show the fiber service address in Ops The order page gets a read-only Fiber Internet section with the service address the customer qualified at, the contract term, the Ziply building key and a map link. The order list shows the service address and term under the billing address. Nothing here is posted back, so staff edits can't change what the customer chose. Co-Authored-By: Claude Opus 5.5 --- .../Views/Orders/OrderEdit.cshtml | 30 +++++++++++++++++++ NumberSearch.Ops/Views/Orders/Orders.cshtml | 11 +++++++ 2 files changed, 41 insertions(+) diff --git a/NumberSearch.Ops/Views/Orders/OrderEdit.cshtml b/NumberSearch.Ops/Views/Orders/OrderEdit.cshtml index e78f0b35..d1de936f 100644 --- a/NumberSearch.Ops/Views/Orders/OrderEdit.cshtml +++ b/NumberSearch.Ops/Views/Orders/OrderEdit.cshtml @@ -374,6 +374,36 @@ + @if (!string.IsNullOrWhiteSpace(Model?.Order?.InternetServiceAddress) || Model?.Order?.InternetTermYears > 0) + { + // Set by the customer at checkout from the Ziply building list, so these are shown but never posted back. + var serviceAddress = Model?.Order?.InternetServiceAddress ?? string.Empty; +
    Fiber Internet
    +
    +
    +
    + + +
    + @if (!string.IsNullOrWhiteSpace(serviceAddress)) + { + + } +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + }
    diff --git a/NumberSearch.Ops/Views/Orders/Orders.cshtml b/NumberSearch.Ops/Views/Orders/Orders.cshtml index e78ae114..20625015 100644 --- a/NumberSearch.Ops/Views/Orders/Orders.cshtml +++ b/NumberSearch.Ops/Views/Orders/Orders.cshtml @@ -193,6 +193,17 @@ @order?.Order?.Zip

    + @if (!string.IsNullOrWhiteSpace(order?.Order?.InternetServiceAddress)) + { + Fiber Internet +

    + @order.Order.InternetServiceAddress + @if (order.Order.InternetTermYears > 0) + { + @order.Order.InternetTermYears year term + } +

    + }
    `; }); // Say which listed building we checked, since a near match might not be the address that was typed. - const checked = availability.exactMatch - ? `Showing services for ${escapeHtml(availability.matchedAddress)}.` - : `We couldn't find this exact address, the closest listed building is ${escapeHtml(availability.matchedAddress)}. Please ${contact} to confirm before ordering.`; + const matched = escapeHtml(availability.matchedAddress); + const checked = availability.matchType == 'Exact' + ? `Showing services for ${matched}.` + : availability.matchType == 'HouseNumber' + ? `We found ${matched} on your street, which may be a different unit or lot. Please ${contact} to confirm before ordering.` + : `We couldn't find this exact address, the closest listed building is ${matched}. Please ${contact} to confirm before ordering.`; rows.unshift(``); document.getElementById("resultstablebody").innerHTML = rows.join(''); document.getElementById("spinner").hidden = true; diff --git a/NumberSearch.Tests/Unit.cs b/NumberSearch.Tests/Unit.cs index 6a8171d8..4580aa27 100644 --- a/NumberSearch.Tests/Unit.cs +++ b/NumberSearch.Tests/Unit.cs @@ -78,6 +78,7 @@ public void InternetBundleDiscountTest() [InlineData("1 Gbps", 1000)] [InlineData("", 0)] [InlineData("fast", 0)] + [InlineData("3000000G", int.MaxValue)] public void ServiceAddressParseMbpsTest(string maxSpeed, int expected) { Assert.Equal(expected, NumberSearch.DataAccess.ServiceAddress.ParseMbps(maxSpeed)); @@ -98,12 +99,13 @@ public void ServiceAddressHouseKeyTest(string houseNumber, string expected) [Fact] public void InternetBundleCanSellAtTest() { - var gig = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "1.0G/1.0G" }; - var slow = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "300.0M/300.0M" }; - var slower = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "100.0M/100.0M" }; - var unreadable = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "" }; - var confirm = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Confirm", MaxSpeed = "1.0G/1.0G" }; - var eia = new NumberSearch.DataAccess.ServiceAddress { Product = "EIA", Status = "Quote", MaxSpeed = "1.0G/1.0G" }; + var gig = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "1.0G/1.0G", BuildingKey = "BK-1" }; + var slow = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "300.0M/300.0M", BuildingKey = "BK-2" }; + var slower = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "100.0M/100.0M", BuildingKey = "BK-3" }; + var unreadable = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "", BuildingKey = "BK-4" }; + var confirm = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Confirm", MaxSpeed = "1.0G/1.0G", BuildingKey = "BK-5" }; + var eia = new NumberSearch.DataAccess.ServiceAddress { Product = "EIA", Status = "Quote", MaxSpeed = "1.0G/1.0G", BuildingKey = "BK-6" }; + var noKey = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "1.0G/1.0G", BuildingKey = "" }; var t300 = NumberSearch.DataAccess.InternetBundle.FiberInternet300ServiceId; var t1g = NumberSearch.DataAccess.InternetBundle.FiberInternet1GServiceId; @@ -116,6 +118,28 @@ public void InternetBundleCanSellAtTest() Assert.False(NumberSearch.DataAccess.InternetBundle.CanSellAt(t300, confirm)); Assert.False(NumberSearch.DataAccess.InternetBundle.CanSellAt(t300, eia)); Assert.False(NumberSearch.DataAccess.InternetBundle.CanSellAt(NumberSearch.DataAccess.InternetBundle.StandardLinesServiceId, gig)); + // Without a building key Cart/Add can't re-qualify the building, so it's never offered at a price. + Assert.False(NumberSearch.DataAccess.InternetBundle.CanSellAt(t300, noKey)); + } + + [Fact] + public void InternetBundleQualifyingAddressTest() + { + var t300 = NumberSearch.DataAccess.InternetBundle.FiberInternet300ServiceId; + var t1g = NumberSearch.DataAccess.InternetBundle.FiberInternet1GServiceId; + var sellable = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "1.0G/1.0G", BuildingKey = "BK-1" }; + var confirm = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Confirm", MaxSpeed = "300.0M/300.0M", BuildingKey = "BK-1" }; + var slow = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "300.0M/300.0M", BuildingKey = "BK-1" }; + var noKey = new NumberSearch.DataAccess.ServiceAddress { Product = "WFI", Status = "Sellable", MaxSpeed = "1.0G/1.0G", BuildingKey = "" }; + + // Rows sharing a key qualify the same way whichever order they come back in. + Assert.Same(sellable, NumberSearch.DataAccess.InternetBundle.QualifyingAddress(t1g, [confirm, sellable])); + Assert.Same(sellable, NumberSearch.DataAccess.InternetBundle.QualifyingAddress(t1g, [sellable, confirm])); + Assert.Same(sellable, NumberSearch.DataAccess.InternetBundle.QualifyingAddress(t300, [slow, confirm, sellable])); + Assert.Same(slow, NumberSearch.DataAccess.InternetBundle.QualifyingAddress(t300, [confirm, slow])); + Assert.Null(NumberSearch.DataAccess.InternetBundle.QualifyingAddress(t1g, [confirm, slow])); + Assert.Null(NumberSearch.DataAccess.InternetBundle.QualifyingAddress(t300, [noKey])); + Assert.Null(NumberSearch.DataAccess.InternetBundle.QualifyingAddress(t300, [])); } [Fact] From 48bc3f6206fc6ac52c542d6febdf8a2f936f2a50 Mon Sep 17 00:00:00 2001 From: webserver Date: Thu, 24 Sep 2026 16:13:37 -0700 Subject: [PATCH 8/8] Stop the migrations at the first error and handle a missing psql - Both SQL files set ON_ERROR_STOP, so a CREATE UNIQUE INDEX that fails on duplicate keys stops psql before the DROP of the index it replaces, instead of leaving the building key lookup with no index - The importer reports a missing psql instead of a FileNotFoundError traceback, and its docstring gives the measured peak memory, about 45MB for the WFI list, rather than 20MB Co-Authored-By: Claude Opus 5.5 --- NumberSearch.DataAccess/InternetBundle.sql | 5 ++++- NumberSearch.DataAccess/ServiceAddresses.sql | 5 ++++- NumberSearch.DataAccess/Ziply/import_ziply_building_list.py | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/NumberSearch.DataAccess/InternetBundle.sql b/NumberSearch.DataAccess/InternetBundle.sql index 5acdbd05..308aec2e 100644 --- a/NumberSearch.DataAccess/InternetBundle.sql +++ b/NumberSearch.DataAccess/InternetBundle.sql @@ -1,5 +1,8 @@ -- Fiber internet contract terms and the phone bundle discount, in support of https://acceleratenetworks.com/Internet. --- Run this against the PostgresqlProd database before deploying. Safe to run again. +-- Run this against the PostgresqlProd database with psql before deploying. Safe to run again. + +-- Stop at the first failed statement, however this file is run, so a failed CREATE never falls through to a later DROP. +\set ON_ERROR_STOP on -- The contract term (2, 3 or 5 years) chosen for fiber internet on an order, 0 when the order has no fiber internet. ALTER TABLE public."Orders" diff --git a/NumberSearch.DataAccess/ServiceAddresses.sql b/NumberSearch.DataAccess/ServiceAddresses.sql index 82a9ad1d..1eb39909 100644 --- a/NumberSearch.DataAccess/ServiceAddresses.sql +++ b/NumberSearch.DataAccess/ServiceAddresses.sql @@ -1,6 +1,9 @@ -- Address-level service availability, in support of https://acceleratenetworks.com/Internet. -- Replaces the FCC census block lookup, which showed providers for every address in a block rather than the addresses we can actually sell. --- Run this against the PostgresqlProd database before deploying, then load the building lists with Ziply/import_ziply_building_list.py. +-- Run this against the PostgresqlProd database with psql before deploying, then load the building lists with Ziply/import_ziply_building_list.py. Safe to run again. + +-- Stop at the first failed statement, however this file is run, so a failed CREATE never falls through to a later DROP. +\set ON_ERROR_STOP on CREATE TABLE IF NOT EXISTS public."ServiceAddresses" ( diff --git a/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py b/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py index 5764e066..c4d78d90 100644 --- a/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py +++ b/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py @@ -5,7 +5,8 @@ ex. import_ziply_building_list.py WFI "/WFI 24635_20260920050445.xlsx" -d numberSearch The lists are 500MB+ of inline string XML once unzipped, so rows are streamed and cleared as they -are read rather than loaded with openpyxl. Peak memory stays around 20MB. Only the rows we can sell +are read rather than loaded with openpyxl. Peak memory is about 45MB for the 117k row WFI list, most of it the +building keys held to catch duplicates. Only the rows we can sell or quote are kept, and the previous rows for the same product are replaced in a single transaction. Uses only the standard library and psql. """ @@ -178,6 +179,8 @@ def main(): subprocess.run(['psql', '-v', 'ON_ERROR_STOP=1', *psql_args], input=script, text=True, check=True) except subprocess.CalledProcessError: sys.exit(f'psql failed loading {source}, see the error above. The import runs in one transaction, so the existing rows are still in place.') + except FileNotFoundError: + sys.exit(f'psql is not installed or not on the PATH, so {source} was not loaded. The existing rows are still in place.') finally: os.unlink(out.name) print(f'Loaded {kept} {product} addresses from {source}, skipped {skipped} not serviceable.')
    TechnologyDownUpServiceSpeedPrice Action
    ${escapeHtml(offer.name)}
    ${escapeHtml(offer.note)}
    ${escapeHtml(offer.speed)}${price}${action}
    ${checked}
    @if (order?.ProductOrders is not null) From f81487e7ce2e22509091bc31f6395a6bc008ecb8 Mon Sep 17 00:00:00 2001 From: webserver Date: Thu, 24 Sep 2026 14:39:29 -0700 Subject: [PATCH 6/8] Qualify fiber by building key and keep one address per order - Pass Ziply's building key to Cart/Add instead of our ServiceAddressId. The importer replaces every row, so row ids change on each import and add to cart failed until the cached availability response expired - Refuse fiber at a second building when the cart already has fiber at another one, since an order records a single service address - Show the reason Cart/Add refused an item, ex. the address check, instead of a generic failure - Disable the term selector once an order is submitted, like the other fields there, since changes to a submitted order aren't saved Co-Authored-By: Claude Opus 5.5 --- NumberSearch.DataAccess/Models/ServiceAddress.cs | 9 ++++++--- NumberSearch.DataAccess/ServiceAddresses.sql | 1 + NumberSearch.Mvc/Controllers/CartAPIController.cs | 12 ++++++++++-- NumberSearch.Mvc/Controllers/Endpoints.cs | 6 +++--- NumberSearch.Mvc/Views/Cart/Order.cshtml | 2 +- NumberSearch.Mvc/Views/Services/Internet.cshtml | 2 +- NumberSearch.Mvc/wwwroot/js/site.js | 13 ++++++++----- 7 files changed, 30 insertions(+), 15 deletions(-) diff --git a/NumberSearch.DataAccess/Models/ServiceAddress.cs b/NumberSearch.DataAccess/Models/ServiceAddress.cs index 5fe9f135..bba778a5 100644 --- a/NumberSearch.DataAccess/Models/ServiceAddress.cs +++ b/NumberSearch.DataAccess/Models/ServiceAddress.cs @@ -86,13 +86,16 @@ public enum MatchType { None, Exact, HouseNumber, Nearby } public readonly record struct LookupResult(MatchType Match, ServiceAddress[] Addresses); - public static async Task GetByIdAsync(long serviceAddressId, string connectionString) + /// + /// Get a listed building by the provider's building key. Unlike ServiceAddressId, the key stays the same when the building lists are re-imported. + /// + public static async Task GetByBuildingKeyAsync(string product, string buildingKey, string connectionString) { await using var connection = new NpgsqlConnection(connectionString); return await connection - .QueryFirstOrDefaultAsync($"SELECT {Columns} FROM public.\"ServiceAddresses\" WHERE \"ServiceAddressId\" = @serviceAddressId", - new { serviceAddressId }) + .QueryFirstOrDefaultAsync($"SELECT {Columns} FROM public.\"ServiceAddresses\" WHERE \"Product\" = @product AND \"BuildingKey\" = @buildingKey", + new { product, buildingKey }) .ConfigureAwait(false); } diff --git a/NumberSearch.DataAccess/ServiceAddresses.sql b/NumberSearch.DataAccess/ServiceAddresses.sql index 82bfe88d..6c204b2f 100644 --- a/NumberSearch.DataAccess/ServiceAddresses.sql +++ b/NumberSearch.DataAccess/ServiceAddresses.sql @@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS public."ServiceAddresses" CREATE INDEX IF NOT EXISTS "ServiceAddresses_Address_idx" ON public."ServiceAddresses" ("Postal", "HouseNumber"); CREATE INDEX IF NOT EXISTS "ServiceAddresses_Street_idx" ON public."ServiceAddresses" ("Postal", "StreetKey"); +CREATE INDEX IF NOT EXISTS "ServiceAddresses_BuildingKey_idx" ON public."ServiceAddresses" ("BuildingKey"); CREATE INDEX IF NOT EXISTS "ServiceAddresses_Location_idx" ON public."ServiceAddresses" ("Latitude", "Longitude"); ALTER TABLE public."ServiceAddresses" OWNER TO "numberSearch"; diff --git a/NumberSearch.Mvc/Controllers/CartAPIController.cs b/NumberSearch.Mvc/Controllers/CartAPIController.cs index 35ae01f8..52bd7f21 100644 --- a/NumberSearch.Mvc/Controllers/CartAPIController.cs +++ b/NumberSearch.Mvc/Controllers/CartAPIController.cs @@ -441,7 +441,7 @@ public async Task RemoveNewClientPhoneMenuOptionAsync([FromRoute] [ApiExplorerSettings(IgnoreApi = true)] [HttpGet("Cart/Add/{type}/{id}/{quantity}")] [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] - public async Task AddToCartAsync([FromRoute] string type, [FromRoute] string id, [FromRoute] int quantity, [FromQuery] long? serviceAddressId) + public async Task AddToCartAsync([FromRoute] string type, [FromRoute] string id, [FromRoute] int quantity, [FromQuery] string? buildingKey) { if (!ModelState.IsValid && !string.IsNullOrWhiteSpace(type) && !string.IsNullOrWhiteSpace(id)) { @@ -481,7 +481,7 @@ public async Task AddToCartAsync([FromRoute] string type, [FromRo if (checkService && InternetBundle.IsFiberInternet(serviceId)) { // Fiber can only be bought at an address the Internet page qualified, so we know which building we're installing at. - var qualified = serviceAddressId is > 0 ? await ServiceAddress.GetByIdAsync(serviceAddressId.Value, mvcConfiguration.PostgresqlProd) : null; + var qualified = !string.IsNullOrWhiteSpace(buildingKey) ? await ServiceAddress.GetByBuildingKeyAsync("WFI", buildingKey.Trim(), mvcConfiguration.PostgresqlProd) : null; if (qualified is null || !InternetBundle.CanSellAt(serviceId, qualified)) { return BadRequest("Check your address on the Internet page before adding fiber internet to your cart."); @@ -936,6 +936,14 @@ public async Task BuyServiceAsync(Guid serviceId, int Quantity, S await httpContext.Session.LoadAsync(); var cart = Cart.GetFromSession(httpContext.Session); + + // An order records a single fiber service address, so fiber at a second building needs its own order. + if (qualifiedAt is not null && InternetBundle.FiberConnections(cart.ProductOrders) > 0 + && !string.IsNullOrWhiteSpace(cart.Order.InternetBuildingKey) && cart.Order.InternetBuildingKey != qualifiedAt.BuildingKey) + { + return new BadRequestObjectResult($"Your cart already has fiber internet at {cart.Order.InternetServiceAddress}. Please place a separate order for fiber at another address."); + } + var checkAdd = cart.AddService(ref service, ref productOrder); if (qualifiedAt is not null) diff --git a/NumberSearch.Mvc/Controllers/Endpoints.cs b/NumberSearch.Mvc/Controllers/Endpoints.cs index d4524032..63e6e43e 100644 --- a/NumberSearch.Mvc/Controllers/Endpoints.cs +++ b/NumberSearch.Mvc/Controllers/Endpoints.cs @@ -220,9 +220,9 @@ await Parallel.ForEachAsync(relatedNumbers, async (number, token) => /// Whether any service is available at the address. /// The address from the provider's building list that matched the query, to show the customer what we checked. /// Whether the matched address is the one searched for, rather than the same building number or the nearest listed building. - /// The listed address to pass to Cart/Add when adding a Sellable fiber tier, or 0. + /// The provider's key for the listed building, to pass to Cart/Add when adding a Sellable fiber tier, or empty. /// The services available at the address. - public readonly record struct InternetAvailability(bool serviceable, string matchedAddress, bool exactMatch, long serviceAddressId, InternetOffer[] offers); + public readonly record struct InternetAvailability(bool serviceable, string matchedAddress, bool exactMatch, string buildingKey, InternetOffer[] offers); private const string FiberTerms = "2, 3 or 5 year term. $15/mo off when bundled with any phone service."; @@ -277,7 +277,7 @@ public static async Task, BadRequest>> // Name the building the fiber offers came from, so the address shown always matches the offers. var shown = tiers.Length > 0 ? sellable : wfi.AsValueEnumerable().FirstOrDefault() ?? lookup.Addresses.AsValueEnumerable().FirstOrDefault(); var matched = shown is null ? string.Empty : $"{shown.StreetAddress.Trim()}, {shown.City}, {shown.State} {shown.Postal}"; - return TypedResults.Ok(new InternetAvailability(offers.Count > 0, matched, exact, tiers.Length > 0 ? sellable!.ServiceAddressId : 0, offers.ToArray())); + return TypedResults.Ok(new InternetAvailability(offers.Count > 0, matched, exact, tiers.Length > 0 ? sellable!.BuildingKey : string.Empty, offers.ToArray())); } /// diff --git a/NumberSearch.Mvc/Views/Cart/Order.cshtml b/NumberSearch.Mvc/Views/Cart/Order.cshtml index 1e456d42..9ca057e5 100644 --- a/NumberSearch.Mvc/Views/Cart/Order.cshtml +++ b/NumberSearch.Mvc/Views/Cart/Order.cshtml @@ -689,7 +689,7 @@ var selectedTerm = Model?.Cart?.Order?.InternetTermYears ?? 0;
    - @foreach (var years in InternetBundle.TermYears) { diff --git a/NumberSearch.Mvc/Views/Services/Internet.cshtml b/NumberSearch.Mvc/Views/Services/Internet.cshtml index 6469ad2d..c904542d 100644 --- a/NumberSearch.Mvc/Views/Services/Internet.cshtml +++ b/NumberSearch.Mvc/Views/Services/Internet.cshtml @@ -65,7 +65,7 @@ showMessage(`We don't have internet service listed at this address yet. Please ${contact} and we'll check for you.`); return; } - const qualified = `serviceAddressId=${encodeURIComponent(availability.serviceAddressId)}`; + const qualified = `buildingKey=${encodeURIComponent(availability.buildingKey)}`; const rows = availability.offers.map(offer => { const price = offer.price > 0 ? `$${offer.price}/mo` : 'Quote'; const action = offer.status == 'Sellable' diff --git a/NumberSearch.Mvc/wwwroot/js/site.js b/NumberSearch.Mvc/wwwroot/js/site.js index e4b96d87..a6eaf1f3 100644 --- a/NumberSearch.Mvc/wwwroot/js/site.js +++ b/NumberSearch.Mvc/wwwroot/js/site.js @@ -62,10 +62,13 @@ function AddToCart(type, id, quantity, element, query) { fetch(route) .then(response => { if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - console.log(`Failed to add ${type} ${id} to cart.`) - alert(`Failed to add ${type} ${id} to cart.`); - spinner.addClass('d-none') + // Plain text errors are written for the customer, ex. fiber that needs an address check first. + const plainText = (response.headers.get('content-type') ?? '').startsWith('text/plain'); + return response.text().then(text => { + const error = new Error(`HTTP error! status: ${response.status}`); + error.userMessage = plainText ? text : ''; + throw error; + }); } return response.text(); // or .text(), .blob(), etc. }) @@ -92,7 +95,7 @@ function AddToCart(type, id, quantity, element, query) { .catch(error => { console.error('There was a problem with the fetch operation:', error); console.log(`Failed to add ${type} ${id} to cart.`) - alert(`Failed to add ${type} ${id} to cart.`); + alert(error.userMessage || `Failed to add ${type} ${id} to cart.`); spinner.addClass('d-none') }); } From cd6d456282646f37008d62ca36a7b073ddf193ea Mon Sep 17 00:00:00 2001 From: webserver Date: Thu, 24 Sep 2026 15:42:59 -0700 Subject: [PATCH 7/8] Make the page and Cart/Add agree on which building row can sell - Cart/Add asks whether any listed row for the building key can sell the tier, the same QualifyingAddress rule the Internet page uses to offer it, so row order can't make them disagree - A Sellable building with no building key is offered as Confirm rather than with an add to cart button that can never succeed - Enforce one row per product and building key with a partial unique index, and have the importer refuse a list with duplicate keys and warn about Sellable rows without one before touching the database - ParseMbps saturates instead of throwing on an absurd speed - Tell the page how the address matched, so the same building number on the street reads differently from the nearest listed building - Tests for duplicate and blank keys and the speed overflow Co-Authored-By: Claude Opus 5.5 --- .../Models/InternetBundle.cs | 13 +++++-- .../Models/ServiceAddress.cs | 21 ++++++++--- NumberSearch.DataAccess/ServiceAddresses.sql | 4 ++- .../Ziply/import_ziply_building_list.py | 22 +++++++++++- .../Controllers/CartAPIController.cs | 6 ++-- NumberSearch.Mvc/Controllers/Endpoints.cs | 13 ++++--- .../Views/Services/Internet.cshtml | 9 +++-- NumberSearch.Tests/Unit.cs | 36 +++++++++++++++---- 8 files changed, 99 insertions(+), 25 deletions(-) diff --git a/NumberSearch.DataAccess/Models/InternetBundle.cs b/NumberSearch.DataAccess/Models/InternetBundle.cs index cea73db0..82ea6879 100644 --- a/NumberSearch.DataAccess/Models/InternetBundle.cs +++ b/NumberSearch.DataAccess/Models/InternetBundle.cs @@ -33,10 +33,19 @@ public static class InternetBundle public static int RequiredMbps(Guid serviceId) => serviceId == FiberInternet1GServiceId ? 1000 : serviceId == FiberInternet300ServiceId ? 300 : 0; /// - /// Whether a fiber tier can be sold at a listed address: only WFI Sellable buildings listed at or above the tier's speed. + /// Whether a fiber tier can be sold at a listed address: only WFI Sellable buildings listed at or above the tier's speed, + /// with a building key so Cart/Add can re-qualify the building. /// public static bool CanSellAt(Guid serviceId, ServiceAddress address) => - IsFiberInternet(serviceId) && address.Product is "WFI" && address.Status is "Sellable" && ServiceAddress.ParseMbps(address.MaxSpeed) >= RequiredMbps(serviceId); + IsFiberInternet(serviceId) && address.Product is "WFI" && address.Status is "Sellable" && !string.IsNullOrWhiteSpace(address.BuildingKey) + && ServiceAddress.ParseMbps(address.MaxSpeed) >= RequiredMbps(serviceId); + + /// + /// The listed row a fiber tier can be sold at, or null. Both the Internet page and Cart/Add ask "can any of these rows sell this tier", + /// so they agree however many rows a building has and whatever order they come back in. + /// + public static ServiceAddress? QualifyingAddress(Guid serviceId, IEnumerable addresses) => + addresses.Where(x => CanSellAt(serviceId, x)).OrderByDescending(x => ServiceAddress.ParseMbps(x.MaxSpeed)).FirstOrDefault(); public static bool IsPhoneService(Guid serviceId) => serviceId == StandardLinesServiceId || serviceId == ConcurrentSeatsServiceId; diff --git a/NumberSearch.DataAccess/Models/ServiceAddress.cs b/NumberSearch.DataAccess/Models/ServiceAddress.cs index bba778a5..ed2736c0 100644 --- a/NumberSearch.DataAccess/Models/ServiceAddress.cs +++ b/NumberSearch.DataAccess/Models/ServiceAddress.cs @@ -76,7 +76,9 @@ public static int ParseMbps(string maxSpeed) { return 0; } - return (int)(char.ToUpperInvariant(match.Groups[2].Value[0]) is 'G' ? value * 1000 : value); + var mbps = char.ToUpperInvariant(match.Groups[2].Value[0]) is 'G' ? value * 1000 : value; + // Saturate rather than overflow on an absurd value, ex. a misaligned column. + return (int)Math.Min(mbps, int.MaxValue); } /// @@ -87,16 +89,25 @@ public enum MatchType { None, Exact, HouseNumber, Nearby } public readonly record struct LookupResult(MatchType Match, ServiceAddress[] Addresses); /// - /// Get a listed building by the provider's building key. Unlike ServiceAddressId, the key stays the same when the building lists are re-imported. + /// Get the listed rows for a building by the provider's building key. Unlike ServiceAddressId, the key stays the same when the building lists are re-imported. + /// The import enforces one row per product and key, but callers treat this as a set so they never depend on which row comes back. /// - public static async Task GetByBuildingKeyAsync(string product, string buildingKey, string connectionString) + public static async Task GetAllByBuildingKeyAsync(string product, string buildingKey, string connectionString) { + if (string.IsNullOrWhiteSpace(buildingKey)) + { + return []; + } + await using var connection = new NpgsqlConnection(connectionString); - return await connection - .QueryFirstOrDefaultAsync($"SELECT {Columns} FROM public.\"ServiceAddresses\" WHERE \"Product\" = @product AND \"BuildingKey\" = @buildingKey", + // The BuildingKey <> '' predicate lets the planner use the partial unique index for every plan, not just custom ones. + var result = await connection + .QueryAsync($"SELECT {Columns} FROM public.\"ServiceAddresses\" WHERE \"Product\" = @product AND \"BuildingKey\" = @buildingKey AND \"BuildingKey\" <> ''", new { product, buildingKey }) .ConfigureAwait(false); + + return [.. result]; } /// diff --git a/NumberSearch.DataAccess/ServiceAddresses.sql b/NumberSearch.DataAccess/ServiceAddresses.sql index 6c204b2f..82a9ad1d 100644 --- a/NumberSearch.DataAccess/ServiceAddresses.sql +++ b/NumberSearch.DataAccess/ServiceAddresses.sql @@ -30,7 +30,9 @@ CREATE TABLE IF NOT EXISTS public."ServiceAddresses" CREATE INDEX IF NOT EXISTS "ServiceAddresses_Address_idx" ON public."ServiceAddresses" ("Postal", "HouseNumber"); CREATE INDEX IF NOT EXISTS "ServiceAddresses_Street_idx" ON public."ServiceAddresses" ("Postal", "StreetKey"); -CREATE INDEX IF NOT EXISTS "ServiceAddresses_BuildingKey_idx" ON public."ServiceAddresses" ("BuildingKey"); +-- One row per product and building, so Cart/Add re-qualifies the same building the Internet page offered. A list that breaks this fails the import, which leaves the existing rows in place. +CREATE UNIQUE INDEX IF NOT EXISTS "ServiceAddresses_Product_BuildingKey_key" ON public."ServiceAddresses" ("Product", "BuildingKey") WHERE "BuildingKey" <> ''; +DROP INDEX IF EXISTS public."ServiceAddresses_BuildingKey_idx"; CREATE INDEX IF NOT EXISTS "ServiceAddresses_Location_idx" ON public."ServiceAddresses" ("Latitude", "Longitude"); ALTER TABLE public."ServiceAddresses" OWNER TO "numberSearch"; diff --git a/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py b/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py index b32b444e..5764e066 100644 --- a/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py +++ b/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py @@ -119,7 +119,10 @@ def main(): sys.exit(__doc__) product, path, psql_args = sys.argv[1], sys.argv[2], sys.argv[3:] source = os.path.basename(path) - kept = skipped = 0 + kept = skipped = blank_sellable = 0 + # Cart/Add re-qualifies a building by its key, so each key must be one row. Checked here so a bad list fails before the database is touched. + seen_keys = {} + duplicate_keys = [] with tempfile.NamedTemporaryFile('w', newline='', suffix='.csv', delete=False) as out: # Quote everything so empty values load as empty strings rather than NULL. @@ -138,12 +141,27 @@ def main(): lat, lon = float(row.get('Latitude') or 0), float(row.get('Longitude') or 0) except ValueError: lat = lon = 0.0 + key = row.get('C2F Building Key', '') + if key: + if key in seen_keys and len(duplicate_keys) < 5: + duplicate_keys.append(f"{key} ({seen_keys[key]} and {row.get('Street Address', '').strip()})") + seen_keys.setdefault(key, row.get('Street Address', '').strip()) + elif s == 'Sellable': + blank_sellable += 1 writer.writerow([row.get('Provider') or 'Ziply Fiber', product, s, row.get('Primary Number', ''), street_key(row.get('Street Name', '')), row.get('Street Address', ''), row.get('City', ''), row.get('State', ''), row.get('Postal', '')[:5], lat, lon, row.get('BFI Max Serviceable Speed', ''), row.get('C2F Building Key', ''), source]) kept += 1 + if duplicate_keys: + os.unlink(out.name) + sys.exit(f'{path} lists the same C2F Building Key on more than one {product} row, ex. {"; ".join(duplicate_keys)}. ' + 'Leaving the existing rows in place.') + + if blank_sellable: + print(f'Warning: {blank_sellable} Sellable {product} rows have no C2F Building Key. The Internet page will ask those customers to contact us.') + if kept == 0: os.unlink(out.name) sys.exit(f'Found the header in {path} but no {product} rows we can sell or quote, leaving the existing rows in place.') @@ -158,6 +176,8 @@ def main(): ) try: subprocess.run(['psql', '-v', 'ON_ERROR_STOP=1', *psql_args], input=script, text=True, check=True) + except subprocess.CalledProcessError: + sys.exit(f'psql failed loading {source}, see the error above. The import runs in one transaction, so the existing rows are still in place.') finally: os.unlink(out.name) print(f'Loaded {kept} {product} addresses from {source}, skipped {skipped} not serviceable.') diff --git a/NumberSearch.Mvc/Controllers/CartAPIController.cs b/NumberSearch.Mvc/Controllers/CartAPIController.cs index 52bd7f21..f218bbea 100644 --- a/NumberSearch.Mvc/Controllers/CartAPIController.cs +++ b/NumberSearch.Mvc/Controllers/CartAPIController.cs @@ -481,8 +481,10 @@ public async Task AddToCartAsync([FromRoute] string type, [FromRo if (checkService && InternetBundle.IsFiberInternet(serviceId)) { // Fiber can only be bought at an address the Internet page qualified, so we know which building we're installing at. - var qualified = !string.IsNullOrWhiteSpace(buildingKey) ? await ServiceAddress.GetByBuildingKeyAsync("WFI", buildingKey.Trim(), mvcConfiguration.PostgresqlProd) : null; - if (qualified is null || !InternetBundle.CanSellAt(serviceId, qualified)) + // Ask whether any listed row for the building can sell this tier, the same rule the Internet page used to offer it. + var rows = await ServiceAddress.GetAllByBuildingKeyAsync("WFI", buildingKey?.Trim() ?? string.Empty, mvcConfiguration.PostgresqlProd); + var qualified = InternetBundle.QualifyingAddress(serviceId, rows); + if (qualified is null) { return BadRequest("Check your address on the Internet page before adding fiber internet to your cart."); } diff --git a/NumberSearch.Mvc/Controllers/Endpoints.cs b/NumberSearch.Mvc/Controllers/Endpoints.cs index 63e6e43e..093dd7e7 100644 --- a/NumberSearch.Mvc/Controllers/Endpoints.cs +++ b/NumberSearch.Mvc/Controllers/Endpoints.cs @@ -220,9 +220,10 @@ await Parallel.ForEachAsync(relatedNumbers, async (number, token) => /// Whether any service is available at the address. /// The address from the provider's building list that matched the query, to show the customer what we checked. /// Whether the matched address is the one searched for, rather than the same building number or the nearest listed building. + /// How the address matched: Exact, HouseNumber (same building number on the street, ex. 512 for 512 1/2), Nearby (the closest listed building within 30m), or None. /// The provider's key for the listed building, to pass to Cart/Add when adding a Sellable fiber tier, or empty. /// The services available at the address. - public readonly record struct InternetAvailability(bool serviceable, string matchedAddress, bool exactMatch, string buildingKey, InternetOffer[] offers); + public readonly record struct InternetAvailability(bool serviceable, string matchedAddress, bool exactMatch, string matchType, string buildingKey, InternetOffer[] offers); private const string FiberTerms = "2, 3 or 5 year term. $15/mo off when bundled with any phone service."; @@ -249,7 +250,9 @@ public static async Task, BadRequest>> var wfi = lookup.Addresses.AsValueEnumerable().Where(x => x.Product is "WFI").ToArray(); // Only an exact address match is sold at the listed price. The same building number or a nearby building might be the building next door. - var sellable = exact ? wfi.AsValueEnumerable().Where(x => x.Status is "Sellable").OrderByDescending(x => ServiceAddress.ParseMbps(x.MaxSpeed)).FirstOrDefault() : null; + // The fastest row that can sell the slowest tier qualifies every tier any row can, since CanSellAt only differs between rows by speed. + // Cart/Add re-qualifies the same building by its key with the same rule, so the two always agree. + var sellable = exact ? InternetBundle.QualifyingAddress(InternetBundle.FiberInternet300ServiceId, wfi) : null; var tiers = sellable is null ? [] : new[] { new InternetOffer("WFI", "Sellable", "Fiber Internet 300 Mbps", "300/300 Mbps", 75, InternetBundle.FiberInternet300ServiceId, FiberTerms), @@ -262,9 +265,9 @@ public static async Task, BadRequest>> } else if (wfi.Length > 0) { - if (sellable is not null) + foreach (var unsellable in exact ? wfi.AsValueEnumerable().Where(x => x.Status is "Sellable").ToArray() : []) { - Log.Warning("[Internet] Sellable building {BuildingKey} is listed at {MaxSpeed}, which is below every fiber tier or can't be read.", sellable.BuildingKey, sellable.MaxSpeed); + Log.Warning("[Internet] Sellable building {BuildingKey} at {StreetAddress} {Postal} is listed at {MaxSpeed}, which is below every fiber tier, can't be read, or has no building key.", unsellable.BuildingKey, unsellable.StreetAddress, unsellable.Postal, unsellable.MaxSpeed); } offers.Add(new("WFI", "Confirm", "Fiber Internet", "Up to 1/1 Gbps", 0, Guid.Empty, "Fiber may be available here. Contact us to confirm before ordering.")); } @@ -277,7 +280,7 @@ public static async Task, BadRequest>> // Name the building the fiber offers came from, so the address shown always matches the offers. var shown = tiers.Length > 0 ? sellable : wfi.AsValueEnumerable().FirstOrDefault() ?? lookup.Addresses.AsValueEnumerable().FirstOrDefault(); var matched = shown is null ? string.Empty : $"{shown.StreetAddress.Trim()}, {shown.City}, {shown.State} {shown.Postal}"; - return TypedResults.Ok(new InternetAvailability(offers.Count > 0, matched, exact, tiers.Length > 0 ? sellable!.BuildingKey : string.Empty, offers.ToArray())); + return TypedResults.Ok(new InternetAvailability(offers.Count > 0, matched, exact, lookup.Match.ToString(), tiers.Length > 0 ? sellable!.BuildingKey : string.Empty, offers.ToArray())); } /// diff --git a/NumberSearch.Mvc/Views/Services/Internet.cshtml b/NumberSearch.Mvc/Views/Services/Internet.cshtml index c904542d..5a77e10c 100644 --- a/NumberSearch.Mvc/Views/Services/Internet.cshtml +++ b/NumberSearch.Mvc/Views/Services/Internet.cshtml @@ -74,9 +74,12 @@ return `
    ${escapeHtml(offer.name)}
    ${escapeHtml(offer.note)}
    ${escapeHtml(offer.speed)}${price}${action}
    ${checked}