High-Level Design: HSA-Tagged Payment Method Exclusivity
| Field | Detail |
|---|---|
| Author(s) | Wallet Customer Service Team |
| Reviewers | TBD |
| Status | In Review |
| Last Updated | 2026-09-30 |
| Version | 1.0 |
This is a high-level design document intended for planning and alignment purposes. All numbers, statuses, thresholds, and timelines mentioned are illustrative and subject to change during implementation. This document is not a specification or implementation contract.
1. Overviewβ
Wallet lets a customer mark one saved card as their Optum Bank HSA card β the card the checkout widget should prefer whenever the merchant supports HSA/FSA spend. This is exposed as a boolean hsaTagged field on a payment method's card details. Two related pieces of work built this capability in wallet-customer-service:
- CC-27375 β Add and Manage HSA Designation for a Payment Method: introduced the
hsaTaggedfield itself, the per-card validation rules that keep it meaningful, and theX-Checkout-Idgate that scopes who can change it. - CC-27491 β Enforce Single Optum Bank HSA-Tagged Payment Method Per Wallet: closed the remaining gap β nothing previously stopped two different cards in the same wallet from both being tagged, especially under concurrent requests. This adds a transactional, lock-protected invariant that guarantees at most one
hsaTagged = truecard per customer, and self-heals if drift is ever found.
This document describes both pieces together since CC-27491 only makes sense in the context of what CC-27375 already built.
- Engineering β Understand the validation rules, the locking strategy, and why a DB constraint wasn't an option.
- QA β Understand the concurrency scenario this guards against and the observable self-healing behavior.
- Consumers (internal services / CCG) β Understand that
hsaTaggedis now a wallet-wide exclusive flag, not just a per-card flag, and that a PATCH can silently untag a different card as a side effect.
2. Problem Statementβ
hsaTaggedlives inside thepayment_method_detailsJSONB blob on thepayment_methodtable β there is no dedicated column, so a SQLUNIQUEconstraint or partial index isn't available to enforce exclusivity.- Without server-side enforcement, a customer (or two concurrent requests, e.g. from a retry or a double-click) could end up with more than one card tagged as their HSA card in the same wallet.
- The checkout widget only prefers a single HSA card. If two cards are tagged, downstream behavior (which card is preferred, what's shown in search/display ordering) becomes ambiguous and undefined.
- Without this design, the invariant "one HSA-tagged card per wallet" is only a client-side convention, not a guarantee.
3. Goals & Non-Goalsβ
Goalsβ
This design's scope is to, for the wallet-customer-service domain:
- Guarantee at most one
hsaTagged = truepayment method per customer, enforced server-side (not just as a client-side convention). - Self-heal any pre-existing drift (more than one tagged card, from before this design shipped) the next time any card is tagged, not just prevent new drift.
- Keep the tag change and every other field change on the target card in a single transaction/save, so they can never commit separately.
- Notify webhook subscribers when a different card is silently untagged as a side effect of enforcing the invariant.
Non-Goalsβ
Explicitly out of scope for this design:
- Adding a dedicated
hsaTaggeddatabase column or aUNIQUEconstraint/partial index (would require a data migration;hsaTaggedremains inside thepayment_method_detailsJSONB blob). - Changing which per-card fields are mutually exclusive with
hsaTagged(that per-card validation ruleset is owned by CC-27375 and is unchanged here). - Exposing a "which card is currently HSA-tagged" convenience lookup endpoint.
- Any change to the
X-Checkout-Idgating rules themselves (owned by CC-27375).
4. Target Audience & Stakeholdersβ
| Stakeholder | Interest / Concern |
|---|---|
| Product | Customers never end up with two conflicting HSA-preferred cards |
| Engineering | Locking strategy is correct under concurrency and doesn't block unrelated writes |
| QA | Concurrent-request and drift-repair scenarios are testable and covered |
| Consumers | A PATCH that sets hsaTagged may emit an extra webhook event for another card |
5. Current State (Before CC-27491)β
CC-27375 already delivered, per individual card, on create (POST /setup-payment-methods) and update (PATCH /payment-methods/{id}):
hsaTagged(Boolean) on theCard/UpdateCardrequest and response DTOs.- Per-card validation (
TagUtil.resolveAndValidate), enforced on both create and update:- A tagged card cannot also be a
manufacturerCard. - A tagged card cannot also be the wallet's
defaultpayment method. - A request carrying an
agentcannot change the tag's value (agent-initiated requests can't tag/untag a card).
- A tagged card cannot also be a
X-Checkout-Idgating: the tag value can only be changed on a request that carries a checkout id. Validation of the rules above still runs even without a checkout id, so a caller can't sneakisDefault/manufacturerCardonto an already-tagged card through an untagged request path.
What CC-27375 did not cover: nothing prevented a second, different card in the same wallet from independently being validated and saved as hsaTagged = true. Each card's save was an independent transaction with no visibility into the rest of the wallet, so two requests β sequential or concurrent β could each pass validation and leave two tagged cards.
6. Proposed Design (After CC-27491)β
Key Componentsβ
| Component | Responsibility |
|---|---|
HsaTagUniquenessService | Owns the single @Transactional method that locks, repairs, and saves |
CustomerJpaRepository.findByIdForUpdate | Takes a PESSIMISTIC_WRITE row lock on the parent customer row |
PaymentMethodRepository.saveExclusivelyTagged | Reactive-layer entry point; runs the blocking transactional call on the bounded-elastic scheduler |
HsaTagReassignmentResult | Carries back the saved target card and any other cards that were untagged as a side effect |
UpdatePaymentMethodCommandHandler / CreateSetupPaymentMethodCommandHandler | Resolve/validate the tag via TagUtil, then route the save through the locked path only when the tag is actually in play |
How It Worksβ
- A create or update request resolves and validates
hsaTaggedexactly as before (CC-27375 rules still apply unchanged). - If the resolved value is
null(noX-Checkout-Idon this request), the save is a plain, unlockedpaymentMethodRepository.save(...)β this branch is taken even if the card being updated is currentlyhsaTagged = true, since thenullcheck short-circuits before the "already tagged" check. If the resolved value is non-nullbut isfalseand the card isn't currently tagged either, it's also a plain unlocked save. - Otherwise (resolved value is
true, or isfalseon a card that's currently tagged), the save goes throughpaymentMethodRepository.saveExclusivelyTagged(target, tagged), which:- Opens a single transaction.
- If
tagged == true: takes a pessimistic write lock on the customer row first β before reading or writing anything else β then loads everyCOMPLETEDpayment method in the wallet and clearshsaTaggedon any other card still carrying it (not just the first found, to self-heal any pre-existing drift). - Saves the target card with the resolved tag value.
- Returns the saved target plus the list of any other cards that were cleared.
- Any cards that were cleared as a side effect get a webhook
PaymentMethodUpdatedevent published, so subscribers see that card'shsaTaggedflip tofalseeven though they didn't request it. This publish happens after the transaction in step 3 commits β see the durability caveat in Risks & Mitigations below.
Step 2's null-resolved branch does a full-entity save of whatever hsaTagged value is already in memory on updatedPaymentMethod. If that entity was read before a concurrent, locked saveExclusivelyTagged call clears this same card's tag, and this unlocked save commits after that clear, the stale in-memory true value is written back β silently re-violating the "at most one tagged card" invariant this design exists to guarantee. This requires: (a) a request touching an already-tagged card with no X-Checkout-Id header, racing (b) a different request tagging a different card in the same wallet. Tracked as an open risk, not yet mitigated β see Risks & Mitigations.
Why a Row Lock on customer, Not payment_methodβ
hsaTagged lives inside a JSONB blob with no dedicated column, so there's nothing to put a unique index on. A lock on the specific payment_method row being tagged doesn't help either: a brand-new card being created has no row yet to lock, so two concurrent "create and tag" requests wouldn't block each other. Locking the parent customer row is the one thing every concurrent request against the same wallet must contend for, so it's the only lock granularity that actually serializes these saves.
The lock acquisition is deliberately the first statement in the transaction, and the transaction does nothing else external (no vendor/API calls), to keep the lock window as short as possible and avoid holding it across any I/O.
7. Data Flowβ
PATCH /payment-methods/{id} (X-Checkout-Id present, hsaTagged: true)
β
βΌ
UpdatePaymentMethodCommandHandler
β TagUtil.resolveAndValidate(...) ββ per-card rules (manufacturerCard / default / agent)
βΌ
saveWithHsaTagReassignmentIfNeeded
β tag in play? ββNoβββΊ paymentMethodRepository.save(...) (unchanged, unlocked path)
β Yes
βΌ
PaymentMethodRepository.saveExclusivelyTagged(target, true)
β
βΌ
HsaTagUniquenessService.saveExclusivelyTagged (single @Transactional)
β 1) customerJpaRepository.findByIdForUpdate(customerId) ββ PESSIMISTIC_WRITE lock, first statement
β 2) findAllByCustomerId(customerId) ββ read the wallet under lock
β 3) clear hsaTagged on every other COMPLETED card that still has it
β 4) save target card with hsaTagged = true
βΌ
HsaTagReassignmentResult(target, clearedOthers)
β
βΌ
clearedOthers β publish PaymentMethodUpdated webhook event per card
8. Key Decisionsβ
| # | Decision | Reasoning |
|---|---|---|
| 1 | Lock the customer row, not payment_method | Only granularity guaranteed to exist and be contended for by every concurrent request against the same wallet, including brand-new-card creation |
| 2 | Clear every stale tagged card found, not just the first | Self-heals any pre-existing drift instead of only preventing new drift |
| 3 | Lock acquisition must be the transaction's first statement, with no external calls in the transaction | Keeps the lock window as short as possible |
| 4 | Untagging (tagged == false) never takes the lock or touches other cards | Untagging can't create a conflict, so it's cheap and unlocked |
| 5 | The target card's save and the tag change happen in the same save call | Prevents a scenario where a card's other field changes (e.g. becoming default) commit separately from its hsaTagged value |
| 6 | Cleared "other" cards emit their own webhook event | Downstream/webhook subscribers must not be left thinking a card is still HSA-tagged when the server silently cleared it |
9. Dependenciesβ
| Dependency | Type | Owner | Risk Level |
|---|---|---|---|
CC-27375 (hsaTagged field + per-card rules) | Internal | Wallet Customer Service | Low β already shipped |
PostgreSQL row-level locking (FOR UPDATE) | Infra | Wallet Customer Service | Low |
Webhook delivery for PaymentMethodUpdated | Internal | Wallet Customer Service | Low |
10. Success Metricsβ
Targets below are illustrative and subject to change during implementation.
| Metric | Indicative Target |
|---|---|
Wallets with more than one hsaTagged = true completed card | 0 |
| Additional latency on PATCH/create when the tag is in play (lock hold time) | Sub-millisecond to low single-digit ms, given the lock guards only a small read + save |
11. Risks & Mitigationsβ
| Risk | Impact | Mitigation |
|---|---|---|
| Two concurrent requests both try to tag a different card in the same wallet | Without a lock, both could succeed, leaving two tagged cards | Pessimistic write lock on the customer row serializes the two transactions |
| Long-held lock blocking unrelated payment-method writes for the same customer | Increased latency on concurrent requests | Lock is the first statement, transaction is minimal, no external calls inside it |
| Silent untagging of another card surprises a consumer that didn't request it | Consumer's view of that card is stale until it processes the webhook event | clearedOthers always gets an explicit PaymentMethodUpdated webhook event |
| Pre-existing drift (from before this change) is never cleaned up | Two tagged cards could persist indefinitely | Self-healing: the next tag operation on the wallet clears all other stale-tagged cards, not just one |
[Open, unmitigated] An update to an already-tagged card with no X-Checkout-Id header takes the unlocked save path regardless of the card's current tag state; if it races a concurrent locked reassignment clearing that same card, the unlocked save can write the stale hsaTagged = true back after the clear, reintroducing a duplicate-tagged wallet | Silently re-violates the single-tag invariant; no lock, no error, no alert | None shipped yet. Candidates: route every save of an already-tagged card through saveExclusivelyTagged regardless of X-Checkout-Id, or add an optimistic-lock/version check that rejects a stale write. Must be resolved before Status on this HLD can move to Approved/Final |
DB commit and webhook publish for clearedOthers are separate operations | If the transaction commits but publishing fails (process crash, broker outage), a retry has no record that an event is owed β subscribers can be left permanently believing the cleared card is still tagged | None shipped yet. Needs a durable outbox record written in the same transaction as the reassignment, or a reconciliation job that re-diffs tag state against last-known-published state |
12. Related Documentationβ
- CC-27375 β Add and Manage HSA Designation for a Payment Method (source ticket; no standalone doc β captured here as background)
- CC-27491 β Enforce Single Optum Bank HSA-Tagged Payment Method Per Wallet (source ticket for this design)
wallet-customer-serviceβPaymentMethodUpdatedwebhook event model doc (docs/PAYMENT_METHOD_EVENTS.md) is written but not yet merged to that repo'smain; intentionally left unlinked here until it has a durable URL to avoid publishing a dead link