Files
commerce/models/paymentorg/paymentorg.go
zeekay 50a5190bce fix(store): a read-modify-write swaps, so no replica loses another's
Refunds and disputes were recorded under an in-process mutex, and the
renewal guard was a read then a write under another. Two processes over
one store, or a webhook beside the billing cycle, could each read the
invoice and each write it back, the second wiping the first's refund.

A swap writes only while the stored entity's updatedAt is still the one
read (insert-if-absent for a new or deleted entity) and otherwise reads
again: db.Swapper on SQLite and Postgres as one statement,
datastore.Swap, mixin.Model.Swap. Refund and dispute recording swap the
invoice or payment record in, and idempotencykey.Begin swaps its first
sighting and its stale re-claim, so a guarded money move runs once
across replicas. The returns and lockPeriod mutexes are gone.

lockFunds stays: api/transaction mounts only in standalone commerce,
whose store has one writer.

The tests open one store file through two handles, each with its own
write lock: 24 refunds delivered twice to both, with dispute reports
between them, land once each; 32 sightings of one key proceed once. A
blind write in the refund recorder fails the first test every run.
2026-10-09 10:40:59 -07:00

106 lines
3.3 KiB
Go

// Package paymentorg records, for every settled payment, the org whose books its
// receipt lives in — by the processor's payment id, in the system namespace.
//
// A processor's callback names a payment and no org: Square sends no X-Org-Id, and a
// sessionless request resolves to the default org. So a refund or a dispute of a
// customer's top-up, or of the payment for a plan's period, is traced to its org
// here, never read off the request.
package paymentorg
import (
"context"
"fmt"
"strings"
"time"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/dispute"
"github.com/hanzoai/commerce/models/mixin"
"github.com/hanzoai/commerce/util/nscontext"
"github.com/hanzoai/orm"
)
func init() {
orm.Register[PaymentOrg]("payment-org", orm.WithStringKey[PaymentOrg]())
}
// PaymentOrg is where one payment's receipt lives: the org, the wallet it credited,
// and whether the charge was a sandbox one. A payment that paid an invoice names it
// in Invoice and credited no wallet. Report is a dispute of a payment that credited
// a wallet, as the processor last reported it.
type PaymentOrg struct {
mixin.Model[PaymentOrg]
Org string `json:"org"`
Subject string `json:"subject"`
Invoice string `json:"invoice,omitempty"`
Test bool `json:"test"`
dispute.Report
}
func (p *PaymentOrg) Load(ps []datastore.Property) error { return datastore.LoadStruct(p, ps) }
func (p *PaymentOrg) Save() ([]datastore.Property, error) { return datastore.SaveStruct(p) }
func db() *datastore.Datastore {
return datastore.New(nscontext.WithNamespace(context.Background(), "system"))
}
// Put records that payment's receipt lives in org, for subject, and the invoice it
// paid ("" for a payment that credited subject's wallet). Idempotent: the payment id
// is the key.
func Put(payment, org, subject, invoice string, test bool) error {
payment = strings.TrimSpace(payment)
if payment == "" || org == "" {
return nil
}
d := db()
p := new(PaymentOrg)
p.Init(d)
p.SetId(payment)
p.Org, p.Subject, p.Invoice, p.Test = org, subject, invoice, test
return p.Put()
}
// Get answers where payment's receipt lives, and false when nothing recorded it.
func Get(payment string) (*PaymentOrg, bool, error) {
d := db()
p := new(PaymentOrg)
p.Init(d)
err := p.Get(d.NewKey(p.Kind(), strings.TrimSpace(payment), 0, nil))
switch {
case err == nil:
return p, true, nil
case err == datastore.ErrNoSuchEntity:
return nil, false, nil
default:
return nil, false, err
}
}
// RecordDispute records a reported state of a dispute of payment on its record and
// reports whether it is the latest known (dispute.Report.SetDispute). A payment
// nothing recorded answers false.
//
// The record is swapped in (mixin.Model.Swap): a report another writer beat to it
// reads it again, so no report is lost to another, on this replica or another.
func RecordDispute(payment, state string, version int64, at time.Time) (bool, error) {
for range mixin.Swaps {
p, found, err := Get(payment)
if err != nil || !found {
return false, err
}
if !p.SetDispute(state, version, at) {
return false, nil
}
ok, err := p.Swap()
if err != nil {
return false, err
}
if ok {
return true, nil
}
}
return false, fmt.Errorf("payment %s changed under %d reads in a row", payment, mixin.Swaps)
}