Compare commits
2 commits
eed5e4a258
...
2611b5458b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2611b5458b | ||
|
|
e379e2b10f |
6 changed files with 718 additions and 14 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>[],
|
||||
|
|
@ -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
|
||||
|
|
|
|||
180
scripts/generate_canada_carrier_guide_pdf.py
Normal file
180
scripts/generate_canada_carrier_guide_pdf.py
Normal file
|
|
@ -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"]} — <font color="#64748B">{v["location"]}</font>', s["vname"]))
|
||||
story.append(Paragraph(v["website"], s["vweb"]))
|
||||
story.append(Paragraph(f'<b>Services:</b> {v["services"]}', s["vsvc"]))
|
||||
story.append(Paragraph(f'<b>Notes:</b> {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(
|
||||
'<b>Ready to launch your Canadian carrier?</b> 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 '
|
||||
'<font color="#1E40AF">performancewest.net/order/canada-crtc</font> 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())
|
||||
196
scripts/workers/campaign_helpers.py
Normal file
196
scripts/workers/campaign_helpers.py
Normal file
|
|
@ -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 <a href="mailto:{EMAIL}" style="color:#e63f2a;text-decoration:none;">{EMAIL}</a> '
|
||||
f'or call <a href="tel:+18884110383" style="color:#e63f2a;text-decoration:none;">{PHONE}</a>'
|
||||
)
|
||||
|
||||
|
||||
# ── HTML helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def hdr(eyebrow, headline, sub=None):
|
||||
s = f'<p style="margin:8px 0 0;font-family:Arial,sans-serif;font-size:13px;color:#a0b4cc;line-height:1.5;">{sub}</p>' if sub else ''
|
||||
return (
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#1a2744;padding:0;">'
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding:18px 40px 14px;">'
|
||||
'<table cellpadding="0" cellspacing="0" border="0"><tr>'
|
||||
'<td style="vertical-align:middle;padding-right:12px;"><img src="https://performancewest.net/images/logo.png" width="90" alt="Performance West" style="display:block;width:90px;height:auto;"></td>'
|
||||
'<td style="vertical-align:middle;border-left:1px solid #2d4e78;padding-left:12px;"><span style="color:#8fa8d0;font-family:Arial,sans-serif;font-size:11px;letter-spacing:1.5px;text-transform:uppercase;">Telecom Services</span></td>'
|
||||
'</tr></table></td></tr></table>'
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e63f2a;height:3px;font-size:0;line-height:0;"> </td></tr></table>'
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding:28px 40px 32px;">'
|
||||
f'<p style="margin:0 0 8px;font-family:Arial,sans-serif;font-size:11px;color:#e63f2a;letter-spacing:2px;text-transform:uppercase;font-weight:600;">{eyebrow}</p>'
|
||||
f'<h1 style="margin:0;font-family:Arial,sans-serif;font-size:24px;font-weight:700;color:#ffffff;line-height:1.3;">{headline}</h1>{s}'
|
||||
'</td></tr></table></td></tr></table>'
|
||||
)
|
||||
|
||||
|
||||
def flagbar(left, right):
|
||||
return (
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#f0f4ff;padding:10px 40px;border-bottom:1px solid #dde5f0;">'
|
||||
'<table cellpadding="0" cellspacing="0" border="0"><tr>'
|
||||
'<td style="padding-right:16px;vertical-align:middle;"><img src="https://performancewest.net/images/flags/usa.png" width="22" height="14" alt="USA" style="display:inline;vertical-align:middle;">'
|
||||
f'<span style="font-family:Arial,sans-serif;font-size:12px;color:#4a5568;margin-left:6px;vertical-align:middle;">{left}</span></td>'
|
||||
'<td style="padding:0 16px;color:#cbd5e0;font-size:14px;vertical-align:middle;">→</td>'
|
||||
'<td style="vertical-align:middle;"><img src="https://performancewest.net/images/flags/canada.png" width="22" height="14" alt="Canada" style="display:inline;vertical-align:middle;">'
|
||||
f'<span style="font-family:Arial,sans-serif;font-size:12px;color:#4a5568;margin-left:6px;vertical-align:middle;">{right}</span></td>'
|
||||
'</tr></table></td></tr></table>'
|
||||
)
|
||||
|
||||
|
||||
def stats(*items):
|
||||
w = 100 // len(items)
|
||||
cells = "".join(
|
||||
f'<td width="{w}%" style="padding:0 6px;"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr>'
|
||||
f'<td style="background:#1a2744;border-radius:6px;padding:18px 12px;text-align:center;">'
|
||||
f'<div style="font-family:Arial,sans-serif;font-size:26px;font-weight:700;color:#ffffff;line-height:1;">{v}</div>'
|
||||
f'<div style="font-family:Arial,sans-serif;font-size:11px;color:#8fa8d0;margin-top:6px;line-height:1.4;">{l}</div>'
|
||||
'</td></tr></table></td>'
|
||||
for v, l in items
|
||||
)
|
||||
return f'<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;"><tr>{cells}</tr></table>'
|
||||
|
||||
|
||||
def cta(text, url):
|
||||
return (
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center">'
|
||||
'<table cellpadding="0" cellspacing="0" border="0" style="margin:28px auto;"><tr>'
|
||||
f'<td style="background:#e63f2a;border-radius:4px;"><a href="{url}" style="display:inline-block;padding:14px 36px;font-family:Arial,sans-serif;font-size:15px;font-weight:700;color:#ffffff;text-decoration:none;">{text}</a></td>'
|
||||
'</tr></table></td></tr></table>'
|
||||
)
|
||||
|
||||
|
||||
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'<td width="25%" style="padding:4px;"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr>'
|
||||
f'<td style="background:rgba(255,255,255,0.09);border:1px solid rgba(255,255,255,0.15);border-radius:6px;padding:8px 6px;text-align:center;">'
|
||||
f'<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:600;color:#ffffff;">{n}</div>'
|
||||
f'<div style="font-family:Arial,sans-serif;font-size:10px;color:#fca5a5;margin-top:2px;">{t}</div>'
|
||||
'</td></tr></table></td>'
|
||||
for n, t in data[i:i+4]
|
||||
)
|
||||
rows += f"<tr>{cells}</tr>"
|
||||
return (
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:28px 0;"><tr>'
|
||||
'<td style="background:#5c0a0a;border-radius:8px;padding:20px 24px;">'
|
||||
'<p style="margin:0 0 14px;font-family:Arial,sans-serif;font-size:14px;font-weight:700;color:#ffffff;text-align:center;">'
|
||||
'Join other US voice carriers registered to do business in Canada '
|
||||
'<img src="https://performancewest.net/images/flags/canada.png" width="22" height="14" alt="Canada" style="display:inline;vertical-align:middle;margin-left:6px;"></p>'
|
||||
f'<table width="100%" cellpadding="0" cellspacing="0" border="0">{rows}</table>'
|
||||
'</td></tr></table>'
|
||||
)
|
||||
|
||||
|
||||
def ftr(note=""):
|
||||
note_html = f'{note}<br>' if note else ''
|
||||
return (
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr>'
|
||||
'<td style="background:#f4f5f7;padding:20px 40px;border-top:1px solid #e8ecf0;text-align:center;">'
|
||||
'<img src="https://performancewest.net/images/logo.png" width="70" alt="Performance West" style="display:block;margin:0 auto 10px;width:70px;height:auto;opacity:0.5;">'
|
||||
'<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:12px;color:#9ca3af;">'
|
||||
'Performance West Inc. · <a href="https://performancewest.net" style="color:#9ca3af;">performancewest.net</a></p>'
|
||||
f'<p style="margin:0;font-family:Arial,sans-serif;font-size:11px;color:#b0b7c3;line-height:1.6;">{note_html}'
|
||||
'<a href="{{ UnsubscribeURL }}" style="color:#b0b7c3;">Unsubscribe</a></p>'
|
||||
'</td></tr></table>'
|
||||
)
|
||||
|
||||
|
||||
def bq(t):
|
||||
return (
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:20px 0;"><tr>'
|
||||
'<td style="border-left:4px solid #e63f2a;padding:12px 20px;background:#fdf4f3;'
|
||||
f'font-family:Arial,sans-serif;font-size:15px;color:#2d3748;line-height:1.7;font-style:italic;">{t}</td></tr></table>'
|
||||
)
|
||||
|
||||
|
||||
def P(t):
|
||||
return f'<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:15px;color:#2d3748;line-height:1.7;">{t}</p>'
|
||||
|
||||
|
||||
def PS(t):
|
||||
return f'<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:14px;color:#6b7280;line-height:1.7;">{t}</p>'
|
||||
|
||||
|
||||
def H2(t):
|
||||
return f'<h2 style="margin:24px 0 10px;font-family:Arial,sans-serif;font-size:17px;font-weight:700;color:#1a2744;">{t}</h2>'
|
||||
|
||||
|
||||
def UL(*items):
|
||||
return (
|
||||
'<ul style="margin:0 0 16px;padding-left:22px;font-family:Arial,sans-serif;font-size:15px;color:#2d3748;line-height:1.7;">'
|
||||
+ "".join(f'<li style="margin-bottom:8px;">{i}</li>' for i in items)
|
||||
+ '</ul>'
|
||||
)
|
||||
|
||||
|
||||
def assemble(hdr_html, fb_html, body_html, ftr_html):
|
||||
inner = (
|
||||
hdr_html + fb_html
|
||||
+ f'<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding:32px 40px;background:#ffffff;" class="body-pad">{body_html}</td></tr></table>'
|
||||
+ ftr_html
|
||||
)
|
||||
return (
|
||||
'<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>'
|
||||
'@media only screen and (max-width:600px){.wrap{width:100%!important;border-radius:0!important;}.body-pad{padding:24px 20px!important;}h1{font-size:20px!important;}}'
|
||||
'body,table,td,p,a{-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}table{border-collapse:collapse!important;}img{border:0;outline:none;text-decoration:none;}'
|
||||
'</style></head><body style="margin:0;padding:0;background:#eef0f3;">'
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#eef0f3;padding:20px 0;"><tr><td align="center">'
|
||||
'<table width="620" cellpadding="0" cellspacing="0" border="0" class="wrap" style="width:620px;max-width:620px;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">'
|
||||
f'<tr><td>{inner}</td></tr></table></td></tr></table></body></html>'
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
156
scripts/workers/create_crtc_usf_campaign.py
Normal file
156
scripts/workers/create_crtc_usf_campaign.py
Normal file
|
|
@ -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 (
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px;"><tr>'
|
||||
'<td style="background:#0f7a3d;border-radius:8px;padding:18px 24px;text-align:center;">'
|
||||
'<p style="margin:0 0 4px;font-family:Arial,sans-serif;font-size:13px;color:#bdf0cf;letter-spacing:1px;text-transform:uppercase;font-weight:600;">Limited-time offer</p>'
|
||||
f'<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:22px;font-weight:800;color:#ffffff;line-height:1.2;">$200 off your Canadian carrier setup</p>'
|
||||
f'<p style="margin:0;font-family:Arial,sans-serif;font-size:14px;color:#d6f5e1;">Use code <span style="font-family:\'Courier New\',monospace;font-weight:700;color:#ffffff;background:rgba(255,255,255,0.18);padding:2px 8px;border-radius:4px;">{CODE}</span> at checkout · expires <strong style="color:#ffffff;">Friday at 11:59pm ET</strong></p>'
|
||||
'</td></tr></table>'
|
||||
)
|
||||
|
||||
|
||||
def guide_block():
|
||||
"""PDF lead-magnet download block (Listmonk can't attach files)."""
|
||||
return (
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;"><tr>'
|
||||
'<td style="background:#f0f4ff;border:1px solid #dde5f0;border-radius:8px;padding:20px 24px;">'
|
||||
'<table cellpadding="0" cellspacing="0" border="0"><tr>'
|
||||
'<td style="vertical-align:middle;padding-right:16px;font-size:34px;line-height:1;">📄</td>'
|
||||
'<td style="vertical-align:middle;">'
|
||||
'<p style="margin:0 0 4px;font-family:Arial,sans-serif;font-size:15px;font-weight:700;color:#1a2744;">Free guide: Canadian Wholesale Carrier & Vendor Reference</p>'
|
||||
'<p style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;color:#4a5568;line-height:1.6;">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.</p>'
|
||||
f'<a href="{GUIDE_URL}" style="display:inline-block;font-family:Arial,sans-serif;font-size:14px;font-weight:700;color:#1e40af;text-decoration:none;">Download the PDF →</a>'
|
||||
'</td></tr></table>'
|
||||
'</td></tr></table>'
|
||||
)
|
||||
|
||||
|
||||
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 <strong>Q3 2026 USF contribution factor at 38.8%</strong> — 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<br>contribution factor"),
|
||||
("+1.8 pts", "increase over<br>Q2 (37.0%)"),
|
||||
("Jul 1", "effective date<br>(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(
|
||||
"<strong>USF contributions</strong> — now 38.8% of interstate/international end-user revenue, filed and remitted via the 499",
|
||||
"<strong>FCC Form 499-A / 499-Q</strong> — annual and quarterly revenue filings, with true-ups and audit exposure",
|
||||
"<strong>Robocall Mitigation Database</strong> — annual recertification; miss it and your traffic gets blocked",
|
||||
"<strong>STIR/SHAKEN</strong> — call-authentication implementation and ongoing attestation",
|
||||
"<strong>CALEA</strong> — lawful-intercept capability, SSI filing, and the cost of a compliant solution",
|
||||
"<strong>Section 214 + Team Telecom</strong> — for international service, with national-security review that can stall financings and M&A",
|
||||
"<strong>State PUC registrations</strong> and <strong>FCC regulatory fees</strong> 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(
|
||||
"<strong>No USF.</strong> Canada funds its contribution program differently — there is no 38.8% factor on your Canadian carrier’s revenue",
|
||||
"<strong>No Robocall Mitigation Database recert</strong> and <strong>no FCC 499</strong> for the Canadian entity",
|
||||
"<strong>No CALEA mandate</strong> in the US sense — lawful-intercept obligations are far lighter and cheaper",
|
||||
"<strong>No Section 214 / Team Telecom</strong> — CRTC registration is a notification, not an application with a national-security review",
|
||||
"<strong>Same +1 country code.</strong> Your customers dial exactly the same way — nothing changes on their end",
|
||||
"<strong>A clean second jurisdiction</strong> — 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 <strong>British Columbia or Ontario</strong> — a separate legal entity from your US company",
|
||||
"<strong>CRTC registration</strong> (domestic reseller + BITS international authorization)",
|
||||
"Canadian DID provisioned under your new carrier identity",
|
||||
"Virtual registered office, <strong>.ca domain</strong> + 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%.<br>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())
|
||||
138
site/public/guides/canada-carrier-guide.pdf
Normal file
138
site/public/guides/canada-carrier-guide.pdf
Normal file
|
|
@ -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<BoN#dRMY\:"(ihX8nPCKPfm=*FDlFU.cK-\gtd2mMiP:LhL!<V4#nL83?,1-&&Mj$d41'=_#hD11)t@4*p0Z20JhCkr>.;UI%7`TeZV?J!b8.b[NijLeZp^-B3e!i_@YU2N#bW745<D)dmsX?K<,'eKSA=+)p(PKc8IUA<;,e0FeSRsQ?qc7cc"?F2@C&bTPGqKBX=9oEBN0m_6!e3pAE)"(>93O'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"RtfT<XH:TS7%CM9Kf&oSL1tDG$%<(CdcP;:I'As*70fV8iXCT"![%]aQj1&!Z)=eLeA(k*8JM:d=bqV_VQ#F=c:=_'TLPo;I#S[AO`]8V<Rn7;m(]Db]1;++,c7Z`6,nf+$1/c[bOiQ"LNA*2j(gAe?OEB9>uG!4L2_rjL!^BJDWqUE%QrZ+mr?#_90RS3([[ieE59Fu=F7mck<BCWmQ6;;XTi[Js),/`C&tN<1@uAa3lHX~>endstream
|
||||
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[<K]EbTlIcFqR+J!TL#e,,a.]MI8<qjqj]6N7:']OSNpfbib#G11TQq\rsc*i@dP>]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)B</&nr0Xi>u3ULV: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<RTs><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,Tj1IDS7j<a9.UL46Xi9h68E`1]BVW+t/k"I$$V3n:N`2_.a3L/646OeAq9IsS9eN1X-fHmDGa\WOllat>GAYu(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$1u<JN<.0OiG&6c&U)DgTJ2(Re:@cFXe:[]p\^MC0H'?$'I5;V8ee;=X!BKZZlR>dMOp+/#.IaKf8B!D+";d%DUT)'T\X\[:p!Lu4(URRh<@o<i*XAp:OH4(ca:Q![\\c*t"fFt1B<l<`RG(pq)/laYNh3X2r&['C:;ZmoQ63+%l_'IG;H2<008p`bjANfcD@Z)#OnGr$)CDFQ`iI-K2#$'e,Zggs0_r7u(D12cVdPcJXj+^11+>QVCWU!/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)e<r$j9UQ<*PmY/\=UK<h?@"=6q1Q#/+F5e'WKT96-<1^(V[3C3$hI-RfHQ^[cRs4N]p:m4e,O\?5_H%B"jC@1pL=-GB?)J<aF?L?a)%l>MZqB_Ie"cq#s!;fX\@cqE<DoY8LnQYi]GO%K1F0bsl9ALTZpRs2'dToD,KVC9YV)smZJs4Wa_`gs?E>$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[D<iF9e1plUgEpqLeQY,`[MhR_Y!$C'Z):(fV_4`D&V^UkZgM*T:Xdj$0\(b+G8SEEk7S3i8B_5Mlki"I[u0$[o'%ubu?KfFl_Ls?*n;F/Kj_V:MjFE=>FpotQYjq*]#!&'.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<K#iXb0=fL:HQ367!*(G]BqVg#a(?,\]&8u<FGts$:*?YaVB@$\`X`Ge>*)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"<br0P[&dB;#0\9(958;rH-%OB*<*1HBYBac*KY:N3>"%S+%L2<BHhX7Bge1VmYABH.)Ij5=L"rr?bi&kjn]og_9*Jl9<6Q?]ITs/S;=8Cj/@I]YY+FbE%/C4/hgZX^WIlJ'sXDreNTFc-%62A5uT[Qkt^M\0PraH,5E3Z>WcF].bU&Q'hPeY"_:<RZpJJ,!$\%c9R*\30nH?RXmnEGX\<3:e8UXuA;b/SiP<4Tm@k[0]"&*!^r,(7VC^>*jVS1FCMW0t0EhZ8+JI$i3N];%:b#6TK22OJ,2HKT\2eig07j#</^B33?T^ld>s)tj+VdO:6'Q^\;cuE+WJ$F9@&ep\Wg_I++S/Pu?E6a3.6_GQW[Tf*ABO7u[k^[_BEsZoV8^r0ZQsfj.LME9o@ApeAqZFoNNOM?_MT%<Wr0*^3SIH3]qg8(W9F]u_,@,dY-OsNo6h!*"iFOYCmoC7`3tHG33e)q7KsU$@IbM0ZKHnq4-.\XI4]gkKo6n0efr[K4QrApV!aPsG4H052L5_'S%*;jHP*l9JiiAfU#\GOHe^Ama<drj.KOY8cD0\pk,7@*;cCG$MMY`/dd$i"ABn&X6_J.:f_j5^(,eJqFaD?*[qU94G$9*egOu\nS;\<;0=-=0lE4lZ6h-D>&!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]ahsi<uY,AuEbTO4`R7MlJG[XNY$O:]ho5k+qcY3B?l(aNl7^Al?%@-_1Y^`;b$5*NY?)-^"?h%YqWsE+b94;Ad\0@31V[J&NCT]"?!ST%A:peLTgkOP1Tn%7Z\:_$GN-pE)"Xn#lY9EsDiG\#`9MAg9K&E?+F<6iKZ2FH=2%i'92;i`BhP5;e7:MCd7*_1cU0NF=sf]1[M[H]2+sN_$R#R=`d)5Lr=tMct[Neh/j6Ws/:,f2MR-"`HC[dsdp1/^nESlm(A4`O*e`$5[a<-ltJ%]n,/HGLkB1fncBD3DQ^&=51@de7p4O6hKF7.9K12F6;-[dL.?,BE&%sVlD3~>endstream
|
||||
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-<N6u2hVu-*H4[,Fsf6;^sR-89OB:jSB)`3tFhssr-\YYe;LOVP`q30VBlLZ^A$TsHMGht*/n2jsG-8G<jId??!K6hoR7UEuNG:krOCq"`R%J#s:\'sW*RFB8[R["5UrU#grs1$2BQVQbj0o70O^.:Yj6pfLXCK_Xi;HE$M-$+Al8[<Rj9g+Xi):@PZn8MR+nR+baac].VJoAQsfl/q5Rl.eAq0pR_2#3D3qKJ8nO%+@Y<_8TL2qnsPK7ui/%(J,D^`ZtR#(ZIN^P?3=cCY98d</8B8gkYjboWPAP1Rj-BXc^Y]aG(`SPB\lfOBB]c*W<r&kKU#2Z:Tm-ta`^dN'=$1k^X_s)lbfV$J-]>[!2+^'?ENA<QN?]cE&9bUeU9:m%,7Z33QAFOdSBQb+/A?Ie8#4p`G;"Zk`7mdut?3ftR[2gB42?P82,Lu@/a)-i^M*Rc-&N<o3pWCIq2Bm9.Q_AIu`bNZ)BE?qZG2O#N#M0'o_/d)^YdEgFdEF)*YW=D8$o5#!'ra??t1D'fjh!]B5I't!-(-WZXr+sjPRi[O$fFkaZ:*(=5me4En8lZ"L)Om';@cT"?gLqA'p&$BT^^M:'"%sQg2F]d`8!CDZB#SpX-S)<b=DHA[m9]+V0XZio1C1?VC>,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<o%%ZrLbk3>;kU9]dWP^ooq#`hk#I^g%!MO$7>"3jC992pG;93gDIHDF>JNUi+B1=Ua6-4MMbG6#*IJCHnFoDeJ&(EAPUpK*B+U(<Kh%io0N8V#26iAQK3b"f;:K(YGueW8(%PG77*ta,P3\H&\(@s*5]0Cf]CG<%@5':%3qmMHcl_N>C6Fn1LK:^M8cc,"O-_[A<W7Q!W)20CM`3+TRWuG8@8.hZjcQ"oOlY$:>uU*':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'<gKHRfVGA-7*C3!D[u?"F(!<q>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(<iOL9Z8c`$KEE6UVX<>r52#=AO/cia]B=&rdpjdLLE7ElHn$WmZFD,<T,`uU1l,ZmKu]d,<WQ#.E3O#.0b^$+k19[pC*eQd4Bu=OH*Qs?os;LAHE:E04HQM59$.F.m``50\gSZf+lrO<["M]&pWl>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<f68M[#80?a2n>$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*4<HlB.u<s6@fikF+b$\BAI"1bBP,8e_<D<69gr[L]_//JqKG#;0&*Qr$():'FIV_4NNnS6kJE>gBLt\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'LNg<k,N<P!$'a<H>n6*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@=G3d9P<IC1f:]QJUkZXf&/T2en3H1'QEO?e'q0;X4-gtDfcRpA=6F?-8cB8eB,K>M77G@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$o<QG=qMTnf&G$X?hWI*G-'>R4HQ\[4h/PM)V5V5TCdN"<X/qBcrDbs`m9QBdI$<\N:NKaTEqni3A8*raiM[X`N-EGrJVu%Xr]<"be#1Ob4Zfg%]Y`mEj7hEbos+&.)@8O_Q5=MK<)W8nA)qOi02<9.=42b([F5'B'Ce6_#J?>KgXqXA>mC-lqZfd_(D#jdD`](Z)][eXJIQeA3?jX_!`bPU]Q\N@23F#Dja.f^kq/9-s/#1e;mSO.i"erGf[<gh/S!r6GP%td*/<I<3YU_H<Z\_)oZGLKi\][rP0Z;Vn8j'<eBP-N:#(mEFM[?iQ<2rQjQ:>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<ELtQ8/12BgNH@>,c#]5PQ6E3bD4RPU8jB5cuBtNH+oF@M&$kh!]Q@c91T]<Z+nTi]3dA8(OXHBs&%6'T0"^<-aBkD^T"f]%&\*Y4i9\XL"i>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
|
||||
[<dc6b7564b74779a92e78c9be042d812c><dc6b7564b74779a92e78c9be042d812c>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 11 0 R
|
||||
/Root 10 0 R
|
||||
/Size 16
|
||||
>>
|
||||
startxref
|
||||
11157
|
||||
%%EOF
|
||||
Loading…
Add table
Add a link
Reference in a new issue