Add editable invoice comments (#7444)
What changed, and why it matters
This commit adds a new editable comment field to BTCPay Server invoices. Store staff can add private notes to invoices through the web interface or API, and these comments appear in invoice reports. The change is a normal feature addition, not a security fix. There is no indication in the commit that it addresses any vulnerability or security incident.
No immediate security action required; treat as routine feature review. If auditing, verify that Razor output encoding and API JSON serialization safely handle comment content, and confirm that removing storeId from UpdateInvoiceMetadata does not bypass authorization in any caller. Consider adding length limits and anti-XSS tests for the comment field.
Security signals we found
New user-supplied string persisted to database and rendered in admin views/API responses
Raw SQL update into jsonb Blob2 column bypasses EF change tracking
UpdateInvoiceMetadata signature changed; deprecated overload ignores storeId, potentially widening access if callers relied on store-scoped enforcement
SearchString timezone conversion changed to ToUniversalTime for DateTimeKind.Local (behavior change, not clearly security-relevant)
Evidence from the diff
The patch introduces an invoice-level Comment property stored in the Blob2 JSONB column, surfaced in UI list/details views, Greenfield API create/update/get responses, and the Invoices report provider. It also refactors UpdateInvoiceMetadata to drop the storeId parameter (keeping a deprecated overload), adds UpdateInvoiceComment using raw SQL against Blob2, and tweaks SearchString timezone conversion to call ToUniversalTime for local DateTime values. No input validation beyond whitespace trimming is visible, and the comment is rendered in HTML via Razor (existing framework encoding applies).
Changed components
BTCPayServer invoice UI (Views/UIInvoice/Invoice.cshtml, ListInvoices.cshtml)Greenfield API invoice controller (Controllers/GreenField/GreenfieldInvoiceController.cs)UI invoice controllers (Controllers/UIInvoiceController.UI.cs, UIInvoiceController.cs)Invoice repository and entity (Services/Invoices/InvoiceRepository.cs, InvoiceEntity.cs)Invoices report provider (Services/Reporting/InvoicesReportProvider.cs)Swagger invoice schemas (wwwroot/swagger/v1/swagger.template.invoices.json)Inspect captured patch +189 / −22
diff --git a/BTCPayServer.Client/Models/InvoiceData.cs b/BTCPayServer.Client/Models/InvoiceData.cs
index 4f22845..174bc06 100644
--- a/BTCPayServer.Client/Models/InvoiceData.cs
+++ b/BTCPayServer.Client/Models/InvoiceData.cs
@@ -19,6 +19,7 @@ namespace BTCPayServer.Client.Models
public InvoiceType Type { get; set; }
public string Currency { get; set; }
public JObject Metadata { get; set; }
+ public string Comment { get; set; }
public CheckoutOptions Checkout { get; set; } = new CheckoutOptions();
public ReceiptOptions Receipt { get; set; } = new ReceiptOptions();
public class ReceiptOptions
diff --git a/BTCPayServer.Client/Models/UpdateInvoiceRequest.cs b/BTCPayServer.Client/Models/UpdateInvoiceRequest.cs
index af45868..0f4dcd5 100644
--- a/BTCPayServer.Client/Models/UpdateInvoiceRequest.cs
+++ b/BTCPayServer.Client/Models/UpdateInvoiceRequest.cs
@@ -5,5 +5,6 @@ namespace BTCPayServer.Client.Models
public class UpdateInvoiceRequest
{
public JObject Metadata { get; set; }
+ public string Comment { get; set; }
}
}
diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs
index 7c6ce47..ec964a2 100644
--- a/BTCPayServer.Tests/PlaywrightTests.cs
+++ b/BTCPayServer.Tests/PlaywrightTests.cs
@@ -1215,6 +1215,14 @@ namespace BTCPayServer.Tests
var invId = await s.CreateInvoice(storeId: s.StoreId, amount: 10_000);
await s.GoToInvoiceCheckout(invId);
await s.PayInvoice();
+
+ // We can leave a comment on the invoice, and it should be included in the export
+ await s.GoToInvoice(invId);
+ await s.Page.FillAsync("#InvoiceComment", "refunded manually from cashier wallet");
+ await s.Page.ClickAsync("#SaveComment");
+ await s.FindAlertMessage(partialText: "The comment has been saved.");
+ await Expect(s.Page.Locator("#InvoiceComment")).ToHaveValueAsync("refunded manually from cashier wallet");
+
await s.GoToInvoices(s.StoreId);
await s.ClickViewReport();
@@ -1224,6 +1232,7 @@ namespace BTCPayServer.Tests
csvInvTester
.ForInvoice(invId)
.AssertValues(
+ ("InvoiceComment", "refunded manually from cashier wallet"),
("Rate (BTC_CAD)", "4500"),
("Rate (BTC_JPY)", "700000"),
("Rate (BTC_EUR)", "4000"),
diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs
index 3d08e01..0f78093 100644
--- a/BTCPayServer.Tests/UnitTest1.cs
+++ b/BTCPayServer.Tests/UnitTest1.cs
@@ -539,9 +539,26 @@ namespace BTCPayServer.Tests
AssertSearchInvoice(acc, true, invoice.Id, "status:settled,exceptionstatus:paidPartial");
AssertSearchInvoice(acc, true, invoice.Id, "status:settled,status:invalid,exceptionstatus:paidPartial,exceptionstatus:paidOver");
+ var invoiceController = acc.GetController<UIInvoiceController>();
+ var comment = "refunded manually from cashier wallet";
+ await invoiceController.Comment(invoice.Id, comment);
+
+ var listResult = await invoiceController.ListInvoices(new InvoicesModel { StoreId = acc.StoreId });
+ var listModel = (InvoicesModel)((ViewResult)listResult).Model;
+ var listedInvoice = Assert.Single(listModel.Invoices, i => i.InvoiceId == invoice.Id);
+ Assert.Equal(comment, listedInvoice.Comment);
+
+ // The comment should be included in the invoices export/report
+ var invoicesReport = await GetReport(acc, new() { ViewName = "Invoices" });
+ var reportInvoiceIdIndex = invoicesReport.GetIndex("InvoiceId");
+ var reportCommentIndex = invoicesReport.GetIndex("InvoiceComment");
+ Assert.Contains(invoicesReport.Data, d =>
+ d[reportInvoiceIdIndex].Value<string>() == invoice.Id &&
+ d[reportCommentIndex]?.Value<string>() == comment);
+
var time = invoice.InvoiceTime;
AssertSearchInvoice(acc, true, invoice.Id, $"startdate:{time.ToString("yyyy-MM-dd HH:mm:ss")}");
- AssertSearchInvoice(acc, true, invoice.Id, $"enddate:{time.ToString().ToLowerInvariant()}");
+ AssertSearchInvoice(acc, true, invoice.Id, $"enddate:{time.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()}");
AssertSearchInvoice(acc, false, invoice.Id,
$"startdate:{time.AddSeconds(1).ToString("yyyy-MM-dd HH:mm:ss")}");
AssertSearchInvoice(acc, false, invoice.Id,
@@ -1624,11 +1641,35 @@ namespace BTCPayServer.Tests
{
Amount = 50.513m,
Currency = "USD",
+ Comment = " API comment ",
Metadata = new JObject() { new JProperty("taxIncluded", 50.516m), new JProperty("orderId", "000000161") }
});
Assert.Equal(50.51m, invoice5g.Amount);
Assert.Equal(50.51m, (decimal)invoice5g.Metadata["taxIncluded"]);
Assert.Equal("000000161", (string)invoice5g.Metadata["orderId"]);
+ Assert.Equal("API comment", invoice5g.Comment);
+
+ invoice5g = await greenfield.GetInvoice(invoice5g.Id);
+ Assert.Equal("API comment", invoice5g.Comment);
+
+ invoice5g = await greenfield.UpdateInvoice(invoice5g.Id, new UpdateInvoiceRequest
+ {
+ Comment = " Updated API comment "
+ });
+ Assert.Equal("Updated API comment", invoice5g.Comment);
+
+ invoice5g = await greenfield.UpdateInvoice(invoice5g.Id, new UpdateInvoiceRequest
+ {
+ Metadata = new JObject { new JProperty("orderId", "000000162") }
+ });
+ Assert.Equal("Updated API comment", invoice5g.Comment);
+ Assert.Equal("000000162", (string)invoice5g.Metadata["orderId"]);
+
+ invoice5g = await greenfield.UpdateInvoice(invoice5g.Id, new UpdateInvoiceRequest
+ {
+ Comment = ""
+ });
+ Assert.Equal("", invoice5g.Comment);
var zeroInvoice = await greenfield.CreateInvoice(user.StoreId, new CreateInvoiceRequest()
{
diff --git a/BTCPayServer.Tests/WalletTests.cs b/BTCPayServer.Tests/WalletTests.cs
index 012b076..23434ba 100644
--- a/BTCPayServer.Tests/WalletTests.cs
+++ b/BTCPayServer.Tests/WalletTests.cs
@@ -1207,7 +1207,7 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
var urlAfterClearAll = new Uri(s.Page.Url);
var qsAfterClearAll = HttpUtility.ParseQueryString(urlAfterClearAll.Query);
Assert.True(string.IsNullOrEmpty(qsAfterClearAll["SearchText"]));
- Assert.Equal(qsAfterClearAll["SearchTerm"], $"timezone:{selectedTimeZone}");
+ Assert.Equal($"timezone:{selectedTimeZone}", qsAfterClearAll["SearchTerm"]);
await s.GoToInvoices(s.StoreId);
await s.Page.ClickAsync("#DateRangeSelector");
diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs
index aa06fb1..acade91 100644
--- a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs
+++ b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs
@@ -158,7 +158,13 @@ namespace BTCPayServer.Controllers.Greenfield
{
if (HttpContext.GetInvoiceDataOrNull() is null)
return InvoiceNotFound();
- var invoice = await _invoiceRepository.UpdateInvoiceMetadata(invoiceId, storeId ?? HttpContext.GetStoreData().Id, request.Metadata);
+ if (request.Metadata is not null)
+ await _invoiceRepository.UpdateInvoiceMetadata(invoiceId, request.Metadata);
+ if (request.Comment is not null)
+ await _invoiceRepository.UpdateInvoiceComment(invoiceId, request.Comment);
+ var invoice = await _invoiceRepository.GetInvoice(invoiceId);
+ if (invoice is null)
+ return InvoiceNotFound();
return Ok(ToModel(invoice));
}
@@ -692,6 +698,7 @@ namespace BTCPayServer.Controllers.Greenfield
AdditionalStatus = entity.ExceptionStatus,
Currency = entity.Currency,
Archived = entity.Archived,
+ Comment = string.IsNullOrWhiteSpace(entity.Comment) ? "" : entity.Comment,
Metadata = entity.Metadata.ToJObject(),
AvailableStatusesForManualMarking = statuses.ToArray(),
Checkout = new InvoiceDataBase.CheckoutOptions
diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
index 019780c..68d724b 100644
--- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs
+++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
@@ -151,6 +151,7 @@ namespace BTCPayServer.Controllers
Events = await _InvoiceRepository.GetInvoiceLogs(invoice.Id),
Metadata = metaData,
Archived = invoice.Archived,
+ Comment = invoice.Comment,
HasRefund = invoice.Refunds.Any(),
CanRefund = invoiceState.CanRefund(),
Refunds = invoice.Refunds,
@@ -603,6 +604,33 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(Invoice), new { invoiceId });
}
+ [HttpPost("invoices/{invoiceId}/comment")]
+ [HttpPost("/stores/{storeId}/invoices/{invoiceId}/comment")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewInvoices)]
+ public async Task<IActionResult> Comment(string invoiceId, string comment, string? returnUrl = null)
+ {
+ var invoice = (await _InvoiceRepository.GetInvoices(new InvoiceQuery
+ {
+ InvoiceId = [invoiceId],
+ StoreId = [HttpContext.GetStoreData().Id],
+ UserId = GetUserIdForInvoiceQuery()
+ })).FirstOrDefault();
+ if (invoice is null)
+ return NotFound();
+
+ await _InvoiceRepository.UpdateInvoiceComment(invoiceId, comment);
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Success,
+ Message = string.IsNullOrWhiteSpace(comment)
+ ? StringLocalizer["The comment has been removed."].Value
+ : StringLocalizer["The comment has been saved."].Value
+ });
+ if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
+ return Redirect(returnUrl);
+ return RedirectToAction(nameof(Invoice), new { invoiceId });
+ }
+
[HttpPost("/stores/{storeId}/invoices/mass-action")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewInvoices)]
public async Task<IActionResult> MassAction(string command, string[] selectedItems, string storeId)
@@ -1096,6 +1124,7 @@ namespace BTCPayServer.Controllers
RedirectUrl = invoice.RedirectURL?.AbsoluteUri ?? string.Empty,
Amount = invoice.Price,
Currency = invoice.Currency,
+ Comment = invoice.Comment,
CanMarkInvalid = state.CanMarkInvalid(),
CanMarkSettled = state.CanMarkComplete(),
Details = InvoicePopulatePayments(invoice),
diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs
index 9ddcf11..15a1d91 100644
--- a/BTCPayServer/Controllers/UIInvoiceController.cs
+++ b/BTCPayServer/Controllers/UIInvoiceController.cs
@@ -167,6 +167,7 @@ namespace BTCPayServer.Controllers
entity.ReceiptOptions = invoice.Receipt ?? new InvoiceDataBase.ReceiptOptions();
if (invoice.Metadata != null)
entity.Metadata = InvoiceMetadata.FromJObject(invoice.Metadata);
+ entity.Comment = string.IsNullOrWhiteSpace(invoice.Comment) ? null : invoice.Comment.Trim();
invoice.Checkout ??= new CreateInvoiceRequest.CheckoutOptions();
entity.Currency = invoice.Currency;
if (invoice.Amount is decimal v)
diff --git a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs
index 1f6ad65..f223371 100644
--- a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs
+++ b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs
@@ -129,6 +129,7 @@ namespace BTCPayServer.Models.InvoicingModels
public Dictionary<string, object> AdditionalData { get; set; }
public List<PaymentEntity> Payments { get; set; }
public bool Archived { get; set; }
+ public string Comment { get; set; }
public bool CanRefund { get; set; }
public bool ShowCheckout { get; set; }
public List<RefundData> Refunds { get; set; }
diff --git a/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs b/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs
index b57c4ef..cc88e19 100644
--- a/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs
+++ b/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs
@@ -27,6 +27,7 @@ namespace BTCPayServer.Models.InvoicingModels
public bool ShowCheckout { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
+ public string Comment { get; set; }
public InvoiceDetailsModel Details { get; set; }
public bool HasRefund { get; set; }
}
diff --git a/BTCPayServer/SearchString.cs b/BTCPayServer/SearchString.cs
index 7f6cb41..742d926 100644
--- a/BTCPayServer/SearchString.cs
+++ b/BTCPayServer/SearchString.cs
@@ -167,7 +167,7 @@ namespace BTCPayServer
{
return localDateTime.Kind switch
{
- DateTimeKind.Local => new DateTimeOffset(localDateTime, TimeZoneInfo.Local.GetUtcOffset(localDateTime)),
+ DateTimeKind.Local => new DateTimeOffset(localDateTime, TimeZoneInfo.Local.GetUtcOffset(localDateTime)).ToUniversalTime(),
DateTimeKind.Utc => new DateTimeOffset(localDateTime, TimeSpan.Zero),
DateTimeKind.Unspecified when tz is not null => new DateTimeOffset(localDateTime, tz.GetUtcOffset(localDateTime)).ToUniversalTime(),
DateTimeKind.Unspecified when tz is null => null,
diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs
index 8f5bc39..fdfeb70 100644
--- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs
+++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs
@@ -762,6 +762,8 @@ namespace BTCPayServer.Services.Invoices
[JsonIgnore]
public bool DisableAccounting { get; set; }
+ public string Comment { get; set; }
+
public RequestBaseUrl GetRequestBaseUrl() => RequestBaseUrl.FromUrl(ServerUrl);
}
diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs
index 9214c26..a3aa41b 100644
--- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs
+++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs
@@ -541,15 +541,16 @@ retry:
}
}
- public async Task<InvoiceEntity> UpdateInvoiceMetadata(string invoiceId, string storeId, JObject metadata)
+ [Obsolete("The storeId parameter is now ignored. This method is deprecated and will be removed in a future release.")]
+ public Task<InvoiceEntity> UpdateInvoiceMetadata(string invoiceId, string storeId, JObject metadata)
+ => UpdateInvoiceMetadata(invoiceId, metadata);
+ public async Task<InvoiceEntity> UpdateInvoiceMetadata(string invoiceId, JObject metadata)
{
retry:
using (var context = _applicationDbContextFactory.CreateContext())
{
var invoiceData = await GetInvoiceRaw(invoiceId, context);
- if (invoiceData == null || (storeId != null &&
- !invoiceData.StoreDataId.Equals(storeId,
- StringComparison.InvariantCultureIgnoreCase)))
+ if (invoiceData == null)
return null;
var blob = invoiceData.GetBlob();
@@ -582,6 +583,28 @@ retry:
return ToEntity(invoiceData);
}
}
+ public async Task UpdateInvoiceComment(string invoiceId, string comment)
+ {
+ await using var context = _applicationDbContextFactory.CreateContext();
+ var newComment = string.IsNullOrWhiteSpace(comment?.Trim()) ? null : comment.Trim();
+ var sql = newComment is null
+ ? """
+ UPDATE "Invoices"
+ SET "Blob2" = COALESCE("Blob2", '{}'::jsonb) - 'comment'
+ WHERE "Id" = @Id
+ """
+ : """
+ UPDATE "Invoices"
+ SET "Blob2" = jsonb_set(COALESCE("Blob2", '{}'::jsonb), '{comment}', to_jsonb(@Comment::text), true)
+ WHERE "Id" = @Id
+ """;
+
+ await context.Database.GetDbConnection().ExecuteAsync(sql, new
+ {
+ Id = invoiceId,
+ Comment = newComment
+ });
+ }
public async Task<bool> MarkInvoiceStatus(string invoiceId, InvoiceStatus status)
{
using (var context = _applicationDbContextFactory.CreateContext())
@@ -636,6 +659,8 @@ retry:
var res = await GetInvoiceRaw(id, context, includeAddressData);
return res == null ? null : ToEntity(res);
}
+
+ [Obsolete("This method is deprecated and will be removed in a future release.")]
public async Task<InvoiceEntity[]> GetInvoices(string[] invoiceIds)
{
var invoiceIdSet = invoiceIds.ToHashSet();
diff --git a/BTCPayServer/Services/Reporting/InvoicesReportProvider.cs b/BTCPayServer/Services/Reporting/InvoicesReportProvider.cs
index f0255a2..9d23a95 100644
--- a/BTCPayServer/Services/Reporting/InvoicesReportProvider.cs
+++ b/BTCPayServer/Services/Reporting/InvoicesReportProvider.cs
@@ -100,6 +100,7 @@ public class InvoicesReportProvider : ReportProvider
new("InvoiceFullStatus", "text"),
new("InvoiceStatus", "text"),
new("InvoiceExceptionStatus", "text"),
+ new("InvoiceComment", "text"),
new("PaymentReceivedDate", "datetime"),
new("PaymentId", "text"),
@@ -181,6 +182,7 @@ public class InvoicesReportProvider : ReportProvider
data.Add(invoiceEntity?.GetInvoiceState().ToString());
data.Add(invoiceEntity?.Status.ToString());
data.Add(invoiceEntity?.ExceptionStatus is null or InvoiceExceptionStatus.None ? "" : invoiceEntity.ExceptionStatus.ToString());
+ data.Add(invoiceEntity?.Comment);
data.Add(payment?.ReceivedTime);
@@ -232,7 +234,9 @@ public class InvoicesReportProvider : ReportProvider
// When we have this field to non-zero, then the invoice has a taxIncluded metadata
is ["posData", "tax"]
// Verbose data
- or ["itemDesc"])
+ or ["itemDesc"]
+ // Exported explicitly as the InvoiceComment column
+ or ["comment"])
return;
switch (obj)
{
diff --git a/BTCPayServer/Views/UIInvoice/Invoice.cshtml b/BTCPayServer/Views/UIInvoice/Invoice.cshtml
index 65515d1..d69763b 100644
--- a/BTCPayServer/Views/UIInvoice/Invoice.cshtml
+++ b/BTCPayServer/Views/UIInvoice/Invoice.cshtml
@@ -306,6 +306,16 @@
</table>
</div>
<div class="d-flex flex-column gap-5">
+ <div>
+ <h3 class="mb-3">Comment</h3>
+ <form asp-action="Comment" asp-route-invoiceId="@Model.Id" method="post">
+ <div class="form-group">
+ <textarea name="comment" id="InvoiceComment" class="form-control" rows="3"
+ placeholder="@StringLocalizer["Add a note or comment for this invoice"]">@Model.Comment</textarea>
+ </div>
+ <button type="submit" id="SaveComment" class="btn btn-primary">Save Comment</button>
+ </form>
+ </div>
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode) ||
!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc))
{
diff --git a/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml b/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml
index 5d19a92..b36feef 100644
--- a/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml
+++ b/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml
@@ -28,6 +28,14 @@
min-width: 200px;
max-width: 300px;
}
+ /* Keep the invoice comment popover above adjacent rows */
+ #invoices .dropup .dropdown-menu {
+ z-index: 1080;
+ }
+ #invoices tr:has(.dropup .dropdown-menu.show) {
+ position: relative;
+ z-index: 1080;
+ }
@@media (max-width: 568px) {
#SearchText {
width: 100%;
@@ -160,14 +168,17 @@
@if (Model.Invoices.Any())
{
- <form method="post" asp-action="MassAction" asp-route-storeId="@Model.StoreId">
+ @* Standalone mass-action form: its controls reference it via the form="mass-action-form" attribute
+ so the per-row comment forms below are not nested inside it (nested forms are invalid HTML). *@
+ <form id="mass-action-form" method="post" asp-action="MassAction" asp-route-storeId="@Model.StoreId">
<input type="hidden" name="storeId" value="@Model.StoreId" />
- <div class="table-responsive">
+ </form>
+ <div class="table-responsive-md">
<table id="invoices" class="table table-hover mass-action">
<thead class="mass-action-head">
<tr>
<th class="mass-action-select-col only-for-js">
- <input type="checkbox" class="form-check-input mass-action-select-all" />
+ <input type="checkbox" class="form-check-input mass-action-select-all" form="mass-action-form" />
</th>
<vc:date-column></vc:date-column>
<th text-translate="true" class="text-nowrap">Invoice Id</th>
@@ -180,7 +191,7 @@
<thead class="mass-action-actions">
<tr>
<th class="mass-action-select-col only-for-js">
- <input type="checkbox" class="form-check-input mass-action-select-all" />
+ <input type="checkbox" class="form-check-input mass-action-select-all" form="mass-action-form" />
</th>
<th colspan="6">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
@@ -189,18 +200,18 @@
<span text-translate="true">selected</span>
</div>
<div class="d-inline-flex align-items-center gap-3">
- <button type="submit" name="command" value="archive" id="ArchiveSelected" class="btn btn-link">
+ <button type="submit" name="command" value="archive" id="ArchiveSelected" class="btn btn-link" form="mass-action-form">
<vc:icon symbol="actions-archive" />
<span text-translate="true">Archive</span>
</button>
@if (Model.Search.HasBooleanFilter("includearchived"))
{
- <button type="submit" name="command" value="unarchive" id="UnarchiveSelected" class="btn btn-link">
+ <button type="submit" name="command" value="unarchive" id="UnarchiveSelected" class="btn btn-link" form="mass-action-form">
<vc:icon symbol="actions-archive" />
<span text-translate="true">Unarchive</span>
</button>
}
- <button type="submit" name="command" value="cpfp" id="BumpFee" class="btn btn-link">
+ <button type="submit" name="command" value="cpfp" id="BumpFee" class="btn btn-link" form="mass-action-form">
<vc:icon symbol="actions-send" />
<span text-translate="true">Bump fee</span>
</button>
@@ -215,7 +226,7 @@
var detailsId = $"invoice_details_{invoice.InvoiceId}";
<tr id="invoice_@invoice.InvoiceId" class="mass-action-row">
<td class="only-for-js align-middle">
- <input name="selectedItems" type="checkbox" class="form-check-input mass-action-select" value="@invoice.InvoiceId" />
+ <input name="selectedItems" type="checkbox" class="form-check-input mass-action-select" value="@invoice.InvoiceId" form="mass-action-form" />
</td>
<td class="align-middle date-col">@invoice.Date.ToBrowserDate()</td>
<td class="text-break align-middle invoiceId-col">
@@ -239,9 +250,24 @@
<td class="align-middle amount-col">
<span data-sensitive>@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</span>
</td>
- <td class="align-middle text-end">
- <div class="d-inline-flex align-items-center gap-2">
- <button class="accordion-button collapsed only-for-js ms-0 d-inline-block" type="button" data-bs-toggle="collapse" data-bs-target="#@detailsId" aria-expanded="false" aria-controls="@detailsId">
+ <td class="align-middle text-end text-nowrap" style="width:1px">
+ <div class="d-inline-flex align-items-center" style="gap:16px;padding-left:24px">
+ <span class="dropup">
+ <button class="btn btn-link p-0 @(!string.IsNullOrEmpty(invoice.Comment) ? "text-primary" : "text-secondary")" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-haspopup="true" aria-expanded="false" title="@(!string.IsNullOrEmpty(invoice.Comment) ? invoice.Comment : StringLocalizer["Add a comment"])">
+ <vc:icon symbol="actions-comment" />
+ </button>
+ <div class="dropdown-menu dropdown-menu-end">
+ <form asp-action="Comment" asp-route-invoiceId="@invoice.InvoiceId" asp-route-returnUrl="@Context.Request.GetCurrentPathWithQueryString()" method="post">
+ <div class="input-group p-2">
+ <textarea name="comment" rows="3" cols="20" class="form-control form-control-sm p-1" placeholder="@StringLocalizer["Add a note or comment for this invoice"]">@invoice.Comment</textarea>
+ </div>
+ <div class="p-2">
+ <button type="submit" class="btn btn-primary btn-sm w-100" text-translate="true">Save comment</button>
+ </div>
+ </form>
+ </div>
+ </span>
+ <button class="accordion-button collapsed only-for-js ms-0 d-inline-block" style="width:auto" type="button" data-bs-toggle="collapse" data-bs-target="#@detailsId" aria-expanded="false" aria-controls="@detailsId">
<vc:icon symbol="caret-down" />
</button>
</div>
@@ -258,8 +284,7 @@
</table>
</div>
- <vc:pager view-model="Model" />
- </form>
+ <vc:pager view-model="Model" />
}
else
{
diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json
index c4af224..5779960 100644
--- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json
+++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json
@@ -754,6 +754,11 @@
"metadata": {
"$ref": "#/components/schemas/InvoiceMetadata"
},
+ "comment": {
+ "type": "string",
+ "nullable": true,
+ "description": "A private comment on the invoice, visible to store users."
+ },
"checkout": {
"type": "object",
"nullable": true,
@@ -1077,6 +1082,11 @@
"properties": {
"metadata": {
"$ref": "#/components/schemas/InvoiceMetadata"
+ },
+ "comment": {
+ "type": "string",
+ "nullable": true,
+ "description": "A private comment on the invoice, visible to store users."
}
}
},
Why this scored 21/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.