openspec: renewal-unlock change
This commit is contained in:
2
openspec/changes/renewal-unlock/.openspec.yaml
Normal file
2
openspec/changes/renewal-unlock/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-10
|
||||||
3
openspec/changes/renewal-unlock/README.md
Normal file
3
openspec/changes/renewal-unlock/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# renewal-unlock
|
||||||
|
|
||||||
|
Order renewal date (lazy expiry), unlock notification to owner when gated (lapsed/credits) via ntfy + optional SMS, admin renews_at field
|
||||||
40
openspec/changes/renewal-unlock/design.md
Normal file
40
openspec/changes/renewal-unlock/design.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## 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).
|
||||||
28
openspec/changes/renewal-unlock/proposal.md
Normal file
28
openspec/changes/renewal-unlock/proposal.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The metering slice (credits) blocks alerts when exhausted, and paid-gating blocks on lapsed orders — but the owner is never *told why* or what to do. The 2014 system had a `late_payment` flow ("your payments are suspended"); this change restores that: an **unlock notification** to the owner when a scan is gated, plus **renewal tracking** (a renews date with lazy expiry) so lapsed status is computed, not manually maintained.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **`orders.renews_at`** (idempotent): admin sets the next renewal date. Gating treats a `paid` order with `renews_at` in the past as lapsed (lazy expiry — no background job needed).
|
||||||
|
- **Unlock notification**: when a scan is blocked because the order is lapsed **or** credits are exhausted, the owner receives a notice: *"Your Where Woof tag was scanned — your plan is lapsed / out of SMS credits. Renew to see the finder's details."* with the tag link.
|
||||||
|
- **Notify channel abstraction** (`internal/notify`): **ntfy** (self-hosted on .13 — free) by default, **SMS** optional via env (`NOTIFY_CHANNEL=ntfy|sms|both`). Email is a documented future channel (SMTP infra absent). Unlock notices are exempt from SMS metering (they're system notices).
|
||||||
|
- **Admin**: OrderResource gains `renews_at`; a lapsed badge on orders past renewal.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `renewal-unlock`: renewal date + lazy expiry + owner unlock notification when gated.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `sms-alerting`: ADDED — gated scans notify the owner with the reason + renewal call-to-action.
|
||||||
|
- `database`: ADDED — `orders.renews_at`.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Go**: `internal/notify` (ntfy + sms channels), scan.go gating calls notify on lapsed/credit blocks, orders query for renews_at, template/message builder.
|
||||||
|
- **Schema**: idempotent ALTER; `make db-up`.
|
||||||
|
- **Admin**: OrderResource renews_at + lapsed indicator.
|
||||||
|
- Config: `NOTIFY_CHANNEL` env (default ntfy), `NTFY_TOPIC` env.
|
||||||
30
openspec/changes/renewal-unlock/specs/renewal-unlock/spec.md
Normal file
30
openspec/changes/renewal-unlock/specs/renewal-unlock/spec.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Renewal date and lazy expiry
|
||||||
|
Orders SHALL have a `renews_at` date. Gating SHALL treat a `paid` order with `renews_at` in the past as lapsed.
|
||||||
|
|
||||||
|
#### Scenario: Past renewal date
|
||||||
|
- **WHEN** a scan arrives on a tag whose order is `paid` but `renews_at` is in the past
|
||||||
|
- **THEN** the order is treated as lapsed (alerts blocked)
|
||||||
|
|
||||||
|
#### Scenario: Future renewal date
|
||||||
|
- **WHEN** `renews_at` is in the future
|
||||||
|
- **THEN** normal paid gating applies
|
||||||
|
|
||||||
|
### Requirement: Unlock notification
|
||||||
|
When a scan is blocked because the order is lapsed or SMS credits are exhausted, the owner SHALL receive a notification stating their plan is lapsed or out of credits, with the tag link and a call to renew. The notification SHALL be sent via the configured channel (ntfy by default; SMS optional) and SHALL NOT consume SMS credits.
|
||||||
|
|
||||||
|
#### Scenario: Lapsed-order scan
|
||||||
|
- **WHEN** a scan is blocked by a lapsed order
|
||||||
|
- **THEN** the owner receives the unlock notice (one per scan)
|
||||||
|
|
||||||
|
#### Scenario: Credits-exhausted scan
|
||||||
|
- **WHEN** a scan is blocked by exhausted credits
|
||||||
|
- **THEN** the owner receives the unlock notice
|
||||||
|
|
||||||
|
### Requirement: Configurable channel
|
||||||
|
The unlock notification channel SHALL be configurable (`NOTIFY_CHANNEL`): `ntfy`, `sms`, or `both` (default `ntfy`).
|
||||||
|
|
||||||
|
#### Scenario: ntfy default
|
||||||
|
- **WHEN** `NOTIFY_CHANNEL` is unset
|
||||||
|
- **THEN** notices go to the configured ntfy topic
|
||||||
18
openspec/changes/renewal-unlock/tasks.md
Normal file
18
openspec/changes/renewal-unlock/tasks.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
## 1. Schema & Notify
|
||||||
|
|
||||||
|
- [ ] 1.1 `db/schema.sql`: idempotent `orders.renews_at TIMESTAMPTZ`; `make db-up`
|
||||||
|
- [ ] 1.2 `internal/notify`: Notifier (ntfy POST + sms via sms.Sender; `NOTIFY_CHANNEL` env: ntfy|sms|both; `NTFY_URL`/`NTFY_TOPIC` env)
|
||||||
|
|
||||||
|
## 2. Gating Integration
|
||||||
|
|
||||||
|
- [ ] 2.1 `scan.go`: `shouldAlert` returns (bool, reason); lapsed branch uses renews_at lazy expiry; handler calls `notifyUnlock` when blocked by lapsed or credits (message: plan lapsed / out of credits + tag link)
|
||||||
|
|
||||||
|
## 3. Admin
|
||||||
|
|
||||||
|
- [ ] 3.1 OrderResource: `renews_at` date-time picker + table column; lapsed badge when past due
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
- [ ] 4.1 New suite: order paid + renews_at past → blocked + unlock notice logged; credits exhausted → blocked + notice; NOTIFY_CHANNEL=sms → notice via log sender; unpaid (non-paid status) → notice
|
||||||
|
- [ ] 4.2 Regressions (72) green; deploy (frontend binary; admin restart)
|
||||||
|
- [ ] 4.3 `openspec validate renewal-unlock`; commit
|
||||||
Reference in New Issue
Block a user