Files
commerce/api/billing/bank_transfers.go
zooqueenandHanzo Dev abab16618d feat(billing): plan coupon for a percent off the first month
POST /v1/billing/subscribe/card takes an optional coupon code. The coupon
is a models/coupon row (Filter "plan", Type percent) in the reserved
platform namespace "admin", beside the platform promo. It must be enabled,
inside [StartDate, EndDate], under Limit and cover the plan; an org and a
card each redeem a code once (models/couponredemption rows), and the use is
reserved under the code's lock before the card is charged and given back on
a decline. Any refusal is a 400 or 409 before the card is touched.

The percent comes off the first charge only, through engine.Discounted,
the same arithmetic the first invoice uses, so charge == AmountDue. The
first invoice names the coupon; the subscription never carries it, so
renewals bill the catalog price (less only a promo the row carries).

GET /v1/billing/plans?coupon=CODE quotes couponCode, couponPercent,
couponFirstCents and couponEnds on covered paid rows and couponError
(unknown, expired, exhausted, not_applicable) on the rest. Without the
parameter the response is unchanged.

SuperAdmin: GET/PUT /v1/platform/coupons/:code (RequirePlatformAdmin),
audited as coupon.created/coupon.updated billing events naming the actor.
middleware.Actor is the one actor-naming rule, moved from billing's minter.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-09 12:18:05 -07:00

250 lines
7.1 KiB
Go

