mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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]>
250 lines
7.1 KiB
Go
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
|
|
}
|