# ExecPlan: Billing Credit Notifications

## Purpose / Big Picture

实现积分通知中心 v1，让创作者在积分到账、积分即将过期、余额较低时收到可运营配置的通知。v1 首个渠道是短信，但实现必须按通知中心抽象建设，保留后续站内信、邮件、飞书等渠道扩展空间。

本计划的来源文档是：

- `docs/billing-credit-notifications.md`
- `docs/billing-credit-notifications-technical-design.md`

核心边界：积分通知只记录和投递通知事实，不改变积分发放、扣减、过期或余额事实。账务真相仍以 `credit_ledger_entries`、`credit_wallet_buckets`、`credit_wallets` 为准。

## Progress

- [x] 2026-05-21 16:21 CST: Created the active ExecPlan from the requirements and technical design documents.
- [x] 2026-05-21 17:07 CST: Added the `notification_records` data model, migration, constants, and default disabled config seed.
- [x] 2026-05-21 17:07 CST: Implemented billing notification staging, scanning, delivery, requeue, policy validation, dry-run, cost estimate, and metrics events.
- [x] 2026-05-21 17:07 CST: Wired Celery tasks for credit grant, expiring credits, low balance, and SMS delivery; preserved `billing.send_low_balance_alert` as a compatibility entry point.
- [x] 2026-05-21 17:07 CST: Added operator APIs and frontend page for notification records, policy config, dry-run, and retry.
- [x] 2026-05-21 17:07 CST: Added teacher-facing balance state and softlimit debug blocking on frontend and backend preview/debug paths.
- [x] 2026-05-21 17:07 CST: Added focused backend/task/frontend tests and ran validation; architecture boundary check is blocked only by unrelated pre-existing untracked route-support files.
- [x] 2026-05-22 18:35 CST: Extended low-balance notifications with estimated-days thresholds based on finalized daily ledger consumption, structured operator form fields, dry-run details, and focused tests.

## Surprises & Discoveries

- Existing subscription purchase SMS already uses an async billing notification pattern in `src/api/flaskr/service/billing/notifications.py`, but it stores state in `BillingOrder.metadata`; this feature needs the more general `notification_records` table because `credit_expiring` and `low_balance` do not belong to a single order.
- Existing `billing.send_low_balance_alert` currently produces alert candidates. It should be preserved as a compatibility entry point while delegating to the new low-balance notification scan.
- `docs/exec-plans/index.md` is generated by `scripts/build_repo_knowledge_index.py`; do not edit it manually when adding this plan.
- The first expiring-bucket scanner draft used `effective_to <= now + window`, which caused a bucket due in 1 day to match 7d/3d/1d windows in the same scan. The final scanner uses a per-window day interval, so each bucket only matches the intended reminder window for that run.
- `python scripts/check_architecture_boundaries.py` still reports unrelated untracked `learn/http`, `shifu/http`, and `route_support.py` boundary violations already present in this worktree; credit notification imports were adjusted to avoid adding new boundary drift.

## Decision Log

- Use `notification_records` as the table name instead of a credit-specific name so future non-credit notification types can reuse the table.
- v1 channel is `sms`; the table still keeps a `channel` column.
- Store v1 policy in `sys_configs` under `BILL_CREDIT_NOTIFICATION_SMS_CONFIG`, default disabled.
- Keep Aliyun provider credentials and sign-name secrets in existing env/config paths; operator config only stores notification policy and template bindings.
- Use fixed dedupe keys:
  - `credit_expiring:{wallet_bucket_bid}:{window}`
  - `credit_granted:{ledger_bid}`
  - `low_balance:{creator_bid}:{threshold}:{date}`
  - `low_balance:{creator_bid}:estimated_days:{days}:lookback:{lookback_days}:{date}`
- `low_balance` supports `fixed` and `estimated_days` threshold policies; `estimated_days` reads `bill_daily_ledger_summary` for complete prior natural days only.
- `softlimit.threshold` remains fixed-only in v1 so daily consumption estimation cannot change debug-blocking behavior.
- Do not reuse `/api/user/send_sms_code`, `/api/user/console_send_sms_code`, or captcha templates.
- softlimit is enforced on both frontend and backend: frontend disables teacher debug entry, backend debug/preview APIs validate `debug_allowed`.
- hardlimit remains a billing admission/runtime boundary and is not controlled by notification policy.

