Files
commerce/api/billing/credit_notes.go
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

164 lines
4.4 KiB
Go

package billing
import (
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/billing/engine"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/credit"
"github.com/hanzoai/commerce/util/json/http"
)
type createCreditNoteRequest struct {
InvoiceId string `json:"invoiceId"`
CustomerId string `json:"customerId,omitempty"`
Amount int64 `json:"amount,omitempty"`
Reason string `json:"reason,omitempty"`
LineItems []credit.CreditNoteLineItem `json:"lineItems,omitempty"`
OutOfBandAmount int64 `json:"outOfBandAmount,omitempty"`
Memo string `json:"memo,omitempty"`
}
// CreateCreditNote creates a credit note against an invoice.
//
// POST /v1/billing/credit-notes
func CreateCreditNote(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
var req createCreditNoteRequest
if err := c.Bind(&req); err != nil {
return http.Fail(c, 400, "invalid request body", err)
}
if req.InvoiceId == "" {
return http.Fail(c, 400, "invoiceId is required", nil)
}
cn, err := engine.CreateCreditNote(db, engine.CreateCreditNoteParams{
InvoiceId: req.InvoiceId,
CustomerId: req.CustomerId,
Amount: req.Amount,
Reason: req.Reason,
LineItems: req.LineItems,
OutOfBandAmount: req.OutOfBandAmount,
Memo: req.Memo,
})
if err != nil {
log.Error("Failed to create credit note: %v", err, c)
return http.Fail(c, 400, err.Error(), err)
}
return c.JSON(201, creditNoteResponse(cn))
}
// GetCreditNote retrieves a credit note by ID.
//
// GET /v1/billing/credit-notes/:id
func GetCreditNote(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
cn := credit.New(db)
if err := cn.GetById(c.Param("id")); err != nil {
return http.Fail(c, 404, "credit note not found", err)
}
return c.JSON(200, creditNoteResponse(cn))
}
// ListCreditNotes lists credit notes, optionally filtered by invoiceId or customerId.
//
// GET /v1/billing/credit-notes?invoiceId=...&customerId=...
func ListCreditNotes(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
rootKey := db.NewKey("synckey", "", 1, nil)
notes := make([]*credit.CreditNote, 0)
q := credit.Query(db).Ancestor(rootKey)
if invId := c.Query("invoiceId"); invId != "" {
q = q.Filter("InvoiceId=", invId)
}
if custId := c.Query("customerId"); custId != "" {
q = q.Filter("CustomerId=", custId)
}
iter := q.Order("-Created").Run()
for {
cn := credit.New(db)
if _, err := iter.Next(cn); err != nil {
break
}
notes = append(notes, cn)
}
results := make([]map[string]interface{}, len(notes))
for i, cn := range notes {
results[i] = creditNoteResponse(cn)
}
return c.JSON(200, results)
}
// VoidCreditNote voids a credit note.
//
// POST /v1/billing/credit-notes/:id/void
func VoidCreditNote(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
cn := credit.New(db)
if err := cn.GetById(c.Param("id")); err != nil {
return http.Fail(c, 404, "credit note not found", err)
}
if err := cn.MarkVoid(); err != nil {
return http.Fail(c, 400, err.Error(), err)
}
if err := cn.Update(); err != nil {
log.Error("Failed to void credit note: %v", err, c)
return http.Fail(c, 500, "failed to void credit note", err)
}
return c.JSON(200, creditNoteResponse(cn))
}
func creditNoteResponse(cn *credit.CreditNote) map[string]interface{} {
resp := map[string]interface{}{
"id": cn.Id(),
"invoiceId": cn.InvoiceId,
"customerId": cn.CustomerId,
"number": cn.Number,
"amount": cn.Amount,
"currency": cn.Currency,
"status": cn.Status,
"created": cn.Created,
}
if cn.Reason != "" {
resp["reason"] = cn.Reason
}
if cn.LineItems != nil {
resp["lineItems"] = cn.LineItems
}
if cn.OutOfBandAmount > 0 {
resp["outOfBandAmount"] = cn.OutOfBandAmount
}
if cn.CreditBalanceTransaction != "" {
resp["creditBalanceTransaction"] = cn.CreditBalanceTransaction
}
if cn.RefundId != "" {
resp["refundId"] = cn.RefundId
}
if cn.Memo != "" {
resp["memo"] = cn.Memo
}
if cn.Metadata != nil {
resp["metadata"] = cn.Metadata
}
return resp
}