Repository navigation
fix(gift): record the expiry of unused daily-gift points in the ledger - #194
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 expiredgift_grantsrows asexpiredand recomputesquotas.gift_balance— i.e. it takes points out ofbalance + gift_balance— but it writes nothing intotransactions. 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 fromtype) ≠balance + gift_balance.Reproduced at runtime against the real functions (fresh user, print-only probe, reverted afterwards):
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, forceexpires_atinto the past →GET /api/wallet→ 12471, withgift=1still 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. Theexpiretype 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.
closingIssuesReferencesis intentionally empty.Changes
src/gift.rs::expire_past_giftsnow sums the amount that is about to expire, and — only when that sum is> 0— inserts onetype='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 noactiveexpired rows). The row records only what actually expired, so partial grants are exact: grant 1.0, consume 0.4, expire ⇒ the row is0.6.expireis an expense type (points leaving the account), the income whitelist stays('earn','topup','gift'):routes/wallet.rs:/api/transactionssummary.expense_ptsand/api/transactions/trendexpensenow filtertype IN ('consume','expire','withdraw')instead oftype = 'consume'. Thenetcolumns already usedELSE -ptsand needed no change, as did the dashboardseriesand the 本月净变化 row.routes/ops.rs: the month 点数流出 counter now filterstype IN ('consume','expire','withdraw').routes/wallet.rs: the/api/transactions?type=filter acceptsexpire(the column-filter select is served by the API, rant 2026-08-25T10:33.26).ui/js/app.js:expireadded toTX_TYPE(previously anexpirerow fell back to the raw stringexpire) and to the type column-filter options.tx.type.expirealready exists in both packs ⇒ zero new i18n keys.transactionsgains no column;typeis already free-formTEXT).time: left at the column default (datetime('now'), the sweep moment).expires_atwould 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).expirefromMONTH_TYPE_LABELSand 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 fromtype==balance + gift_balance), across three lifecycles: granted / consumed / expired, plus partial expiry (row0.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, thenGET /api/transactionsshows theexpirerow withexpense_ptsincluding it,income_ptsnot,net_pts == income − expense,GET /api/transactions?type=expireaccepted, 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: theT()call-site count constant is recalibrated from the gate's own report (537 → 538 — app.js gains oneT("tx.type.expire")call site inTX_TYPE); key counts (786 / 786) and the distinct literal count (430) are unchanged.ui/index.htmlapp.js cache-bust bumped.Checklist
fix/)fix(gift): …)