## Outcomes & Retrospective

Implemented v1 of the积分通知中心:

- Durable `notification_records` table and disabled-by-default `BILL_CREDIT_NOTIFICATION_SMS_CONFIG` policy.
- SMS-only notification service with policy validation, dedupe, staging, delivery, skip states, failed-provider requeue, dry-run, and SMS cost estimate.
- `credit_granted` hooks in paid/manual/trial grant flows, plus scheduled scan tasks for `credit_expiring` and `low_balance`.
- Operator APIs and frontend page for record search, structured policy config, dry-run, and failed-provider requeue.
- Low-balance reminders can optionally trigger by estimated remaining days, using finalized daily consume summaries. Fixed-threshold fallback only applies when there is partial valid consumption history; missing or zero daily consumption summaries do not send estimated-days reminders.
- Billing overview exposes `credit_status`, `debug_allowed`, and `softlimit_threshold`; preview/debug paths enforce softlimit in both frontend and backend.
- Focused backend, task, and frontend tests cover staging, dedupe, skip, provider retry, scan windows, softlimit, Celery schedule/config, operator page rendering, and frontend preview blocking.

## Context and Orientation

Relevant source documents:

- Requirements: `docs/billing-credit-notifications.md`
- Technical design: `docs/billing-credit-notifications-technical-design.md`
- Existing purchase SMS design: `docs/billing-subscription-purchase-sms.md`
- Manual grant semantics: `docs/operator-user-points-grant.md`
- Billing wallet and bucket design: `docs/billing-subscription-design.md`

Likely backend surfaces:

- Billing models and constants: `src/api/flaskr/service/billing/models.py`, `src/api/flaskr/service/billing/consts.py`
- Billing notification precedent: `src/api/flaskr/service/billing/notifications.py`
- Billing tasks: `src/api/flaskr/service/billing/tasks.py`
- Celery beat schedule: `src/api/flaskr/common/celery_app.py`
- Config read/write: `src/api/flaskr/service/config`
- SMS provider helper: `src/api/flaskr/api/sms/aliyun.py`
- Operator APIs: `src/api/flaskr/service/shifu/admin.py`, `src/api/flaskr/service/shifu/admin_dtos.py`, `src/api/flaskr/service/shifu/route.py`

Likely frontend surfaces:

- Operator menu: `src/cook-web/src/app/admin/admin-menu.tsx`
- Operator pages and types: `src/cook-web/src/app/admin/operations`
- Frontend API map: `src/cook-web/src/api/api.ts`
- User-facing strings: `src/i18n/`

## Plan of Work

1. Add the durable notification model and disabled-by-default policy config.
2. Build a billing notification service that owns policy parsing, dedupe, staging, skip decisions, delivery, dry-run, and requeue.
3. Wire event and scan triggers for `credit_granted`, `credit_expiring`, and `low_balance`.
4. Expose operator APIs for records, config, dry-run, and retry.
5. Add the operator frontend page with i18n-backed labels.
6. Add teacher-facing limit state and softlimit debug blocking.
7. Add focused backend, task, frontend, and repository validation coverage.
8. Roll out in disabled, dry-run, small-traffic, and scan-enabled phases.

## Concrete Steps

1. Add `notification_records` to the billing model layer with:
   - `notification_bid`
   - `notification_type`
   - `channel`
   - `creator_bid`
   - `target_user_bid`
   - `mobile_snapshot`
   - `source_type` / `source_bid`
   - `dedupe_key`
   - `status`
   - template, policy, provider response, error, and timestamp fields
   - unique indexes for `notification_bid` and `dedupe_key`
   - query indexes for status/type/time, creator/time, and source lookup
2. Add notification constants and seed `BILL_CREDIT_NOTIFICATION_SMS_CONFIG` with `enabled=false`.
3. Add `src/api/flaskr/service/billing/credit_notifications.py` with:
   - policy load/validate helpers
   - dedupe key builders
   - `stage_credit_granted_notification`
   - `scan_credit_expiring_notifications`
   - `scan_low_balance_notifications`
   - `deliver_credit_notification`
   - `requeue_credit_notification`
   - `dry_run_credit_notifications`
   - `resolve_creator_limit_state`
