Fix daily and weekly job credit reliability - #423
Conversation
…eal MySQL
The API's test suite could not load at all: `Db`'s constructor calls
`knex(config[process.env.NODE_ENV])` at import time, jest sets NODE_ENV=test, and
`knexfile` defined only `development` and `production`. Seven of eleven suites died
with "Cannot read property 'client' of undefined" before running an assertion, and
the two member-service login tests that had been failing since the daily credit
stopped being awaited were invisible behind them. Adding the `test` key takes the
run from 4 executed tests to 20.
That key is also the connection the new database-backed specs use. They refuse to
write unless CTR_INTEGRATION_TEST_DB names the configured schema exactly, so a
reachable database is deliberately not enough to arm them - the API's ordinary
environment names a schema these fixtures must never touch.
The six defects, all reproduced against MySQL 5.7 rather than argued from the code:
- a role paying 0 CityCash and 0 XP still qualifies its holder for payroll, takes
a slot in the capped batch, and gets last_weekly_role_credit stamped;
- among roles paying equal CityCash the selected one is arbitrary, so a
higher-XP role can lose to a lower-XP one;
- the weekly payout commits wallet and ledger in one transaction and member XP
and eligibility in another, so failing the second leaves money moved and the
member still eligible - a retry pays again;
- two overlapping cron executions both select the same member and both pay;
- two logins arriving together both observe "not credited today" and both pay,
and the credit is not awaited, so the token can be returned before it lands;
- a daily credit and a weekly credit touching one wallet each read the balance
and write back their own total, so one of the two is lost.
The concurrency specs install a trigger that delays every wallet update, so the
races are forced rather than hoped for; the rollback specs install a trigger that
fails the member half of a payout at the database, which no mock can do.
Run with --runInBand: they share the member table.
The daily bonus moved four things - wallet balance, ledger row, member XP, and the timestamp that decides whether the bonus may be given again - and committed them in two independent transactions. Failing the second left the money moved with the member still eligible, so the next login paid again. Eligibility was also read before either transaction opened, so two logins arriving together both saw "not credited today" and both paid; and the wallet was credited by reading the balance and writing back read + amount, which loses a concurrent credit to the same wallet. CreditRepository now owns the payout as one transaction: lock the member row with SELECT ... FOR UPDATE, recheck eligibility against the locked row, then write wallet, ledger, XP and timestamp together. A locking read returns the latest committed row rather than the transaction's snapshot, so the second of two concurrent callers sees the first one's timestamp and does nothing. The wallet moves by `balance = balance + ?`, so the database computes the new total from whatever is committed at that moment. `login` now awaits the credit rather than leaving it in flight behind the returned token, and catches its failure: refusing someone their account over a missed bonus is the worse outcome, and since the payout is all-or-nothing there is no partial credit left to clean up. `createMemberAndLogin` and the session refresh - which could previously 500 a member out of an otherwise valid session - go through the same helper. Amounts are untouched, and so is the rule that any role assignment counts as employed for this bonus, including one that pays nothing. Only weekly payroll cares whether a role earns. TransactionRepository.createDailyCreditTransaction is removed rather than left dead: it is the read-then-write wallet update this change exists to stop. Also brings the four touched files to zero lint errors and warnings. The one exception is MemberService.getAccessLevel, still `Promise<any>`: typing it as the `string[]` it returns makes tsc reject `accessLevel === 'admin'` in two controllers, which is always false today. That is an authority bug, out of scope here, and reported rather than fixed.
Weekly payroll had the same split-commit and stale-read shape as the daily bonus, plus an eligibility question it never asked. Eligibility. The inner join to role_assignment existed only to test "does this member hold a job", with no income filter, so any assignment qualified - including a role paying 0 CityCash and 0 XP. Admin is seeded exactly that way, so it is live today: such a member gets a zero-value weekly-role-credit ledger row, has last_weekly_role_credit stamped, and takes one of the batch's capped slots away from someone who actually earned pay. The predicate is now "pays CityCash OR pays XP", never CityCash alone - the two columns are independent, and filtering on CityCash would stop an XP-only role ever accruing its XP. It is applied to the batch query and to role selection alike, so a member cannot be selected on one role and paid for another. Payout. getMembersDueRoleCredit read each member's XP, wallet and paying role and handed them to the payout as arguments; by the time a worker used them another worker might have paid already. It now returns ids and nothing else, and CreditRepository re-reads everything under SELECT ... FOR UPDATE on the member row: eligibility rechecked with the same three conditions the batch selects on, paying role re-resolved, then wallet, ledger, XP and timestamp written in that one transaction. Two overlapping cron executions therefore pay once - the second blocks on the lock, sees the first one's timestamp, and does nothing - and a failure part way through leaves nothing behind for a retry to double. Single pay is unchanged: a citizen holding several jobs is paid for one of them, the highest-paying. Ties on CityCash now go to the role granting more XP rather than to whichever row came back first. place_id still plays no part in who gets paid, which is deliberate: that is A1's question, not this one. Salary and XP values are untouched. TransactionRepository.createWeeklyRoleCreditTransaction is removed for the same reason as its daily counterpart.
Both payouts now fail in the direction the implementation does not write first: the ledger insert is failed at the database, and the member row must come back untouched. Written the other way round, the earlier rollback specs only prove that whichever half runs second cannot commit alone. Also retries the daily credit after a failed attempt, which is where a split payout does its real damage - the weekly equivalent already covered it, and this is the same defect on the other path.
getAccessLevel() was annotated Promise<any>, the sole ESLint warning across the files this branch touches. It resolves to a string[] of access tags, but three legacy callers compare the whole return value to a bare string, so annotating the true string[] makes tsc reject them. Type the return as LegacyAccessLevel = string[] | 'admin' | 'security' instead: narrow enough to name only the two scalars callers actually compare against, wide enough that those callers still compile. The method body is untouched and the emitted JavaScript is byte-for-byte identical across all 142 compiled files. Repairing the always-false comparisons would change who can reach admin functionality, so it is left to a separate authority lane.
getMemberByWalletId was annotated Promise<Member[]>, but the query behind it,
MemberRepository.findByWalletId, selects username alone. The rows it resolves to
have never been full Member records, so the annotation promised columns that are
undefined at runtime.
No caller is affected today: all four sites in admin.controller.ts assign the
result into a variable seeded [{username: 'System'}] and read nothing but
username. The annotation would, however, have silently accepted new code reading
id, email or xp off a row that does not carry them.
Narrow the return to a WalletMemberSummary[] that describes what the query
actually produces, rather than widening the query to match the annotation, which
would change runtime behaviour. Type-only: emitted JavaScript is byte-for-byte
identical across all 142 compiled files.
The overstated annotation was introduced by 9fefaa7, not by the lint-gate commit.
|
Ready for upstream review. |
There was a problem hiding this comment.
Pull request overview
This PR hardens the daily login credit and weekly payroll credit paths so each eligible member is credited exactly once, with wallet balance, ledger row, XP, and eligibility timestamps updated atomically and safely under concurrency.
Changes:
- Introduces
CreditRepositoryto own daily + weekly payouts as single-row-locking, all-in-one DB transactions (atomic walletincrement, ledger insert, XP, and timestamp updates). - Fixes weekly payroll eligibility batching to exclude non-earning roles and to return only member ids; payout re-resolves the paying role under lock.
- Updates login/session paths to await daily credit and swallow credit failures (don’t fail login), and adds DB-backed integration specs for concurrency/rollback behavior.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| api/src/services/role-assignment/role-assignment.service.ts | Switch weekly payout execution to CreditRepository and tighten return types. |
| api/src/services/member/member.service.ts | Route daily credit through CreditRepository, await on login, and improve typing. |
| api/src/services/member/member.service.spec.ts | Update unit tests to assert daily credit call via CreditRepository. |
| api/src/services/member/daily-credit.integration.spec.ts | Add real-DB integration coverage for daily credit correctness, concurrency, and rollback. |
| api/src/repositories/transaction/transaction.repository.ts | Remove legacy credit transaction helpers and improve repository typing. |
| api/src/repositories/role-assignment/role-assignment.repository.ts | Fix weekly batch selection to only include earning roles and return member ids. |
| api/src/repositories/index.ts | Export new CreditRepository. |
| api/src/repositories/credit/credit.repository.ts | New transactional payout implementation (daily + weekly) with row locking. |
| api/src/knexfile.ts | Add test knex env configuration needed for Jest + integration-db specs. |
| api/src/cron/role-credit.ts | Cron now iterates member ids and delegates payout to the new weekly credit path. |
| api/src/cron/role-credit.integration.spec.ts | Add real-DB integration coverage for weekly payroll eligibility, concurrency, and rollback. |
| api/src/controllers/member.controller.ts | Ensure session refresh path awaits daily credit via the new helper. |
| api/spec/integration-db.ts | Add integration DB opt-in + fixtures helpers for safe real-DB tests. |
Suppressed comments (1)
api/src/repositories/credit/credit.repository.ts:215
first()can returnundefinedwhen the member id doesn’t exist, andgiveDailyCreditchecksif (!member). Update the return type to includeundefinedso the type reflects runtime behavior.
private async lockMember(trx: Knex.Transaction, memberId: number): Promise<Member> {
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /** A role a member holds, as the member views list it. */ | ||
| interface MemberRoleSummary { | ||
| id: number; | ||
| place_id: number; |
| /** A role a member holds, with the place it is scoped to if any. */ | ||
| interface MemberRoleRow { | ||
| id: number; | ||
| place_id: number; |
| knex: Knex, | ||
| memberId: number, | ||
| roleId: number, | ||
| placeId: number = null, |
| private async lockMemberDueWeeklyCredit( | ||
| trx: Knex.Transaction, | ||
| memberId: number, | ||
| ): Promise<{ wallet_id: number; due: number }> { |
| it('still logs the member in', async () => { | ||
| const token = await service.login(fakeMember.username, fakeMember.password); | ||
| expect(token).toBe(await service.getMemberToken(fakeMember.id)); | ||
| }); |
Problem
A citizen should receive each credit they earn exactly once, with their wallet, the ledger, their XP and the timestamp that gates the next payment all moving together. Neither job-credit path did that.
Weekly payroll paid people who had not earned anything.
getMembersDueRoleCredit's inner join existed only to ask "does this member hold a job", with no income filter, so anyrole_assignmentrow qualified its holder — including a role paying 0 CityCash and 0 XP. Admin is seeded exactly that way, so this is live today: such a member gets a zero-valueweekly-role-creditledger row, haslast_weekly_role_creditstamped, and takes one of the batch's capped 20 slots away from someone who actually earned pay.XP-only roles had to keep earning.
income_ccandincome_xpare independent columns, so filtering eligibility on CityCash alone would have silently stopped an XP-only role ever accruing its XP — one bug traded for another. No seeded role is XP-only today, which is precisely why it would have gone unnoticed.The daily credit was fire-and-forget.
login()calledmaybeGiveDailyCreditswithout awaiting it, so the token went back with the credit still in flight. The caller could read its own balance and not see it; two quick logins could both observe "not credited today"; and if the request finished first, nothing kept the process interested in the write.Both payouts committed in halves. Wallet and ledger went in one transaction, member XP and the eligibility timestamp in another. Fail the second and the money has moved while the member is still eligible — the next attempt pays again.
Both payouts read eligibility outside the transaction that paid. Two overlapping cron executions select the same member before either marks them paid, and both pay. Two logins arriving together do the same.
Wallet updates could overwrite each other. Every credit read
wallet.balanceand wrote backread + amount. A daily credit and a weekly credit landing on the same wallet — an ordinary Friday morning — lose one of the two.What changed
CreditRepositorynow owns both payouts, each as a single database transaction with the same shape:SELECT ... FOR UPDATE;A locking read returns the latest committed row rather than the transaction's snapshot, so the second of two concurrent callers sees the first one's timestamp and becomes a no-op. Weekly eligibility is evaluated inside the locking
SELECTrather than by a follow-up query, so the answer cannot come from a different point in time than the lock. Wallets move bybalance = balance + ?, so the database computes the new total from whatever is committed at that instant. The daily and weekly payout writers therefore compose safely with each other, and with any other writer that also mutates the balance atomically. The member row is always the first row locked on both paths, so they cannot deadlock against each other.getMembersDueRoleCreditnow returns member ids and nothing else, filtered to members holding a role that pays CityCash or XP. The per-member "which role would pay" lookup it used to do is gone: the payout re-resolves that under the lock, where it is still true.login,createMemberAndLoginand the session refresh all go through one helper that awaits the credit and catches its failure — refusing someone their account over a missed bonus is the worse outcome, and since the payout is all-or-nothing there is no partial credit left behind. The session refresh could previously return a 500 and log a member out for the same reason.TransactionRepository.createDailyCreditTransactionandcreateWeeklyRoleCreditTransactionare removed rather than left dead: they are the read-then-write wallet update this change exists to stop.Residual risk, outside this PR. Independent QA found that pre-existing legacy
TransactionRepositorymethods which read a wallet balance and later write an absolute calculated value can still overwrite an intervening atomic credit — the ledger row and the eligibility timestamp survive, but the credited amount is gone from the wallet. Modernizing those unrelated wallet writers is a separate platform-wide follow-up and is not part of this PR; it is tracked as Cyber-Town-Next-Gen/ctr-restoration#11.Single pay is unchanged — a citizen holding several jobs is paid for one of them, the highest-paying — with ties on CityCash now going to the role granting more XP rather than to whichever row the database returned first.
Recovered work
This branch reconstructs the relevant behaviour onto current upstream. It does not merge
beta, does not merge forkmaster, and cherry-picks nothing from the 29-commitfix/daily-credit-awaitbranch or the 107-commitbetabranch. Credit where it is due:4cc7fb2dtestenvironment, without which no suite could loadcd915c793b5978807af83c74income_cc > 0 OR income_xp > 0, with XP as the CityCash tie-break786231a0(squash-merged upstream ascbcc2b0, PR #204)Out of scope
Deliberately unchanged, and verified unchanged in the diff:
income_cc/income_xpand no seed data is touched;DAILY_CC_AMOUNT(50),DAILY_XP_AMOUNT(5),DAILY_CC_EMPLOYED_AMOUNT(100) andDAILY_XP_EMPLOYED_AMOUNT(10) keep their values, and the specs assert those numbers literally so a future change has to be deliberate;place_idstill plays no part in who gets paid, and there is a test proving a place-scoped assignment pays exactly as an unscoped one. Whether it should is a separate question;api/is touched;Who counts as "employed" for the daily bonus is also unchanged: any role assignment qualifies, including one that pays nothing. Only weekly payroll cares whether a role earns, and there is a test pinning that distinction.
Verification
Node 14.21.3, MySQL 5.7.44 in a throwaway container on port 13311, schema
ctr_b1_itest— created for this work and holding nothing else. The database-backed specs refuse to write unlessCTR_INTEGRATION_TEST_DBnames the configured database exactly, so a merely reachable database does not arm them; without the opt-in they register as skipped, never as a silent pass.All 43 tests pass. The three failing suites are pre-existing and untouched:
wallet.service.spec.ts,club.service.spec.tsandrole.repository.spec.tscontain no tests at all (the last is a stray copy of a repository class), and jest reports "Your test suite must contain at least one test" as a failure. They fail identically on upstream master.Before the fix, on unmodified upstream with only the knexfile key and the new specs added, 11 of the 23 new database-backed tests failed — one for each defect above. Recorded in
before-failures.txt.Concurrency, forced rather than hoped for: a trigger delays every wallet update, so two payouts starting together are guaranteed to overlap.
Re-run five times with no flakes.
Rollback, injected at the database with a trigger raising
SQLSTATE 45000— something no mock can do:Payroll behaviour, each proved against real SQL:
place_idNULL vs 1Lint. All 13 touched files are at zero errors and zero warnings (
eslint --max-warnings=0).MemberService.getAccessLevelno longer returnsPromise<any>: it is typedLegacyAccessLevel = string[] | 'admin' | 'security', a deliberately narrow compatibility type. The method still returns exactly the array it always returned — the change is type-only, and the emitted JavaScript is byte-for-byte identical across all 142 compiled files. The two scalar members exist solely because legacy callers compare the whole return value to a bare string:accessLevel === 'admin'inadmin.controller.tsandaccessLevel === 'security'inmember.controller.ts. Those comparisons can never be true against the array actually returned — a live authority bug worth its own issue. Repairing them would grant access currently denied, which is not this PR's to decide, so they are left untouched and the type accommodates them.Typecheck / build.
tsc --noEmit,npm run buildandnpm run build:prodall exit 0, withdist/laid out exactly as on master.Running the database-backed specs
Point
DB_*at a disposable schema, setCTR_INTEGRATION_TEST_DBto the same database name, and runnpx jest --runInBand(they share themembertable). Bootstrapping that schema needs one manual step, because of a pre-existing upstream defect this PR deliberately does not fix:20260309032638_add_voting_tables.tscreates its tables and then inserts avote_listrow withplace_id = 1in the same migration. On an empty database no place exists yet, the foreign key rejects the insert, and since MySQL DDL is not transactional the three vote tables survive — so every retry dies withER_TABLE_EXISTS_ERROR. Seeds run after migrations, sonpm run db:initcan never succeed on a fresh schema. Working order: migrate (fails), drop the three orphaned vote tables, seed, migrate again.