Compare commits

...

2 commits

Author SHA1 Message Date
justin
2611b5458b 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.
2026-06-17 23:40:01 -05:00
justin
e379e2b10f 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.
2026-06-17 23:34:13 -05:00
6 changed files with 718 additions and 14 deletions

View file

@ -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);
}
}
}
}

View file

@ -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

View 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 &amp; Vendor Reference Guide", s["title"]))
story.append(Paragraph("Upstream voice, DID, SIP &amp; 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"]} &nbsp;&mdash;&nbsp; <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())

View 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;">&nbsp;</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;">&rarr;</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. &nbsp;&middot;&nbsp; <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

View 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 &nbsp;&middot;&nbsp; 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;">&#128196;</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 &amp; 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 &mdash; the upstream vendors you&rsquo;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 &rarr;</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> &mdash; 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 &mdash; 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> &mdash; now 38.8% of interstate/international end-user revenue, filed and remitted via the 499",
"<strong>FCC Form 499-A / 499-Q</strong> &mdash; annual and quarterly revenue filings, with true-ups and audit exposure",
"<strong>Robocall Mitigation Database</strong> &mdash; annual recertification; miss it and your traffic gets blocked",
"<strong>STIR/SHAKEN</strong> &mdash; call-authentication implementation and ongoing attestation",
"<strong>CALEA</strong> &mdash; lawful-intercept capability, SSI filing, and the cost of a compliant solution",
"<strong>Section 214 + Team Telecom</strong> &mdash; for international service, with national-security review that can stall financings and M&amp;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 &mdash; there is no 38.8% factor on your Canadian carrier&rsquo;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 &mdash; lawful-intercept obligations are far lighter and cheaper",
"<strong>No Section 214 / Team Telecom</strong> &mdash; 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 &mdash; nothing changes on their end",
"<strong>A clean second jurisdiction</strong> &mdash; 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 &mdash; for the voice traffic that doesn&rsquo;t need to sit under the FCC, and for the Canadian market you can now sell into.")
+ H2("What we set up &mdash; turnkey, in 6&ndash;10 weeks.")
+ UL(
"Incorporation in <strong>British Columbia or Ontario</strong> &mdash; 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 &mdash; plus a Canadian business-banking referral",
)
+ coupon_banner()
+ cta(f"Start your Canadian carrier setup &mdash; $200 off &rarr;", 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("&mdash; 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&rsquo;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())

View 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