feat(journal): edit a placed bet

Adds an edit flow to the bet journal — an Edit button per row repurposes the inline
entry form (edit mode), saving via a new UpdatePlacedBetUseCase that preserves the
original placed-at and re-grades the outcome against the result (so a changed
selection/event re-settles). A cancel affordance + an editing banner make the mode
obvious; the submit button switches to "Save changes".

- UpdatePlacedBetUseCase (loads existing for PlacedAt, validates the event, re-grades)
  + IBetJournalService.UpdateAsync + service impl; Journal page edit-mode state +
  per-row Edit button; en/ru resx.
- 3 tests: unknown-bet, unknown-event, and preserve-PlacedAt + re-grade.
This commit is contained in:
2026-05-29 13:38:38 +03:00
parent 41148a87a6
commit 1092e2a2c5
8 changed files with 275 additions and 4 deletions
@@ -0,0 +1,86 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Edits an existing <see cref="PlacedBet"/> in the journal — selection, stake, or
/// notes. The original <see cref="PlacedBet.PlacedAt"/> is preserved; the outcome is
/// re-graded from scratch (so changing the selection or event re-settles correctly).
/// </summary>
public sealed class UpdatePlacedBetUseCase
{
private readonly IPlacedBetRepository _bets;
private readonly IEventRepository _events;
private readonly IResultRepository _results;
private readonly ILogger<UpdatePlacedBetUseCase> _logger;
public UpdatePlacedBetUseCase(
IPlacedBetRepository bets,
IEventRepository events,
IResultRepository results,
ILogger<UpdatePlacedBetUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_events = events ?? throw new ArgumentNullException(nameof(events));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <exception cref="InvalidOperationException">
/// The bet id is unknown, or the (possibly changed) event isn't in the store.
/// </exception>
public async Task<PlacedBet> ExecuteAsync(
Guid id,
DomainEventId eventId,
Bet selection,
decimal stake,
string? notes,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(eventId);
ArgumentNullException.ThrowIfNull(selection);
var existing = await _bets.GetAsync(id, ct).ConfigureAwait(false)
?? throw new InvalidOperationException($"Cannot update unknown bet '{id}'.");
var ev = await _events.GetAsync(eventId, ct).ConfigureAwait(false);
if (ev is null)
{
throw new InvalidOperationException(
$"Cannot point a bet at unknown event '{eventId.Value}'. " +
"The event must already be present in the scrape store.");
}
// Preserve the original entry time; re-grade from Pending so a changed
// selection/event settles against the current result.
var toPersist = new PlacedBet(
Id: id,
EventId: eventId,
Selection: selection,
Stake: stake,
PlacedAt: existing.PlacedAt,
Outcome: BetOutcome.Pending,
Notes: notes);
var result = await _results.GetAsync(eventId, ct).ConfigureAwait(false);
if (result is not null)
{
var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(toPersist.Selection, result);
if (graded is not null)
toPersist = toPersist.WithOutcome(graded.Value);
}
await _bets.UpdateAsync(toPersist, ct).ConfigureAwait(false);
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation(
"UpdatePlacedBetUseCase: updated bet {BetId} on event {EventId} stake={Stake} outcome={Outcome}",
id, eventId.Value, stake, toPersist.Outcome);
return toPersist;
}
}