Subject: [PATCH links] admin/billing: fixing payment history calls with a properly formatted response object.

From: Peter Sanchez <[email-masked]>
Date: Mon, 27 Jul 2026 12:06:27 -0600

Changelog-fixed: bug in admin, and billing areas, calling payment
  history queries.
---
 admin/routes.go                   | 25 +---------
 api/api_test.go                   | 77 +++++++++++++++++++++++++++++++
 billing/routes.go                 | 13 +-----
 client.go                         | 28 +++++++++++
 models/models.go                  |  2 +-
 templates/admin_billing_list.html |  2 +-
 6 files changed, 110 insertions(+), 37 deletions(-)

diff --git a/admin/routes.go b/admin/routes.go
index a37d111..e36c97e 100644
--- a/admin/routes.go
+++ b/admin/routes.go
@@ -426,18 +426,7 @@ func (s *Service) OrgDetail(c echo.Context) error {
 		return err
 	}
 
-	type GraphQLHistoryResponse struct {
-		Payments struct {
-			Result   []models.Invoice `json:"result"`
-			PageInfo struct {
-				Cursor      string
-				HasNextPage bool
-				HasPrevPage bool
-			} `json:"pageInfo"`
-		} `json:"getPaymentHistory"`
-	}
-
-	var historyResult GraphQLHistoryResponse
+	var historyResult links.PaymentHistoryResponse
 	op = gqlclient.NewOperation(
 		`query GetPaymentHistory($orgSlug: String, $after: Cursor, $before: Cursor) {
 			getPaymentHistory(input: {
@@ -976,17 +965,7 @@ func (s *Service) BillingList(c echo.Context) error {
 		return err
 	}
 
-	type GraphQLHistoryResponse struct {
-		Payments struct {
-			Result   []models.Invoice `json:"result"`
-			PageInfo struct {
-				Cursor      string
-				HasNextPage bool
-				HasPrevPage bool
-			} `json:"pageInfo"`
-		} `json:"getPaymentHistory"`
-	}
-	var historyResult GraphQLHistoryResponse
+	var historyResult links.PaymentHistoryResponse
 	op = gqlclient.NewOperation(
 		`query GetPaymentHistory($after: Cursor, $before: Cursor, $dStart: String, $dEnd: String, $interval: Int) {
 			getPaymentHistory(input: {
diff --git a/api/api_test.go b/api/api_test.go
index ec779dd..f76d413 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -3816,4 +3816,81 @@ func TestAPI(t *testing.T) {
 		c.Contains(err.Error(), "BaseURL Not Found")
 	})
 
+	t.Run("get payment history org fields", func(t *testing.T) {
+		// User 3 is the seeded superuser. orgId/orgSlug are only populated for
+		// superusers by the getPaymentHistory resolver.
+		superCtx := server.ServerContext(context.Background(), srv)
+		superCtx = auth.Context(superCtx, test.NewTestUser(3, true, true, true, true))
+		superCtx = crypto.Context(superCtx, entropy)
+
+		// models.Invoice.SubscriptionID is a plain int, so the row needs a real
+		// subscription to scan back out
+		var subID int
+		err := sq.Insert("subscriptions").
+			Columns("user_id", "org_id", "stripe_id").
+			Values(1, 2, "sub_paymenthistory").
+			Suffix("RETURNING id").
+			PlaceholderFormat(database.GetPlaceholderFormat()).
+			RunWith(srv.DB).
+			QueryRow().
+			Scan(&subID)
+		c.NoError(err)
+
+		insertInvoice := func(userID, orgID any, stripeID string, amount int) {
+			_, err := sq.Insert("invoices").
+				Columns("status", "user_id", "org_id", "subscription_id", "stripe_id",
+					"currency", "amount", "amount_paid", "amount_net", "amount_refunded",
+					"payment_fee", "hosted_invoice_url").
+				Values(models.InvoiceStatusPaid, userID, orgID, subID, stripeID, "USD",
+					amount, amount, amount-100, 0, 100, "https://stripe.test/"+stripeID).
+				PlaceholderFormat(database.GetPlaceholderFormat()).
+				RunWith(srv.DB).
+				Exec()
+			c.NoError(err)
+		}
+		insertInvoice(1, 2, "in_orgtest", 1000)
+		insertInvoice(nil, nil, "in_orphan", 500)
+
+		var result links.PaymentHistoryResponse
+		op := gqlclient.NewOperation(
+			`query GetPaymentHistory($interval: Int) {
+				getPaymentHistory(input: {interval: $interval, filter: true}) {
+					result {
+						id
+						amount
+						amountRefunded
+						amountPaid
+						amountNet
+						paymentFee
+						orgId
+						orgSlug
+						hostedInvoiceURL
+						createdOn
+					}
+					pageInfo {
+						cursor
+						hasPrevPage
+						hasNextPage
+					}
+				}
+			}`)
+		op.Var("interval", links.FilterLast12Months)
+		err = links.Execute(superCtx, op, &result)
+		c.NoError(err)
+		c.Equal(2, len(result.Payments.Result))
+
+		// Ordered by id DESC, so the orphaned invoice comes first
+		orphan := result.Payments.Result[0]
+		c.Equal(0, orphan.OrgID)
+		c.Equal("", orphan.OrgSlug)
+		c.Equal(500, orphan.Amount)
+
+		orgPayment := result.Payments.Result[1]
+		c.Equal(2, orgPayment.OrgID)
+		c.Equal("business_org", orgPayment.OrgSlug)
+		c.Equal(1000, orgPayment.Amount)
+		c.Equal(900, orgPayment.AmountNet)
+		c.Equal(100, orgPayment.PaymentFee)
+	})
+
 }
diff --git a/billing/routes.go b/billing/routes.go
index 87f71ea..82fd992 100644
--- a/billing/routes.go
+++ b/billing/routes.go
@@ -230,18 +230,7 @@ func (s *Service) SubscriptionHistory(c echo.Context) error {
 		back = c.Echo().Reverse("core:org_list")
 	}
 
-	type GraphQLResponse struct {
-		Payments struct {
-			Result   []models.Invoice `json:"result"`
-			PageInfo struct {
-				Cursor      string
-				HasNextPage bool
-				HasPrevPage bool
-			} `json:"pageInfo"`
-		} `json:"getPaymentHistory"`
-	}
-
-	var result GraphQLResponse
+	var result links.PaymentHistoryResponse
 	op := gqlclient.NewOperation(
 		`query GetPaymentHistory($orgSlug: String, $after: Cursor, $before: Cursor) {
 			getPaymentHistory(input: {
diff --git a/client.go b/client.go
index 8855cdf..95f5318 100644
--- a/client.go
+++ b/client.go
@@ -103,3 +103,31 @@ func Execute(ctx context.Context, op *gqlclient.Operation, result any) error {
 	}
 	return err
 }
+
+// Payment mirrors the GraphQL Payment type returned by getPaymentHistory.
+// It is deliberately not models.Invoice: the API exposes the nullable org
+// fields as plain scalars, which will not decode into sql.Null* types.
+type Payment struct {
+	ID               int       `json:"id"`
+	Amount           int       `json:"amount"`
+	AmountRefunded   int       `json:"amountRefunded"`
+	AmountPaid       int       `json:"amountPaid"`
+	AmountNet        int       `json:"amountNet"`
+	PaymentFee       int       `json:"paymentFee"`
+	OrgID            int       `json:"orgId"`
+	OrgSlug          string    `json:"orgSlug"`
+	HostedInvoiceURL string    `json:"hostedInvoiceURL"`
+	CreatedOn        time.Time `json:"createdOn"`
+}
+
+// PaymentHistoryResponse is a struct for getPaymentHistory gql query response storage
+type PaymentHistoryResponse struct {
+	Payments struct {
+		Result   []Payment `json:"result"`
+		PageInfo struct {
+			Cursor      string
+			HasNextPage bool
+			HasPrevPage bool
+		} `json:"pageInfo"`
+	} `json:"getPaymentHistory"`
+}
diff --git a/models/models.go b/models/models.go
index 0113e44..a350b07 100644
--- a/models/models.go
+++ b/models/models.go
@@ -428,7 +428,7 @@ type Invoice struct {
 	Amount           int            `db:"amount"`
 	AmountPaid       int            `db:"amount_paid"`
 	AmountNet        int            `db:"amount_net"`
-	AmountRefunded   int            `db:"amount_net"`
+	AmountRefunded   int            `db:"amount_refunded"`
 	PaymentFee       int            `db:"payment_fee"`
 	HostedInvoiceURL string         `db:"hosted_invoice_url"`
 	CreatedOn        time.Time      `db:"created_on"`
diff --git a/templates/admin_billing_list.html b/templates/admin_billing_list.html
index 5b981f0..603ec12 100644
--- a/templates/admin_billing_list.html
+++ b/templates/admin_billing_list.html
@@ -109,7 +109,7 @@
                   <td>${{formatAmt .AmountPaid}}</td>
                   <td>${{formatAmt .PaymentFee}}</td>
                   <td>${{formatAmt .AmountNet}}</td>
-                  <td><a href="{{reverse "admin:org_detail" .OrgSlug}}">{{.OrgSlug}}</a></td>
+                  <td>{{if .OrgSlug}}<a href="{{reverse "admin:org_detail" .OrgSlug}}">{{.OrgSlug}}</a>{{end}}</td>
                   <td>{{formatDate .CreatedOn}}</td>
               </tr>
           {{end}}
-- 
2.54.0

---

From: "Peter Sanchez" <[email-masked]>
Date: Mon, 27 Jul 2026 12:27:44 -0600

Applied.

To [email-masked]:~netlandish/links
   fa02dbc..700e742  master -> master