Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166])
	by code.netlandish.com (Postfix) with ESMTP id 5B844175
	for <~netlandish/links-dev@lists.code.netlandish.com>; Mon, 27 Jul 2026 18:06:02 +0000 (UTC)
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.128.180; helo=mail-yw1-f180.google.com; envelope-from=peter@netlandish.com; receiver=<UNKNOWN> 
Authentication-Results: mail.netlandish.com;
	dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=qXme5F8g
Received: from mail-yw1-f180.google.com (mail-yw1-f180.google.com [209.85.128.180])
	by mail.netlandish.com (Postfix) with ESMTP id 2F4101D80BE
	for <~netlandish/links-dev@lists.code.netlandish.com>; Mon, 27 Jul 2026 18:06:33 +0000 (UTC)
Received: by mail-yw1-f180.google.com with SMTP id 00721157ae682-81f3b227a4aso37415237b3.1
        for <~netlandish/links-dev@lists.code.netlandish.com>; Mon, 27 Jul 2026 11:06:33 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=netlandish.com; s=google; t=1785175593; x=1785780393; darn=lists.code.netlandish.com;
        h=content-transfer-encoding:mime-version:message-id:date:subject:cc
         :to:from:from:to:cc:subject:date:message-id:reply-to:content-type;
        bh=4yHsOh2ocwRaxh4ASYhT0Ea2BZ75Kcyv63PiP20sHlA=;
        b=qXme5F8gX+pHGoCkxGc8sw8RQrN8I1ijgWsTCxfFa5DxJUOZj5dWqLDEwlkL6M0yUZ
         hx7P27G81Q61fThXO8IajqjKbWE68CK95j/XvsgH+R60ahEme7NUgDhKtK3OFqHWfwdC
         boDXC32OAd4Bs2iDG7/7BCDao1Rvlanx/9VxY=
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=1e100.net; s=20251104; t=1785175593; x=1785780393;
        h=content-transfer-encoding:mime-version:message-id:date:subject:cc
         :to:from:x-gm-gg:x-gm-message-state:from:to:cc:subject:date
         :message-id:reply-to:content-type;
        bh=4yHsOh2ocwRaxh4ASYhT0Ea2BZ75Kcyv63PiP20sHlA=;
        b=R+s2X8q9PpCYyMJGSTrUk2rDoZc56hVIZaAniVLP5qVC0zWzFkxbroKFNGagVufdTb
         Oi1MDqWIZeBfBVJTGd9G1ou9dupwfFTY1k7qmr6OT6WCGFGtPClcSSNygdH/3TTrFiOP
         +ZGJJGEKFTzUYR2mTyXZoP+ewsGqllblFFRhfe5g92764wyRS+J1vLH1aOeKgRmKWyO7
         s4vNTPYayus7uEHabw7WuTt416bpcuomeAMphJdI25JYLj06QV/OBZuzH7WglWHvID0t
         w1OZjS2cYPDQjMx6wWDKtbFDpdVebLiEII1hD2wC2H98MZzwa2Dn9UMhW9EmOPrwNoio
         ML3g==
X-Gm-Message-State: AOJu0YwQHy1I4HKH3N200V1SSimfhEZXtCLrhH/2FFDFqteMu1Q6+CUI
	OvRV24Y8xj6bly3Y/DXR/h1MC4et626WAXlmaeqfBVFRydWnQNjQ5GdJfxwE89JyLhiojP+msOE
	jEuKhMOY=
X-Gm-Gg: AR+sD13JMkde84bqwm7jTSNoYoDBXuGIohbclx6ejT2L2PKzabZpKzT1sVMgHLDOeUj
	T2ImSXVnIB8YnMgAB5SUC0TPWoU4uOmiXwrNITMX8S0zJEXjZ41Fqvs2ptc3z4BnvxRDN4Foy/r
	zKFD7lhxPMIB1XmWcBlxaR+5JK2Pb1YhRn0RDi+wzwwXRmdDXiwZuwvvR5fmkOqDi2H1WpnBeJP
	qJNeoJb+nJ1uwbWW7ciGU6fMGahkfssV6C7HL6KJt5B7ohOQi0iP2WFjj7TiwTzvvS0b45gAfvb
	OwhR3iYdXS0CxaxN9UdiXp0SPqfh21y1dBo688v0g8oV33YJBSSR60sLElUHDuUTNfsZNpkamt1
	qthOJ4Ecm8prX5U0p6yuBDtTMuoSaW0sQWcieUD3YikM7TkSLa86W00bkaacEMmjF64GGsxGdW/
	s=
X-Received: by 2002:a05:690c:688b:b0:81d:9e11:683b with SMTP id 00721157ae682-81f69e52cbamr29050897b3.63.1785175593207;
        Mon, 27 Jul 2026 11:06:33 -0700 (PDT)
Received: from localhost ([186.77.196.89])
        by smtp.gmail.com with ESMTPSA id 00721157ae682-81f6592a91fsm35281047b3.42.2026.07.27.11.06.32
        (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
        Mon, 27 Jul 2026 11:06:32 -0700 (PDT)
From: Peter Sanchez <peter@netlandish.com>
To: ~netlandish/links-dev@lists.code.netlandish.com
Cc: Peter Sanchez <peter@netlandish.com>
Subject: [PATCH links] admin/billing: fixing payment history calls with a properly formatted response object.
Date: Mon, 27 Jul 2026 12:06:27 -0600
Message-ID: <20260727180629.10188-1-peter@netlandish.com>
X-Mailer: git-send-email 2.54.0
MIME-Version: 1.0
Content-Transfer-Encoding: 8bit

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

