Fix LN invoices (#1955)

* Fix LN invoices

This commit adds more to the previous LN fix in the case of a partial payment to an invoice. While it generated a new LN invoice after 1 partial payment was made, there were some new issues uncovered:
* Any other subsequent partial payments was not listened to  and did not generate an invoice ( fixed by listeneing to received payment event and makng sure that the status was already set `to partialPaid`)
* Any other subsequent partial payments caused a DbConcurrency error and did not generate an invoice ( Fixed in `MarkUnassigned`)
This commit is contained in:
Andrew Camilleri
2020-10-17 08:57:21 +02:00
committed by GitHub
parent ee3aa49eee
commit e3a0fe88c1
6 changed files with 129 additions and 114 deletions

View File

@@ -300,32 +300,35 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(PullPaymentController.ViewPullPayment), return RedirectToAction(nameof(PullPaymentController.ViewPullPayment),
"PullPayment", "PullPayment",
new { pullPaymentId = ppId }); new { pullPaymentId = ppId });
} }
private InvoiceDetailsModel InvoicePopulatePayments(InvoiceEntity invoice) private InvoiceDetailsModel InvoicePopulatePayments(InvoiceEntity invoice)
{ {
var model = new InvoiceDetailsModel(); return new InvoiceDetailsModel
model.Archived = invoice.Archived;
model.Payments = invoice.GetPayments();
foreach (var data in invoice.GetPaymentMethods())
{ {
var accounting = data.Calculate(); Archived = invoice.Archived,
var paymentMethodId = data.GetId(); Payments = invoice.GetPayments(),
var cryptoPayment = new InvoiceDetailsModel.CryptoPayment(); CryptoPayments = invoice.GetPaymentMethods().Select(
data =>
cryptoPayment.PaymentMethodId = paymentMethodId; {
cryptoPayment.PaymentMethod = paymentMethodId.ToPrettyString(); var accounting = data.Calculate();
cryptoPayment.Due = _CurrencyNameTable.DisplayFormatCurrency(accounting.Due.ToDecimal(MoneyUnit.BTC), paymentMethodId.CryptoCode); var paymentMethodId = data.GetId();
cryptoPayment.Paid = _CurrencyNameTable.DisplayFormatCurrency(accounting.CryptoPaid.ToDecimal(MoneyUnit.BTC), paymentMethodId.CryptoCode); return new InvoiceDetailsModel.CryptoPayment
cryptoPayment.Overpaid = _CurrencyNameTable.DisplayFormatCurrency(accounting.OverpaidHelper.ToDecimal(MoneyUnit.BTC), paymentMethodId.CryptoCode); {
var paymentMethodDetails = data.GetPaymentMethodDetails(); PaymentMethodId = paymentMethodId,
cryptoPayment.Address = paymentMethodDetails.GetPaymentDestination(); PaymentMethod = paymentMethodId.ToPrettyString(),
cryptoPayment.Rate = ExchangeRate(data); Due = _CurrencyNameTable.DisplayFormatCurrency(accounting.Due.ToDecimal(MoneyUnit.BTC),
model.CryptoPayments.Add(cryptoPayment); paymentMethodId.CryptoCode),
} Paid = _CurrencyNameTable.DisplayFormatCurrency(
return model; accounting.CryptoPaid.ToDecimal(MoneyUnit.BTC),
paymentMethodId.CryptoCode),
Overpaid = _CurrencyNameTable.DisplayFormatCurrency(
accounting.OverpaidHelper.ToDecimal(MoneyUnit.BTC), paymentMethodId.CryptoCode),
Address = data.GetPaymentMethodDetails().GetPaymentDestination(),
Rate = ExchangeRate(data)
};
}).ToList()
};
} }
[HttpPost("invoices/{invoiceId}/archive")] [HttpPost("invoices/{invoiceId}/archive")]

View File