4. Add Celery tasks:
   - `billing.scan_credit_expiring_notifications`
   - `billing.scan_low_balance_notifications`
   - `billing.send_credit_notification`
   - compatibility wrapper or delegation from `billing.send_low_balance_alert`
5. Hook `credit_granted` into successful credit grant flows only after ledger and bucket facts commit.
6. Add operator API DTOs and routes for:
   - list/filter notification records
   - get/update policy config
   - dry-run
   - requeue `failed_provider`
7. Add the operator frontend page:
   - menu entry "积分通知"
   - record table and filters
   - failure detail
   - single-record requeue
   - policy config form
   - dry-run result panel
8. Add teacher-facing balance state:
   - expose `normal`, `softlimit`, `hardlimit`
   - expose `debug_allowed`
   - disable frontend debug entry for softlimit
   - guard backend debug/preview APIs with the same policy
9. Add observability:
   - generated count
   - sent count
   - provider failure count
   - skip counts by reason
   - duplicate suppression count
   - requeue count
   - SMS cost estimate
10. Update tests and keep this ExecPlan progress current as each step lands.

## Validation and Acceptance

- `credit_granted`, `credit_expiring`, and `low_balance` records are created with stable dedupe keys and do not duplicate on retry.
- Notification delivery records `sent`, `skipped_no_mobile`, `skipped_opt_out`, `suppressed_duplicate`, or `failed_provider` without mutating wallet, bucket, or ledger facts.
- Provider failures can be requeued from the operator surface; terminal skip states are not requeued unless policy changes explicitly allow it.
- Operator users can list/filter records, inspect failure details, update policy config, run dry-run, and requeue `failed_provider`.
- Teacher-facing surfaces receive `normal`, `softlimit`, `hardlimit`, and `debug_allowed`.
- softlimit disables teacher debug on frontend and backend; hardlimit remains enforced by billing admission/runtime behavior.
- User-facing frontend strings are stored in `src/i18n/`, not hardcoded in components.
- Minimum validation for docs-only changes: `python scripts/check_repo_harness.py`.
- Implementation validation must include focused backend tests, task tests, frontend tests for the operator page, and architecture boundary checks when shared contracts change.

## Idempotence and Recovery

- `dedupe_key` uniqueness is the primary duplicate defense for all notification types.
- `credit_granted` uses the ledger BID so repeated grant requests that reuse an existing ledger do not send another notification.
- `credit_expiring` uses bucket BID plus window, so repeated scans of the same bucket/window are safe.
- `low_balance` uses creator, threshold, and date, so repeated daily scans do not spam the same creator.
- `low_balance` estimated-days rules use creator, trigger days, lookback days, and date, so fixed and automatic thresholds remain independently idempotent.
- Worker delivery should lock the record and only process `pending` or `failed_provider`.
- If a provider call fails, keep the record in `failed_provider` with response/error details so it can be retried.
- No-mobile, opt-out, blacklist, frequency, budget, and duplicate suppression are terminal unless the policy explicitly changes and an operator chooses a new action.
- Notification failure must not roll back a successful billing fact.

## Interfaces and Dependencies

- New durable table: `notification_records`.
- New policy key: `BILL_CREDIT_NOTIFICATION_SMS_CONFIG`.
- New notification types: `credit_expiring`, `credit_granted`, `low_balance`.
- v1 channel: `sms`.
- Source IDs:
  - `wallet_bucket_bid`
  - `ledger_bid`
  - `creator_bid`
- Low-balance threshold policy variants:
  - `{ "kind": "fixed", "value": "..." }`
  - `{ "kind": "estimated_days", "days": 7, "lookback_days": 7, "min_consumed_days": 2, "fallback_fixed_value": "0" }`
- New or updated task entries:
  - `billing.scan_credit_expiring_notifications`
  - `billing.scan_low_balance_notifications`
  - `billing.send_credit_notification`
  - `billing.send_low_balance_alert` compatibility path
- New operator endpoints under `/shifu/admin/operations/credit-notifications`.
- Existing dependencies to preserve:
  - Aliyun SMS helper `send_sms_ali`
  - billing ledger, wallet, and bucket accounting
  - existing auth SMS routes and captcha templates
  - existing hardlimit admission behavior
