Files
zooqueenandHanzo Dev a265087b13 No route makes or records a refund; a refund or dispute is recorded only from the processor's signed webhook, once, and an inquiry that closes without a chargeback restores its period
Commerce never initiates a refund. POST /v1/billing/refunds, POST /v1/billing/refund,
POST /v1/order/:orderid/refund and POST /v1/payment/:paymentid/refund are gone with
their handlers (engine.CreateRefund, checkout Refund, square.Refund) and the helpers
only they used; GET /v1/billing/refunds stays read-only. A claim is settled by a
replacement order, never a refund.

A refund or dispute of a plan's payment reaches its invoice only from the
processor's webhook, under the processor's own refund id, through one locked
writer that reads the invoice afresh (engine.RecordRefund, engine.RecordDispute).
Every state a dispute reports reaches the invoice; INQUIRY_CLOSED holds the
payment no longer, WON stays final and LOST stays disputed.

No backfill of the payment-to-invoice index is run: the only plan payments made
before v1.50.139 are the team's own card purchases (z24, zk) and one Square
record.

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

103 lines
3.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package claim is the order-claim domain (Medusa v2 core parity: order
// claims): a customer reports a problem with delivered items (damaged, wrong,
// missing) and the merchant resolves it with a replacement order. A claim is
// never settled by a refund.
//
// A Claim references an order and carries its claimed lines as claimitem rows
// (parent by ClaimId). It is pending until the merchant accepts (→ replacement)
// or rejects it. Accept is idempotent: an already-accepted claim returns its
// prior outcome (ReplacementOrderId) without creating a second replacement.
// AmountCents is a projection computed at accept time from the claimed
// quantities × the order's line prices — never a mutable counter — so it is
// exact and re-derivable.
package claim
import (
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/mixin"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/json"
"github.com/hanzoai/orm"
. "github.com/hanzoai/commerce/types"
)
func init() { orm.Register[Claim]("claim") }
// Claim lifecycle statuses.
const (
StatusPending = "pending"
StatusAccepted = "accepted"
StatusRejected = "rejected"
)
// ResolutionReplace is how an accepted claim is settled: a replacement order.
const ResolutionReplace = "replace"
// ValidResolution reports whether r is a resolution a claim may be settled by.
func ValidResolution(r string) bool {
return r == ResolutionReplace
}
// Claim links a problem report to an order and resolves to a replacement order.
// Its claimed lines live as claimitem rows keyed by ClaimId.
type Claim struct {
mixin.Model[Claim]
// OrderId is the order this claim is filed against.
OrderId string `json:"orderId"`
// Resolution is replace, the default.
Resolution string `json:"resolution" orm:"default:replace"`
// Status is pending|accepted|rejected. Default pending.
Status string `json:"status" orm:"default:pending"`
// Reason is an optional free-text summary of the claim (per-line reasons
// live on the claim items).
Reason string `json:"reason,omitempty"`
CurrencyCode currency.Type `json:"currencyCode" orm:"default:usd"`
// AmountCents is the settled amount, computed at accept from the claimed
// quantities × the order line prices. Zero until accepted.
AmountCents currency.Cents `json:"amountCents"`
// ReplacementOrderId is set when an accepted claim was resolved with a
// replacement order.
ReplacementOrderId string `json:"replacementOrderId,omitempty"`
Metadata Map `json:"metadata,omitempty" datastore:"-"`
Metadata_ string `json:"-" datastore:",noindex"`
}
func (cl *Claim) Load(ps []datastore.Property) (err error) {
if err = datastore.LoadStruct(cl, ps); err != nil {
return err
}
if len(cl.Metadata_) > 0 {
err = json.DecodeBytes([]byte(cl.Metadata_), &cl.Metadata)
}
return err
}
func (cl *Claim) Save() ([]datastore.Property, error) {
cl.Metadata_ = string(json.EncodeBytes(&cl.Metadata))
return datastore.SaveStruct(cl)
}
// IsOpen reports whether the claim is still awaiting a decision.
func (cl *Claim) IsOpen() bool {
return cl.Status == StatusPending
}
func New(db *datastore.Datastore) *Claim {
cl := new(Claim)
cl.Init(db)
return cl
}
func Query(db *datastore.Datastore) datastore.Query {
return db.Query("claim")
}