Skip to content

fix(gift): record the expiry of unused daily-gift points in the ledger - #194

Merged
argszero merged 1 commit into
mainfrom
fix/gift-expire-ledger
Sep 13, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/gift-expire-ledger

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The daily newcomer gift, once it expires, leaves the account without a ledger row. src/gift.rs::expire_past_gifts (the lazy sweeper run before every daily grant and before every consumption) marks the expired gift_grants rows as expired and recomputes quotas.gift_balance — i.e. it takes points out of balance + gift_balance — but it writes nothing into transactions. Every other balance movement is recorded: grant ⇒ type='gift' (gift.rs), consumption ⇒ type='consume' (billing::settle), top-up ⇒ type='topup'. So the ledger and the account cannot be reconciled: SUM(transactions) (direction from type) ≠ balance + gift_balance.

Reproduced at runtime against the real functions (fresh user, print-only probe, reverted afterwards):

[1-gift-granted]  ledger=1   balance=0 gift=1   total=1   reconcile=true   txs=["gift=1"]
[2-gift-consumed] ledger=0.6 balance=0 gift=0.6 total=0.6 reconcile=true   txs=["gift=1","consume=0.4"]
[3-gift-expired]  ledger=1   balance=0 gift=0   total=0   reconcile=false  txs=["gift=1"]   grants=["1:expired"]

Legs 1–2 are the positive controls (the invariant holds today), leg 3 is the defect. The same is reachable through the real HTTP handler: GET /api/wallet → 12472, force expires_at into the past → GET /api/wallet → 12471, with gift=1 still the only ledger row.

Severity — ledger/display, not settlement. No settlement or enforcement path reads transactions; the points really do leave the account either way (the balance is already correct). What is wrong is that the bill and the 本月点数变化 net omit one class of event, so the panel's total disagrees with the account. The expire type is designed, not accidental: MONTH_TYPE_LABELS (ui/js/app.js) has carried ["expire", T("tx.type.expire")] since the commit that built that panel, and both i18n packs already carry the key — so no new translation key is introduced.

Related Issue

No issue exists for this (the work came from an internal recon cycle); none is fabricated. closingIssuesReferences is intentionally empty.

Changes

  • src/gift.rs::expire_past_gifts now sums the amount that is about to expire, and — only when that sum is > 0 — inserts one type='expire' transaction for it. One row per sweep (not per grant), no row when nothing expired, and the sweep stays idempotent (a second call sees no active expired rows). The row records only what actually expired, so partial grants are exact: grant 1.0, consume 0.4, expire ⇒ the row is 0.6.
  • Convention applied in every reader that classifies an "expense" — expire is an expense type (points leaving the account), the income whitelist stays ('earn','topup','gift'):
    • routes/wallet.rs: /api/transactions summary.expense_pts and /api/transactions/trend expense now filter type IN ('consume','expire','withdraw') instead of type = 'consume'. The net columns already used ELSE -pts and needed no change, as did the dashboard series and the 本月净变化 row.
    • routes/ops.rs: the month 点数流出 counter now filters type IN ('consume','expire','withdraw').
    • routes/wallet.rs: the /api/transactions?type= filter accepts expire (the column-filter select is served by the API, rant 2026-08-25T10:33.26).
    • ui/js/app.js: expire added to TX_TYPE (previously an expire row fell back to the raw string expire) and to the type column-filter options. tx.type.expire already exists in both packs ⇒ zero new i18n keys.
  • No configuration / data-structure change (transactions gains no column; type is already free-form TEXT).
  • Decision — the row's time: left at the column default (datetime('now'), the sweep moment). expires_at would date the row before rows already inserted, and every other writer stamps the moment of the write (the lazy grant is stamped when granted, not when the user first qualified).
  • Decision point: if expiry should stay unrecorded instead, the alternative is to drop expire from MONTH_TYPE_LABELS and document that the ledger is not balance-reconciling. This PR deliberately chooses the reconciling ledger.

Tests

  • cargo test 全部通过 (184 passed, baseline 181 + 3 new)
  • cargo fmt --check 通过
  • cargo clippy --all-targets -- -D warnings 通过
  • 新增/更新了单元测试:
    • gift::tests::gift_expiry_is_recorded_in_the_ledger — the assertion is the specification (SUM(transactions) with direction from type == balance + gift_balance), across three lifecycles: granted / consumed / expired, plus partial expiry (row 0.6). Legs 1–2 are positive controls.
    • gift::tests::gift_expiry_is_idempotent_and_writes_nothing_when_nothing_expired — no row when no points expired (control), and exactly one row after three sweeps.
    • routes::wallet::tests::expired_gift_is_visible_in_the_ledger_through_the_handlers — drives the real handlers: GET /api/wallet (the sweeper runs), asserts the account drops by 1, then GET /api/transactions shows the expire row with expense_pts including it, income_pts not, net_pts == income − expense, GET /api/transactions?type=expire accepted, and the trend expense column includes it.

A/B: reverting only the INSERT in expire_past_gifts (keeping all reader changes) yields exactly 3 red / 181 green — the three new tests, and nothing else. The i18n gate is unaffected by the revert.

src/i18n_pack.rs: the T() call-site count constant is recalibrated from the gate's own report (537 → 538 — app.js gains one T("tx.type.expire") call site in TX_TYPE); key counts (786 / 786) and the distinct literal count (430) are unchanged. ui/index.html app.js cache-bust bumped.

Checklist

  • 分支命名符合约定(fix/)
  • Commit message 使用 Conventional Commits 格式 (fix(gift): …)
  • 单一职责,改动最小化

@argszero
argszero merged commit e3f85d2 into main Sep 13, 2026
1 check passed
@argszero
argszero deleted the fix/gift-expire-ledger branch September 13, 2026 01:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant