Files
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

189 lines
6.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package giftcard is the HTTP surface for gift cards: base CRUD via rest plus
// money actions (redeem / balance / void). Every handler is tenant-scoped via
// middleware.GetOrganization(c) + org.Namespaced(c.Context()) — the same isolation the
// rest of the /v1 surface uses — so a caller only ever touches its own org's
// gift cards.
//
// Money actions are idempotent (see models/giftcard.Redeem): a redeem carries a
// caller idempotency key (X-Idempotency-Key header or body.idempotencyKey) and
// re-submitting it debits once, never twice.
package giftcard
import (
"errors"
"strings"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/middleware"
giftcardModel "github.com/hanzoai/commerce/models/giftcard"
"github.com/hanzoai/commerce/models/giftcardredemption"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/json"
"github.com/hanzoai/commerce/util/json/http"
"github.com/hanzoai/commerce/util/rest"
)
func Route(router *zip.Group, args ...zip.Handler) {
namespaced := middleware.Namespace()
api := rest.New(giftcardModel.GiftCard{})
// The model kind is "gift-card", so the generated base-CRUD id param would be
// ":gift-cardid" — but the money subroutes below, getCard, and every caller use
// ":giftcardid". Gin panics on that same-position wildcard-name mismatch
// (":giftcardid" vs ":gift-cardid") the moment both are registered on the
// /gift-card group, taking down the whole router. Pin the CRUD param to
// "giftcardid" so base + subroutes agree (external URL /gift-card/<id>/… is
// unchanged — the param name is internal to ByName()).
api.ParamId = "giftcardid"
api.POST("/:giftcardid/redeem", namespaced, Redeem)
api.POST("/:giftcardid/void", namespaced, Void)
api.GET("/:giftcardid/balance", namespaced, Balance)
api.GET("/:giftcardid/redemptions", namespaced, ListRedemptions)
api.Route(router, args...)
// The redemption ledger is also exposed read-only for audit/reconciliation.
rest.New(giftcardredemption.GiftCardRedemption{}).Route(router, args...)
}
// getCard loads the org-scoped gift card named in the path, or writes a 404 and
// returns nil.
func getCard(c *zip.Ctx) (*datastore.Datastore, *giftcardModel.GiftCard) {
org := middleware.GetOrganization(c)
db := datastore.NewNamespaced(org.Namespaced(c.Context()))
id := c.Param("giftcardid")
g := giftcardModel.New(db)
if err := g.GetById(id); err != nil {
http.Fail(c, 404, "No gift card found with id: "+id, err)
return nil, nil
}
return db, g
}
type redeemRequest struct {
AmountCents currency.Cents `json:"amountCents"`
Currency currency.Type `json:"currency"`
OrderId string `json:"orderId"`
IdempotencyKey string `json:"idempotencyKey"`
}
type redeemResponse struct {
Redemption *giftcardredemption.GiftCardRedemption `json:"redemption"`
BalanceCents currency.Cents `json:"balanceCents"`
GiftCardId string `json:"giftCardId"`
}
// Redeem draws amountCents from the card. Idempotent on the idempotency key.
// Admin-only: redeeming moves money off a card, so it is gated INSIDE the
// handler (the route middleware no-ops on the IAM path — Red HIGH-4).
func Redeem(c *zip.Ctx) error {
if !middleware.RequireAdmin(c) {
return nil
}
db, g := getCard(c)
if g == nil {
return nil
}
var req redeemRequest
if err := json.DecodeBytes(c.Body(), &req); err != nil {
return http.Fail(c, 400, "Failed decode request body", err)
}
// Idempotency key precedence: explicit body field, else the standard header.
key := strings.TrimSpace(req.IdempotencyKey)
if key == "" {
key = strings.TrimSpace(c.Header("X-Idempotency-Key"))
}
line, bal, err := giftcardModel.Redeem(db, g, req.AmountCents, req.Currency, req.OrderId, key)
if err != nil {
return http.Fail(c, redeemStatus(err), err.Error(), err)
}
return http.Render(c, 200, redeemResponse{Redemption: line, BalanceCents: bal, GiftCardId: g.Id()})
}
// redeemStatus maps money errors to HTTP codes. Client-correctable errors are
// 4xx; nothing here 500s (a 500 would suggest we moved money then failed).
func redeemStatus(err error) int {
switch {
case errors.Is(err, giftcardModel.ErrInsufficientFunds):
return 402 // Payment Required
case errors.Is(err, giftcardModel.ErrNotRedeemable),
errors.Is(err, giftcardModel.ErrNonPositiveAmount),
errors.Is(err, giftcardModel.ErrMissingIdempotency),
errors.Is(err, giftcardModel.ErrCurrencyMismatch):
return 400
default:
return 500
}
}
type voidRequest struct {
RedemptionId string `json:"redemptionId"`
}
// Void reverses a prior redemption (append-only compensating line). Idempotent.
// Admin-only (money move) — gated inside the handler like Redeem.
func Void(c *zip.Ctx) error {
if !middleware.RequireAdmin(c) {
return nil
}
db, g := getCard(c)
if g == nil {
return nil
}
var req voidRequest
if err := json.DecodeBytes(c.Body(), &req); err != nil {
return http.Fail(c, 400, "Failed decode request body", err)
}
if strings.TrimSpace(req.RedemptionId) == "" {
return http.Fail(c, 400, "redemptionId is required", errors.New("missing redemptionId"))
}
bal, err := giftcardModel.Void(db, g, req.RedemptionId)
if err != nil {
if errors.Is(err, datastore.ErrNoSuchEntity) {
return http.Fail(c, 404, "No redemption found with id: "+req.RedemptionId, err)
}
return http.Fail(c, 500, "Failed to void redemption", err)
}
return http.Render(c, 200, map[string]any{"balanceCents": bal, "giftCardId": g.Id()})
}
// Balance returns the projected spendable balance (initial − Σ redemptions).
func Balance(c *zip.Ctx) error {
db, g := getCard(c)
if g == nil {
return nil
}
bal, err := giftcardModel.BalanceCents(db, g)
if err != nil {
return http.Fail(c, 500, "Failed to compute balance", err)
}
return http.Render(c, 200, map[string]any{
"giftCardId": g.Id(),
"code": g.Code,
"currency": g.Currency,
"initialBalanceCents": g.InitialBalanceCents,
"balanceCents": bal,
})
}
// ListRedemptions returns the append-only debit ledger for a card.
func ListRedemptions(c *zip.Ctx) error {
db, g := getCard(c)
if g == nil {
return nil
}
lines := make([]*giftcardredemption.GiftCardRedemption, 0, 16)
if _, err := giftcardredemption.Query(db).Filter("GiftCardId=", g.Id()).GetAll(&lines); err != nil {
return http.Fail(c, 500, "Failed to list redemptions", err)
}
return http.Render(c, 200, lines)
}