Files
commerce/thirdparty/mercury/api/webhook.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

131 lines
4.0 KiB
Go

package api
import (
"errors"
"strings"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/config"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/models/order"
"github.com/hanzoai/commerce/models/payment"
"github.com/hanzoai/commerce/thirdparty/mercury"
"github.com/hanzoai/commerce/util/json"
"github.com/hanzoai/commerce/util/nscontext"
)
var (
ErrSignatureInvalid = errors.New("invalid Mercury webhook signature")
ErrPayloadRead = errors.New("failed to read webhook payload")
ErrPayloadParse = errors.New("failed to parse webhook payload")
)
// Webhook handles Mercury bank webhook POSTs.
//
// POST /api/v1/mercury/webhook
//
// On transaction.created with direction="credit":
// 1. Verify HMAC signature via Mercury-Signature header
// 2. Parse reference from externalMemo or note (format: "orgName:orderId")
// 3. Look up the order in the org namespace
// 4. Mark order as paid if it is pending
//
// Returns 200 OK to Mercury regardless of processing outcome.
func Webhook(c *zip.Ctx) error {
// Read raw body for signature verification.
body := c.Body()
// Verify signature — UNCONDITIONALLY. An unset secret used to skip
// verification entirely and process the payload, so a missing config was an
// unverified endpoint that credited money. Fail closed: no secret, no trust.
signature := c.Header("Mercury-Signature")
secret := config.Mercury.WebhookSecret
if secret == "" || !mercury.VerifySignature(body, signature, secret) {
log.Error("Mercury webhook: %v", ErrSignatureInvalid, c)
return c.String(200, "ok")
}
// Parse payload.
var payload mercury.WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
log.Error("Mercury webhook: %v: %v", ErrPayloadParse, err, c)
return c.String(200, "ok")
}
log.Info("Mercury webhook received: eventType=%s resourceId=%s",
payload.EventType, payload.ResourceID, c)
// Only process incoming credits on transaction.created.
if payload.EventType != "transaction.created" {
return c.String(200, "ok")
}
tx := payload.Data
if tx.Direction != "credit" {
return c.String(200, "ok")
}
// Extract order reference from externalMemo or note.
// Expected format: "orgName:orderId"
ref := tx.ExternalMemo
if ref == "" {
ref = tx.Note
}
if ref == "" {
log.Info("Mercury webhook: credit transaction %s has no reference, skipping", tx.ID, c)
return c.String(200, "ok")
}
orgName, orderID := parseReference(ref)
if orgName == "" || orderID == "" {
log.Info("Mercury webhook: could not parse reference %q from transaction %s", ref, tx.ID, c)
return c.String(200, "ok")
}
// Create a namespaced datastore for the org.
ctx := nscontext.WithNamespace(c.Context(), orgName)
db := datastore.New(ctx)
// Look up the order.
ord := order.New(db)
if err := ord.GetById(orderID); err != nil {
log.Error("Mercury webhook: order %s not found in org %s: %v", orderID, orgName, err, c)
return c.String(200, "ok")
}
// Only credit orders that are still awaiting payment.
if ord.PaymentStatus != payment.Unpaid {
log.Info("Mercury webhook: order %s already has payment status %s, skipping",
orderID, ord.PaymentStatus, c)
return c.String(200, "ok")
}
// Mark order as paid (same logic as wire credit handler).
ord.Status = order.Open
ord.PaymentStatus = payment.Paid
if err := ord.Put(); err != nil {
log.Error("Mercury webhook: failed to update order %s: %v", orderID, err, c)
return c.String(200, "ok")
}
log.Info("Mercury webhook: credited order=%s org=%s amount=%.2f txId=%s counterparty=%s",
orderID, orgName, tx.Amount, tx.ID, tx.CounterpartyName, c)
return c.String(200, "ok")
}
// parseReference splits a wire reference string into org name and order ID.
// Accepted formats:
//
// "orgName:orderId"
// "orderId" (returns empty orgName)
func parseReference(ref string) (orgName, orderID string) {
ref = strings.TrimSpace(ref)
if idx := strings.Index(ref, ":"); idx > 0 && idx < len(ref)-1 {
return ref[:idx], ref[idx+1:]
}
return "", ref
}