diff --git a/scripts/Dockerfile b/scripts/Dockerfile index 5b2002c..d4a8313 100644 --- a/scripts/Dockerfile +++ b/scripts/Dockerfile @@ -20,6 +20,9 @@ RUN playwright install chromium && playwright install-deps chromium # Copy all scripts COPY scripts/ /app/scripts/ COPY docs/product-facts.md /app/docs/product-facts.md +# Authoritative service prices: build_trucking_campaigns.py reads this to compute +# coupon discounted prices that match what checkout charges (service-fee only). +COPY api/src/service-catalog.ts /app/api/src/service-catalog.ts COPY ["docs/MCS-150 Form.pdf", "/app/docs/MCS-150 Form.pdf"] COPY ["docs/MCS-150B Form.pdf", "/app/docs/MCS-150B Form.pdf"] COPY ["docs/MCS-150C Form.pdf", "/app/docs/MCS-150C Form.pdf"] diff --git a/scripts/build_trucking_campaigns.py b/scripts/build_trucking_campaigns.py index 5df95ab..0b013ba 100644 --- a/scripts/build_trucking_campaigns.py +++ b/scripts/build_trucking_campaigns.py @@ -127,6 +127,109 @@ _WEIGHT_TAX_LP = { } +# ── Authoritative service prices (single source of truth) ─────────────────── +# The discounted prices shown in the coupon email MUST match what checkout +# actually charges, so we read them from the same catalog the API uses +# (api/src/service-catalog.ts). We parse it directly (the prod box has python3 +# but not node) and cache per-process. Each entry: {price_cents, discountable}. +# If the catalog can't be read (file missing in an image), the price helpers +# degrade to percent-only copy rather than guessing a number. +_CATALOG_PATH = os.getenv( + "SERVICE_CATALOG_TS", os.path.join(ROOT, "api", "src", "service-catalog.ts") +) +_CATALOG_CACHE: dict | None = None + + +def _load_service_catalog() -> dict: + """Parse api/src/service-catalog.ts -> {slug: {price_cents, discountable}}. + + discountable defaults to True (the catalog only marks the exceptions with + `discountable: false`), matching the TS object's own semantics. + """ + global _CATALOG_CACHE + if _CATALOG_CACHE is not None: + return _CATALOG_CACHE + catalog: dict = {} + try: + import re as _re + ts = open(_CATALOG_PATH, encoding="utf-8").read() + m = _re.search(r"export const COMPLIANCE_SERVICES[^=]*=\s*\{(.*)\n\};", ts, _re.S) + body = m.group(1) if m else "" + for em in _re.finditer(r'"([a-z0-9\-]+)":\s*\{(.*?)\}', body, _re.S): + slug, inner = em.group(1), em.group(2) + pm = _re.search(r"price_cents:\s*(\d+)", inner) + if not pm: + continue + discountable = _re.search(r"discountable:\s*false", inner) is None + catalog[slug] = { + "price_cents": int(pm.group(1)), + "discountable": discountable, + } + except Exception as exc: # noqa: BLE001 + LOG.warning("[coupon] could not load service catalog (%s); coupon copy " + "will be percent-only", exc) + _CATALOG_CACHE = catalog + return catalog + + +# The service whose price the coupon copy quotes. This is the specific service +# each campaign's body is *about*, which is NOT always the slug the CTA links to +# (e.g. the MCS-150 email talks about the $79 MCS-150 update but the button opens +# the $399 full-compliance bundle). Pricing from the wrong slug would advertise a +# discount the landing page doesn't show, so the price slug is explicit here. +PRICE_SLUG_BY_CAMPAIGN = { + "mcs150": "mcs150-update", + "inactive": "usdot-reactivation", +} + + +def price_slug_for(campaign_type: str, phy_state: str | None = None) -> str: + """The catalog slug whose price the coupon copy should quote for a segment. + + Main campaigns quote their specific service (MCS-150, reactivation); the + deficiency segments quote the same slug their CTA links to (resolved, incl. + per-state overrides, by lp_slug_for).""" + return PRICE_SLUG_BY_CAMPAIGN.get(campaign_type) or lp_slug_for(campaign_type, phy_state) + + +def discounted_price_attribs(campaign_type: str, phy_state: str | None, + coupon_pct: str | None) -> dict: + """Per-recipient price merge fields, computed on the fly to match checkout. + + Mirrors the server exactly: percent discount on the SERVICE fee only + (discount_cents = round(fee * pct / 100)); non-discountable services (e.g. + boc3-filing, a $25 passthrough) get NO price discount. Returns: + coupon_price_full "$79" (service list price) + coupon_price_deal "$47" (after discount) + coupon_priceable "1"/"" (whether a real discounted number is available) + Blank when no coupon, no catalog entry, or the service isn't discountable, so + the templates fall back to percent-only copy and never print a false number. + """ + blank = {"coupon_price_full": "", "coupon_price_deal": "", "coupon_priceable": ""} + if not coupon_pct: + return blank + try: + pct = int(coupon_pct) + except (TypeError, ValueError): + return blank + slug = price_slug_for(campaign_type, phy_state) + entry = _load_service_catalog().get(slug) + if not entry or not entry.get("discountable") or entry["price_cents"] <= 0: + return blank + full = entry["price_cents"] + discount = round(full * pct / 100) # same formula as the API + deal = full - discount + + def _fmt(cents: int) -> str: + return f"${cents // 100}" if cents % 100 == 0 else f"${cents / 100:.2f}" + + return { + "coupon_price_full": _fmt(full), + "coupon_price_deal": _fmt(deal), + "coupon_priceable": "1", + } + + def build_lp_link(campaign_type: str, phy_state: str | None) -> str: """Return the order landing-page URL for a (segment, state).""" seg = DEFICIENCY_SEGMENTS.get(campaign_type) @@ -384,12 +487,27 @@ def coupon_attribs(coupon_code: str | None, coupon_pct: str | None = None) -> di def lp_link_with_coupon(campaign_type: str, phy_state: str | None, - coupon_code: str | None) -> str: - """build_lp_link + a ?code= query param so the LP pre-applies the deal.""" + coupon_code: str | None, dot: str | None = None) -> str: + """Order landing-page URL as the email's `lp_link` attrib. + + Always emits a `?`-started query (carrying the carrier's DOT, plus the daily + `code=` when a coupon is active) so that EVERY template can safely append its + own params with a leading `&`. This eliminates a class of broken-CTA bugs: + previously `build_lp_link()` returned a bare path, so a template that wrote + `{{ lp_link }}&utm_source=...` produced `/order/slug&utm_source=...` (an + invalid URL that 404s), and one that wrote `{{ lp_link }}?dot=...` double-`?`d + once a coupon added its own `?code=`. With the query owned here, both template + styles converge on `{{ lp_link }}&utm_source=...` and are correct whether or + not the coupon is on. + """ url = build_lp_link(campaign_type, phy_state) + params = [] + if dot: + params.append(f"dot={dot}") if coupon_code: - sep = "&" if "?" in url else "?" - url = f"{url}{sep}code={coupon_code}" + params.append(f"code={coupon_code}") + if params: + url = f"{url}?" + "&".join(params) return url # ── TZ config: tz_key -> (states, send_hour_utc) ───────────────────────────── @@ -1309,8 +1427,9 @@ def run(send_date: date, dry_run: bool = False, preview: bool = False, "email": TEST_EMAIL, "name": r0[2] or "Sample Carrier", "attribs": {"dot_number": r0[0], "company": r0[2] or "", "state": r0[3] or "", - "lp_link": lp_link_with_coupon(campaign_type, r0[4], p_code), - **coupon_attribs(p_code, p_pct)}, + "lp_link": lp_link_with_coupon(campaign_type, r0[4], p_code, dot=str(r0[0])), + **coupon_attribs(p_code, p_pct), + **discounted_price_attribs(campaign_type, r0[4], p_pct)}, }] else: subscribers = [] @@ -1320,8 +1439,9 @@ def run(send_date: date, dry_run: bool = False, preview: bool = False, "email": row[1], "name": row[2] or row[1], "attribs": {"dot_number": row[0], "company": row[2] or "", "state": row[3] or "", - "lp_link": lp_link_with_coupon(campaign_type, row[4], c_code), - **coupon_attribs(c_code, c_pct)}, + "lp_link": lp_link_with_coupon(campaign_type, row[4], c_code, dot=str(row[0])), + **coupon_attribs(c_code, c_pct), + **discounted_price_attribs(campaign_type, row[4], c_pct)}, }) # Create list + add subscribers diff --git a/scripts/create_deficiency_source_campaigns.py b/scripts/create_deficiency_source_campaigns.py index 29ed8fe..2f73079 100644 --- a/scripts/create_deficiency_source_campaigns.py +++ b/scripts/create_deficiency_source_campaigns.py @@ -78,12 +78,59 @@ def _price_box(headline, sub): ) +def _deal_box(headline, sub): + """Coupon-aware offer box. + + When the daily same-day coupon is active (coupon_code attrib set), show the + deal. If the build step computed a real discounted price for this service + (coupon_priceable set — true for discountable services), show the exact + "was $X, now $Y" numbers, which are calculated on the fly to match what + checkout charges (service fee only) for the current A/B arm. If the service + isn't discountable (e.g. BOC-3, a $25 passthrough) the price attribs are + blank and we fall back to percent-only copy so we never print a false price. + With no coupon at all it shows the normal price box. + """ + priced = ( + f'
{headline} ' + 'for ' + '{{ .Subscriber.Attribs.coupon_price_full }} ' + '{{ .Subscriber.Attribs.coupon_price_deal }}.
' + ) + unpriced = ( + f'{headline}
' + ) + deal = ( + 'TODAY ONLY - {{ .Subscriber.Attribs.coupon_pct }}% OFF OUR SERVICE FEE
' + '{{ if .Subscriber.Attribs.coupon_priceable }}' + priced + '{{ else }}' + unpriced + '{{ end }}' + 'Use code ' + '{{ .Subscriber.Attribs.coupon_code }} ' + '(already applied when you click below).
' + '' + 'Expires {{ .Subscriber.Attribs.coupon_expires }}. Discount applies to our ' + 'service fee; government filing fees are billed at cost.