package billing
import (
"fmt"
"strings"
"github.com/google/uuid"
"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/banktransferinstruction"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/json/http"
)
type createBankTransferInstructionRequest struct {
CustomerId string `json:"customerId"`
Currency string `json:"currency"`
Type string `json:"type"` // "ach" | "wire" | "sepa"
BankName string `json:"bankName"`
AccountHolder string `json:"accountHolder,omitempty"`
AccountNumber string `json:"accountNumber"`
RoutingNumber string `json:"routingNumber,omitempty"`
IBAN string `json:"iban,omitempty"`
BIC string `json:"bic,omitempty"`
}
// CreateBankTransferInstruction creates bank transfer details for a customer.
//
// POST /v1/billing/bank-transfer-instructions
func CreateBankTransferInstruction(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
var req createBankTransferInstructionRequest
if err := c.Bind(&req); err != nil {
return http.Fail(c, 400, "invalid request body", err)
}
if req.CustomerId == "" {
return http.Fail(c, 400, "customerId is required", nil)
}
if req.Type == "" {
return http.Fail(c, 400, "type is required (ach, wire, sepa)", nil)
}
if req.Type != "ach" && req.Type != "wire" && req.Type != "sepa" {
return http.Fail(c, 400, "type must be one of: ach, wire, sepa", nil)
}
if req.BankName == "" {
return http.Fail(c, 400, "bankName is required", nil)
}
if req.AccountNumber == "" {
return http.Fail(c, 400, "accountNumber is required", nil)
}
inst := banktransferinstruction.New(db)
inst.CustomerId = req.CustomerId
inst.Type = req.Type
inst.BankName = req.BankName
inst.AccountHolder = req.AccountHolder
inst.AccountNumber = maskAccountNumber(req.AccountNumber)
inst.RoutingNumber = req.RoutingNumber
inst.IBAN = req.IBAN
inst.BIC = req.BIC
// Generate unique payment reference (first 8 chars of UUID)
inst.Reference = uuid.New().String()[:8]
if req.Currency != "" {
inst.Currency = currency.Type(strings.ToLower(req.Currency))
}
if err := inst.Create(); err != nil {
log.Error("Failed to create bank transfer instruction: %v", err, c)
return http.Fail(c, 500, "failed to create bank transfer instruction", err)
}
return c.JSON(201, instructionResponse(inst))
}
// ListBankTransferInstructions lists bank transfer instructions, optionally
// filtered by customerId.
//
// GET /v1/billing/bank-transfer-instructions?customerId=...
func ListBankTransferInstructions(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
rootKey := db.NewKey("synckey", "", 1, nil)
q := banktransferinstruction.Query(db).Ancestor(rootKey)
customerId := strings.TrimSpace(c.Query("customerId"))
if customerId != "" {
q = q.Filter("CustomerId=", customerId)
}
status := strings.TrimSpace(c.Query("status"))
if status != "" {
q = q.Filter("Status=", status)
}
iter := q.Order("-Created").Run()
items := make([]map[string]interface{}, 0)
for {
item := banktransferinstruction.New(db)
if _, err := iter.Next(item); err != nil {
break
}
items = append(items, instructionResponse(item))
}
return c.JSON(200, map[string]any{
"instructions": items,
"count": len(items),
})
}
// GetBankTransferInstruction returns a single bank transfer instruction by ID.
//
// GET /v1/billing/bank-transfer-instructions/:id
func GetBankTransferInstruction(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
id := c.Param("id")
inst := banktransferinstruction.New(db)
if err := inst.GetById(id); err != nil {
return http.Fail(c, 404, "bank transfer instruction not found", err)
}
return c.JSON(200, instructionResponse(inst))
}
type reconcileRequest struct {
Reference string `json:"reference"`
Amount int64 `json:"amount"` // cents
Currency string `json:"currency"`
}
// ReconcileInboundTransfer matches an incoming bank transfer by reference
// and creates a balance transaction for the customer.
//
// POST /v1/billing/bank-transfer-instructions/reconciliation/match
func ReconcileInboundTransfer(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
var req reconcileRequest
if err := c.Bind(&req); err != nil {
return http.Fail(c, 400, "invalid request body", err)
}
if req.Reference == "" {
return http.Fail(c, 400, "reference is required", nil)
}
if req.Amount <= 0 {
return http.Fail(c, 400, "amount must be positive", nil)
}
// Look up instruction by reference
rootKey := db.NewKey("synckey", "", 1, nil)
instructions := make([]*banktransferinstruction.BankTransferInstruction, 0)
q := banktransferinstruction.Query(db).Ancestor(rootKey).
Filter("Reference=", req.Reference).
Filter("Status=", "active")
if _, err := q.GetAll(&instructions); err != nil || len(instructions) == 0 {
return http.Fail(c, 404, "no active instruction found for reference", nil)
}
inst := instructions[0]
cur := currency.Type("usd")
if req.Currency != "" {
cur = currency.Type(strings.ToLower(req.Currency))
}
// Create a balance transaction via the engine
description := fmt.Sprintf("Bank transfer received (ref: %s, type: %s)", inst.Reference, inst.Type)
bt, err := engine.AdjustCustomerBalance(db, inst.CustomerId, req.Amount, cur, "bank_transfer", description, middleware.Actor(c))
if err != nil {
log.Error("Failed to reconcile bank transfer: %v", err, c)
return http.Fail(c, 500, "failed to reconcile transfer", err)
}
return c.JSON(200, map[string]any{
"matched": true,
"reference": inst.Reference,
"customerId": inst.CustomerId,
"amount": req.Amount,
"currency": cur,
"transferType": inst.Type,
"transaction": map[string]interface{}{
"id": bt.Id(),
"amount": bt.Amount,
"endingBalance": bt.EndingBalance,
"type": bt.Type,
"created": bt.CreatedAt,
},
})
}
// maskAccountNumber returns last 4 digits prefixed with asterisks.
// If the input is 4 characters or fewer, it is returned as-is.
func maskAccountNumber(acct string) string {
if len(acct) <= 4 {
return acct
}
return "****" + acct[len(acct)-4:]
}
func instructionResponse(i *banktransferinstruction.BankTransferInstruction) map[string]interface{} {
resp := map[string]interface{}{
"id": i.Id(),
"customerId": i.CustomerId,
"currency": i.Currency,
"type": i.Type,
"reference": i.Reference,
"bankName": i.BankName,
"accountNumber": i.AccountNumber,
"status": i.Status,
"createdAt": i.CreatedAt,
"updatedAt": i.UpdatedAt,
}
if i.AccountHolder != "" {
resp["accountHolder"] = i.AccountHolder
}
if i.RoutingNumber != "" {
resp["routingNumber"] = i.RoutingNumber
}
if i.IBAN != "" {
resp["iban"] = i.IBAN
}
if i.BIC != "" {
resp["bic"] = i.BIC
}
if i.Metadata != nil {
resp["metadata"] = i.Metadata
}
return resp
}