@@ -29,12 +29,20 @@ namespace BTCPayServer.HostedServices
public List<object> Events { get; set; } = new List<object>(); public List<object> Events { get; set; } = new List<object>();
bool _Dirty = false; bool _Dirty = false;
private bool _Unaffect;
public void MarkDirty() public void MarkDirty()
{ {
_Dirty = true; _Dirty = true;
} }
public void UnaffectAddresses()
{
_Unaffect = true;
}
public bool Dirty => _Dirty; public bool Dirty => _Dirty;
public bool Unaffect => _Unaffect;
} }
readonly InvoiceRepository _InvoiceRepository; readonly InvoiceRepository _InvoiceRepository;
@@ -63,15 +71,12 @@ namespace BTCPayServer.HostedServices
if (invoice.Status == InvoiceStatus.New && invoice.ExpirationTime <= DateTimeOffset.UtcNow) if (invoice.Status == InvoiceStatus.New && invoice.ExpirationTime <= DateTimeOffset.UtcNow)
{ {
context.MarkDirty(); context.MarkDirty();
await _InvoiceRepository.UnaffectAddress(invoice.Id); context.UnaffectAddresses();
invoice.Status = InvoiceStatus.Expired; invoice.Status = InvoiceStatus.Expired;
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.Expired)); context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.Expired));
if (invoice.ExceptionStatus == InvoiceExceptionStatus.PaidPartial) if (invoice.ExceptionStatus == InvoiceExceptionStatus.PaidPartial)
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.ExpiredPaidPartial)); context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.ExpiredPaidPartial));
} }
var payments = invoice.GetPayments().Where(p => p.Accounted).ToArray();
var allPaymentMethods = invoice.GetPaymentMethods(); var allPaymentMethods = invoice.GetPaymentMethods();
var paymentMethod = GetNearestClearedPayment(allPaymentMethods, out var accounting); var paymentMethod = GetNearestClearedPayment(allPaymentMethods, out var accounting);
if (paymentMethod == null) if (paymentMethod == null)
@@ -85,7 +90,7 @@ namespace BTCPayServer.HostedServices
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.PaidInFull)); context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.PaidInFull));
invoice.Status = InvoiceStatus.Paid; invoice.Status = InvoiceStatus.Paid;
invoice.ExceptionStatus = accounting.Paid > accounting.TotalDue ? InvoiceExceptionStatus.PaidOver : InvoiceExceptionStatus.None; invoice.ExceptionStatus = accounting.Paid > accounting.TotalDue ? InvoiceExceptionStatus.PaidOver : InvoiceExceptionStatus.None;
await _InvoiceRepository.UnaffectAddress(invoice.Id); context.UnaffectAddresses();
context.MarkDirty(); context.MarkDirty();
} }
else if (invoice.Status == InvoiceStatus.Expired && invoice.ExceptionStatus != InvoiceExceptionStatus.PaidLate) else if (invoice.Status == InvoiceStatus.Expired && invoice.ExceptionStatus != InvoiceExceptionStatus.PaidLate)
@@ -136,14 +141,14 @@ namespace BTCPayServer.HostedServices
// And not enough amount confirmed // And not enough amount confirmed
(confirmedAccounting.Paid < accounting.MinimumTotalDue)) (confirmedAccounting.Paid < accounting.MinimumTotalDue))
{ {
await _InvoiceRepository.UnaffectAddress(invoice.Id); context.UnaffectAddresses();
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.FailedToConfirm)); context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.FailedToConfirm));
invoice.Status = InvoiceStatus.Invalid; invoice.Status = InvoiceStatus.Invalid;
context.MarkDirty(); context.MarkDirty();
} }
else if (confirmedAccounting.Paid >= accounting.MinimumTotalDue) else if (confirmedAccounting.Paid >= accounting.MinimumTotalDue)
{ {
await _InvoiceRepository.UnaffectAddress(invoice.Id); context.UnaffectAddresses();
invoice.Status = InvoiceStatus.Confirmed; invoice.Status = InvoiceStatus.Confirmed;
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.Confirmed)); context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.Confirmed));
context.MarkDirty(); context.MarkDirty();
@@ -279,6 +284,10 @@ namespace BTCPayServer.HostedServices
break; break;
var updateContext = new UpdateInvoiceContext(invoice); var updateContext = new UpdateInvoiceContext(invoice);
await UpdateInvoice(updateContext); await UpdateInvoice(updateContext);
if (updateContext.Unaffect)
{
await _InvoiceRepository.UnaffectAddress(invoice.Id);
}
if (updateContext.Dirty) if (updateContext.Dirty)
{ {
await _InvoiceRepository.UpdateInvoiceStatus(invoice.Id, invoice.GetInvoiceState()); await _InvoiceRepository.UpdateInvoiceStatus(invoice.Id, invoice.GetInvoiceState());

View File

@@ -25,10 +25,5 @@ namespace BTCPayServer.Payments.Lightning
{ {
return 0.0m; return 0.0m;
} }
public void SetPaymentDetails(IPaymentMethodDetails newPaymentMethodDetails)
{
BOLT11 = newPaymentMethodDetails.GetPaymentDestination();
}
} }
} }

