Files
zooqueenandHanzo Dev a265087b13 No route makes or records a refund; a refund or dispute is recorded only from the processor's signed webhook, once, and an inquiry that closes without a chargeback restores its period
Commerce never initiates a refund. POST /v1/billing/refunds, POST /v1/billing/refund,
POST /v1/order/:orderid/refund and POST /v1/payment/:paymentid/refund are gone with
their handlers (engine.CreateRefund, checkout Refund, square.Refund) and the helpers
only they used; GET /v1/billing/refunds stays read-only. A claim is settled by a
replacement order, never a refund.

A refund or dispute of a plan's payment reaches its invoice only from the
processor's webhook, under the processor's own refund id, through one locked
writer that reads the invoice afresh (engine.RecordRefund, engine.RecordDispute).
Every state a dispute reports reaches the invoice; INQUIRY_CLOSED holds the
payment no longer, WON stays final and LOST stays disputed.

No backfill of the payment-to-invoice index is run: the only plan payments made
before v1.50.139 are the team's own card purchases (z24, zk) and one Square
record.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-01 17:04:54 -07:00

92 lines
2.1 KiB
Go

package billing
import (
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/refund"
"github.com/hanzoai/commerce/util/json/http"
)
// GetRefund retrieves a refund by ID.
//
// GET /v1/billing/refunds/:id
func GetRefund(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
r := refund.New(db)
if err := r.GetById(c.Param("id")); err != nil {
return http.Fail(c, 404, "refund not found", err)
}
return c.JSON(200, refundResponse(r))
}
// ListRefunds lists refunds, optionally filtered by paymentIntentId or invoiceId.
//
// GET /v1/billing/refunds?paymentIntentId=...&invoiceId=...
func ListRefunds(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
rootKey := db.NewKey("synckey", "", 1, nil)
refunds := make([]*refund.Refund, 0)
q := refund.Query(db).Ancestor(rootKey)
if piId := c.Query("paymentIntentId"); piId != "" {
q = q.Filter("PaymentIntentId=", piId)
}
if invId := c.Query("invoiceId"); invId != "" {
q = q.Filter("InvoiceId=", invId)
}
iter := q.Order("-Created").Run()
for {
r := refund.New(db)
if _, err := iter.Next(r); err != nil {
break
}
refunds = append(refunds, r)
}
results := make([]map[string]interface{}, len(refunds))
for i, r := range refunds {
results[i] = refundResponse(r)
}
return c.JSON(200, results)
}
func refundResponse(r *refund.Refund) map[string]interface{} {
resp := map[string]interface{}{
"id": r.Id(),
"amount": r.Amount,
"currency": r.Currency,
"status": r.Status,
"created": r.CreatedAt,
}
if r.PaymentIntentId != "" {
resp["paymentIntentId"] = r.PaymentIntentId
}
if r.InvoiceId != "" {
resp["invoiceId"] = r.InvoiceId
}
if r.Reason != "" {
resp["reason"] = r.Reason
}
if r.ProviderRef != "" {
resp["providerRef"] = r.ProviderRef
}
if r.ReceiptNumber != "" {
resp["receiptNumber"] = r.ReceiptNumber
}
if r.FailureReason != "" {
resp["failureReason"] = r.FailureReason
}
if r.Metadata != nil {
resp["metadata"] = r.Metadata
}
return resp
}