## Context

docs/PRODUCT-MODEL.md: the "pay to unlock" flow (2014 late_payment) needs the owner told when their tag is gated. This slice adds renewal tracking (renews_at + lazy expiry) and the unlock notification. Channels: ntfy (self-hosted, free) default; SMS optional. Email deferred (no SMTP infra yet — documented).

## Goals / Non-Goals

**Goals:**
- Lapsed = `renews_at` in the past (lazy, computed at gating) — no cron needed.
- Gated scans (lapsed or credits exhausted) notify the owner once per scan with the reason + tag link.
- Notices never consume SMS credits; channel configurable.

**Non-Goals:**
- Email channel (no SMTP yet) — documented future.
- Automated renewal reminders (30/7/1 days) — later.
- Stripe renewal payments — later (sets renews_at).

## Decisions

1. **`orders.renews_at TIMESTAMPTZ`** (idempotent, nullable). Gating: order exists && status != 'paid' → blocked (as before); **or** status == 'paid' && renews_at valid && renews_at < now() → treated as lapsed → blocked + notify.
2. **`internal/notify` package**: `Notifier` with `Send(ownerPhone, message)`. Channels by env: `ntfy` (POST to NTFY_URL + topic, default http://192.168.20.13:9090? — check ntfy port; the network map says ntfy on .150 phone + NTFY used; .13 has mosquitto... actually ntfy: the map lists NTFY for notifications; ntfy server on .13? The containers list didn't show ntfy explicitly, but the user uses NTFY. I'll make NTFY_URL env-configurable and default to a sensible value; if unreachable, log and continue), `sms` (reuse sms.Sender), `both`. Unlock messages are exempt from metering by design (not sent through the credit path).
3. **Gating integration**: in `shouldAlert` (lapsed branch) and the credit-exhausted branch — instead of just returning false, call `a.notifyUnlock(tag)` (once per scan). The Scan handler already knows the outcome; simplest: `shouldAlert` returns a reason enum, and the handler notifies on reason lapsed/credits. Refactor: `shouldAlert` → returns (bool, reason). Handler: if !alertSent && (reason == lapsed || reason == credits) → notifyUnlock.
4. **Admin**: OrderResource `renews_at` (date-time picker); lapsed badge when renews_at past (table column state).
5. **Message**: "Where Woof: your tag {code} was scanned. Your plan is {lapsed | out of SMS credits}. Renew to see the finder's details: https://where-woof.com/account". Keep short for SMS.

## Risks / Trade-offs

- [ntfy unreachable] → log + continue (graceful).
- [Unlock SMS exempt from metering] → intentional (system notice to owner); could be rate-limited by the existing daily caps if abused — acceptable.
- [Lazy expiry means status flips only when scanned] → fine (no cron); the admin badge shows computed lapsed.

## Migration Plan

1. `make db-up` (renews_at ALTER); sqlc regen.
2. `internal/notify`; shouldAlert reason refactor + notifyUnlock; config env.
3. Admin renews_at field + lapsed badge.
4. Verify (new suite + regressions); deploy.

## Open Questions

- ntfy topic name + URL (defaults + env override).
