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:
@@ -37,6 +37,7 @@ public static class ApplicationModule
|
||||
services.AddScoped<ResolvePendingBetsUseCase>();
|
||||
services.AddScoped<BuildBetJournalReportUseCase>();
|
||||
services.AddScoped<DeletePlacedBetUseCase>();
|
||||
services.AddScoped<UpdatePlacedBetUseCase>();
|
||||
|
||||
services.AddScoped<RunBacktestUseCase>();
|
||||
services.AddScoped<SaveStrategyUseCase>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -259,14 +259,28 @@
|
||||
<p class="m-journal__form-error" data-test="journal-add-error">@_formError</p>
|
||||
}
|
||||
|
||||
@if (_editingId is not null)
|
||||
{
|
||||
<p class="m-journal__form-hint" data-test="journal-editing-banner">@L["Journal.Editing"]</p>
|
||||
}
|
||||
|
||||
<div class="m-journal__form-actions">
|
||||
@if (_editingId is not null)
|
||||
{
|
||||
<button type="button"
|
||||
class="m-chip m-journal__chip"
|
||||
@onclick="CancelEdit"
|
||||
data-test="journal-edit-cancel">
|
||||
@L["Journal.Action.Cancel"]
|
||||
</button>
|
||||
}
|
||||
<button type="button"
|
||||
class="m-chip m-journal__submit"
|
||||
@onclick="SubmitAsync"
|
||||
disabled="@_submitting"
|
||||
data-test="journal-add-submit">
|
||||
<span class="m-journal__chip-glyph @(_submitting ? "is-spinning" : null)" aria-hidden="true">+</span>
|
||||
<span>@L["Journal.Action.Submit"]</span>
|
||||
<span class="m-journal__chip-glyph @(_submitting ? "is-spinning" : null)" aria-hidden="true">@(_editingId is null ? "+" : "✓")</span>
|
||||
<span>@(_editingId is null ? L["Journal.Action.Submit"] : L["Journal.Action.SaveEdit"])</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -359,6 +373,14 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button"
|
||||
class="m-chip m-journal__chip m-journal__chip--ghost"
|
||||
@onclick="@(() => BeginEdit(row))"
|
||||
data-test="@($"journal-edit-{row.Id}")"
|
||||
aria-label="@L["Journal.Action.EditBet"]">
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span>@L["Journal.Action.EditBet"]</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="m-chip m-journal__chip m-journal__chip--ghost"
|
||||
@onclick="@(() => RequestDelete(row.Id))"
|
||||
@@ -702,6 +724,7 @@
|
||||
private bool _resolving;
|
||||
private string? _formError;
|
||||
private Guid? _pendingDeleteId;
|
||||
private Guid? _editingId;
|
||||
private AddBetForm _form = new();
|
||||
|
||||
// Kelly stake-helper state — page-local (not persisted with the bet). Bankroll
|
||||
@@ -850,11 +873,20 @@
|
||||
StateHasChanged();
|
||||
var ct = _loadCts?.Token ?? CancellationToken.None;
|
||||
try
|
||||
{
|
||||
if (_editingId is { } editId)
|
||||
{
|
||||
await Service.UpdateAsync(editId, _form, ct);
|
||||
Snackbar.Add(L["Journal.Edited"].Value, Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Service.AddAsync(_form, ct);
|
||||
Snackbar.Add(L["Journal.Submitted"].Value, Severity.Success);
|
||||
}
|
||||
_editingId = null;
|
||||
_form = new AddBetForm();
|
||||
_formError = null;
|
||||
Snackbar.Add(L["Journal.Submitted"].Value, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
@@ -891,6 +923,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginEdit(BetJournalRowVm row)
|
||||
{
|
||||
var b = row.Bet;
|
||||
_editingId = row.Id;
|
||||
_pendingDeleteId = null;
|
||||
_form = new AddBetForm
|
||||
{
|
||||
EventId = b.EventId.Value,
|
||||
Type = b.Selection.Type,
|
||||
Side = b.Selection.Side,
|
||||
Value = b.Selection.Value?.Value,
|
||||
Rate = b.Selection.Rate.Value,
|
||||
Stake = b.Stake,
|
||||
Notes = b.Notes,
|
||||
};
|
||||
_formError = null;
|
||||
}
|
||||
|
||||
private void CancelEdit()
|
||||
{
|
||||
_editingId = null;
|
||||
_form = new AddBetForm();
|
||||
_formError = null;
|
||||
}
|
||||
|
||||
private void RequestDelete(Guid id)
|
||||
{
|
||||
_pendingDeleteId = id;
|
||||
|
||||
@@ -424,6 +424,10 @@
|
||||
<data name="Journal.Action.Refresh"><value>Refresh</value></data>
|
||||
<data name="Journal.Action.Resolve"><value>Resolve pending</value></data>
|
||||
<data name="Journal.Action.Submit"><value>Record bet</value></data>
|
||||
<data name="Journal.Action.SaveEdit"><value>Save changes</value></data>
|
||||
<data name="Journal.Action.EditBet"><value>Edit</value></data>
|
||||
<data name="Journal.Edited"><value>Bet updated.</value></data>
|
||||
<data name="Journal.Editing"><value>Editing an existing bet — save to apply your changes, or cancel.</value></data>
|
||||
<data name="Journal.Action.Delete"><value>Delete</value></data>
|
||||
<data name="Journal.Action.Confirm"><value>Confirm</value></data>
|
||||
<data name="Journal.Action.Cancel"><value>Cancel</value></data>
|
||||
|
||||
@@ -437,6 +437,10 @@
|
||||
<data name="Journal.Action.Refresh"><value>Обновить</value></data>
|
||||
<data name="Journal.Action.Resolve"><value>Рассчитать ожидающие</value></data>
|
||||
<data name="Journal.Action.Submit"><value>Записать</value></data>
|
||||
<data name="Journal.Action.SaveEdit"><value>Сохранить изменения</value></data>
|
||||
<data name="Journal.Action.EditBet"><value>Изменить</value></data>
|
||||
<data name="Journal.Edited"><value>Ставка обновлена.</value></data>
|
||||
<data name="Journal.Editing"><value>Редактирование ставки — сохраните изменения или отмените.</value></data>
|
||||
<data name="Journal.Action.Delete"><value>Удалить</value></data>
|
||||
<data name="Journal.Action.Confirm"><value>Подтвердить</value></data>
|
||||
<data name="Journal.Action.Cancel"><value>Отмена</value></data>
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class BetJournalService : IBetJournalService
|
||||
private readonly RecordPlacedBetUseCase _record;
|
||||
private readonly ResolvePendingBetsUseCase _resolve;
|
||||
private readonly DeletePlacedBetUseCase _delete;
|
||||
private readonly UpdatePlacedBetUseCase _update;
|
||||
private readonly IEventRepository _events;
|
||||
|
||||
public BetJournalService(
|
||||
@@ -26,12 +27,14 @@ public sealed class BetJournalService : IBetJournalService
|
||||
RecordPlacedBetUseCase record,
|
||||
ResolvePendingBetsUseCase resolve,
|
||||
DeletePlacedBetUseCase delete,
|
||||
UpdatePlacedBetUseCase update,
|
||||
IEventRepository events)
|
||||
{
|
||||
_build = build ?? throw new ArgumentNullException(nameof(build));
|
||||
_record = record ?? throw new ArgumentNullException(nameof(record));
|
||||
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
||||
_delete = delete ?? throw new ArgumentNullException(nameof(delete));
|
||||
_update = update ?? throw new ArgumentNullException(nameof(update));
|
||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||
}
|
||||
|
||||
@@ -92,6 +95,29 @@ public sealed class BetJournalService : IBetJournalService
|
||||
return stored.Id;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Guid betId, AddBetForm form, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(form);
|
||||
|
||||
if (!form.IsValid(out var error))
|
||||
throw new ArgumentException(error ?? "Invalid form.", nameof(form));
|
||||
|
||||
var selection = new Bet(
|
||||
scope: MatchScope.Instance,
|
||||
type: form.Type,
|
||||
side: form.Side,
|
||||
value: form.Value is { } v ? new OddsValue(v) : null,
|
||||
rate: new OddsRate(form.Rate));
|
||||
|
||||
await _update.ExecuteAsync(
|
||||
betId,
|
||||
new DomainEventId(form.EventId.Trim()),
|
||||
selection,
|
||||
form.Stake,
|
||||
form.Notes,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(Guid betId, CancellationToken ct) =>
|
||||
_delete.ExecuteAsync(betId, ct);
|
||||
|
||||
|
||||
@@ -18,6 +18,14 @@ public interface IBetJournalService
|
||||
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
|
||||
Task<Guid> AddAsync(AddBetForm form, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing bet's selection / stake / notes, preserving its original
|
||||
/// placed-at and re-grading against the event result.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
|
||||
/// <exception cref="InvalidOperationException">The bet id or its event is unknown.</exception>
|
||||
Task UpdateAsync(Guid betId, AddBetForm form, CancellationToken ct);
|
||||
|
||||
/// <summary>Removes a bet by id. No-op when the id is unknown.</summary>
|
||||
Task DeleteAsync(Guid betId, CancellationToken ct);
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Marathon.Application.Tests.UseCases;
|
||||
|
||||
public sealed class UpdatePlacedBetUseCaseTests
|
||||
{
|
||||
private static readonly TimeSpan Msk = TimeSpan.FromHours(3);
|
||||
private static readonly DateTimeOffset PlacedAt = new(2026, 5, 16, 12, 0, 0, Msk);
|
||||
|
||||
private readonly IPlacedBetRepository _bets = Substitute.For<IPlacedBetRepository>();
|
||||
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
|
||||
|
||||
private UpdatePlacedBetUseCase CreateSut() =>
|
||||
new(_bets, _events, _results, NullLogger<UpdatePlacedBetUseCase>.Instance);
|
||||
|
||||
private static Bet Selection(Side side = Side.Side1, decimal rate = 2.0m) =>
|
||||
new(MatchScope.Instance, BetType.Win, side, null, new OddsRate(rate));
|
||||
|
||||
private static PlacedBet Existing(Guid id, EventId eventId) =>
|
||||
new(id, eventId, Selection(rate: 1.5m), 50m, PlacedAt, BetOutcome.Pending, "old note");
|
||||
|
||||
private static Event Ev(EventId id) =>
|
||||
new(id, new SportCode(11), "England", "L", "C", PlacedAt.AddDays(1), "Home", "Away");
|
||||
|
||||
[Fact]
|
||||
public async Task Throws_When_BetUnknown()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
_bets.GetAsync(id, Arg.Any<CancellationToken>()).Returns((PlacedBet?)null);
|
||||
|
||||
var act = async () => await CreateSut().ExecuteAsync(id, new EventId("e"), Selection(), 100m, null);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*unknown bet*");
|
||||
await _bets.DidNotReceive().UpdateAsync(Arg.Any<PlacedBet>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throws_When_EventUnknown()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var eid = new EventId("missing");
|
||||
_bets.GetAsync(id, Arg.Any<CancellationToken>()).Returns(Existing(id, eid));
|
||||
_events.GetAsync(eid, Arg.Any<CancellationToken>()).Returns((Event?)null);
|
||||
|
||||
var act = async () => await CreateSut().ExecuteAsync(id, eid, Selection(), 100m, null);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*unknown event*");
|
||||
await _bets.DidNotReceive().UpdateAsync(Arg.Any<PlacedBet>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Updates_PreservesPlacedAt_AppliesChanges_AndRegrades()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var eid = new EventId("evt-1");
|
||||
_bets.GetAsync(id, Arg.Any<CancellationToken>()).Returns(Existing(id, eid));
|
||||
_events.GetAsync(eid, Arg.Any<CancellationToken>()).Returns(Ev(eid));
|
||||
// Side1 wins → the (new) Side1 selection re-grades to Won.
|
||||
_results.GetAsync(eid, Arg.Any<CancellationToken>())
|
||||
.Returns(new EventResult(eid, 2, 0, Side.Side1, PlacedAt.AddHours(3)));
|
||||
|
||||
var updated = await CreateSut().ExecuteAsync(
|
||||
id, eid, Selection(side: Side.Side1, rate: 2.5m), stake: 120m, notes: "new note");
|
||||
|
||||
updated.Id.Should().Be(id);
|
||||
updated.PlacedAt.Should().Be(PlacedAt); // original entry time preserved
|
||||
updated.Stake.Should().Be(120m);
|
||||
updated.Selection.Rate.Value.Should().Be(2.5m);
|
||||
updated.Notes.Should().Be("new note");
|
||||
updated.Outcome.Should().Be(BetOutcome.Won); // re-graded against the result
|
||||
|
||||
await _bets.Received(1).UpdateAsync(
|
||||
Arg.Is<PlacedBet>(b => b.Id == id && b.Outcome == BetOutcome.Won && b.Stake == 120m),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _bets.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user