Skip to content

Commit 613599b

Browse files
authored
Merge pull request #35 from thgO-O/fix/approved-deposit-status
Handle approved DePix deposits and update later depix_sent details
2 parents d13f672 + 2e63978 commit 613599b

8 files changed

Lines changed: 830 additions & 70 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using BTCPayServer.Client.Models;
2+
using BTCPayServer.Plugins.Depix.Data.Enums;
3+
using BTCPayServer.Services.Invoices;
4+
using Xunit;
5+
6+
namespace BTCPayServer.Plugins.Depix.Tests;
7+
8+
public class DepixStatusTests
9+
{
10+
[Theory]
11+
[InlineData("approved", DepixStatus.Approved)]
12+
[InlineData("APPROVED", DepixStatus.Approved)]
13+
[InlineData("DEPIX-SENT", DepixStatus.DepixSent)]
14+
public void TryParseRecognizesKnownDepositStatuses(string value, DepixStatus expected)
15+
{
16+
Assert.True(DepixStatusExtensions.TryParse(value, out var status));
17+
Assert.Equal(expected, status);
18+
Assert.DoesNotContain('-', status.ToApiString());
19+
}
20+
21+
[Fact]
22+
public void ApprovedSettlesInvoice()
23+
{
24+
var state = DepixStatus.Approved.ToInvoiceState(new InvoiceState(InvoiceStatus.New, InvoiceExceptionStatus.None));
25+
26+
Assert.NotNull(state);
27+
Assert.Equal(InvoiceStatus.Settled, state!.Status);
28+
Assert.Equal(InvoiceExceptionStatus.None, state.ExceptionStatus);
29+
}
30+
31+
[Theory]
32+
[InlineData(DepixStatus.Approved, true)]
33+
[InlineData(DepixStatus.DepixSent, true)]
34+
[InlineData(DepixStatus.UnderReview, false)]
35+
public void OnlyApprovedAndDepixSentAreConfirmedPaymentStatuses(DepixStatus status, bool expected)
36+
{
37+
Assert.Equal(expected, status.IsConfirmedPaymentStatus());
38+
}
39+
40+
[Theory]
41+
[InlineData(DepixStatus.Approved, "depix_sent", false)]
42+
[InlineData(DepixStatus.DepixSent, "approved", true)]
43+
[InlineData(DepixStatus.Approved, "refunded", false)]
44+
public void StatusPrecedencePreventsDowngrades(DepixStatus incoming, string current, bool expected)
45+
{
46+
Assert.Equal(expected, incoming.ShouldReplace(current));
47+
}
48+
}
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using BTCPayServer;
8+
using BTCPayServer.Client.Models;
9+
using BTCPayServer.Data;
10+
using BTCPayServer.Events;
11+
using BTCPayServer.Logging;
12+
using BTCPayServer.Payments;
13+
using BTCPayServer.Plugins.Depix.Data.Models;
14+
using BTCPayServer.Plugins.Depix.PaymentHandlers;
15+
using BTCPayServer.Plugins.Depix.Services;
16+
using BTCPayServer.Rating;
17+
using BTCPayServer.Services.Invoices;
18+
using BTCPayServer.Services.Stores;
19+
using BTCPayServer.Tests;
20+
using Newtonsoft.Json.Linq;
21+
using Xunit;
22+
using Xunit.Abstractions;
23+
24+
namespace BTCPayServer.Plugins.Depix.Tests;
25+
26+
[Collection(SharedPluginTestCollection.CollectionName)]
27+
public sealed class DepixWebhookServiceTests : IAsyncLifetime
28+
{
29+
private static readonly PaymentMethodId PixPmid = new("PIX");
30+
private readonly UnitTestBase _unitTestBase;
31+
private ServerTester _server = null!;
32+
33+
public DepixWebhookServiceTests(SharedPluginTestFixture fixture, ITestOutputHelper output)
34+
{
35+
_ = fixture;
36+
_unitTestBase = new UnitTestBase(output);
37+
}
38+
39+
public async Task InitializeAsync()
40+
{
41+
_server = _unitTestBase.CreateServerTester(scope: CreateScopePath(), newDb: true);
42+
await _server.StartAsync();
43+
}
44+
45+
public Task DisposeAsync()
46+
{
47+
_server.Dispose();
48+
return Task.CompletedTask;
49+
}
50+
51+
[Fact(Timeout = TestUtils.TestTimeout)]
52+
public async Task ApprovedWebhookCreatesPaymentAndDepixSentUpdatesExistingPayment()
53+
{
54+
var storeId = await CreateStoreAsync();
55+
var invoice = await CreatePixInvoiceAsync(storeId, qrId: "qr-approved-1", valueInCents: 1234);
56+
var service = _server.PayTester.GetService<DepixService>();
57+
var events = _server.PayTester.GetService<EventAggregator>();
58+
var receivedPaymentEvents = new List<InvoiceEvent>();
59+
var needUpdateEvents = new List<InvoiceNeedUpdateEvent>();
60+
var dataChangedEvents = new List<InvoiceDataChangedEvent>();
61+
using var receivedPaymentSubscription = events.Subscribe<InvoiceEvent>(evt =>
62+
{
63+
if (evt.InvoiceId == invoice.Id && evt.Name == InvoiceEvent.ReceivedPayment)
64+
receivedPaymentEvents.Add(evt);
65+
});
66+
using var needUpdateSubscription = events.Subscribe<InvoiceNeedUpdateEvent>(evt =>
67+
{
68+
if (evt.InvoiceId == invoice.Id)
69+
needUpdateEvents.Add(evt);
70+
});
71+
using var dataChangedSubscription = events.Subscribe<InvoiceDataChangedEvent>(evt =>
72+
{
73+
if (evt.InvoiceId == invoice.Id)
74+
dataChangedEvents.Add(evt);
75+
});
76+
77+
await service.ProcessWebhookAsync(storeId, new DepositWebhookBody
78+
{
79+
QrId = "qr-approved-1",
80+
Status = "approved",
81+
ValueInCents = 1234,
82+
PayerName = "Alice"
83+
}, CancellationToken.None);
84+
85+
var approvedPayment = await GetPixPaymentAsync(invoice.Id);
86+
Assert.Equal(PaymentStatus.Settled, approvedPayment.Status);
87+
var receivedPaymentEvent = Assert.Single(receivedPaymentEvents);
88+
Assert.Equal(approvedPayment.Id, receivedPaymentEvent.Payment.Id);
89+
var approvedDetails = GetPixPaymentDetails(approvedPayment);
90+
Assert.Equal("approved", approvedDetails.Status);
91+
Assert.Equal("Alice", approvedDetails.PayerName);
92+
93+
await service.ProcessWebhookAsync(storeId, new DepositWebhookBody
94+
{
95+
QrId = "qr-approved-1",
96+
Status = "depix_sent",
97+
ValueInCents = 1234,
98+
BlockchainTxId = "liquid-tx-1"
99+
}, CancellationToken.None);
100+
101+
var invoiceAfterDepixSent = await _server.PayTester.InvoiceRepository.GetInvoice(invoice.Id);
102+
var payments = invoiceAfterDepixSent.GetPayments(false).Where(p => p.PaymentMethodId == PixPmid).ToList();
103+
var updatedPayment = Assert.Single(payments);
104+
var updatedDetails = GetPixPaymentDetails(updatedPayment);
105+
Assert.Equal("depix_sent", updatedDetails.Status);
106+
Assert.Equal("liquid-tx-1", updatedDetails.BlockchainTxId);
107+
Assert.Equal("Alice", updatedDetails.PayerName);
108+
Assert.Equal("depix_sent", (await GetPixPromptDetailsAsync(invoice.Id)).Status);
109+
Assert.Contains(needUpdateEvents, evt => evt.InvoiceId == invoice.Id);
110+
Assert.Contains(dataChangedEvents, evt => evt.InvoiceId == invoice.Id);
111+
}
112+
113+
[Fact(Timeout = TestUtils.TestTimeout)]
114+
public async Task ApprovedAfterDepixSentDoesNotDowngradeStoredStatus()
115+
{
116+
var storeId = await CreateStoreAsync();
117+
var invoice = await CreatePixInvoiceAsync(storeId, qrId: "qr-out-of-order", valueInCents: 1234);
118+
var service = _server.PayTester.GetService<DepixService>();
119+
120+
await service.ProcessWebhookAsync(storeId, new DepositWebhookBody
121+
{
122+
QrId = "qr-out-of-order",
123+
Status = "depix_sent",
124+
ValueInCents = 1234,
125+
BlockchainTxId = "liquid-tx-1"
126+
}, CancellationToken.None);
127+
128+
await service.ProcessWebhookAsync(storeId, new DepositWebhookBody
129+
{
130+
QrId = "qr-out-of-order",
131+
Status = "approved",
132+
ValueInCents = 1234,
133+
BankTxId = "bank-tx-late",
134+
PayerName = "Late payer"
135+
}, CancellationToken.None);
136+
137+
var invoiceAfterApproved = await _server.PayTester.InvoiceRepository.GetInvoice(invoice.Id);
138+
var payment = Assert.Single(invoiceAfterApproved.GetPayments(false), p => p.PaymentMethodId == PixPmid);
139+
var details = GetPixPaymentDetails(payment);
140+
Assert.Equal("depix_sent", details.Status);
141+
Assert.Equal("liquid-tx-1", details.BlockchainTxId);
142+
Assert.Equal("bank-tx-late", details.BankTxId);
143+
Assert.Equal("Late payer", details.PayerName);
144+
145+
var promptDetails = await GetPixPromptDetailsAsync(invoice.Id);
146+
Assert.Equal("depix_sent", promptDetails.Status);
147+
Assert.Equal("Late payer", promptDetails.Payer?.Name);
148+
}
149+
150+
[Fact(Timeout = TestUtils.TestTimeout)]
151+
public async Task DuplicateWebhookWithMismatchedAmountDoesNotChangeStoredAmount()
152+
{
153+
var storeId = await CreateStoreAsync();
154+
var invoice = await CreatePixInvoiceAsync(storeId, qrId: "qr-amount-mismatch", valueInCents: 1234);
155+
var service = _server.PayTester.GetService<DepixService>();
156+
157+
await service.ProcessWebhookAsync(storeId, new DepositWebhookBody
158+
{
159+
QrId = "qr-amount-mismatch",
160+
Status = "approved",
161+
ValueInCents = 1234,
162+
PayerName = "Alice"
163+
}, CancellationToken.None);
164+
165+
await service.ProcessWebhookAsync(storeId, new DepositWebhookBody
166+
{
167+
QrId = "qr-amount-mismatch",
168+
Status = "depix_sent",
169+
ValueInCents = 9999,
170+
BlockchainTxId = "liquid-tx-mismatch"
171+
}, CancellationToken.None);
172+
173+
var invoiceAfterMismatch = await _server.PayTester.InvoiceRepository.GetInvoice(invoice.Id);
174+
var payment = Assert.Single(invoiceAfterMismatch.GetPayments(false), p => p.PaymentMethodId == PixPmid);
175+
var details = GetPixPaymentDetails(payment);
176+
Assert.Equal(12.34m, payment.Value);
177+
Assert.Equal("depix_sent", details.Status);
178+
Assert.Equal(1234, details.ValueInCents);
179+
Assert.Equal("liquid-tx-mismatch", details.BlockchainTxId);
180+
Assert.Equal("Alice", details.PayerName);
181+
182+
var promptDetails = await GetPixPromptDetailsAsync(invoice.Id);
183+
Assert.Equal("depix_sent", promptDetails.Status);
184+
Assert.Equal(1234, promptDetails.ValueInCents);
185+
}
186+
187+
[Fact(Timeout = TestUtils.TestTimeout)]
188+
public async Task FirstConfirmedWebhookWithMismatchedAmountUsesPromptAmount()
189+
{
190+
var storeId = await CreateStoreAsync();
191+
var invoice = await CreatePixInvoiceAsync(storeId, qrId: "qr-first-amount-mismatch", valueInCents: 1234);
192+
var service = _server.PayTester.GetService<DepixService>();
193+
194+
await service.ProcessWebhookAsync(storeId, new DepositWebhookBody
195+
{
196+
QrId = "qr-first-amount-mismatch",
197+
Status = "approved",
198+
ValueInCents = 9999,
199+
PayerName = "Alice"
200+
}, CancellationToken.None);
201+
202+
var payment = await GetPixPaymentAsync(invoice.Id);
203+
Assert.Equal(12.34m, payment.Value);
204+
var details = GetPixPaymentDetails(payment);
205+
Assert.Equal("approved", details.Status);
206+
Assert.Null(details.ValueInCents);
207+
Assert.Equal("Alice", details.PayerName);
208+
209+
var promptDetails = await GetPixPromptDetailsAsync(invoice.Id);
210+
Assert.Equal("approved", promptDetails.Status);
211+
Assert.Equal(1234, promptDetails.ValueInCents);
212+
}
213+
214+
private async Task<string> CreateStoreAsync()
215+
{
216+
var account = _server.NewAccount();
217+
await account.RegisterAsync(isAdmin: true);
218+
await account.CreateStoreAsync();
219+
return account.StoreId;
220+
}
221+
222+
private async Task<InvoiceEntity> CreatePixInvoiceAsync(
223+
string storeId,
224+
string qrId,
225+
int valueInCents)
226+
{
227+
var storeRepository = _server.PayTester.GetService<StoreRepository>();
228+
var handlers = _server.PayTester.GetService<PaymentMethodHandlerDictionary>();
229+
var handler = handlers[PixPmid];
230+
var invoiceRepository = _server.PayTester.InvoiceRepository;
231+
var store = await storeRepository.FindStore(storeId) ?? throw new InvalidOperationException("Store not found.");
232+
233+
var invoice = invoiceRepository.CreateNewInvoice(storeId);
234+
invoice.Currency = "BRL";
235+
invoice.Price = valueInCents / 100m;
236+
invoice.AddRate(new CurrencyPair("BRL", "BRL"), 1m);
237+
var details = new DePixPaymentMethodDetails
238+
{
239+
QrId = qrId,
240+
Status = "pending",
241+
ValueInCents = valueInCents
242+
};
243+
244+
invoice.SetPaymentPrompt(PixPmid, new PaymentPrompt
245+
{
246+
Currency = "BRL",
247+
Divisibility = 2,
248+
Destination = "https://example.invalid/pix.png",
249+
Details = JToken.FromObject(details, handler.Serializer)
250+
});
251+
252+
await invoiceRepository.CreateInvoiceAsync(new InvoiceCreationContext(
253+
store,
254+
store.GetStoreBlob(),
255+
invoice,
256+
new InvoiceLogs(),
257+
handlers,
258+
invoicePaymentMethodFilter: null));
259+
260+
return invoice;
261+
}
262+
263+
private async Task<PaymentEntity> GetPixPaymentAsync(string invoiceId)
264+
{
265+
var invoice = await _server.PayTester.InvoiceRepository.GetInvoice(invoiceId);
266+
return Assert.Single(invoice.GetPayments(false), p => p.PaymentMethodId == PixPmid);
267+
}
268+
269+
private DePixPaymentData GetPixPaymentDetails(PaymentEntity payment)
270+
{
271+
var handler = _server.PayTester.GetService<PaymentMethodHandlerDictionary>()[PixPmid];
272+
return payment.GetDetails<DePixPaymentData>(handler) ?? throw new InvalidOperationException("Missing Pix payment details.");
273+
}
274+
275+
private async Task<DePixPaymentMethodDetails> GetPixPromptDetailsAsync(string invoiceId)
276+
{
277+
var invoice = await _server.PayTester.InvoiceRepository.GetInvoice(invoiceId);
278+
var prompt = invoice.GetPaymentPrompt(PixPmid) ?? throw new InvalidOperationException("Missing Pix prompt.");
279+
var handler = _server.PayTester.GetService<PaymentMethodHandlerDictionary>()[PixPmid];
280+
return handler.ParsePaymentPromptDetails(prompt.Details) as DePixPaymentMethodDetails ??
281+
throw new InvalidOperationException("Missing Pix prompt details.");
282+
}
283+
284+
private static string CreateScopePath()
285+
{
286+
return Path.Combine(Path.GetTempPath(), "depix-webhook-service", Guid.NewGuid().ToString("N"));
287+
}
288+
}

0 commit comments

Comments
 (0)