Files
commerce/api/billing/refund_once_test.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

100 lines
3.5 KiB
Go

package billing
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/refund"
"github.com/hanzoai/commerce/util/test/ae"
)
// Commerce never makes a refund. One is recorded only from the processor's signed
// webhook, under the processor's own refund id, through one locked writer.
// A refund the processor reports more than once — created, then updated, each
// COMPLETED — is one refund.
func TestWebhook_ARefundReportedTwiceCountsOnce(t *testing.T) {
const secret = "whsec_rf_twice"
registerSquare(t, secret)
ctx := ae.NewContext()
defer ctx.Close()
org := webhookOrg(t, ctx, "rf-twice", true)
withFakeSquare(t, squareMock("cust_tw", "ccof_tw", "sqpay_tw"))
subscribed(t, ctx, org, "rf-twice", "team", 2, nil)
deliver(t, ctx, secret,
refundEvent("evt_rf_tw_0", "refund.created", "sqrf_tw", "sqpay_tw", "COMPLETED", 2500),
refundEvent("evt_rf_tw_1", "refund.updated", "sqrf_tw", "sqpay_tw", "COMPLETED", 2500))
paidAs(t, viewOf(t, ctx, org, "rf-twice"), "card", 2, 2500)
}
// Refunds of one payment landing at once are all kept: each is written under the
// one lock, onto the invoice as it stands then.
func TestWebhook_RefundsLandingTogetherAreAllKept(t *testing.T) {
const secret = "whsec_rf_together"
registerSquare(t, secret)
ctx := ae.NewContext()
defer ctx.Close()
org := webhookOrg(t, ctx, "rf-together", true)
withFakeSquare(t, squareMock("cust_tg", "ccof_tg", "sqpay_tg"))
subscribed(t, ctx, org, "rf-together", "team", 2, nil)
const n = 8
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
b := refundEvent(fmt.Sprintf("evt_rf_tg_%d", i), "refund.updated", fmt.Sprintf("sqrf_tg_%d", i), "sqpay_tg", "COMPLETED", 100)
if r := deliverWebhook(ctx, "", secret, b, ""); r.StatusCode != http.StatusOK {
t.Errorf("delivery %d: %d", i, r.StatusCode)
}
}(i)
}
wg.Wait()
paidAs(t, viewOf(t, ctx, org, "rf-together"), "card", 2, 5000-n*100)
}
// POST /v1/billing/refunds and /v1/billing/refund are not routed, even for the
// platform, and a request to either writes nothing: no refund row, and the invoice
// holds all it was paid.
func TestRefundsAPI_POSTIsNotRoutedAndWritesNothing(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("rf-gone")
withFakeSquare(t, squareMock("cust_gone", "ccof_gone", "sqpay_gone"))
sub := subscribed(t, ctx, org, "rf-gone", "team", 2, nil)
app := engineWithSeed(func(c *zip.Ctx) {
c.Locals("organization", org)
c.SetContext(ctx)
platformApp(c)
})
for path, body := range map[string]string{
"/v1/billing/refunds": fmt.Sprintf(`{"invoiceId":%q,"amount":1000}`, sub.CurrentInvoiceId),
"/v1/billing/refund": `{"user":"rf-gone","amount":1000,"originalTransactionId":"x"}`,
} {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("POST %s: %d %s; want 404 or 405", path, resp.StatusCode, bodyOf(resp))
}
}
rows := make([]*refund.Refund, 0)
if _, err := refund.Query(datastore.New(org.Namespaced(ctx))).GetAll(&rows); err != nil || len(rows) != 0 {
t.Fatalf("refund rows = %d (%v); want none", len(rows), err)
}
paidAs(t, viewOf(t, ctx, org, "rf-gone"), "card", 2, 5000)
}