diff --git a/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinTestPayer.cs b/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinTestPayer.cs index 4f75042..5d77373 100644 --- a/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinTestPayer.cs +++ b/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinTestPayer.cs @@ -138,7 +138,7 @@ private async Task CreateSenderPsbtAsync(SystemUri payme paymentAmount = Money.Satoshis(checked((long)amountSats.Value)).ToDecimal(MoneyUnit.BTC); using var _ = parsedUri.CheckPjSupported(); } - catch (PjParseException ex) + catch (UriParseException ex) { throw new InvalidOperationException($"Invalid BIP21 URI '{paymentUrl}': {ex.Message}", ex); } @@ -233,9 +233,9 @@ private async Task RequestProposalAsync(SystemUri paymentUrl, IReadOnlyLis } proposalPsbtBase64 = await PollForProposalAsync(current, senderPersister, ohttpRelayUrls, cancellationToken).ConfigureAwait(false); } - catch (SenderPersistedException.ResponseException ex) + catch (SenderPersistedException ex) when (SenderResponseOf(ex) is { } senderResponse) { - throw CreateSenderResponseFailure(ex); + throw CreateSenderResponseFailure(ex, senderResponse); } if (string.IsNullOrWhiteSpace(proposalPsbtBase64)) @@ -355,13 +355,23 @@ private async Task RequestProposalAsync(SystemUri paymentUrl, IReadOnlyLis throw new InvalidOperationException($"All configured OHTTP relays failed for payer store '{_payer.StoreId}'. LastError='{lastError?.Message}'", lastError); } - private InvalidOperationException CreateSenderResponseFailure(SenderPersistedException.ResponseException ex) + private InvalidOperationException CreateSenderResponseFailure(SenderPersistedException ex, global::Payjoin.ResponseException senderResponse) { return new InvalidOperationException( - $"Payjoin receiver rejected the proposal for payer store '{_payer.StoreId}': {FormatSenderResponseException(ex.v1)}", + $"Payjoin receiver rejected the proposal for payer store '{_payer.StoreId}': {FormatSenderResponseException(senderResponse)}", ex); } + private static global::Payjoin.ResponseException? SenderResponseOf(SenderPersistedException exception) + { + return exception switch + { + SenderPersistedException.Fatal { v1: SenderException.Response response } => response.v1, + SenderPersistedException.Transient { v1: SenderException.Response response } => response.v1, + _ => null + }; + } + private static string FormatSenderResponseException(global::Payjoin.ResponseException responseException) { return responseException switch diff --git a/BTCPayServer.Plugins.Payjoin.Tests/PayjoinSettlementFlowTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/PayjoinSettlementFlowTests.cs index 807f288..8da97f9 100644 --- a/BTCPayServer.Plugins.Payjoin.Tests/PayjoinSettlementFlowTests.cs +++ b/BTCPayServer.Plugins.Payjoin.Tests/PayjoinSettlementFlowTests.cs @@ -407,7 +407,7 @@ private sealed class FixedPsbtProposal(string psbt) : IPayjoinProposal public RequestResponse CreatePostRequest(string ohttpRelay) => throw new NotSupportedException(); public PayjoinProposalTransition ProcessResponse(byte[] body, ClientResponse ohttpContext) => throw new NotSupportedException(); public string Psbt() => psbt; - public PayjoinOutPoint[] UtxosToBeLocked() => throw new NotSupportedException(); + public bool ProposalTxidIsStable() => throw new NotSupportedException(); } private sealed class NoOpSessionProcessor : IPayjoinReceiverSessionProcessor diff --git a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinAccountingBridgeResetTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinAccountingBridgeResetTests.cs new file mode 100644 index 0000000..5d6cf88 --- /dev/null +++ b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinAccountingBridgeResetTests.cs @@ -0,0 +1,183 @@ +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Plugins.Payjoin.Data; +using BTCPayServer.Plugins.Payjoin.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Options; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; +using System.Linq; +using Xunit; + +namespace BTCPayServer.Plugins.Payjoin.Tests.Services; + +public class PayjoinAccountingBridgeResetTests +{ + private const string ExpectedTransactionId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private const string FallbackTransactionId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + [Fact] + public async Task ResetForNewSessionAsyncClearsPriorSessionTrackingOnUnarmedPendingBridges() + { + using var context = new TestContext(); + var service = context.CreateService(); + var now = DateTimeOffset.UtcNow; + await CreateBridgeAsync(service, "invoice-1", now.AddHours(1)); + await service.AttachFallbackAsync("invoice-1", FallbackTransactionId, 0, 900, 900, "CCDD", CancellationToken.None); + + var reset = await service.ResetForNewSessionAsync("invoice-1", 1200, now.AddHours(2), CancellationToken.None); + + Assert.NotNull(reset); + Assert.Equal(PayjoinAccountingBridgeStatus.PendingFallback, reset!.Status); + Assert.Null(reset.FallbackTransactionId); + Assert.Null(reset.SettlementScript); + Assert.Null(reset.ExpectedFinalTransactionId); + Assert.Null(reset.ExpectedFinalOutputIndex); + Assert.Null(reset.ExpectedFinalValueSats); + Assert.Equal(1200, reset.EffectiveInvoiceValueSats); + Assert.Equal(now.AddHours(2), reset.ExpiresAt); + } + + [Fact] + public async Task ResetForNewSessionAsyncPreservesArmedBridgesSoTheOldProposalStillReconciles() + { + // The previous session already handed a signed proposal to the sender: the expected final + // transaction is the only handle that makes that settlement creditable, so recreating the + // session must not wipe it. The old accounting flow stays live until the new session + // produces its own finalized proposal and overwrites the expectation itself. + using var context = new TestContext(); + var service = context.CreateService(); + var now = DateTimeOffset.UtcNow; + await CreateBridgeAsync(service, "invoice-1", now.AddHours(1)); + await service.AttachFallbackAsync("invoice-1", FallbackTransactionId, 0, 900, 900, "CCDD", CancellationToken.None); + await service.SetExpectedFinalTransactionAsync("invoice-1", ExpectedTransactionId, 1, 950, CancellationToken.None); + + var reset = await service.ResetForNewSessionAsync("invoice-1", 1200, now.AddHours(2), CancellationToken.None); + + Assert.NotNull(reset); + Assert.Equal(PayjoinAccountingBridgeStatus.PendingFinalTransaction, reset!.Status); + Assert.Equal(ExpectedTransactionId, reset.ExpectedFinalTransactionId); + Assert.Equal(FallbackTransactionId, reset.FallbackTransactionId); + Assert.Equal("CCDD", reset.SettlementScript); + + // The old session's final transaction becomes observable afterwards and still reconciles. + var reconciled = await service.MarkReconciledAsync("invoice-1", ExpectedTransactionId, 1, 950, now, CancellationToken.None); + Assert.Equal(PayjoinAccountingBridgeStatus.Reconciled, reconciled!.Status); + Assert.Equal(ExpectedTransactionId, reconciled.ExpectedFinalTransactionId); + } + + [Fact] + public async Task ResetForNewSessionAsyncRevivesUnarmedExpiredBridges() + { + using var context = new TestContext(); + var service = context.CreateService(); + var now = DateTimeOffset.UtcNow; + await CreateBridgeAsync(service, "invoice-1", now.AddMinutes(-5)); + await service.ExpirePendingAsync(now, CancellationToken.None); + + var reset = await service.ResetForNewSessionAsync("invoice-1", 1000, now.AddHours(1), CancellationToken.None); + + Assert.NotNull(reset); + Assert.Equal(PayjoinAccountingBridgeStatus.PendingFallback, reset!.Status); + Assert.Equal(now.AddHours(1), reset.ExpiresAt); + } + + [Fact] + public async Task ResetForNewSessionAsyncLeavesArmedExpiredAndFailedBridgesForReview() + { + using var context = new TestContext(); + var service = context.CreateService(); + var now = DateTimeOffset.UtcNow; + await CreateBridgeAsync(service, "invoice-armed", now.AddHours(1), ExpectedTransactionId); + await CreateBridgeAsync(service, "invoice-failed", now.AddHours(1)); + await service.MarkFailedAsync("invoice-failed", "problem", CancellationToken.None); + using (var db = context.CreateDbContext()) + { + var armed = db.AccountingBridges.Single(x => x.InvoiceId == "invoice-armed"); + armed.Status = PayjoinAccountingBridgeStatus.Expired; + db.SaveChanges(); + } + + var armedResult = await service.ResetForNewSessionAsync("invoice-armed", 1000, now.AddHours(2), CancellationToken.None); + var failedResult = await service.ResetForNewSessionAsync("invoice-failed", 1000, now.AddHours(2), CancellationToken.None); + + Assert.Equal(PayjoinAccountingBridgeStatus.Expired, armedResult!.Status); + Assert.Equal(ExpectedTransactionId, armedResult.ExpectedFinalTransactionId); + Assert.Equal(PayjoinAccountingBridgeStatus.Failed, failedResult!.Status); + Assert.Equal("problem", failedResult.FailureMessage); + } + + [Fact] + public async Task ResetForNewSessionAsyncLeavesFreshPendingBridgesUntouched() + { + using var context = new TestContext(); + var service = context.CreateService(); + var now = DateTimeOffset.UtcNow; + var created = await CreateBridgeAsync(service, "invoice-1", now.AddHours(1)); + + var reset = await service.ResetForNewSessionAsync("invoice-1", 5000, now.AddHours(3), CancellationToken.None); + + Assert.NotNull(reset); + Assert.Equal(created.EffectiveInvoiceValueSats, reset!.EffectiveInvoiceValueSats); + Assert.Equal(created.ExpiresAt, reset.ExpiresAt); + } + + private static Task CreateBridgeAsync( + PayjoinAccountingBridgeService service, + string invoiceId, + DateTimeOffset? expiresAt, + string? expectedFinalTransactionId = null) + { + return service.CreateOrGetAsync( + new CreatePayjoinAccountingBridgeRequest( + invoiceId, + "store-1", + PayjoinConstants.BitcoinCode, + "BTC-BTC", + expiresAt, + EffectiveInvoiceValueSats: 1000, + ExpectedFinalTransactionId: expectedFinalTransactionId), + CancellationToken.None); + } + + private sealed class TestContext : IDisposable + { + private readonly TestDbContextFactory _dbContextFactory = new(); + private readonly PostgresPayjoinUniqueConstraintViolationDetector _uniqueConstraintViolationDetector = new(); + + public PayjoinAccountingBridgeService CreateService() => new(_dbContextFactory, _uniqueConstraintViolationDetector); + + public PayjoinPluginDbContext CreateDbContext() => _dbContextFactory.CreateContext(); + + public void Dispose() + { + using var db = _dbContextFactory.CreateContext(); + db.Database.EnsureDeleted(); + } + } + + private sealed class TestDbContextFactory : PayjoinPluginDbContextFactory + { + private static readonly InMemoryDatabaseRoot SharedDatabaseRoot = new(); + private readonly DbContextOptions _dbContextOptions; + + public TestDbContextFactory() + : base(Options.Create(new DatabaseOptions + { + ConnectionString = "Host=localhost;Database=payjoin-plugin-tests;Username=postgres" + })) + { + var databaseName = $"payjoin-bridge-reset-tests-{Guid.NewGuid():N}"; + _dbContextOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName, SharedDatabaseRoot) + .Options; + + using var db = CreateContext(); + db.Database.EnsureCreated(); + } + + public override PayjoinPluginDbContext CreateContext(Action? npgsqlOptionsAction = null) + { + return new PayjoinPluginDbContext(_dbContextOptions); + } + } +} diff --git a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverPollerTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverPollerTests.cs index 2b42bd2..3aaa68a 100644 --- a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverPollerTests.cs +++ b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverPollerTests.cs @@ -297,6 +297,7 @@ private sealed class NoOpAccountingBridgeService : IPayjoinAccountingBridgeServi public Task> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult>([]); public Task GetRequiringAttentionAsync(string storeId, CancellationToken cancellationToken) => Task.FromResult(new PayjoinAccountingBridgeAttentionResult([], 0)); public Task TryRetryAsync(string invoiceId, string storeId, DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult(null); + public Task ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) => Task.FromResult(null); } private sealed class NoOpAccountingPaymentService : IPayjoinAccountingPaymentService @@ -335,6 +336,7 @@ public Task> GetPendingAsync(D LastMarkedFailedMessage = failureMessage; return Task.FromResult(null); } + public Task ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) => Task.FromResult(null); public Task> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) { ExpirePendingInvocationCount++; diff --git a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverProposalFinalizerTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverProposalFinalizerTests.cs index b44b857..9acceb7 100644 --- a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverProposalFinalizerTests.cs +++ b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverProposalFinalizerTests.cs @@ -170,7 +170,7 @@ private sealed class FixedPsbtProposal(string psbt) : IPayjoinProposal public RequestResponse CreatePostRequest(string ohttpRelay) => throw new NotSupportedException(); public PayjoinProposalTransition ProcessResponse(byte[] body, ClientResponse ohttpContext) => throw new NotSupportedException(); public string Psbt() => psbt; - public PayjoinOutPoint[] UtxosToBeLocked() => throw new NotSupportedException(); + public bool ProposalTxidIsStable() => throw new NotSupportedException(); } private sealed class ThrowingProposal : IPayjoinProposal @@ -179,7 +179,7 @@ private sealed class ThrowingProposal : IPayjoinProposal public RequestResponse CreatePostRequest(string ohttpRelay) => throw new NotSupportedException(); public PayjoinProposalTransition ProcessResponse(byte[] body, ClientResponse ohttpContext) => throw new NotSupportedException(); public string Psbt() => throw new NotSupportedException(); - public PayjoinOutPoint[] UtxosToBeLocked() => throw new NotSupportedException(); + public bool ProposalTxidIsStable() => throw new NotSupportedException(); } private sealed class UnusedRelayRequestSender : IPayjoinReceiverRelayRequestSender @@ -219,5 +219,6 @@ private sealed class RecordingBridgeService(PayjoinAccountingBridgeState? bridge public Task> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetRequiringAttentionAsync(string storeId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task TryRetryAsync(string invoiceId, string storeId, DateTimeOffset now, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) => throw new NotSupportedException(); } } diff --git a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverSessionProcessorTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverSessionProcessorTests.cs index baaa431..072db33 100644 --- a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverSessionProcessorTests.cs +++ b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverSessionProcessorTests.cs @@ -290,6 +290,7 @@ private sealed class NoOpAccountingBridgeService : IPayjoinAccountingBridgeServi public Task> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult>([]); public Task GetRequiringAttentionAsync(string storeId, CancellationToken cancellationToken) => Task.FromResult(new PayjoinAccountingBridgeAttentionResult([], 0)); public Task TryRetryAsync(string invoiceId, string storeId, DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult(null); + public Task ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) => Task.FromResult(null); } private sealed class NoOpAccountingPaymentService : IPayjoinAccountingPaymentService diff --git a/BTCPayServer.Plugins.Payjoin/Controllers/UIPayJoinController.cs b/BTCPayServer.Plugins.Payjoin/Controllers/UIPayJoinController.cs index 3a5fced..c80f560 100644 --- a/BTCPayServer.Plugins.Payjoin/Controllers/UIPayJoinController.cs +++ b/BTCPayServer.Plugins.Payjoin/Controllers/UIPayJoinController.cs @@ -145,7 +145,7 @@ public async Task> RunTestPayment([FromBody paymentAmount = Money.Satoshis(checked((long)amountSats.Value)).ToDecimal(MoneyUnit.BTC); using var _ = parsedUri.CheckPjSupported(); } - catch (PjParseException ex) + catch (UriParseException ex) { return RunTestPaymentFailure($"Invalid BIP21 URI: {ex.Message}"); } diff --git a/BTCPayServer.Plugins.Payjoin/Services/PayjoinAccountingBridgeService.cs b/BTCPayServer.Plugins.Payjoin/Services/PayjoinAccountingBridgeService.cs index 949d651..7e5c02b 100644 --- a/BTCPayServer.Plugins.Payjoin/Services/PayjoinAccountingBridgeService.cs +++ b/BTCPayServer.Plugins.Payjoin/Services/PayjoinAccountingBridgeService.cs @@ -74,6 +74,8 @@ internal interface IPayjoinAccountingBridgeService Task GetRequiringAttentionAsync(string storeId, CancellationToken cancellationToken); Task TryRetryAsync(string invoiceId, string storeId, DateTimeOffset now, CancellationToken cancellationToken); + + Task ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken); } internal sealed class PayjoinAccountingBridgeService : IPayjoinAccountingBridgeService @@ -240,6 +242,61 @@ public async Task> GetPendingA cancellationToken); } + public async Task ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) + { + using var context = _dbContextFactory.CreateContext(); + var bridge = await TryLoadByInvoiceIdAsync(context, invoiceId, cancellationToken).ConfigureAwait(false); + if (bridge is null) + { + return null; + } + + // A freshly created receiver session produces its own fallback, settlement script and final + // transaction, so tracking data from a previous session on the same invoice no longer applies: + // the fallback-attach guard would hold on to the old fallback while later writes would overwrite + // the rest, leaving the record describing two different sessions at once. Reconciled records are + // final, and failed or expired records that already awaited a final transaction stay untouched + // for operator review. + // + // A pending bridge that is already armed is the exception: its expected final transaction + // describes a signed proposal the previous session handed to the sender, which can still + // confirm, and that expectation is the only thing that makes the settlement creditable (the + // settlement output is not an invoice address the platform tracks on its own). The old + // accounting flow therefore stays live through session recreation, and the new session's own + // writes take the record over stage by stage: attaching its fallback replaces the fallback + // data, committing outputs replaces the settlement script, and finalizing its proposal + // replaces the expected final transaction. + var isResettablePending = (bridge.Status is PayjoinAccountingBridgeStatus.PendingFallback or PayjoinAccountingBridgeStatus.PendingFinalTransaction) && + bridge.ExpectedFinalTransactionId is null; + var isResettableExpired = bridge.Status == PayjoinAccountingBridgeStatus.Expired && bridge.ExpectedFinalTransactionId is null; + if (!isResettablePending && !isResettableExpired) + { + return ToState(bridge); + } + + var hasPriorSessionData = bridge.FallbackTransactionId is not null || + bridge.SettlementScript is not null; + if (!hasPriorSessionData && bridge.Status != PayjoinAccountingBridgeStatus.Expired) + { + return ToState(bridge); + } + + bridge.FallbackTransactionId = null; + bridge.FallbackOutputIndex = null; + bridge.FallbackValueSats = null; + bridge.SettlementScript = null; + bridge.ExpectedFinalTransactionId = null; + bridge.ExpectedFinalOutputIndex = null; + bridge.ExpectedFinalValueSats = null; + bridge.EffectiveInvoiceValueSats = effectiveInvoiceValueSats ?? bridge.EffectiveInvoiceValueSats; + bridge.FailureMessage = null; + bridge.Status = PayjoinAccountingBridgeStatus.PendingFallback; + bridge.ExpiresAt = expiresAt ?? bridge.ExpiresAt; + bridge.UpdatedAt = DateTimeOffset.UtcNow; + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return ToState(bridge); + } + public async Task> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) { using var context = _dbContextFactory.CreateContext(); diff --git a/BTCPayServer.Plugins.Payjoin/Services/PayjoinInvoicePaymentUrlService.cs b/BTCPayServer.Plugins.Payjoin/Services/PayjoinInvoicePaymentUrlService.cs index 07cec2b..70fe649 100644 --- a/BTCPayServer.Plugins.Payjoin/Services/PayjoinInvoicePaymentUrlService.cs +++ b/BTCPayServer.Plugins.Payjoin/Services/PayjoinInvoicePaymentUrlService.cs @@ -151,7 +151,7 @@ private static bool IsPayjoinEnabled(string paymentUrl) using var _ = parsedUri.CheckPjSupported(); return true; } - catch (PjParseException) + catch (UriParseException) { return false; } diff --git a/BTCPayServer.Plugins.Payjoin/Services/PayjoinUriSessionService.cs b/BTCPayServer.Plugins.Payjoin/Services/PayjoinUriSessionService.cs index a1ff1a3..03ffdb7 100644 --- a/BTCPayServer.Plugins.Payjoin/Services/PayjoinUriSessionService.cs +++ b/BTCPayServer.Plugins.Payjoin/Services/PayjoinUriSessionService.cs @@ -127,6 +127,7 @@ public async Task BuildAsync( } } + var createdFreshSession = false; if (session is null) { var selectedRelay = await _mailroomManager.SelectBootstrapRouteAsync( @@ -148,13 +149,14 @@ public async Task BuildAsync( storeId, monitoringExpiresAt, bootstrapPersister.Load()); + createdFreshSession = true; } var persister = _receiverSessionStore.CreatePersister(session); using var replay = PayjoinMethods.ReplayReceiverEventLog(persister); using var history = replay.SessionHistory(); - await EnsureAccountingBridgeAsync(invoiceId, storeId, cryptoCode, due, monitoringExpiresAt, cancellationToken).ConfigureAwait(false); + await EnsureAccountingBridgeAsync(invoiceId, storeId, cryptoCode, due, monitoringExpiresAt, createdFreshSession, cancellationToken).ConfigureAwait(false); using var pjUri = history.PjUri(); var payjoinUri = pjUri.AsString(); if (string.IsNullOrWhiteSpace(payjoinUri)) @@ -215,19 +217,20 @@ internal static ulong ToExpirationSeconds(DateTimeOffset monitoringExpiresAt) return (ulong)Math.Min(remainingSeconds, uint.MaxValue); } - private Task EnsureAccountingBridgeAsync( + private async Task EnsureAccountingBridgeAsync( string invoiceId, string storeId, string cryptoCode, decimal due, DateTimeOffset monitoringExpiresAt, + bool createdFreshSession, CancellationToken cancellationToken) { var effectiveInvoiceValueSats = due > 0m ? Money.Coins(due).Satoshi : (long?)null; - return _accountingBridgeService.CreateOrGetAsync( + await _accountingBridgeService.CreateOrGetAsync( new CreatePayjoinAccountingBridgeRequest( invoiceId, storeId, @@ -235,7 +238,12 @@ private Task EnsureAccountingBridgeAsync( PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode).ToString(), monitoringExpiresAt, EffectiveInvoiceValueSats: effectiveInvoiceValueSats), - cancellationToken); + cancellationToken).ConfigureAwait(false); + + if (createdFreshSession) + { + await _accountingBridgeService.ResetForNewSessionAsync(invoiceId, effectiveInvoiceValueSats, monitoringExpiresAt, cancellationToken).ConfigureAwait(false); + } } private string LogExpectedFallbackAndReturnBip21(string bip21, string invoiceId, string reason) diff --git a/BTCPayServer.Plugins.Payjoin/Services/RunTestPaymentService.cs b/BTCPayServer.Plugins.Payjoin/Services/RunTestPaymentService.cs index 2af20a7..a0636d9 100644 --- a/BTCPayServer.Plugins.Payjoin/Services/RunTestPaymentService.cs +++ b/BTCPayServer.Plugins.Payjoin/Services/RunTestPaymentService.cs @@ -272,13 +272,13 @@ private async Task RequestProposalAsync( current.Dispose(); } } - catch (BuildSenderException ex) + catch (SenderInputException ex) { throw new RunTestPaymentExecutionException($"Sender build failed: {ex.Message}", ex); } - catch (SenderPersistedException.ResponseException ex) + catch (SenderPersistedException ex) when (SenderResponseOf(ex) is { } senderResponse) { - throw new RunTestPaymentExecutionException($"Sender rejected by receiver: {FormatSenderResponseException(ex.v1)}", ex); + throw new RunTestPaymentExecutionException($"Sender rejected by receiver: {FormatSenderResponseException(senderResponse)}", ex); } catch (HttpRequestException ex) { @@ -442,6 +442,16 @@ private static IReadOnlyList GetConfiguredRelayUrls(RunTestPaymentCon throw new RunTestPaymentExecutionException("No OHTTP relays configured for test payment."); } + private static global::Payjoin.ResponseException? SenderResponseOf(SenderPersistedException exception) + { + return exception switch + { + SenderPersistedException.Fatal { v1: SenderException.Response response } => response.v1, + SenderPersistedException.Transient { v1: SenderException.Response response } => response.v1, + _ => null + }; + } + private static string FormatSenderResponseException(global::Payjoin.ResponseException responseException) { return responseException switch diff --git a/rust-payjoin b/rust-payjoin index f4a6008..dee54d6 160000 --- a/rust-payjoin +++ b/rust-payjoin @@ -1 +1 @@ -Subproject commit f4a6008a3a531530434bdd6911db864bcb42fc66 +Subproject commit dee54d6512f6860ffefefa93681694a623ab2328