View File

@@ -137,76 +137,32 @@ namespace BTCPayServer.Payments.Lightning
readonly CompositeDisposable leases = new CompositeDisposable(); readonly CompositeDisposable leases = new CompositeDisposable();
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
leases.Add(_Aggregator.Subscribe<Events.InvoiceEvent>(inv => leases.Add(_Aggregator.Subscribe<Events.InvoiceEvent>(async inv =>
{ {
if (inv.Name == InvoiceEvent.Created) if (inv.Name == InvoiceEvent.Created)
{ {
_CheckInvoices.Writer.TryWrite(inv.Invoice.Id); _CheckInvoices.Writer.TryWrite(inv.Invoice.Id);
} }
if (inv.Name == InvoiceEvent.ReceivedPayment && inv.Invoice.Status == InvoiceStatus.New && inv.Invoice.ExceptionStatus == InvoiceExceptionStatus.PaidPartial)
{
var pm = inv.Invoice.GetPaymentMethods().First();
if (pm.Calculate().Due.GetValue(pm.Network as BTCPayNetwork) > 0m)
{
await CreateNewLNInvoiceForBTCPayInvoice(inv.Invoice);
}
}
})); }));
leases.Add(_Aggregator.Subscribe<Events.InvoiceDataChangedEvent>(async inv => leases.Add(_Aggregator.Subscribe<Events.InvoiceDataChangedEvent>(async inv =>
{ {
if (inv.State.Status == InvoiceStatus.New && if (inv.State.Status == InvoiceStatus.New &&
inv.State.ExceptionStatus == InvoiceExceptionStatus.PaidPartial) inv.State.ExceptionStatus == InvoiceExceptionStatus.PaidPartial)
{ {
var invoice = await _InvoiceRepository.GetInvoice(inv.InvoiceId); var invoice = await _InvoiceRepository.GetInvoice(inv.InvoiceId);
var paymentMethods = invoice.GetPaymentMethods() await CreateNewLNInvoiceForBTCPayInvoice(invoice);
.Where(method => method.GetId().PaymentType == PaymentTypes.LightningLike).ToArray();
var store = await _storeRepository.FindStore(invoice.StoreId);
if (paymentMethods.Any())
{
var logs = new InvoiceLogs();
logs.Write("Partial payment detected, attempting to update all lightning payment methods with new bolt11 with correct due amount.", InvoiceEventData.EventSeverity.Info);
foreach (var paymentMethod in paymentMethods)
{
try
{
var supportedMethod =
invoice.GetSupportedPaymentMethod<LightningSupportedPaymentMethod>(
paymentMethod.GetId()).First();
var prepObj =
_lightningLikePaymentHandler.PreparePayment(supportedMethod, store,
paymentMethod.Network);
var newPaymentMethodDetails =
await _lightningLikePaymentHandler.CreatePaymentMethodDetails(
logs, supportedMethod,
paymentMethod, store, paymentMethod.Network, prepObj);
var instanceListenerKey = (paymentMethod.Network.CryptoCode,
supportedMethod.GetLightningUrl().ToString());
if (_InstanceListeners.TryGetValue(instanceListenerKey, out var instanceListener))
{
await _InvoiceRepository.NewPaymentDetails(invoice.Id, newPaymentMethodDetails,
paymentMethod.Network);
instanceListener.AddListenedInvoice(new ListenedInvoice()
{
Expiration = invoice.ExpirationTime,
Uri = supportedMethod.GetLightningUrl().BaseUri.AbsoluteUri,
PaymentMethodDetails = (LightningLikePaymentMethodDetails) newPaymentMethodDetails,
SupportedPaymentMethod = supportedMethod,
PaymentMethod = paymentMethod,
Network = (BTCPayNetwork) paymentMethod.Network,
InvoiceId = invoice.Id
});
_Aggregator.Publish(new Events.InvoiceNewPaymentDetailsEvent(invoice.Id,
newPaymentMethodDetails, paymentMethod.GetId()));
}
}
catch (Exception e)
{
logs.Write($"Could not update {paymentMethod.GetId().ToPrettyString()}: {e.Message}",
InvoiceEventData.EventSeverity.Error);
}
}
await _InvoiceRepository.AddInvoiceLogs(invoice.Id, logs);
_CheckInvoices.Writer.TryWrite(inv.InvoiceId);
}
} }
})); }));
_CheckingInvoice = CheckingInvoice(_Cts.Token); _CheckingInvoice = CheckingInvoice(_Cts.Token);
_ListenPoller = new Timer(async s => _ListenPoller = new Timer(async s =>
@@ -224,6 +180,66 @@ namespace BTCPayServer.Payments.Lightning
return Task.CompletedTask; return Task.CompletedTask;
} }
private async Task CreateNewLNInvoiceForBTCPayInvoice(InvoiceEntity invoice)
{
var paymentMethods = invoice.GetPaymentMethods()
.Where(method => method.GetId().PaymentType == PaymentTypes.LightningLike)
.ToArray();
var store = await _storeRepository.FindStore(invoice.StoreId);
if (paymentMethods.Any())
{
var logs = new InvoiceLogs();
logs.Write(
"Partial payment detected, attempting to update all lightning payment methods with new bolt11 with correct due amount.",
InvoiceEventData.EventSeverity.Info);
foreach (var paymentMethod in paymentMethods)
{
try
{
var supportedMethod = invoice
.GetSupportedPaymentMethod<LightningSupportedPaymentMethod>(paymentMethod.GetId()).First();
var prepObj =
_lightningLikePaymentHandler.PreparePayment(supportedMethod, store, paymentMethod.Network);
var newPaymentMethodDetails =
(LightningLikePaymentMethodDetails)(await _lightningLikePaymentHandler
.CreatePaymentMethodDetails(logs, supportedMethod, paymentMethod, store,
paymentMethod.Network, prepObj));
var instanceListenerKey = (paymentMethod.Network.CryptoCode,
supportedMethod.GetLightningUrl().ToString());
if (_InstanceListeners.TryGetValue(instanceListenerKey, out var instanceListener))
{
await _InvoiceRepository.NewPaymentDetails(invoice.Id, newPaymentMethodDetails,
paymentMethod.Network);
instanceListener.AddListenedInvoice(new ListenedInvoice()
{
Expiration = invoice.ExpirationTime,
Uri = supportedMethod.GetLightningUrl().BaseUri.AbsoluteUri,
PaymentMethodDetails = newPaymentMethodDetails,
SupportedPaymentMethod = supportedMethod,
PaymentMethod = paymentMethod,
Network = (BTCPayNetwork)paymentMethod.Network,
InvoiceId = invoice.Id
});
_Aggregator.Publish(new Events.InvoiceNewPaymentDetailsEvent(invoice.Id,
newPaymentMethodDetails, paymentMethod.GetId()));
}
}
catch (Exception e)
{
logs.Write($"Could not update {paymentMethod.GetId().ToPrettyString()}: {e.Message}",
InvoiceEventData.EventSeverity.Error);
}
}
await _InvoiceRepository.AddInvoiceLogs(invoice.Id, logs);
_CheckInvoices.Writer.TryWrite(invoice.Id);
}
}
TimeSpan _PollInterval = TimeSpan.FromMinutes(1.0); TimeSpan _PollInterval = TimeSpan.FromMinutes(1.0);
public TimeSpan PollInterval public TimeSpan PollInterval
{ {

View File

@@ -260,7 +260,7 @@ retry:
var existingPaymentMethod = paymentMethod.GetPaymentMethodDetails(); var existingPaymentMethod = paymentMethod.GetPaymentMethodDetails();
if (existingPaymentMethod.GetPaymentDestination() != null) if (existingPaymentMethod.GetPaymentDestination() != null)
{ {
MarkUnassigned(invoiceId, invoiceEntity, context, paymentMethod.GetId()); MarkUnassigned(invoiceId, context, paymentMethod.GetId());
} }
paymentMethod.SetPaymentMethodDetails(paymentMethodDetails); paymentMethod.SetPaymentMethodDetails(paymentMethodDetails);
#pragma warning disable CS0618 #pragma warning disable CS0618
@@ -323,36 +323,29 @@ retry:
catch (DbUpdateException) { } // Probably the invoice does not exists anymore catch (DbUpdateException) { } // Probably the invoice does not exists anymore
} }
private static void MarkUnassigned(string invoiceId, InvoiceEntity entity, ApplicationDbContext context, PaymentMethodId paymentMethodId) private static void MarkUnassigned(string invoiceId, ApplicationDbContext context,
PaymentMethodId paymentMethodId)
{ {
foreach (var address in entity.GetPaymentMethods()) var paymentMethodIdStr = paymentMethodId?.ToString();
var addresses = context.HistoricalAddressInvoices.Where(data =>
(data.InvoiceDataId == invoiceId && paymentMethodIdStr == null ||
data.CryptoCode == paymentMethodIdStr) &&
data.UnAssigned == null);
foreach (var historicalAddressInvoiceData in addresses)
{ {
if (paymentMethodId != null && paymentMethodId != address.GetId()) historicalAddressInvoiceData.UnAssigned = DateTimeOffset.UtcNow;
continue;
var historical = new HistoricalAddressInvoiceData();
historical.InvoiceDataId = invoiceId;
historical.SetAddress(address.GetPaymentMethodDetails().GetPaymentDestination(), address.GetId().ToString());
historical.UnAssigned = DateTimeOffset.UtcNow;
context.Attach(historical);
context.Entry(historical).Property(o => o.UnAssigned).IsModified = true;
} }
} }
public async Task UnaffectAddress(string invoiceId) public async Task UnaffectAddress(string invoiceId)
{ {
using (var context = _ContextFactory.CreateContext()) await using var context = _ContextFactory.CreateContext();
MarkUnassigned(invoiceId, context, null);
try
{ {
var invoiceData = await context.FindAsync<Data.InvoiceData>(invoiceId).ConfigureAwait(false); await context.SaveChangesAsync();
if (invoiceData == null)
return;
var invoiceEntity = invoiceData.GetBlob(_Networks);
MarkUnassigned(invoiceId, invoiceEntity, context, null);
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateException) { } //Possibly, it was unassigned before
} }
catch (DbUpdateException) { } //Possibly, it was unassigned before
} }
private string[] SearchInvoice(string searchTerms) private string[] SearchInvoice(string searchTerms)

View File

@@ -205,8 +205,7 @@
</div> </div>
} }
<partial name="ListInvoicesPaymentsPartial" model="(Model, false)" /> <partial name="ListInvoicesPaymentsPartial" model="(Model, true)" />
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">