mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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
166 lines
6.1 KiB
Go
166 lines
6.1 KiB
Go
package billing
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/middleware"
|
|
"github.com/hanzoai/commerce/models/idempotencykey"
|
|
"github.com/hanzoai/commerce/models/transaction"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/hanzoai/commerce/util/json/http"
|
|
|
|
. "github.com/hanzoai/commerce/types"
|
|
)
|
|
|
|
type refundRequest struct {
|
|
User string `json:"user"`
|
|
Currency string `json:"currency"`
|
|
Amount int64 `json:"amount"` // cents
|
|
OriginalTransactionID string `json:"originalTransactionId"`
|
|
Notes string `json:"notes"`
|
|
}
|
|
|
|
// Refund creates a deposit tagged "refund" to REVERSE a prior charge, correcting
|
|
// an overcharge. The metadata links back to the original transaction.
|
|
//
|
|
// H1 (money-correctness). Previously this minted an arbitrary, uncapped credit
|
|
// against an UNVALIDATED originalTransactionId — a refund could exceed the
|
|
// original, name a non-existent or foreign transaction, refund a credit (doubling
|
|
// a deposit), or be replayed to double-refund. It is now fully validated and
|
|
// idempotent: the original MUST exist in THIS org's ledger, be a charge
|
|
// (Withdraw) for the SAME subject, and bound the amount (refund ≤ original); and
|
|
// there is AT MOST ONE refund per original transaction (keyed idempotency), so a
|
|
// retry replays and any second refund of the same charge is refused. Fail-closed
|
|
// throughout.
|
|
//
|
|
// POST /v1/billing/refund
|
|
func Refund(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
var req refundRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return http.Fail(c, 400, "invalid request body", err)
|
|
}
|
|
|
|
req.User = strings.ToLower(strings.TrimSpace(req.User))
|
|
if req.User == "" {
|
|
return http.Fail(c, 400, "user is required", nil)
|
|
}
|
|
|
|
if req.Amount <= 0 {
|
|
return http.Fail(c, 400, "amount must be positive", nil)
|
|
}
|
|
// Server-authoritative ceiling (shared with Deposit) — defense in depth.
|
|
if maxCents := depositMaxCents(); req.Amount > maxCents {
|
|
return http.Fail(c, 400, fmt.Sprintf("amount %d exceeds maximum refund of %d cents", req.Amount, maxCents), nil)
|
|
}
|
|
|
|
req.OriginalTransactionID = strings.TrimSpace(req.OriginalTransactionID)
|
|
if req.OriginalTransactionID == "" {
|
|
return http.Fail(c, 400, "originalTransactionId is required", nil)
|
|
}
|
|
|
|
// The original MUST exist in this org's namespaced ledger. GetById returns
|
|
// datastore.ErrNoSuchEntity for an unknown id → 404 (never a silent mint).
|
|
orig := transaction.New(db)
|
|
if err := orig.GetById(req.OriginalTransactionID); err != nil {
|
|
return http.Fail(c, 404, "original transaction not found", err)
|
|
}
|
|
// Only a CHARGE is refundable. Refunding a Deposit/credit would double it.
|
|
if orig.Type != transaction.Withdraw {
|
|
return http.Fail(c, 400, "original transaction is not a refundable charge", nil)
|
|
}
|
|
// The refund must return money to the party that was charged — never route
|
|
// one subject's charge to another subject's balance.
|
|
if !strings.EqualFold(strings.TrimSpace(orig.DestinationId), req.User) {
|
|
return http.Fail(c, 400, "refund subject does not match the original transaction", nil)
|
|
}
|
|
// Bound: never refund more than was charged.
|
|
if req.Amount > int64(orig.Amount) {
|
|
return http.Fail(c, 400, fmt.Sprintf("refund %d exceeds original charge of %d cents", req.Amount, int64(orig.Amount)), nil)
|
|
}
|
|
|
|
// Currency defaults to (and must match) the original charge.
|
|
cur := currency.Type(strings.ToLower(strings.TrimSpace(req.Currency)))
|
|
if cur == "" {
|
|
cur = orig.Currency
|
|
}
|
|
if cur == "" {
|
|
cur = "usd"
|
|
}
|
|
if orig.Currency != "" && cur != orig.Currency {
|
|
return http.Fail(c, 400, "refund currency does not match the original transaction", nil)
|
|
}
|
|
|
|
// Idempotency (H1): AT MOST ONE refund per original transaction. Keyed on the
|
|
// original tx id (NOT a client-chosen key — the invariant is per-charge), so a
|
|
// retry replays the stored result and a second refund of the same charge is
|
|
// refused. Scoped to the subject; the datastore is already org-namespaced.
|
|
// Fail CLOSED (503) if the guard store is unreachable — a refund we cannot
|
|
// dedupe must not run.
|
|
rec, replay, gerr := idempotencykey.Begin(db, "billing-refund:"+req.User, req.OriginalTransactionID)
|
|
if gerr != nil {
|
|
log.Error("refund idempotency Begin failed (user=%s, orig=%s): %v", req.User, req.OriginalTransactionID, gerr, c)
|
|
return http.Fail(c, 503, "refund temporarily unavailable; retry", gerr)
|
|
}
|
|
if replay {
|
|
if rec.Status == idempotencykey.StatusCompleted && rec.Response != "" {
|
|
c.SetHeader("Content-Type", "application/json")
|
|
return c.Bytes(200, []byte(rec.Response))
|
|
}
|
|
return http.Fail(c, 409, "a refund for this transaction is already in progress", nil)
|
|
}
|
|
|
|
notes := req.Notes
|
|
if notes == "" {
|
|
notes = fmt.Sprintf("Refund: %d cents %s (original tx: %s)", req.Amount, cur, req.OriginalTransactionID)
|
|
}
|
|
|
|
trans := transaction.New(db)
|
|
trans.Type = transaction.Deposit
|
|
trans.DestinationId = req.User
|
|
trans.DestinationKind = "iam-user"
|
|
trans.Currency = cur
|
|
trans.Amount = currency.Cents(req.Amount)
|
|
trans.Notes = notes
|
|
trans.Tags = "refund"
|
|
trans.Metadata = Map{
|
|
"refundType": "billing-correction",
|
|
"originalTransactionId": req.OriginalTransactionID,
|
|
}
|
|
|
|
if org.TestMode() {
|
|
trans.Test = true
|
|
}
|
|
|
|
if err := trans.Create(); err != nil {
|
|
// No money moved — release the guard so a later retry is not wedged.
|
|
_ = rec.Delete()
|
|
log.Error("Failed to create refund transaction: %v", err, c)
|
|
return http.Fail(c, 500, "failed to create refund", err)
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"transactionId": trans.Id(),
|
|
"user": req.User,
|
|
"amount": req.Amount,
|
|
"currency": cur,
|
|
"type": "deposit",
|
|
"tags": "refund",
|
|
"originalTransactionId": req.OriginalTransactionID,
|
|
}
|
|
// Seal the guard with the exact success body so a same-tx retry replays it.
|
|
if body, mErr := json.Marshal(resp); mErr == nil {
|
|
_ = idempotencykey.Complete(rec, string(body))
|
|
}
|
|
|
|
return c.JSON(201, resp)
|
|
}
|