From e379e2b10fbf0687bff3d35d7199d4424dea22ee Mon Sep 17 00:00:00 2001 From: justin Date: Wed, 17 Jun 2026 23:34:13 -0500 Subject: [PATCH 1/2] CRTC: ERPNext as portal source of truth + harden discount expiry + carrier guide PDF - checkout.ts: generalize ensureCompliancePortalUser -> ensurePortalUser and call it in the CRTC post-payment path so PayPal/crypto/webhook-confirmed CRTC orders always get an ERPNext Customer + Website User (the single source of truth for portal login/password), matching the compliance fix from the PayPal incident. Also flip portal_user_created for canada_crtc/formation. - canada-crtc.ts: enforce discount active+start/expiry windows, global usage limit and applies_to scope server-side at checkout (was active-only), so a promo like CANADA200 actually stops working after its expiry. - scripts/generate_canada_carrier_guide_pdf.py: render the public Canadian wholesale carrier/vendor guide PDF (reuses the canonical VENDORS list) to site/public/guides/canada-carrier-guide.pdf for the CRTC campaign lead magnet. --- api/src/routes/canada-crtc.ts | 27 ++- api/src/routes/checkout.ts | 35 +++- scripts/generate_canada_carrier_guide_pdf.py | 180 +++++++++++++++++++ site/public/guides/canada-carrier-guide.pdf | 138 ++++++++++++++ 4 files changed, 366 insertions(+), 14 deletions(-) create mode 100644 scripts/generate_canada_carrier_guide_pdf.py create mode 100644 site/public/guides/canada-carrier-guide.pdf diff --git a/api/src/routes/canada-crtc.ts b/api/src/routes/canada-crtc.ts index 7a0773e..57d4e1e 100644 --- a/api/src/routes/canada-crtc.ts +++ b/api/src/routes/canada-crtc.ts @@ -226,17 +226,34 @@ router.post("/api/v1/canada-crtc/orders", submitLimiter, async (req, res) => { : company_type === "numbered_tradename" ? TRADE_NAME_SERVICE_FEE : 0; - // Discount code (applies to service fee only, not gov fees) + // Discount code (applies to service fee only, not gov fees). + // Enforce active + start/expiry windows + global usage limit + scope + // server-side so promotional expiry (e.g. "expires Friday") is honored at + // checkout, not just in the front-end validator. Mirrors GET + // /api/v1/discount/:code in routes/discounts.ts. let discountCents = 0; if (discount_code) { const dcResult = await pool.query("SELECT * FROM discount_codes WHERE code = $1 AND active = TRUE", [discount_code.toUpperCase()]); if (dcResult.rows.length > 0) { const dc = dcResult.rows[0]; - const discountableAmount = SERVICE_FEE + typeAddon; // discount applies to full service fee - if (dc.discount_type === "percent") { - discountCents = Math.round((discountableAmount * dc.discount_value) / 100); + const now = new Date(); + const notStarted = dc.starts_at && new Date(dc.starts_at) > now; + const expired = dc.expires_at && new Date(dc.expires_at) < now; + const usedUp = dc.max_uses !== null && dc.current_uses >= dc.max_uses; + const outOfScope = (() => { + if (!dc.applies_to) return false; // NULL = all services + const allowed = String(dc.applies_to).split(",").map((s: string) => s.trim().toLowerCase()); + return !allowed.includes("canada-crtc"); + })(); + if (notStarted || expired || usedUp || outOfScope) { + console.warn(`[canada-crtc] discount ${dc.code} rejected at checkout`, { notStarted, expired, usedUp, outOfScope }); } else { - discountCents = Math.min(dc.discount_value, discountableAmount); + const discountableAmount = SERVICE_FEE + typeAddon; // discount applies to full service fee + if (dc.discount_type === "percent") { + discountCents = Math.round((discountableAmount * dc.discount_value) / 100); + } else { + discountCents = Math.min(dc.discount_value, discountableAmount); + } } } } diff --git a/api/src/routes/checkout.ts b/api/src/routes/checkout.ts index 3e24e4b..719d37f 100644 --- a/api/src/routes/checkout.ts +++ b/api/src/routes/checkout.ts @@ -167,19 +167,21 @@ async function findOrCreateCustomer( } /** - * Ensure a compliance customer has an ERPNext portal account and persist the + * Ensure a paid customer has an ERPNext portal account and persist the * `portal_user_created` flag on their order rows. * - * ERPNext (portal.performancewest.net) is the single customer portal. Normal + * ERPNext (portal.performancewest.net) is the SINGLE source of truth for the + * customer portal — login, password, and the customer/order records. Normal * Stripe checkout provisions the Website User up-front via findOrCreateCustomer, * but PayPal / crypto / remediation-pipeline orders reach handlePaymentComplete * without ever creating one — leaving those customers unable to log in. This - * runs in that shared post-payment path so EVERY paid compliance order gets a - * portal account regardless of how it was created or paid. Fully idempotent: - * ensureWebsiteUser no-ops if the User already exists, and we only flip the - * flag (which gates the delivery worker's set-password invite) the first time. + * runs in that shared post-payment path so EVERY paid order (compliance, + * canada_crtc, formation) gets an ERPNext Customer + Website User regardless of + * how it was created or paid. Fully idempotent: ensureWebsiteUser no-ops if the + * User already exists, and we only flip the flag (which gates the delivery + * worker's set-password invite) the first time. */ -async function ensureCompliancePortalUser( +async function ensurePortalUser( orderId: string, orderType: string, rows: Record[], @@ -241,7 +243,11 @@ async function ensureCompliancePortalUser( if (portalUserCreated) { const table = orderType === "compliance_batch" || orderType === "compliance" ? "compliance_orders" - : null; + : orderType === "canada_crtc" + ? "canada_crtc_orders" + : orderType === "formation" + ? "formation_orders" + : null; if (table) { try { if (orderType === "compliance_batch") { @@ -1825,7 +1831,7 @@ export async function handlePaymentComplete( // come straight here and would otherwise skip it (this was the cause of // customers who paid via PayPal being unable to log in). Idempotent. try { - await ensureCompliancePortalUser(order_id, order_type, updated.rows); + await ensurePortalUser(order_id, order_type, updated.rows); } catch (portalErr) { console.error("[checkout] Compliance portal-user provisioning failed (non-fatal):", portalErr); } @@ -1874,6 +1880,17 @@ export async function handlePaymentComplete( if (order_type === "canada_crtc") { const soName = (order.erpnext_sales_order as string) || null; + // Ensure the customer has an ERPNext portal account (the single source of + // truth for login/password). create-session provisions it up-front for + // card/ACH, but PayPal / crypto / webhook-confirmed orders reach here + // directly and would otherwise skip it — the same gap that left PayPal + // compliance customers unable to log in. Idempotent. See ensurePortalUser. + try { + await ensurePortalUser(order_id, order_type, updated.rows); + } catch (portalErr) { + console.error("[checkout] CRTC portal-user provisioning failed (non-fatal):", portalErr); + } + // PayPal: funds are instant — advance directly to "Client Selection" // Stripe card/ACH/Klarna: advance to "Awaiting Funds" — balance.available webhook handles next step // Crypto: advance to "Awaiting Funds" — manual admin step later diff --git a/scripts/generate_canada_carrier_guide_pdf.py b/scripts/generate_canada_carrier_guide_pdf.py new file mode 100644 index 0000000..4cc1bf0 --- /dev/null +++ b/scripts/generate_canada_carrier_guide_pdf.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +""" +Generate the public "Canadian Wholesale Carrier & Vendor Reference Guide" PDF. + +This is the lead-magnet attached (as a hosted download link) to the CRTC +USF-increase campaign. It is the marketing-facing, generic version of the +per-client vendor guide in +scripts/document_gen/templates/crtc_vendor_guide_generator.py -- it reuses the +SAME vetted vendor list (one source of truth) but renders a branded, standalone +PDF with reportlab (no per-client entity name) suitable for hosting at +site/public/guides/canada-carrier-guide.pdf. + +Usage: + python3 scripts/generate_canada_carrier_guide_pdf.py + python3 scripts/generate_canada_carrier_guide_pdf.py --out path/to/file.pdf +""" +from __future__ import annotations + +import argparse +import os +import sys +from datetime import datetime + +from reportlab.lib.colors import HexColor +from reportlab.lib.enums import TA_CENTER, TA_LEFT +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import ( + HRFlowable, + Image, + ListFlowable, + ListItem, + Paragraph, + SimpleDocTemplate, + Spacer, +) + +# Reuse the canonical vendor list so the public guide and the per-client binder +# guide never drift. Load the module BY PATH to avoid importing the +# scripts.document_gen package __init__ (which pulls in jinja2/docx and other +# heavy deps we don't need just for the VENDORS list). +import importlib.util as _ilu + +_VENDOR_MODULE = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "document_gen", "templates", "crtc_vendor_guide_generator.py", +) +_spec = _ilu.spec_from_file_location("_crtc_vendor_guide", _VENDOR_MODULE) +_mod = _ilu.module_from_spec(_spec) # type: ignore[arg-type] +_spec.loader.exec_module(_mod) # type: ignore[union-attr] +VENDORS = _mod.VENDORS + +NAVY = HexColor("#1A2744") +GRAY = HexColor("#64748B") +SLATE = HexColor("#374151") +LINK = HexColor("#1E40AF") +RED = HexColor("#E63F2A") +GREEN = HexColor("#059669") + +DEFAULT_OUT = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "site", "public", "guides", "canada-carrier-guide.pdf", +) +LOGO = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "site", "public", "images", "logo.png", +) + + +def _styles() -> dict: + base = getSampleStyleSheet() + return { + "title": ParagraphStyle("title", parent=base["Title"], fontName="Helvetica-Bold", + fontSize=20, textColor=NAVY, alignment=TA_CENTER, spaceAfter=4), + "subtitle": ParagraphStyle("subtitle", parent=base["Normal"], fontName="Helvetica", + fontSize=11, textColor=GRAY, alignment=TA_CENTER, spaceAfter=2), + "date": ParagraphStyle("date", parent=base["Normal"], fontName="Helvetica", + fontSize=10, textColor=GRAY, alignment=TA_CENTER, spaceAfter=14), + "h2": ParagraphStyle("h2", parent=base["Heading2"], fontName="Helvetica-Bold", + fontSize=14, textColor=NAVY, spaceBefore=10, spaceAfter=6), + "body": ParagraphStyle("body", parent=base["Normal"], fontName="Helvetica", + fontSize=10, textColor=SLATE, leading=15, spaceAfter=8, alignment=TA_LEFT), + "intro_small": ParagraphStyle("intro_small", parent=base["Normal"], fontName="Helvetica-Oblique", + fontSize=9, textColor=GRAY, leading=13, spaceAfter=8), + "vname": ParagraphStyle("vname", parent=base["Normal"], fontName="Helvetica-Bold", + fontSize=13, textColor=NAVY, spaceBefore=12, spaceAfter=1), + "vweb": ParagraphStyle("vweb", parent=base["Normal"], fontName="Helvetica", + fontSize=9, textColor=LINK, spaceAfter=3), + "vsvc": ParagraphStyle("vsvc", parent=base["Normal"], fontName="Helvetica", + fontSize=9.5, textColor=SLATE, leading=13, spaceAfter=2), + "vnotes": ParagraphStyle("vnotes", parent=base["Normal"], fontName="Helvetica", + fontSize=9, textColor=GRAY, leading=12, spaceAfter=2), + "disc": ParagraphStyle("disc", parent=base["Normal"], fontName="Helvetica-Oblique", + fontSize=7, textColor=HexColor("#999999"), leading=10, spaceBefore=14), + } + + +def build_guide(out_path: str) -> str: + s = _styles() + os.makedirs(os.path.dirname(out_path), exist_ok=True) + doc = SimpleDocTemplate( + out_path, pagesize=letter, + topMargin=0.85 * inch, bottomMargin=0.85 * inch, + leftMargin=1 * inch, rightMargin=1 * inch, + title="Canadian Wholesale Carrier & Vendor Reference Guide", + author="Performance West Inc.", + ) + story: list = [] + + if os.path.exists(LOGO): + try: + img = Image(LOGO, width=1.4 * inch, height=1.18 * inch, kind="proportional") + img.hAlign = "CENTER" + story.append(img) + story.append(Spacer(1, 8)) + except Exception: + pass + + story.append(Paragraph("Canadian Wholesale Carrier & Vendor Reference Guide", s["title"])) + story.append(Paragraph("Upstream voice, DID, SIP & UCaaS partners for CRTC-registered carriers", s["subtitle"])) + story.append(Paragraph(datetime.now().strftime("%B %Y"), s["date"])) + story.append(HRFlowable(width="100%", thickness=2, color=RED, spaceAfter=12)) + + story.append(Paragraph( + "As a CRTC-registered Canadian telecommunications service provider, you will need " + "upstream wholesale partners for voice termination, DID numbers, SIP trunking, and " + "(optionally) white-label UCaaS or broadband. This guide lists vendors commonly used " + "by Canadian telecom resellers so you can hit the ground running once your carrier " + "entity is live.", s["body"])) + story.append(Paragraph( + "Many large US carriers and wholesale providers (Lumen, Bandwidth, Telnyx, Sinch, etc.) " + "will board CRTC-registered Canadian carriers for cross-border voice and data. If you " + "have a specific US carrier you want to work with, contact them directly and ask about " + "their requirements for onboarding a Canadian wholesale partner -- some require an RMD " + "filing, others only need a CRTC registration letter and proof of incorporation.", s["body"])) + story.append(Paragraph( + "Performance West does not endorse or guarantee any of these vendors. This list is " + "provided for reference only. Evaluate each provider against your own traffic volume, " + "coverage, and pricing requirements.", s["intro_small"])) + + story.append(HRFlowable(width="100%", thickness=0.75, color=HexColor("#E5E7EB"), spaceBefore=6, spaceAfter=2)) + story.append(Paragraph("Recommended Canadian Wholesale Partners", s["h2"])) + + for v in VENDORS: + story.append(Paragraph(f'{v["name"]}  —  {v["location"]}', s["vname"])) + story.append(Paragraph(v["website"], s["vweb"])) + story.append(Paragraph(f'Services: {v["services"]}', s["vsvc"])) + story.append(Paragraph(f'Notes: {v["notes"]}', s["vnotes"])) + + story.append(Spacer(1, 10)) + story.append(HRFlowable(width="100%", thickness=2, color=NAVY, spaceBefore=6, spaceAfter=8)) + story.append(Paragraph( + 'Ready to launch your Canadian carrier? Performance West handles the complete ' + 'turnkey setup -- BC incorporation, CRTC domestic + BITS registration, registered ' + 'office, .ca domain, Canadian DID, and corporate binder. Learn more at ' + 'performancewest.net/order/canada-crtc or call ' + '1-888-411-0383.', s["body"])) + + story.append(Paragraph( + "Document prepared by Performance West Inc., a regulatory compliance consulting firm. " + "Performance West Inc. is not a law firm and this document does not constitute legal " + "advice or legal representation. Vendor information is current as of the date shown " + "above and may change without notice.", s["disc"])) + + doc.build(story) + print(f"[guide] wrote {out_path} ({os.path.getsize(out_path)} bytes, {len(VENDORS)} vendors)") + return out_path + + +def main() -> int: + ap = argparse.ArgumentParser(description="Generate the public Canada carrier vendor guide PDF") + ap.add_argument("--out", default=DEFAULT_OUT, help=f"output path (default: {DEFAULT_OUT})") + args = ap.parse_args() + build_guide(args.out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/site/public/guides/canada-carrier-guide.pdf b/site/public/guides/canada-carrier-guide.pdf new file mode 100644 index 0000000..508d675 --- /dev/null +++ b/site/public/guides/canada-carrier-guide.pdf @@ -0,0 +1,138 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 5 0 R /F3 6 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 70 /Length 1296 /SMask 4 0 R + /Subtype /Image /Type /XObject /Width 83 +>> +stream +Gb"/hb>/d;*65Ml>%j7P5]-qa]Ejti9tc@DGjq@B:F%9A"@s5_,h`Ah*f>hNU-p4:Gt*a!e7eKI#!IM77t03lmBSf04#8t)ma?P?LUK;9qgLd.2RHmEX/`2"d]GZ<5rZ_ZJ._n?AaG)+cR#kEG,e5T-]oPH/mrn^Yb2-.`C3`GEU`b/=!fnb`ChJj1]$+;8:eg>]e[EP6(Bo4S"hN#&6\MdZWAK`3/-(DVG'oYP.;UI%7`TeZV?J!b8.b[NijLeZp^-B3e!i_@YU2N#bW74593O'QnGX^p"03@$F3kKdeZID;?BtW_*Lg'GeI[aQK!]:R>-;7iBY?nEF7CW%.D%O?ZI1*0'6f@d7#CMF>6;8^\^JOlZ]6[a\.QS\0Oe<;_qVY7$3b32UofB&C73BiI`6^eE,\;jp4/remhUIfO1glr?-s@iQOd%_t,&jf52506b?J2(=00QZh<`gklB.a)AS(A!#iY(Ds`_'=ig8i"q]DlXBaIm@sRI_)Kd3VigRKr;fYCAL6D0t[>+td:+o,ScU_Jn2nJH-61E&e<3:63ocB'IEUYGPsH2Qm%Q;O?%@!SaiHrk@K+@A+6+[WL3uWLdg=i8iBL;Da0?b[.4h8#W1j@@CAc4`bVk)IN1RSU19h'J0eiWlICtp#F4L1/0>$IeHf_BYe!HrO82K?Y(!;C,c%D2ND:'HgL5'lqE$>ZIeco\3H2'523,BZIE3(%)F@DE7gSU(1FE8SD)B3^%KVIL,42gn7)(^)]63IgT>P>1c:mgHYtAhZ<6O(qNJUu'ni$pNj0F-rcT5`KXGN/%X@C!b@6XHlO@o"]n$os=mT7pJ.ZdSFU6o)p@RIe`M/uRZr]/b;>#MWWo&IMnO3H,e3qUXR0s[bZ),D"RtfTuG!4L2_rjL!^BJDWqUE%QrZ+mr?#_90RS3([[ieE59Fu=F7mckendstream +endobj +4 0 obj +<< +/BitsPerComponent 8 /ColorSpace /DeviceGray /Decode [ 0 1 ] /Filter [ /ASCII85Decode /FlateDecode ] /Height 70 /Length 1642 + /Subtype /Image /Type /XObject /Width 83 +>> +stream +Gb"/fD+ml0(k<.([%J.q@JC,HYj!DM&>DDoL_CtE5h(dDJ]=e0+<\8beW;?:P3Kgo*+MGlOj%R\d&"qq<'WVo;dpMuj#\J&(sp235S*rFU[]6&PG2<4q*$E:@I2"r\IcGDJG[O(Vf-SJhC\2qPZ`)^E6o_#9?@&a"B\Ngh8>EN$V52L^?-;L[$.RAUGBB.a*i&`t/Q"+UB>F_Ac24^#B\rj6OePV^)6sbL2(ORn%T)Bu3ULV:E+oZOmW]@RIU[nbYiWqkJN-&TI9iSr*J./,Ebe.C:`ajt+(q8NgA`cstog\CA0T2k8o6f!@=sL5mg]3[5Y^n-mQ-*?*!6S/d,hG(eJ-m+6jnOW]^`&tr(LB>ej>;ONF]m0]?fT]%8;&2;puhn)NDUV,\55CpLlZ;F/2_;km_iUdIU)@Z+!9<7I,d^mpq=LV.P3HRi,s"i0Qi,;9!Q=EI0qX.2sq*_p%[=lp=5/2c$7dKVeO_'*1q(SI4*1XCT%BuF970BkH/@O6aVis*0B.NW]_jO*P1cu+\-UjE=aM/*EH,Tj1IDS7jGAYu(S%CZ%hF,$R3&g!9om8g@E:uKM:*de-l55j"TjpJ(8P(=lBUe6Y@jKJrU1n-VVcA;X;:n86jebj,59ql('qcTkVR13uL:)dG<"..]VJ%*<,]D[k)l(nNQ#]2i/>j$LWEbGaOU2X0E)CFIVN$1udMOp+/#.IaKf8B!D+";d%DUT)'T\X\[:p!Lu4(URRh<@oQVCWU!/TYCE@q8M:,,QMA4b^]1g3pQ-V,R#,T7lC(Phk?i&?)lKVKO-Sqj(/dmroS568iWN!GuSuAAT^*BSf7de;Yf3#sEW%6I?@Vq^Lk)]5PhQ%4SH`cuU9"8s>A-i)S[]D$E^^m':b9nr9T^tP9YKqtOH6B_9qJt7u(\Fcr$*IR(Oi9W$qPPRbI5-YC4hm[@]+-RF3U0<4[.)2qO&XbBlSn&O0Q:].WU'JLqUsV5O!G:r6V04NMo.j9f7M.OKL4[:D'$08SQ!Hmg`J+ni/9'$mQBmeQ)0*7!aenYf"4lZNKBj(I/%[e8.7_q,X)eMZqB_Ie"cq#s!;fX\@cqE$utT7HXsj94cV;P])1Y;B=:*5p9(:@WUtSo1q"?P3;<2t1X$~>endstream +endobj +5 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +6 0 obj +<< +/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +7 0 obj +<< +/Contents 13 0 R /MediaBox [ 0 0 612 792 ] /Parent 12 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject << +/FormXob.4ad94e19863e704c886468add7da1aa5 3 0 R +>> +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/Contents 14 0 R /MediaBox [ 0 0 612 792 ] /Parent 12 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +9 0 obj +<< +/Contents 15 0 R /MediaBox [ 0 0 612 792 ] /Parent 12 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +10 0 obj +<< +/PageMode /UseNone /Pages 12 0 R /Type /Catalog +>> +endobj +11 0 obj +<< +/Author (Performance West Inc.) /CreationDate (D:20260617232550-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260617232550-05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (Canadian Wholesale Carrier & Vendor Reference Guide) /Trapped /False +>> +endobj +12 0 obj +<< +/Count 3 /Kids [ 7 0 R 8 0 R 9 0 R ] /Type /Pages +>> +endobj +13 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1981 +>> +stream +GauHK=a/Rh'Roe[\5+\oNN@PBT55JpUqrp_/.[1IU]I?Y1h9(-nF(^:7L1]&1d3pZF(,B6k2($eM]K%lbL-?e"i0dJ/oMbL>Sp8QnLYj&+5kE=m-C(gS90D!$9^J%cqCIma1'PcgU8:Zp@Od*\!!u+na,h4MrG,:=?ah>T/oO`H8Mr+1lapfN(K=()0#ogi.dm&NF1F2;2d6"-H<@s5od;K0k[DFpotQYjq*]#!&'.uQVf^\6u9D*o=!,@5\V3t_Fn,]LqrQ`5I"Lu!^EoQ=JPZ$OHlK7J0ehW]muUObls(&6cAK!8&trO6rYHI&uJo!mpY#;$9D\&64=a-#-_o0m&':sLgVaPn[+meV!r(T=qcP6[='QA<9q3#98]@O@@P=*itq=V[q*(S_FO(@XJ?H_oclOh;"P*)PJFXeIgMGu<';91d*I.7$k:7.jq2p_#/)/muq>=KsuDP2a)r,P$S\cDn;=T"NbZ(dJe:e5pEM5Znm:4CW\8M7S(Djm3%>RDEBg0@W:NkNg00=Nsu;]/c;+]T$K=KXX_[-eSX[Z=#e-&n];cO<[!s93j1`'JNkUCT&QA4]rlB#!jQ[F-X#k$>(5)`BtI`W]>d$i&,,%WKro$E)gb""%S+%L2WcF].bU&Q'hPeY"_:*jVS1FCMW0t0EhZ8+JI$i3N];%:b#6TK22OJ,2HKT\2eig07j#s)tj+VdO:6'Q^\;cuE+WJ$F9@&ep\Wg_I++S/Pu?E6a3.6_GQW[Tf*ABO7u[k^[_BEsZoV8^r0ZQsfj.LME9o@ApeAqZFoNNOM?_MT%&!3`b9c5u:1E#9r3i('D][YUHDh"k53HiUTL=]dUa-i?K>d.?e)R2A>*tYh/N?6D!G++S3Uhr9^c-n-;el?k@m11eYXg)_M%K!^VFeMAhKNXfr'\E#=0GNmN./D`jGK6aIC()ga^8Q$B5s!Wh5js+?a1'OLK)=e3[F?4W2B-.$.b2!Zkj`,5RTDIXO0XBYQ/ZYM#[QdJW]ahsiendstream +endobj +14 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2255 +>> +stream +GauHL>BAdn'7JbFd;Q:r+Br)FhJDQ7:h@:M+mW+M07u*s`0,l!8?dKVr]<86!kbr]];W-[!2+^'?ENA,RR>`)7(\`4T/+35@s3f<2Y,A!"'Nj1I1M=4Z[Q>stCdB%4GWG&8*.O&&sF?qC]WBcX8=,+k._$6M:V0s&t??7^H][IbH4UI8,DOp?F.MJkeH:Q"odVQ_#GUZRVo$om>N(B@U,JD;F@CEAo=9,="j-d-)UnH3QpV%2K1=@rDNSQ>'mT<\Q8aH1?GrpR(1.jI^ho25e63Ui$CG`Oi?("3/MAR]r?#0',E*BgMVLK"DV/GZAU/F#)H/kfalLPdA6P_h[JZ\O)8>H[%7nN"LE*r1eW6JRXnXpj4L4%mUg-?j*63m`dOG+WWU#+'+KG9&c1fTcR_;H/77lflbo;L2f^(;BC>G!Zi!-A_mR6#>RjD2.S&Le)6=C]hfr1V%+fog$E7hu8\-JDj'g;kU9]dWP^ooq#`hk#I^g%!MO$7>"3jC992pG;93gDIHDF>JNUi+B1=Ua6-4MMbG6#*IJCHnFoDeJ&(EAPUpK*B+U(C6Fn1LK:^M8cc,"O-_[AuU*':4*BO8=oqo<1iXUfA!5p/oTg?=K?+1\Sm=$omg[aj$jD$^,%"A.87WFTUo"YrB4)>Q\m#34+V/i$Rb\3M6[HufuHRto.]082@0'-=-fo:Ea#,b#]H`ddqFT(Lu;.fk%6eA%Q1f*Zk';lG$70)GN:[P_:FA$@Q?Xk!kJjb3WZklL5h)X8Uml&sqsRNnocj'*(RlR\!hRMs;J[P"2rXlVb,^pMjcr\g`&UVS75+CJ&?oj75'YQKTCbU'$q<]>=X\:EUYN-d'6&Co!c!1pI9:&t*ocQ,5UtiAp"]9:bO@U6E3k:daH*mpUbWr*So?WDJq[Vq,TO+kWDs,P['No=X:h3?#()rg>o1i`kO3D@GgW*]f.+\BdN\q_.32e&8l\6'`Bp=lYUhMg"7K4+FOM-nfb=jBRX+m>YPDD;U(r52#=AO/cia]B=&rdpjdLLE7ElHn$WmZFD,e5q0uk)1/6oiZ[W:AT)XkKjpb7ljn+X4oVKF-M<>BsElR\E5`"cLK!STepb!Ao"RkDseX+e;+VfC"H+qeSgp>.,XHCj?W3`aR;57bLEX\jokk?llB$[f`!n:TYl)MhId=Wgf(eMm1+Si6'j=E/.cV2f?C?6VQM=!,A5#;lL&KK""S__3Q;LC(F)(9BnB6A)#UKnI/@"#lUsFPN;2?%C#TJ%c9;2H24,M,)7XM4s704de]uQBL@#Qh0,j5%bZ9?0+Fl?a4n`q+Al1/rTN6K;UF__$4!/qdD[&o^.h&LRId0ljV5XeOO3$P&rrI9.P'[~>endstream +endobj +15 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1736 +>> +stream +Gau0BhfId8&:Vr4Z&b[cNb)^QqpJ'Wh$oo#QQGd>%s\bq^hR]-Q/kd$dfO!s+Dti!l!^;'hqbBe7M`I;U,!Z3s07aPF+j@.Jj8A!,'@KL5`\8Tf2b%e7MXmQA0d&/'h:X=.&c`6?(6A59M$[L`+;s/rM",BdC'D<8pZ^`Y-F,1<_,G*Y'H5mDk'o\c4pXX&YP*eh-38rmi!lGiiWnC1_oPWn:=3;r.?*W2QQ%;6s2B<_#CcA/O-=,?\OX^h*hp*Y.IlD;*/l(@[NAF]6aDe!%M9L]O8jjXHDrHVg)Bbl;'N7]=eu5Xo19o,GdDQi24u8h/Y[unU.a(N-J5PBf*4gBLt\A2U+),O(.+"AQeWEHY=f=*C,Y.re?nR-D1E<9onEk%L^B0^6r9?$pR-G?2LKSQTV(q&Q=6H0M)Y+T#0Ng7G?1jcG`TQH<:Og0f&QS=M!V)VD5lRiH[W2[1W@g&"!o*NFOinC?./j9!8>V`-ar=!1_+JX\ZUSHQhJWV'LNgn6*mBA7^b9;XVW.WA'\hTNq[&\J?aNnXuW!Lc1-mrS@VCmJ,8/1Skgln:DV)o^UH+j*)^B-VI)?o*mKkW?-L<];$Wt%sBU1T1/(OQdVX0j7l??-da=f5DSG%Qde[B:^gZ,4."Z/7GV9ta8U$<\3%Iqp?VL/q&KWoMl>V/cR&6^VD\&t5Xm>?bluSm!$OJFnj106VGR7@=G3d9PM77G@AMDW;?Ic#L,mJZ:S.#&%R&*b&L_HW4L(a#>3>K4An8T0l!.9`$ZdERR?gSmBX;EJA1\OKIW*m_!%B@kbA4qaD)I9op%GiTa+DMWKOZK46DrNW$LnC51d5=K"Xpp/_SqG9`Y:fg01B_]_F(FJUfep5Q6TPDHQJ]TL?1Ku"!CYrtp%FUsRMA$V+=RT$oR4HQ\[4h/PM)V5V5TCdN"KgXqXA>mC-lqZfd_(D#jdD`](Z)][eXJIQeA3?jX_!`bPU]Q\N@23F#Dja.f^kq/9-s/#1e;mSO.i"erGf[a>b[eg8dloSjEHaq>_bP?s,n^b:/?TNW&ERAVprN70hL*up@^*'=9mL]sW"Mp!bV:tl$\#38@Mq?5]12f_*$f/pTq\mCg8'@#RC#h3"DMhO?5,X/j7.@J,\5hgj7$_JL&TQPKj*=4o4C_NDB7_KD7d4E](I@El-bUZ8hh,c#]5PQ6E3bD4RPU8jB5cuBtNH+oF@M&$kh!]Q@c91T]R;$\SrV"KcU]![8JnTlt^Y+MD);]ei>AS5Ss!rRK"SP^p$4.bV_;8]aC;qHdC)pPQ(WUh.Y@~>endstream +endobj +xref +0 16 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000001717 00000 n +0000003565 00000 n +0000003677 00000 n +0000003792 00000 n +0000004050 00000 n +0000004245 00000 n +0000004440 00000 n +0000004510 00000 n +0000004837 00000 n +0000004909 00000 n +0000006982 00000 n +0000009329 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 11 0 R +/Root 10 0 R +/Size 16 +>> +startxref +11157 +%%EOF From 2611b5458bfbd701399a774eecfb803d9068062c Mon Sep 17 00:00:00 2001 From: justin Date: Wed, 17 Jun 2026 23:40:01 -0500 Subject: [PATCH 2/2] CRTC USF campaign: shared campaign_helpers + Q3 38.8% USF email builder - campaign_helpers.py: extract the branded Listmonk HTML helpers (hdr/flagbar/ stats/cta/footer/P/UL/etc.) + create_campaign() from create_campaigns.py into a side-effect-free shared module; create_campaign() now takes an altbody so every campaign ships a plaintext alternative (deliverability). - create_crtc_usf_campaign.py: build the one-off CRTC email hooked on the Q3 2026 USF factor (38.8%, +1.8pts, eff Jul 1), with a $200-off CANADA200 banner (expires Fri 23:59 ET, CTA links carry ?code= for auto-apply), the full US carrier burden vs Canada advantage, BC/ON incorporation, and a hosted carrier-guide PDF download. Creates a DRAFT only; sending stays manual. --- scripts/workers/campaign_helpers.py | 196 ++++++++++++++++++++ scripts/workers/create_crtc_usf_campaign.py | 156 ++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 scripts/workers/campaign_helpers.py create mode 100644 scripts/workers/create_crtc_usf_campaign.py diff --git a/scripts/workers/campaign_helpers.py b/scripts/workers/campaign_helpers.py new file mode 100644 index 0000000..026d28b --- /dev/null +++ b/scripts/workers/campaign_helpers.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +campaign_helpers.py — Shared Listmonk campaign building blocks. + +Branded, email-client-safe HTML helpers (header, flag bar, stat tiles, CTA +button, footer, paragraphs, lists) plus the Listmonk POST helper. Extracted +from create_campaigns.py so multiple campaign scripts (the original 7-email +drips and one-off promos like the CRTC USF campaign) can share ONE source of +truth for the look-and-feel and the API plumbing. + +Importing this module has NO side effects (it does not POST anything). +""" + +import sys + +import requests + +# ── Listmonk connection ────────────────────────────────────────────────────── +LISTMONK_URL = "https://lists.performancewest.net" +AUTH = ("api", "6X1rKPea61N4rZ1S65Hx5zvqzbCj30F6nvEe9oVGH_Y") + +# ── Brand constants ────────────────────────────────────────────────────────── +URL = "https://performancewest.net/order/canada-crtc" +PHONE = "1-888-411-0383" +EMAIL = "info@performancewest.net" +CONTACT = ( + f'Email {EMAIL} ' + f'or call {PHONE}' +) + + +# ── HTML helpers ───────────────────────────────────────────────────────────── + +def hdr(eyebrow, headline, sub=None): + s = f'

{sub}

' if sub else '' + return ( + '
' + '
' + '' + '' + '' + '
Performance WestTelecom Services
' + '
 
' + '
' + f'

{eyebrow}

' + f'

{headline}

{s}' + '
' + ) + + +def flagbar(left, right): + return ( + '
' + '' + '' + '' + '' + '
USA' + f'{left}Canada' + f'{right}
' + ) + + +def stats(*items): + w = 100 // len(items) + cells = "".join( + f'' + f'
' + f'
{v}
' + f'
{l}
' + '
' + for v, l in items + ) + return f'{cells}
' + + +def cta(text, url): + return ( + '
' + '' + f'' + '
{text}
' + ) + + +def carriers_block(): + data = [ + ("Twilio","NASDAQ: TWLO"),("Bandwidth","NASDAQ: BAND"),("Telnyx","Washington DC"),("RingCentral","NYSE: RNG"), + ("Vonage","Holmdel NJ"),("8x8","NASDAQ: EGHT"),("Zoom Phone","NASDAQ: ZM"),("Dialpad","San Ramon CA"), + ("Google Voice","Alphabet / GOOGL"),("Ooma","NYSE: OOMA"),("Onvoy / Sinch","OMX: SINCH"),("Sangoma","NASDAQ: SANG"), + ] + rows = "" + for i in range(0, len(data), 4): + cells = "".join( + f'' + f'
' + f'
{n}
' + f'
{t}
' + '
' + for n, t in data[i:i+4] + ) + rows += f"{cells}" + return ( + '' + '
' + '

' + 'Join other US voice carriers registered to do business in Canada ' + 'Canada

' + f'{rows}
' + '
' + ) + + +def ftr(note=""): + note_html = f'{note}
' if note else '' + return ( + '' + '
' + 'Performance West' + '

' + 'Performance West Inc.  ·  performancewest.net

' + f'

{note_html}' + 'Unsubscribe

' + '
' + ) + + +def bq(t): + return ( + '' + '
{t}
' + ) + + +def P(t): + return f'

{t}

' + + +def PS(t): + return f'

{t}

' + + +def H2(t): + return f'

{t}

' + + +def UL(*items): + return ( + '
    ' + + "".join(f'
  • {i}
  • ' for i in items) + + '
' + ) + + +def assemble(hdr_html, fb_html, body_html, ftr_html): + inner = ( + hdr_html + fb_html + + f'
{body_html}
' + + ftr_html + ) + return ( + '' + '
' + '' + f'
{inner}
' + ) + + +def create_campaign(name, subject, lists, body_html, altbody=None, status="draft"): + """POST a campaign to Listmonk. + + altbody: optional plaintext alternative. Strongly recommended for + deliverability — an HTML-only campaign is a spam signal. Pass the output of + scripts._email_plaintext.html_to_text(body_html) (or a hand-written + plaintext). When omitted, Listmonk generates its own plaintext at send time. + """ + s = requests.Session() + s.auth = AUTH + payload = { + "name": name, "subject": subject, "lists": lists, + "type": "regular", "content_type": "html", + "body": body_html, "status": status, + } + if altbody is not None: + payload["altbody"] = altbody + r = s.post(f"{LISTMONK_URL}/api/campaigns", json=payload, timeout=30) + if not r.ok: + print(f" ERROR {r.status_code}: {r.text[:200]}", file=sys.stderr) + return None + cid = r.json().get('data', {}).get('id', '?') + print(f" [{cid}] {name}") + return cid diff --git a/scripts/workers/create_crtc_usf_campaign.py b/scripts/workers/create_crtc_usf_campaign.py new file mode 100644 index 0000000..5eb99b5 --- /dev/null +++ b/scripts/workers/create_crtc_usf_campaign.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +create_crtc_usf_campaign.py — One-off CRTC marketing email hooked on the +Q3 2026 USF contribution-factor increase. + +Audience : Listmonk list 3 (FCC Carriers - Direct Contacts, ~6,046 enabled). +Hook : Q3 2026 federal USF contribution factor = 38.8% (up from 37.0% in + Q2), effective Jul 1 (FCC PN DA-26-546A1). +Offer : $200 off the CRTC carrier package service fee with code CANADA200, + valid through Fri Jun 19 2026 23:59 ET. The CRTC order page + auto-applies ?code= from the URL, so the CTA links carry it. +Lead : "Canadian Wholesale Carrier & Vendor Reference Guide" PDF, hosted at +magnet performancewest.net/guides/canada-carrier-guide.pdf (Listmonk + campaigns can't attach files, so we link a prominent download). + +Creates the campaign in Listmonk as a DRAFT. Sending is a separate, manual, +STOP-and-confirm step in the Listmonk UI (or set status via API). Run: + + python3 scripts/workers/create_crtc_usf_campaign.py # draft on list 3 + python3 scripts/workers/create_crtc_usf_campaign.py --test # draft to a test list/email +""" +import argparse +import os +import sys + +# Make `scripts` importable whether run from repo root or scripts/workers. +_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if _REPO not in sys.path: + sys.path.insert(0, _REPO) + +from scripts._email_plaintext import html_to_text # noqa: E402 +from scripts.workers.campaign_helpers import ( # noqa: E402 + CONTACT, P, PS, H2, UL, bq, cta, hdr, flagbar, stats, assemble, ftr, + create_campaign, +) + +LIST_ID = 3 +CODE = "CANADA200" +GUIDE_URL = "https://performancewest.net/guides/canada-carrier-guide.pdf" +ORDER_URL = f"https://performancewest.net/order/canada-crtc?code={CODE}" + + +def coupon_banner(): + """Prominent $200-off banner with expiry.""" + return ( + '' + '
' + '

Limited-time offer

' + f'

$200 off your Canadian carrier setup

' + f'

Use code {CODE} at checkout  ·  expires Friday at 11:59pm ET

' + '
' + ) + + +def guide_block(): + """PDF lead-magnet download block (Listmonk can't attach files).""" + return ( + '' + '
' + '' + '' + '
📄' + '

Free guide: Canadian Wholesale Carrier & Vendor Reference

' + '

12 vetted Canadian wholesale partners for voice termination, DIDs, SIP trunking and UCaaS — the upstream vendors you’ll work with once your Canadian carrier is live.

' + f'Download the PDF →' + '
' + '
' + ) + + +def build_body(): + return ( + P("Hi {{ .Subscriber.FirstName }},") + + P("If you contribute to the federal Universal Service Fund, your Q3 number just went up again.") + + bq("The FCC has set the Q3 2026 USF contribution factor at 38.8% — up from 37.0% in Q2, and effective July 1. That is the rate you remit on the interstate and international end-user revenue you report on your 499.") + + P("38.8% is near the highest the factor has ever been. A decade ago it sat in the mid-teens. For a small or mid-size US carrier, that is a steadily rising tax on every interstate dollar you bill — on top of everything else the FCC requires.") + + stats( + ("38.8%", "Q3 2026 USF
contribution factor"), + ("+1.8 pts", "increase over
Q2 (37.0%)"), + ("Jul 1", "effective date
(FCC DA-26-546A1)"), + ) + + H2("The US carrier burden, in one place.") + + P("USF is just the line item that moved this quarter. The full load a registered US carrier carries:") + + UL( + "USF contributions — now 38.8% of interstate/international end-user revenue, filed and remitted via the 499", + "FCC Form 499-A / 499-Q — annual and quarterly revenue filings, with true-ups and audit exposure", + "Robocall Mitigation Database — annual recertification; miss it and your traffic gets blocked", + "STIR/SHAKEN — call-authentication implementation and ongoing attestation", + "CALEA — lawful-intercept capability, SSI filing, and the cost of a compliant solution", + "Section 214 + Team Telecom — for international service, with national-security review that can stall financings and M&A", + "State PUC registrations and FCC regulatory fees on top of the federal load", + ) + + H2("Why smaller carriers are standing up a Canadian operation.") + + P("A CRTC-registered Canadian carrier is a separate legal entity in a separate regulatory jurisdiction. For the voice traffic you move there, the US compliance stack simply does not apply:") + + UL( + "No USF. Canada funds its contribution program differently — there is no 38.8% factor on your Canadian carrier’s revenue", + "No Robocall Mitigation Database recert and no FCC 499 for the Canadian entity", + "No CALEA mandate in the US sense — lawful-intercept obligations are far lighter and cheaper", + "No Section 214 / Team Telecom — CRTC registration is a notification, not an application with a national-security review", + "Same +1 country code. Your customers dial exactly the same way — nothing changes on their end", + "A clean second jurisdiction — an FCC enforcement action against your US entity does not reach a Canadian corporation", + ) + + bq("You do not give up your US business. You add a Canadian carrier alongside it — for the voice traffic that doesn’t need to sit under the FCC, and for the Canadian market you can now sell into.") + + H2("What we set up — turnkey, in 6–10 weeks.") + + UL( + "Incorporation in British Columbia or Ontario — a separate legal entity from your US company", + "CRTC registration (domestic reseller + BITS international authorization)", + "Canadian DID provisioned under your new carrier identity", + "Virtual registered office, .ca domain + up to 14 email addresses", + "Full corporate binder, delivered digitally — plus a Canadian business-banking referral", + ) + + coupon_banner() + + cta(f"Start your Canadian carrier setup — $200 off →", ORDER_URL) + + guide_block() + + PS(f"Questions about how the Canadian structure would work for your traffic? {CONTACT}. The {CODE} discount is good through Friday at 11:59pm ET.") + + P("— Performance West") + ) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--test", action="store_true", help="create against a test list id (env CRTC_TEST_LIST) instead of list 3") + ap.add_argument("--name", default="CRTC USF Q3 \u2014 38.8% increase + $200 off (CANADA200)") + ap.add_argument("--subject", default="USF jumps to 38.8% in Q3 \u2014 here\u2019s the Canadian alternative ($200 off)") + args = ap.parse_args() + + lists = [int(os.getenv("CRTC_TEST_LIST", "0"))] if args.test else [LIST_ID] + if args.test and lists == [0]: + print("--test requires CRTC_TEST_LIST env var (a test list id)", file=sys.stderr) + return 1 + + body = assemble( + hdr( + "USF Increase \u2014 Q3 2026", + "USF just hit 38.8%.
There’s a Canadian alternative.", + "The federal contribution factor rose again, effective July 1", + ), + flagbar( + "US carrier \u2014 38.8% USF + the full FCC stack", + "Canadian CRTC carrier \u2014 no USF, separate jurisdiction", + ), + build_body(), + ftr(""), + ) + altbody = html_to_text(body) + + print(f"=== Creating CRTC USF campaign (list {lists}) ===") + cid = create_campaign(args.name, args.subject, lists, body, altbody=altbody, status="draft") + if cid: + print(f"\nDraft created (id {cid}). Review/preview in Listmonk, then send manually.") + print(f"Body: {len(body):,} chars HTML / {len(altbody):,} chars plaintext") + return 0 if cid else 1 + + +if __name__ == "__main__": + raise SystemExit(main())