feat(payments): payment-event dispatcher + the provider-return UX
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s

Deliver payment_events to connected clients as an in-app wallet-refresh push: a
background dispatcher drains undispatched events and publishes a KindNotification
"payment" signal, marking each delivered; the client bumps a wallet-refresh
counter the open Wallet screen watches, re-fetching in place. A return-focus
refetch is the fallback. The Robokassa Success/Fail return now serves a
self-closing page (the payment opens in a separate window) so the customer drops
back into the live app instead of a cold start. Integration test for the event
drain/mark queue.
This commit is contained in:
Ilia Denisov
2026-07-09 18:26:06 +02:00
parent 04435a3283
commit 3367cc2bf1
9 changed files with 174 additions and 11 deletions
@@ -101,6 +101,45 @@ func TestPaymentsFundAmountMismatch(t *testing.T) {
}
}
// TestPaymentsEventDispatchDrain verifies the payment_events dispatcher queue: a recorded event is
// returned as undispatched until marked, then drops out (so the dispatcher delivers it once).
func TestPaymentsEventDispatchDrain(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
if err := svc.RecordPaymentEvent(ctx, acc, nil, "succeeded", []byte(`{"chips":10,"source":"direct"}`)); err != nil {
t.Fatalf("record event: %v", err)
}
// testDB is shared, so filter the queue to our account.
find := func() *payments.PaymentEvent {
evs, err := svc.UndispatchedEvents(ctx, 100)
if err != nil {
t.Fatalf("undispatched: %v", err)
}
for i := range evs {
if evs[i].AccountID == acc {
return &evs[i]
}
}
return nil
}
mine := find()
if mine == nil {
t.Fatal("recorded event not in the undispatched queue")
}
if mine.Type != "succeeded" {
t.Errorf("event type = %s, want succeeded", mine.Type)
}
if err := svc.MarkEventDispatched(ctx, mine.EventID); err != nil {
t.Fatalf("mark dispatched: %v", err)
}
if find() != nil {
t.Error("event still undispatched after MarkEventDispatched")
}
}
// TestPaymentsExpiredOrderStillCredits verifies an expired pending order is still honoured by a
// later valid callback (§9/D23: expiry is cosmetic, the money is real).
func TestPaymentsExpiredOrderStillCredits(t *testing.T) {