feature: stored-value gift card API (issue, balance, idempotent redeem, ledger) - #295
feature: stored-value gift card API (issue, balance, idempotent redeem, ledger)#295vanessasalas-cog wants to merge 9 commits into
Conversation
…edeem, ledger) Co-Authored-By: vanessa.salas <vanessa.salas@cognition.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
… of login redirect Co-Authored-By: vanessa.salas <vanessa.salas@cognition.ai>
End-to-end run against a live appBooted the app (
The one failure was the bad-credentials path flagged in review: |
…idempotency keys (feature hardening) Co-Authored-By: vanessa.salas <vanessa.salas@cognition.ai>
| .securityMatcher("/api/v1/stored-value/**") | ||
| .csrf(csrf -> csrf.disable()) | ||
| .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | ||
| .authorizeHttpRequests(authz -> authz.anyRequest().authenticated()) |
There was a problem hiding this comment.
🟨 Any authenticated user can redeem any card
The stored-value filter chain authorizes only anyRequest().authenticated(), and cards carry no owner. Any registered bank account that learns a card token can query balance, redeem funds, and read the ledger for a card it did not issue, because redeem and getCard in StoredValueService never check the principal against a card owner.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Accurate description of the behaviour, and it's a deliberate design point rather than an oversight — but it does need a product decision, so I'm not changing it unilaterally.
The card is a bearer instrument: possession of the token is the entitlement, exactly like a physical gift card, and the caller is a partner integration authenticating to the platform (not the cardholder). Cards therefore have no owner, and the token is the unguessable secret (UUIDv4, never logged in full, never returned as the PAN-equivalent reference).
If BHN wants per-partner tenancy instead — an issuer/owner column on stored_value_card, scoping every lookup to the authenticated principal, and a 404 (not 403) on cross-tenant access to avoid token probing — that's a small follow-up, but it changes the contract in docs/stored-value-api.md, so it should be an explicit decision. Flagging it to the requester.
…le (stored-value feature) Co-Authored-By: vanessa.salas <vanessa.salas@cognition.ai>
Co-Authored-By: vanessa.salas <vanessa.salas@cognition.ai>
…tus on idempotent replay (stored-value feature) Co-Authored-By: vanessa.salas <vanessa.salas@cognition.ai>
…t instead of skewing counters (feature tests) Co-Authored-By: vanessa.salas <vanessa.salas@cognition.ai>
…d-value feature) Co-Authored-By: vanessa.salas <vanessa.salas@cognition.ai>
…esources (feature hardening) Co-Authored-By: vanessa.salas <vanessa.salas@cognition.ai>
Summary
Adds a partner-facing stored-value / gift-card surface under
/api/v1/stored-value, written spec-first: the OpenAPI 3.0 contract and the invariant table live indocs/stored-value-api.md, and the code implements exactly that contract.Endpoints (stateless HTTP Basic, own
SecurityFilterChainat@Order(1); the Thymeleaf/form-login chain is untouched):/api/v1/stored-value/cardscardToken+ fee/expirydisclosure/api/v1/stored-value/cards/{token}/balanceEXPIREDonce pastexpiresAt/api/v1/stored-value/cards/{token}/redeemIdempotency-Key/api/v1/stored-value/cards/{token}/transactionsInvariants enforced (and tested)
uk_stored_value_txn_idempotency (card_id, idempotency_key), not just the application check; replays return the original entry withreplayed: trueand move no money. Same key with a different amount is a 409 rather than a silent re-price. The header is bounded (@Size(max = 128)) so an oversized key is a 400, never a column-overflow 500.StoredValueService.issueCard, so the invariant holds for direct service callers, not only for requests that pass throughIssueCardRequestbean validation.card_referenceis persisted but never serialised in any DTO and never logged; log lines carry****<last4>of the token only.disclosure { feesAssessed:false, feePolicy, expiryPolicy, expiresAt }.401with the same{code, message, details, timestamp}body as every other error — the entry point serialises a realErrorResponsethrough the injectedObjectMapper.httpBasicgets that same entry point asexceptionHandling, so a bad password can't fall through to the form-login redirect — the live run below caught it returning302 /loginplus aJSESSIONIDon a chain declaredSTATELESS.Other errors share that one shape via a
@RestControllerAdvicescoped to this controller, so existing MVC error behaviour is unchanged. Dispatch-level failures (unsupported method/media type) are raised before a handler resolves and so never reach the advice's catch-all — Spring answers those with the correct 4xx itself, asserted by test.Persistence follows the repo's existing approach — JPA entities under
ddl-auto=update, plus a checked-in DDL script alongside the existing one atsrc/main/resources/db/stored_value_schema.sql (deliberately not understatic/, which Spring Boot serves unauthenticated).Also sets the executable bit on
mvnwso the documented./mvnw clean testruns as written.Test evidence
./mvnw clean testagainst MySQL 8 atjdbc:mysql://localhost:3306/bankappdb: 30 tests, 0 failures, 0 errors.StoredValueConcurrencyTest— 20 threads each redeeming 10.00 from a 100.00 card: exactly 10 succeed, 10 getINSUFFICIENT_BALANCE, balance lands on 0.00 with 11 ledger rows; a second case fires 20 concurrent requests with one shared key and asserts a single 10.00 debit.StoredValueApiIntegrationTest— full HTTP flow over the real DB: issue → balance → partial + full redeem → ledger, idempotent replay, key conflict, over-redemption, expired-card redeem and inquiry, validation/missing-header/oversized-header errors, unsupported method, 401 (full error body) for both missing and wrong credentials, and that no response exposescardReference.StoredValueServiceTest— unit coverage of expiry, over-redemption, replay, conflict, amount validation including the issuance cap, token masking.The repo has no GitHub Actions workflow; CI is the Jenkins pipeline, which runs the same Maven build.
Devin-Org: engineering
Link to Devin session: https://app.devin.ai/sessions/5091c8eaef664e4c875bda220fd137a6
Requested by: @vanessasalas-cog
Devin Review