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