diff --git a/api/src/service-catalog.ts b/api/src/service-catalog.ts index 0027e58..4036036 100644 --- a/api/src/service-catalog.ts +++ b/api/src/service-catalog.ts @@ -272,7 +272,7 @@ export const COMPLIANCE_SERVICES: Record = { // ── DOT / FMCSA Motor Carrier Services ────────────────────────────── "mcs150-update": { name: "MCS-150 Biennial Update", - price_cents: 3900, + price_cents: 7900, erpnext_item: "MCS150-UPDATE", discountable: true, }, diff --git a/docs/legal/Office-of-Attorney-General_Envelope-108633.pdf b/docs/legal/Office-of-Attorney-General_Envelope-108633.pdf new file mode 100644 index 0000000..c6471e9 Binary files /dev/null and b/docs/legal/Office-of-Attorney-General_Envelope-108633.pdf differ diff --git a/scripts/build_trucking_campaigns.py b/scripts/build_trucking_campaigns.py index 7e233ca..d459290 100644 --- a/scripts/build_trucking_campaigns.py +++ b/scripts/build_trucking_campaigns.py @@ -135,6 +135,111 @@ def build_lp_link(campaign_type: str, phy_state: str | None) -> str: slug = "ca-mcp-carb" return f"{SITE_DOMAIN}/order/{slug}" + +def lp_slug_for(campaign_type: str, phy_state: str | None = None) -> str: + """The order-page slug (== the discountable service slug) for a segment.""" + seg = DEFICIENCY_SEGMENTS.get(campaign_type) + slug = seg["lp_slug"] if seg else "dot-full-compliance" + if campaign_type == "state_weight_tax" and phy_state in _WEIGHT_TAX_LP: + slug = _WEIGHT_TAX_LP[phy_state] + if campaign_type == "state_emissions" and phy_state == "CA": + slug = "ca-mcp-carb" + return slug + + +# ── Daily same-day coupon ─────────────────────────────────────────────────── +# Every send day gets ONE random 5-letter coupon at COUPON_PCT off, valid only +# through 23:59:59 of the send date (America/New_York). The code is written to +# the app's `discount_codes` table; the existing /api/v1/discount validator and +# checkout enforce expiry + the service-fee-only scope (pass-through government +# fees are never discounted). The code + prices are merged into the email so the +# recipient sees a real, expiring deal. +COUPON_PCT = int(os.getenv("CAMPAIGN_COUPON_PCT", "40")) +# Eligible slugs = every discountable service a trucking campaign can link to. +# Pass-through-only slugs (boc3-filing $25 passthrough, etc.) are intentionally +# eligible too: the discount math only touches the service-fee portion, so a +# code scoped to them simply yields $0 off the passthrough and full off the fee. +COUPON_SLUGS = ( + "mcs150-update,usdot-reactivation,dot-drug-alcohol,dot-full-compliance," + "ucr-registration,state-trucking-bundle,intrastate-authority,irp-registration," + "ifta-application,hazmat-phmsa,state-emissions,state-weight-tax,trucking-wrap-up," + "boc3-filing" +) +_ET = timezone(timedelta(hours=-5)) # EST anchor; close enough for an end-of-day cutoff +_COUPON_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ" # no I/O to avoid confusion + + +def _random_coupon_code() -> str: + import secrets + return "".join(secrets.choice(_COUPON_ALPHABET) for _ in range(5)) + + +def get_or_create_daily_coupon(conn, send_date: date) -> str: + """Return the 5-letter coupon code for `send_date`, creating it if needed. + + Idempotent: a marker in `description` (campaign-daily:) lets a re-run + on the same day reuse the existing code instead of minting a duplicate. + """ + marker = f"campaign-daily:{send_date.isoformat()}" + cur = conn.cursor() + cur.execute("SELECT code FROM discount_codes WHERE description = %s LIMIT 1", (marker,)) + row = cur.fetchone() + if row: + return row[0] + + # 23:59:59 ET of the send date + expires = datetime.combine(send_date, datetime.min.time(), tzinfo=_ET) + timedelta( + hours=23, minutes=59, seconds=59 + ) + starts = datetime.combine(send_date, datetime.min.time(), tzinfo=_ET) + + # Retry on the (rare) code collision against the UNIQUE constraint. + for _ in range(25): + code = _random_coupon_code() + try: + cur.execute( + """ + INSERT INTO discount_codes + (code, description, discount_type, discount_value, applies_to, + max_uses_per_email, active, starts_at, expires_at) + VALUES (%s, %s, 'percent', %s, %s, 1, TRUE, %s, %s) + ON CONFLICT (code) DO NOTHING + RETURNING code + """, + (code, marker, COUPON_PCT, COUPON_SLUGS, starts, expires), + ) + r = cur.fetchone() + if r: + conn.commit() + LOG.info("[coupon] daily code %s (%d%% off, expires %s ET)", + code, COUPON_PCT, expires.isoformat()) + return r[0] + except Exception: + conn.rollback() + raise RuntimeError("could not mint a unique daily coupon code") + + +def coupon_attribs(coupon_code: str | None) -> dict: + """Merge fields for the same-day deal, blank when no coupon is active.""" + if not coupon_code: + return {"coupon_code": "", "coupon_pct": "", "coupon_expires": ""} + return { + "coupon_code": coupon_code, + "coupon_pct": str(COUPON_PCT), + # Human-readable cutoff for the email body. + "coupon_expires": "11:59 PM ET tonight", + } + + +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.""" + url = build_lp_link(campaign_type, phy_state) + if coupon_code: + sep = "&" if "?" in url else "?" + url = f"{url}{sep}code={coupon_code}" + return url + # ── TZ config: tz_key -> (states, send_hour_utc) ───────────────────────────── # 4AM EST = 09:00 UTC, each TZ +1hr so they get it at ~4AM local TIMEZONE_CONFIG = { @@ -653,6 +758,15 @@ def run(send_date: date, dry_run: bool = False, preview: bool = False, warmup_cap: bool = True) -> None: conn = psycopg2.connect(DB_URL) + # Mint (or reuse) the same-day coupon for this send date so every campaign + # in the run shares one expiring code. Preview/dry runs skip the write. + daily_coupon = None + if not dry_run and not preview: + try: + daily_coupon = get_or_create_daily_coupon(conn, send_date) + except Exception as exc: # noqa: BLE001 + LOG.warning("[coupon] could not mint daily coupon: %s (sending without)", exc) + base_mcs150 = get_base_campaign(CAMPAIGN_MCS150_ID) base_inactive = get_base_campaign(CAMPAIGN_INACTIVE_ID) @@ -788,7 +902,8 @@ 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": build_lp_link(campaign_type, r0[4])}, + "lp_link": lp_link_with_coupon(campaign_type, r0[4], daily_coupon), + **coupon_attribs(daily_coupon)}, }] else: subscribers = [ @@ -796,7 +911,8 @@ 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": build_lp_link(campaign_type, row[4])}, + "lp_link": lp_link_with_coupon(campaign_type, row[4], daily_coupon), + **coupon_attribs(daily_coupon)}, } for row in rows ] diff --git a/site/src/components/OrderPriceBanner.astro b/site/src/components/OrderPriceBanner.astro index 85604eb..888f96a 100644 --- a/site/src/components/OrderPriceBanner.astro +++ b/site/src/components/OrderPriceBanner.astro @@ -5,17 +5,29 @@ export interface Props { priceCents?: number; govFeeLabel?: string; note?: string; + serviceSlug?: string; } -const { priceCents, govFeeLabel, note } = Astro.props; +const { priceCents, govFeeLabel, note, serviceSlug } = Astro.props; const hasPrice = typeof priceCents === "number"; +const API = import.meta.env.PUBLIC_API_URL || ""; --- {hasPrice && ( -