mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Square keeps an event's created_at across every retry, and the receiver refused anything older than five minutes, so every retry of an event first sent while cloud was down (Recreate, ten rollouts a day) was refused until Square gave up. The verifier now lives in payment/providers/square and needs only the key and the URL; thirdparty/square is the API client and no longer registers itself or verifies anything, so every registry holds one Square type. Replay is stopped by the event id instead: each verified event is recorded once for the whole deployment, keyed on the verifying processor and the event id with an insert-if-absent swap, so a repeat is a duplicate under any path or org. The path names the one processor that verifies, and only one that holds its webhook secret may: an unknown name, or a verifier that needs no secret, is refused rather than tried. The books an event lands in are the verifying account's, else the deployment's brand — never a header or session, which on a replay are the replayer's. An effect that did not land (a store or the ledger did not answer) leaves the delivery open with a 503, so the provider redelivers; it used to be acknowledged and never retried. A settlement reads the payment index before the org's books, because an in-session charge's receipt is in the payer's org. POST /v1/commerce/webhooks/:provider is declared once, in setupRoutes, so standalone and embedded commerce serve the same address; the /v1/billing/webhooks spelling is gone. thirdparty/kms read /v1/kms/orgs/<org>/secrets/..., which cloud no longer serves, and was off in production anyway, so per-org payment credentials never loaded. It is deleted. secrets.Org reads orgs/<org>/<name> through the host's reader, the caller re-pointed at that org with zip.ActingAs so the KMS tenancy check admits it, and payment.Hydrate loads an org's credentials where the org receives the money: storefront checkout, storefront subscriptions and wire, and the brand's bank for the billing wire rail. Hanzo's own billing no longer reads the paying org's credentials; it charges the deployment's account, as it did. The standalone binary reads through hanzoai/kms/sdk/go.
604 lines
27 KiB
Go
604 lines
27 KiB
Go
package billing
|
|
|
|
// webhook_seam_test.go is the money-in half of the provider callback: WHICH WALLET a
|
|
// settlement funds in the ONE ledger the spend gate reads, and — more often — which
|
|
// ones it must refuse to fund.
|
|
//
|
|
// The callback used to write only into commerce's own transaction store, which
|
|
// nothing in the embedded binary spends from, so an asynchronously-settled payment
|
|
// moved real money and the balance that decides whether a request is served never
|
|
// changed. It also resolved the payer from the provider's payload, where no field
|
|
// names a wallet in our books: `reference_id` is an ORDER id wherever production sets
|
|
// it, `customer_id` is the provider's own customer. Both were being written as an
|
|
// "iam-user" destination — the gateway-spendable wallet.
|
|
//
|
|
// So these pin the two halves that have to hold together:
|
|
//
|
|
// CREDIT ONCE, AT THE RIGHT ADDRESS — a settlement whose payer is resolvable from
|
|
// what commerce WROTE reaches the seam at that payer's (org, subject, test), and a
|
|
// redelivery does not credit it again.
|
|
//
|
|
// REFUSE EVERYTHING ELSE — an in-session charge (its own endpoint already credited the
|
|
// spendable ledger), an invoice already settled, and above all a payment nobody in
|
|
// these books can be tied to. A wrong credit is not a small error here: it is one
|
|
// customer's money in another's wallet, reported as success.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/models/billinginvoice"
|
|
"github.com/hanzoai/commerce/models/organization"
|
|
"github.com/hanzoai/commerce/models/subscription"
|
|
"github.com/hanzoai/commerce/models/transaction"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/hanzoai/commerce/payment/processor"
|
|
"github.com/hanzoai/commerce/util/test/ae"
|
|
)
|
|
|
|
// renewalEvent builds a signed invoice.paid delivery for an externally-billed
|
|
// subscription: the invoice is the settled object and it names the subscription that
|
|
// owns it, which is the only path from a callback to a wallet in our books.
|
|
func renewalEvent(eventID, invoiceID, subID string, amountCents int64) []byte {
|
|
return []byte(fmt.Sprintf(
|
|
`{"merchant_id":"M1","type":"invoice.paid","event_id":%q,"created_at":%q,`+
|
|
`"data":{"type":"invoice","id":%q,"object":{"id":%q,"subscription_id":%q,`+
|
|
`"amount_money":{"amount":%d,"currency":"USD"}}}}`,
|
|
eventID, time.Now().UTC().Format(time.RFC3339), invoiceID, invoiceID, subID, amountCents,
|
|
))
|
|
}
|
|
|
|
// seedProviderSubscription writes the subscription an external provider bills — the
|
|
// one record that ties a provider's subscription id to a billing subject in our
|
|
// books. subject is the wallet key the spend gate debits: the org slug, or the finer
|
|
// "<org>/<user>" of a member who spends from their own.
|
|
func seedProviderSubscription(t *testing.T, org *organization.Organization, ctx context.Context, providerID, subject string) {
|
|
t.Helper()
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
sub := subscription.New(db)
|
|
sub.ProviderId = providerID
|
|
sub.ProviderType = "square"
|
|
sub.UserId = subject
|
|
sub.Status = subscription.Active
|
|
sub.MustCreate()
|
|
}
|
|
|
|
// seedInternalSubscription writes a subscription as commerce's OWN endpoints create one:
|
|
// ProviderType internal/bundle and ProviderId UNSET. Every production subscription is
|
|
// this shape — no call site anywhere sets ProviderId.
|
|
func seedInternalSubscription(t *testing.T, org *organization.Organization, ctx context.Context, subject string) {
|
|
t.Helper()
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
sub := subscription.New(db)
|
|
sub.ProviderType = "internal"
|
|
sub.UserId = subject
|
|
sub.Status = subscription.Active
|
|
sub.MustCreate()
|
|
}
|
|
|
|
// seedInSessionCharge writes the ledger row a card endpoint writes when it takes a
|
|
// payment — stamped with the processor's own reference for that charge, which is
|
|
// exactly what the endpoint does at charge time (payment_core.go, topup.go).
|
|
func seedInSessionCharge(t *testing.T, org *organization.Organization, ctx context.Context, subject, paymentID string, cents int64) {
|
|
t.Helper()
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
tr := transaction.New(db)
|
|
tr.Type = transaction.Deposit
|
|
tr.DestinationId = subject
|
|
tr.DestinationKind = transaction.IAMUserKind
|
|
tr.Currency = currency.USD
|
|
tr.Amount = currency.Cents(cents)
|
|
tr.Tags = "topup"
|
|
tr.SourceKind = "square-payment"
|
|
tr.SourceId = paymentID
|
|
tr.Test = org.TestMode()
|
|
tr.MustCreate()
|
|
}
|
|
|
|
// seedPaidInvoice writes the invoice a collection settles: marked paid, carrying the
|
|
// processor's reference for the charge that paid it. That is what CollectInvoice and
|
|
// the first-invoice-on-subscribe path both write (engine MarkPaid).
|
|
func seedPaidInvoice(t *testing.T, org *organization.Organization, ctx context.Context, subject, paymentID string, cents int64) {
|
|
t.Helper()
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
inv := billinginvoice.New(db)
|
|
inv.UserId = subject
|
|
inv.Currency = currency.USD
|
|
inv.AmountDue = cents
|
|
inv.Status = billinginvoice.Open
|
|
if err := inv.MarkPaid("card", paymentID); err != nil {
|
|
t.Fatalf("mark invoice paid: %v", err)
|
|
}
|
|
if err := inv.Create(); err != nil {
|
|
t.Fatalf("seed paid invoice: %v", err)
|
|
}
|
|
}
|
|
|
|
// webhookOrg PERSISTS the org a sessionless callback will resolve, and returns the
|
|
// same record the handler will read.
|
|
//
|
|
// A callback carries no session, so its org comes from the STORE — and the store is
|
|
// now also where its test-ness comes from, since the resolver stopped overwriting the
|
|
// record with Live. An in-memory org built by the test is therefore not the org the
|
|
// handler sees: GetOrCreate would mint a fresh one, defaulting to sandbox, and the
|
|
// test would assert against a different record than the one that decided the books.
|
|
func webhookOrg(t *testing.T, ctx context.Context, name string, live bool) *organization.Organization {
|
|
t.Helper()
|
|
o := organization.New(datastore.New(ctx))
|
|
o.Name = name
|
|
if err := o.GetOrCreate("Name=", name); err != nil {
|
|
t.Fatalf("seed org %q: %v", name, err)
|
|
}
|
|
o.Live = live
|
|
o.MustUpdate()
|
|
return o
|
|
}
|
|
|
|
// (i) A pure-async settlement credits the SEAM — the ledger the spend gate reads —
|
|
// at the subscription's real (org, subject), in the books the org transacts in, and
|
|
// exactly once however many times the provider redelivers it.
|
|
//
|
|
// This is the whole money-in gap in one test: before, this payment credited commerce's
|
|
// own store at a wallet named after a Square customer, and the gate's balance never
|
|
// moved.
|
|
func TestWebhookSeam_AsyncSettlementCreditsTheSpendableLedger(t *testing.T) {
|
|
const secret = "whsec_seam_async"
|
|
registerSquare(t, secret)
|
|
fake := newFakeLedger()
|
|
injectLedger(t, fake)
|
|
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "seam-async", true) // Live ⇒ the live books
|
|
seedProviderSubscription(t, org, ctx, "sub_async", "seam-async/alice")
|
|
|
|
for i := 0; i < 3; i++ {
|
|
body := renewalEvent(fmt.Sprintf("evt_async_%d", i), "inv_async", "sub_async", 7500)
|
|
if r := deliverWebhook(ctx, "seam-async", secret, body, ""); r.StatusCode != http.StatusOK {
|
|
t.Fatalf("delivery %d: status=%d", i+1, r.StatusCode)
|
|
}
|
|
}
|
|
|
|
posted := fake.credits()
|
|
if len(posted) != 1 {
|
|
t.Fatalf("the seam took %d credits for ONE settlement, want 1 — a redelivered payment was credited again: %+v", len(posted), posted)
|
|
}
|
|
got := posted[0]
|
|
if got.Org != "seam-async" {
|
|
t.Errorf("CreditInput.Org=%q, want seam-async (the ledger holding the payer's wallet)", got.Org)
|
|
}
|
|
if got.Subject != "seam-async/alice" {
|
|
t.Errorf("CreditInput.Subject=%q, want seam-async/alice — the subscription's own owner, which is the key the spend gate debits", got.Subject)
|
|
}
|
|
if got.Test {
|
|
t.Errorf("CreditInput.Test=true for a LIVE org — a real payment was parked in books no gate reads")
|
|
}
|
|
if got.AmountCents != 7500 {
|
|
t.Errorf("CreditInput.AmountCents=%d, want 7500", got.AmountCents)
|
|
}
|
|
if got.IdempotencyKey != "inv_async" {
|
|
t.Errorf("CreditInput.IdempotencyKey=%q, want inv_async — the settlement's own id, which is what makes every path credit it once", got.IdempotencyKey)
|
|
}
|
|
if !got.Cash {
|
|
t.Errorf("CreditInput.Cash=false for a settled payment — the customer's money landed as credit Hanzo minted")
|
|
}
|
|
}
|
|
|
|
// (ii) A settlement this callback ALREADY credited is not credited again — rule 1,
|
|
// exercised where it can actually change the answer.
|
|
//
|
|
// It drives invoice.paid, because that is the only event type that reaches a credit
|
|
// at all: a payment.* delivery is refused by the event-type gate whatever rules 1 and
|
|
// 2 say, so asserting "no credit" on one proves nothing about them. Here a resolvable
|
|
// subscription IS present, so the ONLY thing standing between this delivery and a
|
|
// second credit is the stamped receipt the first delivery wrote.
|
|
func TestWebhookSeam_AnAlreadyCreditedSettlementIsNotCreditedTwice(t *testing.T) {
|
|
const secret = "whsec_seam_insession"
|
|
registerSquare(t, secret)
|
|
fake := newFakeLedger()
|
|
injectLedger(t, fake)
|
|
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "seam-door", true)
|
|
seedProviderSubscription(t, org, ctx, "sub_door", "seam-door/alice")
|
|
// The receipt a previous delivery (or a card endpoint) wrote for this settlement.
|
|
seedInSessionCharge(t, org, ctx, "seam-door/alice", "inv_door", 4200)
|
|
|
|
body := renewalEvent("evt_door", "inv_door", "sub_door", 4200)
|
|
if r := deliverWebhook(ctx, "seam-door", secret, body, ""); r.StatusCode != http.StatusOK {
|
|
t.Fatalf("status=%d, want 200 (the event is valid and recorded; it just must not credit)", r.StatusCode)
|
|
}
|
|
|
|
if n := len(fake.credits()); n != 0 {
|
|
t.Fatalf("the seam took %d credits for a settlement already carrying a receipt, want 0 — the customer was credited twice for one payment: %+v", n, fake.credits())
|
|
}
|
|
if got := balanceOf(t, ctx, org, "seam-door/alice"); got != 4200 {
|
|
t.Fatalf("local balance=%d, want 4200 (the single existing receipt)", got)
|
|
}
|
|
}
|
|
|
|
// (iii) A settlement whose payer cannot be named REFUSES, and refuses ALL THE WAY:
|
|
// no seam credit, no local row, and above all nothing credited to the service org
|
|
// whose token happened to resolve the namespace.
|
|
//
|
|
// This is the shape production actually delivers — a payment link, a charge raised in
|
|
// the provider's dashboard, a checkout capture — and it is the one the old code was
|
|
// most wrong about. `reference_id` here is an order id and `customer_id` is a Square
|
|
// customer; both used to become an "iam-user" wallet holding real money.
|
|
func TestWebhookSeam_UnresolvablePayerRefuses(t *testing.T) {
|
|
const secret = "whsec_seam_unknown"
|
|
registerSquare(t, secret)
|
|
fake := newFakeLedger()
|
|
injectLedger(t, fake)
|
|
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "seam-unknown", true)
|
|
|
|
// reference_id carries what checkout puts there: an ORDER id, not a wallet.
|
|
body := settlementEvent("evt_unknown", "pay_unknown", "COMPLETED", "ord_9f3c", 90000)
|
|
if r := deliverWebhook(ctx, "seam-unknown", secret, body, ""); r.StatusCode != http.StatusOK {
|
|
t.Fatalf("status=%d, want 200 — the delivery is valid and recorded, it just credits nothing", r.StatusCode)
|
|
}
|
|
|
|
if n := len(fake.credits()); n != 0 {
|
|
t.Fatalf("the seam took %d credits for a payment with no resolvable payer, want 0 — money landed in a guessed wallet: %+v", n, fake.credits())
|
|
}
|
|
for _, guess := range []string{"ord_9f3c", "seam-unknown", "cust_seam-unknown"} {
|
|
if got := balanceOf(t, ctx, org, guess); got != 0 {
|
|
t.Errorf("balance of %q = %d, want 0 — an unattributable settlement minted balance at a guessed address", guess, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// (iv) A SANDBOX org's settlement funds the SANDBOX books, and the live ones stay at
|
|
// zero.
|
|
//
|
|
// Two things had to be true at once for this to hold, and neither was. The seam could
|
|
// not carry which books to write, so every credit through it landed live; and the
|
|
// callback's own org resolver overwrote the org record with Live — on the strength of
|
|
// a deployment-wide override that no longer exists — so a sandbox merchant's
|
|
// settlement was stamped live before the seam ever saw it. Together they made a Square
|
|
// SANDBOX callback mint balance that buys real inference.
|
|
func TestWebhookSeam_SandboxSettlementFundsSandboxBooks(t *testing.T) {
|
|
const secret = "whsec_seam_sandbox"
|
|
registerSquare(t, secret)
|
|
fake := newFakeLedger()
|
|
injectLedger(t, fake)
|
|
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "seam-sandbox", false)
|
|
seedProviderSubscription(t, org, ctx, "sub_sandbox", "seam-sandbox/alice")
|
|
|
|
body := renewalEvent("evt_sandbox", "inv_sandbox", "sub_sandbox", 3300)
|
|
if r := deliverWebhook(ctx, "seam-sandbox", secret, body, ""); r.StatusCode != http.StatusOK {
|
|
t.Fatalf("status=%d, want 200", r.StatusCode)
|
|
}
|
|
|
|
posted := fake.credits()
|
|
if len(posted) != 1 {
|
|
t.Fatalf("seam credits=%d, want 1: %+v", len(posted), posted)
|
|
}
|
|
if !posted[0].Test {
|
|
t.Fatalf("CreditInput.Test=false for a SANDBOX org — sandbox money was minted into the books the spend gate buys real inference from")
|
|
}
|
|
|
|
// The sandbox books hold it and the live books are empty — the read repeats the
|
|
// whole address, so a wrong bucket cannot hide behind a right total.
|
|
if bal, _ := fake.Balance(context.Background(), "seam-sandbox", "seam-sandbox/alice", "usd", true); bal != 3300 {
|
|
t.Errorf("sandbox balance=%d, want 3300", bal)
|
|
}
|
|
if bal, _ := fake.Balance(context.Background(), "seam-sandbox", "seam-sandbox/alice", "usd", false); bal != 0 {
|
|
t.Errorf("LIVE balance=%d, want 0 — a sandbox charge funded spendable money", bal)
|
|
}
|
|
}
|
|
|
|
// ─── settlementDestination, asked directly ─────────────────────────────────
|
|
//
|
|
// The resolver answers three different things and the handler collapses two of them
|
|
// into "no credit", so driving HTTP cannot tell them apart. The difference is the
|
|
// whole operational value of rules 1 and 2: an ACCOUNTED-FOR payment is silent, and
|
|
// an UNATTRIBUTABLE one raises a RECONCILE alarm. An alarm that also fires on every
|
|
// successful subscription renewal is an alarm somebody mutes, and then the real one
|
|
// is missed too.
|
|
|
|
// verdict names which of the three answers the resolver gave.
|
|
type verdict string
|
|
|
|
const (
|
|
credited verdict = "credit" // an address: this payment funds a wallet
|
|
accountedFor verdict = "silent-skip" // (nil, ""): another path owns the credit
|
|
unattributabl verdict = "reconcile-alarm" // (nil, why): refused and reported
|
|
)
|
|
|
|
func classify(d *destination, why string) verdict {
|
|
switch {
|
|
case d != nil:
|
|
return credited
|
|
case why == "":
|
|
return accountedFor
|
|
default:
|
|
return unattributabl
|
|
}
|
|
}
|
|
|
|
// resolve asks the resolver the way the handler does, with the object already
|
|
// unwrapped.
|
|
func resolve(t *testing.T, org *organization.Organization, ctx context.Context, eventType string, object map[string]interface{}) (*destination, string) {
|
|
t.Helper()
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
pay := unwrapObject(object, "payment")
|
|
who, why, err := settlementDestination(db, org,
|
|
&processor.WebhookEvent{Type: eventType, Processor: processor.Square, Data: object},
|
|
pay, "square-payment", stringField(pay, "id"))
|
|
if err != nil {
|
|
t.Fatalf("settlementDestination: %v", err)
|
|
}
|
|
return who, why
|
|
}
|
|
|
|
// RULE 1, alone: a stamped receipt makes a settlement ACCOUNTED FOR — silent, not an
|
|
// alarm. This is what the in-session card endpoints buy: their charge already credited
|
|
// the spendable ledger through the host, so the callback must stand down without
|
|
// reporting a reconciliation nobody needs to do.
|
|
func TestSettlementDestination_AStampedChargeIsAccountedForAndSilent(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "res-stamped", true)
|
|
seedInSessionCharge(t, org, ctx, "res-stamped/alice", "pay_door", 4200)
|
|
|
|
d, why := resolve(t, org, ctx, "payment.updated", map[string]interface{}{
|
|
"id": "pay_door", "status": "COMPLETED",
|
|
})
|
|
if got := classify(d, why); got != accountedFor {
|
|
t.Fatalf("verdict=%s (why=%q), want %s — an in-session charge either got credited again or raised a false alarm", got, why, accountedFor)
|
|
}
|
|
}
|
|
|
|
// RULE 2, alone: a charge that PAID AN INVOICE is accounted for too. The money bought
|
|
// a plan; the collection waterfall already recorded it against the invoice.
|
|
//
|
|
// This is the renewal callback, which is the case rule 2 exists for: without it every
|
|
// successful subscription renewal raises a RECONCILE alarm.
|
|
func TestSettlementDestination_AnInvoiceCollectionIsAccountedForAndSilent(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "res-invoice", true)
|
|
seedPaidInvoice(t, org, ctx, "res-invoice/alice", "pay_collected", 1500)
|
|
|
|
d, why := resolve(t, org, ctx, "payment.updated", map[string]interface{}{
|
|
"id": "pay_collected", "status": "COMPLETED",
|
|
})
|
|
if got := classify(d, why); got != accountedFor {
|
|
t.Fatalf("verdict=%s (why=%q), want %s — a settled invoice collection was credited as prepaid balance, or raised an alarm on a renewal that worked", got, why, accountedFor)
|
|
}
|
|
}
|
|
|
|
// RULE 3, alone: an externally-billed subscription resolves to ITS OWN owner.
|
|
func TestSettlementDestination_ASubscriptionResolvesItsOwner(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "res-sub", true)
|
|
seedProviderSubscription(t, org, ctx, "sub_ext", "res-sub/alice")
|
|
|
|
d, why := resolve(t, org, ctx, "invoice.paid", map[string]interface{}{
|
|
"id": "inv_1", "subscription_id": "sub_ext",
|
|
})
|
|
if got := classify(d, why); got != credited {
|
|
t.Fatalf("verdict=%s (why=%q), want %s", got, why, credited)
|
|
}
|
|
if d.subject != "res-sub/alice" || d.org != "res-sub" {
|
|
t.Fatalf("resolved (%q,%q), want (res-sub, res-sub/alice)", d.org, d.subject)
|
|
}
|
|
}
|
|
|
|
// THE WILDCARD. A lifecycle delivery that names NO subscription must REFUSE.
|
|
//
|
|
// `Filter("ProviderId=", "")` is not a lookup that misses — it MATCHES, because no
|
|
// production call site ever sets ProviderId, so every subscription commerce created
|
|
// carries "". The first one found would be credited for whatever the payload asked.
|
|
// The emptier the payload, the more it matches.
|
|
//
|
|
// The delivery below is the reachable shape: the settled object nested one level
|
|
// down, so the payment id reads fine while nothing at the level the subscription link
|
|
// was read from says anything at all.
|
|
func TestSettlementDestination_ADeliveryNamingNoSubscriptionRefuses(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "res-wild", true)
|
|
// An INTERNAL subscription: exactly what production has, ProviderId unset.
|
|
seedInternalSubscription(t, org, ctx, "res-wild/victim-treasury")
|
|
|
|
d, why := resolve(t, org, ctx, "invoice.paid", map[string]interface{}{
|
|
"payment": map[string]interface{}{"id": "pay_wild", "status": "COMPLETED"},
|
|
})
|
|
if got := classify(d, why); got != unattributabl {
|
|
t.Fatalf("verdict=%s, want %s — a blank subscription key matched an unrelated internal subscription and credited %q", got, unattributabl, func() string {
|
|
if d != nil {
|
|
return d.subject
|
|
}
|
|
return ""
|
|
}())
|
|
}
|
|
}
|
|
|
|
// AN INVOICE ID IS NOT A SUBSCRIPTION ID, even when one happens to equal the other.
|
|
//
|
|
// The object's own "id" used to be a general fallback for the subscription key, and on
|
|
// an invoice.paid that id is the INVOICE's. Normally it simply misses. It stops missing
|
|
// the moment some subscription's ProviderId collides with it — and then a delivery that
|
|
// names no subscription at all resolves to one, and credits its owner. The key is read
|
|
// from an explicit link, or the delivery is refused.
|
|
func TestSettlementDestination_AnInvoiceIdIsNotASubscriptionKey(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "res-collide", true)
|
|
// A subscription whose provider key collides with the invoice id below.
|
|
seedProviderSubscription(t, org, ctx, "inv_collide", "res-collide/victim")
|
|
|
|
d, why := resolve(t, org, ctx, "invoice.paid", map[string]interface{}{
|
|
"id": "inv_collide", // the INVOICE's id, and no subscription link anywhere
|
|
})
|
|
if got := classify(d, why); got != unattributabl {
|
|
t.Fatalf("verdict=%s, want %s — an invoice id was accepted as a subscription key and credited %q", got, unattributabl, func() string {
|
|
if d != nil {
|
|
return d.subject
|
|
}
|
|
return ""
|
|
}())
|
|
}
|
|
}
|
|
|
|
// THE SPLICE: a subscription link sitting BESIDE the settled object, not inside it.
|
|
//
|
|
// This is why the resolver is handed the unwrapped `pay` map rather than re-deriving
|
|
// the key from the envelope. Read the subscription link off the ENVELOPE and the two
|
|
// values stop describing one payment: the amount and the payment id come from the
|
|
// object nested inside, while the payer comes from a sibling key an attacker put
|
|
// there. Splice a victim's subscription id next to your own payment object and the
|
|
// victim's wallet is credited for your amount.
|
|
//
|
|
// The envelope here is `data.object` — the processor passes exactly that through as
|
|
// event.Data (thirdparty/square/processor.go, Data: evt.Data.Object) — so the splice
|
|
// goes INSIDE object, beside "payment". A key at the `data` level never survives.
|
|
//
|
|
// One map, read once, is the whole defence, and this test is what keeps it that way.
|
|
func TestSettlementDestination_ASubscriptionSplicedBesideTheObjectRefuses(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "res-splice", true)
|
|
seedProviderSubscription(t, org, ctx, "sub_victim", "res-splice/victim")
|
|
|
|
d, why := resolve(t, org, ctx, "invoice.paid", map[string]interface{}{
|
|
"subscription_id": "sub_victim", // spliced beside the object, not into it
|
|
"payment": map[string]interface{}{
|
|
"id": "p2", "status": "COMPLETED",
|
|
"amount_money": map[string]interface{}{"amount": float64(999999), "currency": "USD"},
|
|
},
|
|
})
|
|
if got := classify(d, why); got != unattributabl {
|
|
t.Fatalf("verdict=%s, want %s — a subscription id spliced beside the settled object resolved to %q: the payer and the payment came from different maps", got, unattributabl, func() string {
|
|
if d != nil {
|
|
return d.subject
|
|
}
|
|
return ""
|
|
}())
|
|
}
|
|
}
|
|
|
|
// And the same splice through the whole handler, with the money on the wire.
|
|
func TestWebhookSeam_ASplicedSubscriptionMintsNothing(t *testing.T) {
|
|
const secret = "whsec_seam_splice"
|
|
registerSquare(t, secret)
|
|
fake := newFakeLedger()
|
|
injectLedger(t, fake)
|
|
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "seam-splice", true)
|
|
seedProviderSubscription(t, org, ctx, "sub_victim", "seam-splice/victim")
|
|
|
|
body := []byte(fmt.Sprintf(
|
|
`{"merchant_id":"M1","type":"invoice.paid","event_id":"evt_splice","created_at":%q,`+
|
|
`"data":{"type":"invoice","id":"inv_splice","object":{"subscription_id":"sub_victim",`+
|
|
`"payment":{"id":"p2","status":"COMPLETED",`+
|
|
`"amount_money":{"amount":999999,"currency":"USD"}}}}}`,
|
|
time.Now().UTC().Format(time.RFC3339)))
|
|
|
|
if r := deliverWebhook(ctx, "seam-splice", secret, body, ""); r.StatusCode != http.StatusOK {
|
|
t.Fatalf("status=%d, want 200", r.StatusCode)
|
|
}
|
|
if n := len(fake.credits()); n != 0 {
|
|
t.Fatalf("a spliced subscription link minted %d credits: %+v", n, fake.credits())
|
|
}
|
|
if got := balanceOf(t, ctx, org, "seam-splice/victim"); got != 0 {
|
|
t.Fatalf("victim balance=%d, want 0 — a spliced envelope key credited an unrelated wallet", got)
|
|
}
|
|
}
|
|
|
|
// And the same hole through the whole handler, with money on the line: the amount is
|
|
// the attacker's, the wallet is a stranger's, and nothing may move.
|
|
func TestWebhookSeam_ADeliveryNamingNoSubscriptionMintsNothing(t *testing.T) {
|
|
const secret = "whsec_seam_wild"
|
|
registerSquare(t, secret)
|
|
fake := newFakeLedger()
|
|
injectLedger(t, fake)
|
|
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "seam-wild", true)
|
|
seedInternalSubscription(t, org, ctx, "seam-wild/victim-treasury")
|
|
|
|
body := []byte(fmt.Sprintf(
|
|
`{"merchant_id":"M1","type":"invoice.paid","event_id":"evt_wild","created_at":%q,`+
|
|
`"data":{"type":"invoice","id":"inv_wild","object":{"payment":{"id":"pay_wild",`+
|
|
`"status":"COMPLETED","amount_money":{"amount":999999,"currency":"USD"}}}}}`,
|
|
time.Now().UTC().Format(time.RFC3339)))
|
|
|
|
if r := deliverWebhook(ctx, "seam-wild", secret, body, ""); r.StatusCode != http.StatusOK {
|
|
t.Fatalf("status=%d, want 200", r.StatusCode)
|
|
}
|
|
if n := len(fake.credits()); n != 0 {
|
|
t.Fatalf("a delivery naming no subscription minted %d credits: %+v", n, fake.credits())
|
|
}
|
|
if got := balanceOf(t, ctx, org, "seam-wild/victim-treasury"); got != 0 {
|
|
t.Fatalf("victim balance=%d, want 0 — an unrelated wallet was credited from a blank key", got)
|
|
}
|
|
}
|
|
|
|
// A refund or a dispute of a settled payment takes the money back from the wallet the
|
|
// payment credited — found from the payment itself, since the provider sends no org —
|
|
// once per refund or dispute id; a refund still pending takes nothing, and a dispute
|
|
// the merchant wins gives its money back.
|
|
func TestWebhookSeam_ARefundOrDisputeTakesThePaymentBack(t *testing.T) {
|
|
const secret = "whsec_seam_clawback"
|
|
registerSquare(t, secret)
|
|
fake := newFakeLedger()
|
|
injectLedger(t, fake)
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := webhookOrg(t, ctx, "seam-claw", true)
|
|
seedProviderSubscription(t, org, ctx, "sub_claw", "seam-claw/alice")
|
|
if r := deliverWebhook(ctx, "seam-claw", secret, renewalEvent("evt_pay", "pay_claw", "sub_claw", 5000), ""); r.StatusCode != http.StatusOK {
|
|
t.Fatalf("settle: %d", r.StatusCode)
|
|
}
|
|
refund := func(event, status string, cents int64) []byte {
|
|
return []byte(fmt.Sprintf(`{"merchant_id":"M1","type":"refund.updated","event_id":%q,"created_at":%q,`+
|
|
`"data":{"type":"refund","id":"rf_1","object":{"refund":{"id":"rf_1","payment_id":"pay_claw","status":%q,`+
|
|
`"amount_money":{"amount":%d,"currency":"USD"}}}}}`, event, time.Now().UTC().Format(time.RFC3339), status, cents))
|
|
}
|
|
dispute := func(event, typ, state string) []byte {
|
|
return []byte(fmt.Sprintf(`{"merchant_id":"M1","type":%q,"event_id":%q,"created_at":%q,`+
|
|
`"data":{"type":"dispute","id":"dp_1","object":{"dispute":{"id":"dp_1","state":%q,"disputed_payment":{"payment_id":"pay_claw"},`+
|
|
`"amount_money":{"amount":3000,"currency":"USD"}}}}}`, typ, event, time.Now().UTC().Format(time.RFC3339), state))
|
|
}
|
|
// No X-Org-Id on any of these: the provider sends none.
|
|
for _, b := range [][]byte{refund("evt_rf_0", "PENDING", 2000), refund("evt_rf_1", "COMPLETED", 2000), refund("evt_rf_2", "COMPLETED", 2000),
|
|
dispute("evt_dp_0", "dispute.created", "EVIDENCE_REQUIRED"), dispute("evt_dp_1", "dispute.state.changed", "WON")} {
|
|
if r := deliverWebhook(ctx, "", secret, b, ""); r.StatusCode != http.StatusOK {
|
|
t.Fatalf("delivery: %d", r.StatusCode)
|
|
}
|
|
}
|
|
if len(fake.clawbacks) != 2 {
|
|
t.Fatalf("clawbacks = %+v, want the completed refund once and the dispute once", fake.clawbacks)
|
|
}
|
|
for i, want := range []struct {
|
|
ref string
|
|
cents int64
|
|
}{{"refund:rf_1", 2000}, {"dispute:dp_1", 3000}} {
|
|
got := fake.clawbacks[i]
|
|
if got.Org != "seam-claw" || got.Subject != "seam-claw/alice" || got.Payment != "pay_claw" || got.Ref != want.ref || got.AmountCents != want.cents {
|
|
t.Fatalf("clawback %d = %+v, want %s of %d from seam-claw/alice", i, got, want.ref, want.cents)
|
|
}
|
|
}
|
|
if len(fake.restores) != 1 || fake.restores[0].Ref != "dispute:dp_1" || fake.restores[0].Org != "seam-claw" {
|
|
t.Fatalf("restores = %+v, want the won dispute given back once", fake.restores)
|
|
}
|
|
}
|