Skip to content

feat: refund PB→merchant payments#40

Open
knutties wants to merge 30 commits into
mainfrom
feat/payment-refund
Open

feat: refund PB→merchant payments#40
knutties wants to merge 30 commits into
mainfrom
feat/payment-refund

Conversation

@knutties

Copy link
Copy Markdown
Contributor

Summary

  • Admin-initiated refund of settled PB payments, with up to two compensating transaction rows mirroring the payment's pool split.
  • Multiple partial refunds per payment; sum ≤ original. Self-first pool allocation (restores holder's self-pool flexibility first).
  • New TB transfer code 210 + create_payment_refund / create_payment_refund_split (linked) ledger helpers.
  • New find_refunds_of / sum_refunds_of repo helpers (type-agnostic — reusable later for multi-partial transfer reversal).
  • Schema change: tighten the existing reverses_transaction_id partial unique index to WHERE type='transfer', preserving transfer-reversal's at-most-one invariant while letting payment refunds stack.

Surface area

  • HTTP: POST /pb-accounts/{account_id}/payments/{payment_id}/refund (Smithy: RefundPBAccountPayment).
  • Admin UI: Refund button on settled-payment detail; refund-history block on partially/fully refunded payments; "Refund of payment" affordance on refund rows.
  • Errors: RefundNotRefundable (409), RefundAmountInvalid (400), PaymentFullyRefunded (409).

Spec + plan

  • docs/superpowers/specs/2026-05-30-payment-refund-design.md
  • docs/superpowers/plans/2026-05-30-payment-refund.md

Test plan

  • cargo check -p pba-service && cargo check --tests -p pba-service clean
  • just fmt-check / just lint (clippy -D warnings) / just build pass
  • just e2e-all:
    • API suite: 12 features / 98 scenarios / 591 steps — all pass
    • UI suite on shared API features: 32 passed, 63 skipped (UI-side bindings filter scenarios with no UI step), 0 failed
    • UI suite on tests/ui_features/: 5 features / 29 scenarios / 189 steps — all pass (incl. new payment_refund_admin.feature)
  • Manual UI walkthrough: make a payment, click Refund, submit, see refund-history block; verify Refund button disappears after full refund.

Future symmetry

The schema change and the new repo helpers are designed for reuse: a follow-up PR can relax transfer reversal to multi-partial by dropping the partial unique index and adopting sum_refunds_of in reverse_transfer. No further repo or schema work needed beyond that.

🤖 Generated with Claude Code

knutties and others added 30 commits May 30, 2026 08:55
Admin-initiated refund of settled PB→merchant payments. Each refund is a
new compensating transaction (1 or 2 rows mirroring the payment's pool
split) plus matching code-210 TB transfer(s) debiting the merchant
sentinel. Multiple partial refunds allowed per payment; refund credits
self-pool first then others-pool. The schema change is forward-compatible
so transfer reversal can later move to the same multi-partial pattern
without further repo work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
13 bite-sized TDD-driven tasks (with task 6 split into 8 sub-tasks for the
service method) covering migration, error variants, domain type_label, repo
helpers, ledger code 210, refund_payment service, DTOs, handler, route,
Smithy + SDK regen, cucumber + UI cucumber, admin UI, docs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces uq_transactions_reverses with uq_transactions_reverses_transfer
restricted to type='transfer', so payment refunds can have multiple rows
pointing at the same original payment row while transfer reversal's
at-most-one invariant is preserved.
RefundNotRefundable (409), RefundAmountInvalid (400), and
PaymentFullyRefunded (409).
Type-agnostic helpers: find_refunds_of returns every row linked back to a
given original via reverses_transaction_id (ordered by created_at, id);
sum_refunds_of aggregates their amount. Designed to be reused when transfer
reversal moves to the multi-partial pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…split

Debit MERCHANT_SETTLEMENT_TB_ID, credit one or two PB pool accounts. The
split variant uses linked TB transfers for atomicity. The merchant sentinel
has no balance constraint, so over-amount is caught upstream in the service
layer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Multi-partial refund of settled PB→merchant payments. Self-first pool
allocation, idempotency replay, and atomic insert+TB-transfer flow.
End-to-end behaviour pinned by Cucumber payment_refund.feature (Task 10);
no unit tests added here per plan (pb_payment_service has no DB-touching
unit-test scaffold).
…ent replay loop bound

Cosmetic: original_amount and remaining_refundable are pure functions of
data loaded before the PG transaction; computing them after tx.commit
read misleadingly. Move them above the begin() call. Add a one-line
comment on the idempotency-replay sum_refunds_of loop noting that the
input always has ≤2 rows for payments written by make_payment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Thin handler wrapping PbPaymentService::refund_payment.
The /accounts/... routes exist as compat aliases for endpoints that
previously lived there. Refund is brand new; nothing to preserve. Keep
only the /pb-accounts/.../refund route the Smithy spec declares.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add five refund-tracking fields to PbaWorld (last_refund_correlation_id,
last_refund_amount_to_self, last_refund_amount_to_others,
last_refund_remaining, previous_refund_correlation_id) and wire them into
the Default impl.  Append refund step bindings to payment_steps.rs
(When/Then steps covering happy-path, idempotency replay, wrong account,
refund-of-refund, frozen account, and error classification).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add 11 automated Cucumber scenarios covering full/partial refund of
others-only and split payments, sequential partial refunds up to
PaymentFullyRefunded, RefundAmountInvalid (over-amount and zero),
RefundNotRefundable (refund-of-refund), frozen-account rejection with
reactivation retry, idempotency replay (same key → same correlation_id),
and wrong-account rejection (TransactionNotFound).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ing assertions

Scenario 6 now asserts the 'remaining refundable' value embedded in the
RefundAmountInvalid error message. Scenario 8 asserts the
'is_itself_a_refund' reason on RefundNotRefundable. Scenario 11 corrected
to expect RefundNotRefundable with reason='wrong_account' (the per-row
check fires before the empty-result-set TransactionNotFound branch).
Build the refund form, POST handler calls pb_payment_service.refund_payment,
redirects to the original payment's detail page on success, re-renders the
form with the AppError Display string on failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…transaction detail

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- account_id check uses !all(==) instead of any(!=) for clearer intent.
- process_refund_payment redirect uses the prefixed() helper, matching
  the convention every other redirect in the admin layer uses.
- Hoist 'use BTreeMap' to the top of the file.
- Log DB errors swallowed in the refund-history display path instead of
  silently falling back to an empty list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add last_payment_id / last_refund_id fields to UiWorld (initialized to None).
Add refund navigation helpers + when/then step bindings in payment_steps.rs.
Add combined 'I transfer N paisa from the normal account to the PB account'
convenience step to UI transfer_steps.rs used by payment_refund_admin.feature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Five UI Cucumber scenarios exercising the admin refund button, form, and
history through chromiumoxide: visible on settled payment, absent on refund
row, absent after full refund (history shown), present after partial refund
with reduced remaining, and inline error on over-amount refund.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…match

Removing the input's max attribute lets Scenario 5 (over-amount) reach the
server-rendered error path instead of being blocked by Chrome's native
constraint validation. The refund-history assertion now matches the
specific <td>₹X.XX</td> cell rather than the bare amount, so it can't
spuriously match the payment's amount stat-card.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add /pb-accounts/{id}/payments/{id}/refund row to the README API table.
Add 'Refunding a PB → merchant payment' subsection to WHAT.md covering
multi-partial refunds, self-first pool routing, admin-only access, and
the no-MCC-recheck / no-InsufficientFunds invariants.
CI's just fmt-check (cargo fmt -p pba-service -- --check) flagged
formatting in error.rs, transaction_repo.rs, pb_payment_service.rs,
admin/handlers.rs, api/handlers/pb.rs, tests/steps/payment_steps.rs,
and tests/ui_steps/payment_steps.rs. Generated pba_client is excluded
from fmt-check by design.
… PB account

The UI runner picks up tests/features/*.feature (except @api-tagged scenarios)
and runs them via chromium. Its 'Given a normal account exists' step sets
world.account_id to the normal account id, so if it runs after 'Given a
"health" account exists' the chromium-based 'I pay …' step would navigate
to /admin/accounts/{normal_id}/payment and fail. Match transfer_reversal.feature's
convention: normal first, then health, so account_id ends as the PB account.
Mirror make_payment's payment_id pattern. The primary refund leg (self if
both pools touched, else the single leg) now uses refund_correlation_id as
its row id, so /admin/transactions/{correlation_id} resolves to a real row.
The secondary leg keeps a fresh Uuid::now_v7(). Idempotency-key placement
and find_by_correlation_id semantics are unchanged.
After a refund, the refund row is the most-recent transaction on the
account, so re-discovery would clobber the payment id with a refund-row id
and break subsequent 'Refund button visible on the payment' assertions.
Use the cached id when present.
The cache-id change in 0002a9b chained .await on a single line; rustfmt
breaks the chain to three lines. CI's just fmt-check caught it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant