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
133 lines
4.0 KiB
Go
133 lines
4.0 KiB
Go
package square
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"strconv"
|
|
|
|
"github.com/hanzoai/commerce/email"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/models/order"
|
|
"github.com/hanzoai/commerce/models/organization"
|
|
"github.com/hanzoai/commerce/models/payment"
|
|
"github.com/hanzoai/commerce/models/types/accounts"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/hanzoai/commerce/models/user"
|
|
"github.com/hanzoai/commerce/payment/processor"
|
|
squarelib "github.com/hanzoai/commerce/thirdparty/square"
|
|
"github.com/hanzoai/money"
|
|
)
|
|
|
|
var NonSquarePayment = errors.New("only refunds for Square payments are supported via this handler")
|
|
var ZeroRefund = errors.New("refund amount cannot be 0")
|
|
var NegativeRefund = errors.New("refund amount must be a positive integer")
|
|
var NoPaymentsToRefund = errors.New("order has no payments to refund")
|
|
|
|
// Refund refunds refundAmount against ord's Square payments. idempotencyKey, when
|
|
// non-empty, is forwarded (per payment, deterministically) to Square as ITS
|
|
// idempotency key so a retried refund is de-duplicated AT THE GATEWAY — the money
|
|
// move is idempotent across pods/mutexes/in-flight guards. Empty key = legacy
|
|
// non-idempotent behavior (Square generates a random key).
|
|
func Refund(org *organization.Organization, ord *order.Order, refundAmount currency.Cents, idempotencyKey string) error {
|
|
if refundAmount == 0 {
|
|
return ZeroRefund
|
|
}
|
|
if refundAmount < 0 {
|
|
return NegativeRefund
|
|
}
|
|
|
|
db := ord.Datastore()
|
|
ctx := db.Context
|
|
|
|
if int64(refundAmount) > int64(ord.Total) {
|
|
return errors.New("requested refund amount is greater than the order total")
|
|
}
|
|
if ord.Refunded+refundAmount > ord.Total {
|
|
return errors.New("previously refunded amounts and requested refund amount exceed the order total")
|
|
}
|
|
|
|
payments, err := ord.GetPayments()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Fail closed on a zero-payment order: there is nothing to refund, and the
|
|
// receipt email below dereferences payments[0]. Without this guard an order
|
|
// whose Paid was set without any payment record would panic mid-refund
|
|
// (Red HIGH-3) instead of returning a clean error.
|
|
if len(payments) == 0 {
|
|
return NoPaymentsToRefund
|
|
}
|
|
|
|
for _, pay := range payments {
|
|
if pay.Type != accounts.SquareType {
|
|
return NonSquarePayment
|
|
}
|
|
}
|
|
|
|
if ord.Paid < refundAmount {
|
|
return errors.New("refund amount exceeds total payment amount")
|
|
}
|
|
|
|
sqCfg := org.SquareConfig(org.TestMode())
|
|
proc := squarelib.NewProcessor(squarelib.Config{
|
|
AccessToken: sqCfg.AccessToken,
|
|
LocationID: sqCfg.LocationId,
|
|
WebhookSecret: org.Square.WebhookSignatureKey,
|
|
Environment: org.SquareEnvironment(),
|
|
})
|
|
|
|
refundRemaining := refundAmount
|
|
for _, p := range payments {
|
|
var amount currency.Cents
|
|
if p.Amount <= refundRemaining {
|
|
amount = p.Amount
|
|
} else {
|
|
amount = refundRemaining
|
|
}
|
|
|
|
if !p.Test {
|
|
// Per-payment DETERMINISTIC gateway key: same (base key, payment,
|
|
// amount) ⇒ same Square idempotency key ⇒ Square de-dupes a retry.
|
|
var gwKey string
|
|
if idempotencyKey != "" {
|
|
sum := sha256.Sum256([]byte(idempotencyKey + "|" + p.Account.Square.PaymentId + "|" + strconv.FormatInt(int64(amount), 10)))
|
|
gwKey = "rfnd_" + hex.EncodeToString(sum[:20])
|
|
}
|
|
_, err := proc.Refund(context.Background(), processor.RefundRequest{
|
|
TransactionID: p.Account.Square.PaymentId,
|
|
Amount: money.FromMinor(int64(amount), ord.Currency.Money()),
|
|
Reason: "customer refund",
|
|
IdempotencyKey: gwKey,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
refundRemaining -= amount
|
|
if refundRemaining == 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
log.Info("Square refund amount: %v", refundAmount)
|
|
ord.Refunded = ord.Refunded + refundAmount
|
|
ord.Paid = ord.Paid - refundAmount
|
|
|
|
usr := user.New(db)
|
|
usr.GetById(ord.UserId)
|
|
|
|
if ord.Total == ord.Refunded {
|
|
email.SendOrderRefunded(ctx, org, ord, usr, payments[0])
|
|
ord.PaymentStatus = payment.Refunded
|
|
ord.Status = order.Cancelled
|
|
} else {
|
|
email.SendOrderRefunded(ctx, org, ord, usr, payments[0])
|
|
}
|
|
|
|
return ord.Put()
|
|
}
|