diff --git a/NumberSearch.DataAccess/InternetBundle.sql b/NumberSearch.DataAccess/InternetBundle.sql new file mode 100644 index 00000000..308aec2e --- /dev/null +++ b/NumberSearch.DataAccess/InternetBundle.sql @@ -0,0 +1,17 @@ +-- Fiber internet contract terms and the phone bundle discount, in support of https://acceleratenetworks.com/Internet. +-- 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" + 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" +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..82ea6879 --- /dev/null +++ b/NumberSearch.DataAccess/Models/InternetBundle.cs @@ -0,0 +1,100 @@ +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; + + /// + /// 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, + /// 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" && !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; + + /// + /// 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. + /// + 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); + + // 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 5f7f31f1..d55c6012 100644 --- a/NumberSearch.DataAccess/Models/Order.cs +++ b/NumberSearch.DataAccess/Models/Order.cs @@ -52,13 +52,25 @@ 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; } + /// + /// 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\" 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 }) @@ -72,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\" 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 }) @@ -87,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\" " + + ("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); @@ -101,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\" " + + ("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") @@ -116,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\" " + + ("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") @@ -130,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\" ) " + - "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\", \"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) @@ -151,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 " + + "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, 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 new file mode 100644 index 00000000..ed2736c0 --- /dev/null +++ b/NumberSearch.DataAccess/Models/ServiceAddress.cs @@ -0,0 +1,182 @@ +using Dapper; + +using Npgsql; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +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 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\", \"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. + /// + 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(); + } + + /// + /// 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 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; + } + 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); + } + + /// + /// 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); + + /// + /// 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 GetAllByBuildingKeyAsync(string product, string buildingKey, string connectionString) + { + if (string.IsNullOrWhiteSpace(buildingKey)) + { + return []; + } + + await using var connection = new NpgsqlConnection(connectionString); + + // 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]; + } + + /// + /// 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 street = (await connection + .QueryAsync($"SELECT {Columns} FROM public.\"ServiceAddresses\" " + + "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); + } + + var houseKey = ToHouseKey(houseNumber); + var sameBuilding = houseKey.Length > 0 ? street.Where(x => ToHouseKey(x.HouseNumber) == houseKey).ToArray() : []; + if (sameBuilding.Length > 0) + { + return new(MatchType.HouseNumber, sameBuilding); + } + } + + if (latitude is 0 && longitude is 0) + { + return new(MatchType.None, []); + } + + // 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 new(MatchType.None, []); + } + + return new(MatchType.Nearby, [.. 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..1eb39909 --- /dev/null +++ b/NumberSearch.DataAccess/ServiceAddresses.sql @@ -0,0 +1,47 @@ +-- 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 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" +( + "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_Street_idx" ON public."ServiceAddresses" ("Postal", "StreetKey"); +-- 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"; + +-- 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..c4d78d90 --- /dev/null +++ b/NumberSearch.DataAccess/Ziply/import_ziply_building_list.py @@ -0,0 +1,190 @@ +#!/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 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. +""" +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 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: + 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): + 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 = 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. + writer = csv.writer(out, quoting=csv.QUOTE_ALL) + 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 + continue + try: + 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.') + + 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) + 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.') + + +if __name__ == '__main__': + main() diff --git a/NumberSearch.Mvc/Controllers/CartAPIController.cs b/NumberSearch.Mvc/Controllers/CartAPIController.cs index c6b4b1d3..f218bbea 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] string? buildingKey) { if (!ModelState.IsValid && !string.IsNullOrWhiteSpace(type) && !string.IsNullOrWhiteSpace(id)) { @@ -478,7 +478,19 @@ 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. + // 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."); + } + result = await cart.BuyServiceAsync(serviceId, quantity, qualified); + } + else if (checkService) { result = await cart.BuyServiceAsync(serviceId, quantity); } @@ -907,7 +919,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) { @@ -926,8 +938,22 @@ public async Task BuyServiceAsync(Guid serviceId, int Quantity) 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) + { + 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 b2390989..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. @@ -828,6 +831,31 @@ public async Task SubmitAsync(CartResult input) order.VoiceProductAcknowledgedUtc = DateTime.Now; } + 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); + 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.InternetServiceAddress = string.Empty; + order.InternetBuildingKey = string.Empty; + } + order.DateSubmitted = DateTime.Now; if (order.OrderId != Guid.Empty) @@ -1233,7 +1261,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) ? InternetBundle.FiberNotes(order.InternetTermYears, order.InternetServiceAddress, service.Description) : $"{service.Description}", cost = service.Price, quantity = productOrder.Quantity }); @@ -1328,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 @@ -1353,6 +1385,18 @@ public static ProductOrderSummary GetInvoiceItemsFromProductOrders(ref Order ord } } + var bundleOrders = cart?.ProductOrders ?? []; + if (InternetBundle.Discount(bundleOrders) > 0) + { + reoccuringItems.Add(new Line_Items + { + product_key = InternetBundle.Name, + notes = InternetBundle.IsPartnerOnly(bundleOrders) ? InternetBundle.PartnerDescription : InternetBundle.Description, + cost = InternetBundle.DiscountPerConnection * -1, + quantity = InternetBundle.FiberConnections(bundleOrders) + }); + } + return new ProductOrderSummary(emailSubject ?? string.Empty, totalCost, totalPortingCost, totalNumberPurchasingCost); } diff --git a/NumberSearch.Mvc/Controllers/Endpoints.cs b/NumberSearch.Mvc/Controllers/Endpoints.cs index 40f9802b..093dd7e7 100644 --- a/NumberSearch.Mvc/Controllers/Endpoints.cs +++ b/NumberSearch.Mvc/Controllers/Endpoints.cs @@ -1,18 +1,16 @@ ๏ปฟ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; using PhoneNumbersNA; +using Serilog; + using System.Collections.Concurrent; using System.ComponentModel.DataAnnotations; -using System.IO.Compression; using ZLinq; @@ -205,122 +203,84 @@ 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, 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 matchType, string buildingKey, InternetOffer[] offers); + + 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")); + 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(); - // 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 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. + // 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), + new InternetOffer("WFI", "Sellable", "Fiber Internet 1 Gbps", "1/1 Gbps", 115, InternetBundle.FiberInternet1GServiceId, FiberTerms), + }.AsValueEnumerable().Where(x => InternetBundle.CanSellAt(x.serviceId, sellable)).ToArray(); - foreach (var p in techs) + if (tiers.Length > 0) + { + offers.AddRange(tiers); + } + else if (wfi.Length > 0) + { + foreach (var unsellable in exact ? wfi.AsValueEnumerable().Where(x => x.Status is "Sellable").ToArray() : []) { - var speedWinner = canidates.AsValueEnumerable().Where(x => x.technology == p).MaxBy(x => x.up); - singlePerTech.Add(speedWinner); + 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); } - - return TypedResults.Ok(singlePerTech.AsValueEnumerable().Where(x => x.up > 0).OrderByDescending(x => x.down).ToArray()); + 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.")); } - else + + if (lookup.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.")); } + + // 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, lookup.Match.ToString(), tiers.Length > 0 ? sellable!.BuildingKey : string.Empty, 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/Cart/Order.cshtml b/NumberSearch.Mvc/Views/Cart/Order.cshtml index be32abef..9ca057e5 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"); @@ -536,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; + }
  • @@ -547,6 +557,24 @@
  • } } + + if (bundleDiscount > 0) + { + couponTotal += bundleDiscount * -1; +
  • +
    +
    + @InternetBundle.Name + @if (fiberConnections > 1) + { + x @fiberConnections + } +
    + @(InternetBundle.IsPartnerOnly(productOrderList) ? InternetBundle.PartnerDescription : InternetBundle.Description) +
    + $@bundleDiscount +
  • + } }
  • @@ -656,6 +684,28 @@
    + @if (fiberConnections > 0) + { + var selectedTerm = Model?.Cart?.Order?.InternetTermYears ?? 0; +
    + + + + 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 7b404bd4..5a77e10c 100644 --- a/NumberSearch.Mvc/Views/Services/Internet.cshtml +++ b/NumberSearch.Mvc/Views/Services/Internet.cshtml @@ -6,163 +6,90 @@ } @@ -172,7 +99,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 +109,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 +155,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 +165,7 @@ else {
    - + @@ -253,9 +179,9 @@ - - - + + + diff --git a/NumberSearch.Mvc/wwwroot/js/site.js b/NumberSearch.Mvc/wwwroot/js/site.js index 896c01e0..a6eaf1f3 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,13 +56,19 @@ 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) { - 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. }) @@ -89,7 +95,7 @@ function AddToCart(type, id, quantity, element) { .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') }); } diff --git a/NumberSearch.Ops/Controllers/OrdersController.cs b/NumberSearch.Ops/Controllers/OrdersController.cs index 9993df74..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 = $"{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) }); @@ -1617,6 +1617,19 @@ 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(); + if (NumberSearch.DataAccess.InternetBundle.Discount(bundleLines) > 0) + { + 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..71b4be77 100644 --- a/NumberSearch.Ops/PostgreSQL/Order.cs +++ b/NumberSearch.Ops/PostgreSQL/Order.cs @@ -38,5 +38,11 @@ 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; } + 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 560587e5..c5c23ddd 100644 --- a/NumberSearch.Ops/PostgreSQL/numberSearchContext.cs +++ b/NumberSearch.Ops/PostgreSQL/numberSearchContext.cs @@ -438,6 +438,14 @@ 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); + 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.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 + } +

    + }
    TechnologyDownUpServiceSpeedPrice Action
    @if (order?.ProductOrders is not null) 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 +} diff --git a/NumberSearch.Tests/Unit.cs b/NumberSearch.Tests/Unit.cs index d0e05bdb..4580aa27 100644 --- a/NumberSearch.Tests/Unit.cs +++ b/NumberSearch.Tests/Unit.cs @@ -26,5 +26,136 @@ 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 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])); + + // 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)] + [InlineData("3000000G", int.MaxValue)] + 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", 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; + + 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)); + // 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] + 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() + { + // 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)); + } } }