Files
commerce/billing/engine/refunds.go
zooqueenandHanzo Dev ca7fd95c5c A dispute moves money only while Square withholds the funds, every dispute report is ordered by the dispute's version, and Square's dispute.state.updated is read as dispute.state.changed was
dispute.state.updated and dispute.state.changed both normalize to dispute.updated
and both are clawback events. A dispute report carries the dispute's version and
updated_at, and dispute.Report (embedded in the invoice and in the wallet payment's
record, models/paymentorg) keeps the latest: a report older by version, else by
updated_at, else by phase (inquiry, chargeback, decided), changes nothing, and WON
stays final.

An inquiry holds no funds: it takes nothing from a wallet, and on an invoice it is
recorded while the period stays paid. A chargeback (EVIDENCE_REQUIRED, PROCESSING),
or one lost or accepted, takes a wallet payment back once and unpays a period; a
chargeback won gives the wallet payment back and restores the period.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-01 17:42:55 -07:00

123 lines
3.6 KiB
Go

package engine
import (
"fmt"
"sync"
"time"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/models/credit"
"github.com/hanzoai/commerce/models/paymentorg"
)
// returns serializes the read-modify-write of a payment's refunds and dispute —
// on its invoice, or on a wallet payment's record — within the process, the one
// writer of a tenant's books. Commerce never makes a refund: both are recorded
// only from the processor's signed webhook, through here.
var returns sync.Mutex
// RecordRefund records on invoice id the refund the processor gave back under its
// own refundID, once — a refund reported twice is one entry — reading the invoice
// afresh under returns.
func RecordRefund(db *datastore.Datastore, id, refundID string, amount int64) error {
returns.Lock()
defer returns.Unlock()
inv := billinginvoice.New(db)
if err := inv.GetById(id); err != nil {
return err
}
if !inv.Refund("refund:"+refundID, amount) {
return nil
}
return inv.Update()
}
// RecordDispute records on invoice id the state a dispute against its payment
// reached, ordered by the dispute's version (or updated_at): a report older than
// the one the invoice holds changes nothing. It reads the invoice afresh under
// returns.
func RecordDispute(db *datastore.Datastore, id, state string, version int64, at time.Time) error {
returns.Lock()
defer returns.Unlock()
inv := billinginvoice.New(db)
if err := inv.GetById(id); err != nil {
return err
}
if !inv.SetDispute(state, version, at) {
return nil
}
return inv.Update()
}
// RecordPaymentDispute records the state a dispute of a wallet payment reached on
// the payment's record (models/paymentorg), under returns, and reports whether it
// is the latest known — the one report whose money the caller may move.
func RecordPaymentDispute(payment, state string, version int64, at time.Time) (bool, error) {
returns.Lock()
defer returns.Unlock()
return paymentorg.RecordDispute(payment, state, version, at)
}
// CreateCreditNoteParams holds the parameters for creating a credit note.
type CreateCreditNoteParams struct {
InvoiceId string
CustomerId string
Amount int64
Reason string
LineItems []credit.CreditNoteLineItem
OutOfBandAmount int64
Memo string
}
// CreateCreditNote creates a credit note against an invoice.
func CreateCreditNote(db *datastore.Datastore, params CreateCreditNoteParams) (*credit.CreditNote, error) {
if params.InvoiceId == "" {
return nil, fmt.Errorf("invoiceId is required")
}
inv := billinginvoice.New(db)
if err := inv.GetById(params.InvoiceId); err != nil {
return nil, fmt.Errorf("invoice not found: %w", err)
}
cn := credit.New(db)
cn.InvoiceId = params.InvoiceId
cn.CustomerId = params.CustomerId
if cn.CustomerId == "" {
cn.CustomerId = inv.UserId
}
cn.Currency = inv.Currency
cn.Reason = params.Reason
cn.Memo = params.Memo
cn.LineItems = params.LineItems
cn.OutOfBandAmount = params.OutOfBandAmount
// Calculate total amount from line items
if params.Amount > 0 {
cn.Amount = params.Amount
} else {
var total int64
for _, li := range params.LineItems {
total += li.Amount
}
total += params.OutOfBandAmount
cn.Amount = total
}
// Auto-number (simple increment based on query count)
rootKey := db.NewKey("synckey", "", 1, nil)
existing := make([]*credit.CreditNote, 0)
if _, err := credit.Query(db).Ancestor(rootKey).GetAll(&existing); err == nil {
cn.SetNumber(len(existing) + 1)
} else {
cn.SetNumber(1)
}
if err := cn.Create(); err != nil {
return nil, fmt.Errorf("failed to create credit note: %w", err)
}
return cn, nil
}