Checking access…

Skip to main content
Version: v2

High-Level Design: HSA-Tagged Payment Method Exclusivity

FieldDetail
Author(s)Wallet Customer Service Team
ReviewersTBD
StatusIn Review
Last Updated2026-09-30
Version1.0
Disclaimer

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 hsaTagged field itself, the per-card validation rules that keep it meaningful, and the X-Checkout-Id gate 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 = true card 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.

Who should read this?
  • 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 hsaTagged is 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​

  • hsaTagged lives inside the payment_method_details JSONB blob on the payment_method table β€” there is no dedicated column, so a SQL UNIQUE constraint 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 = true payment 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 hsaTagged database column or a UNIQUE constraint/partial index (would require a data migration; hsaTagged remains inside the payment_method_details JSONB 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-Id gating rules themselves (owned by CC-27375).

4. Target Audience & Stakeholders​

StakeholderInterest / Concern
ProductCustomers never end up with two conflicting HSA-preferred cards
EngineeringLocking strategy is correct under concurrency and doesn't block unrelated writes
QAConcurrent-request and drift-repair scenarios are testable and covered
ConsumersA 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 the Card / UpdateCard request 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 default payment method.
    • A request carrying an agent cannot change the tag's value (agent-initiated requests can't tag/untag a card).
  • X-Checkout-Id gating: 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 sneak isDefault/manufacturerCard onto 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​

ComponentResponsibility
HsaTagUniquenessServiceOwns the single @Transactional method that locks, repairs, and saves
CustomerJpaRepository.findByIdForUpdateTakes a PESSIMISTIC_WRITE row lock on the parent customer row
PaymentMethodRepository.saveExclusivelyTaggedReactive-layer entry point; runs the blocking transactional call on the bounded-elastic scheduler
HsaTagReassignmentResultCarries back the saved target card and any other cards that were untagged as a side effect
UpdatePaymentMethodCommandHandler / CreateSetupPaymentMethodCommandHandlerResolve/validate the tag via TagUtil, then route the save through the locked path only when the tag is actually in play

How It Works​

  1. A create or update request resolves and validates hsaTagged exactly as before (CC-27375 rules still apply unchanged).
  2. If the resolved value is null (no X-Checkout-Id on this request), the save is a plain, unlocked paymentMethodRepository.save(...) β€” this branch is taken even if the card being updated is currently hsaTagged = true, since the null check short-circuits before the "already tagged" check. If the resolved value is non-null but is false and the card isn't currently tagged either, it's also a plain unlocked save.
  3. Otherwise (resolved value is true, or is false on a card that's currently tagged), the save goes through paymentMethodRepository.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 every COMPLETED payment method in the wallet and clears hsaTagged on 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.
  4. Any cards that were cleared as a side effect get a webhook PaymentMethodUpdated event published, so subscribers see that card's hsaTagged flip to false even though they didn't request it. This publish happens after the transaction in step 3 commits β€” see the durability caveat in Risks & Mitigations below.
Known gap: unlocked path can resurrect a just-cleared tag

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​

#DecisionReasoning
1Lock the customer row, not payment_methodOnly granularity guaranteed to exist and be contended for by every concurrent request against the same wallet, including brand-new-card creation
2Clear every stale tagged card found, not just the firstSelf-heals any pre-existing drift instead of only preventing new drift
3Lock acquisition must be the transaction's first statement, with no external calls in the transactionKeeps the lock window as short as possible
4Untagging (tagged == false) never takes the lock or touches other cardsUntagging can't create a conflict, so it's cheap and unlocked
5The target card's save and the tag change happen in the same save callPrevents a scenario where a card's other field changes (e.g. becoming default) commit separately from its hsaTagged value
6Cleared "other" cards emit their own webhook eventDownstream/webhook subscribers must not be left thinking a card is still HSA-tagged when the server silently cleared it

9. Dependencies​

DependencyTypeOwnerRisk Level
CC-27375 (hsaTagged field + per-card rules)InternalWallet Customer ServiceLow β€” already shipped
PostgreSQL row-level locking (FOR UPDATE)InfraWallet Customer ServiceLow
Webhook delivery for PaymentMethodUpdatedInternalWallet Customer ServiceLow

10. Success Metrics​

caution

Targets below are illustrative and subject to change during implementation.

MetricIndicative Target
Wallets with more than one hsaTagged = true completed card0
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​

RiskImpactMitigation
Two concurrent requests both try to tag a different card in the same walletWithout a lock, both could succeed, leaving two tagged cardsPessimistic write lock on the customer row serializes the two transactions
Long-held lock blocking unrelated payment-method writes for the same customerIncreased latency on concurrent requestsLock is the first statement, transaction is minimal, no external calls inside it
Silent untagging of another card surprises a consumer that didn't request itConsumer's view of that card is stale until it processes the webhook eventclearedOthers always gets an explicit PaymentMethodUpdated webhook event
Pre-existing drift (from before this change) is never cleaned upTwo tagged cards could persist indefinitelySelf-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 walletSilently re-violates the single-tag invariant; no lock, no error, no alertNone 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 operationsIf 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 taggedNone 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

  • 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 β€” PaymentMethodUpdated webhook event model doc (docs/PAYMENT_METHOD_EVENTS.md) is written but not yet merged to that repo's main; intentionally left unlinked here until it has a durable URL to avoid publishing a dead link