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.
888 lines
39 KiB
Go
888 lines
39 KiB
Go
package billing
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
nethttp "net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/billing/creditledger"
|
|
"github.com/hanzoai/commerce/billing/engine"
|
|
"github.com/hanzoai/commerce/checkout"
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/mintauth"
|
|
"github.com/hanzoai/commerce/models/billingevent"
|
|
"github.com/hanzoai/commerce/models/billinginvoice"
|
|
"github.com/hanzoai/commerce/models/dispute"
|
|
"github.com/hanzoai/commerce/models/idempotencykey"
|
|
"github.com/hanzoai/commerce/models/organization"
|
|
"github.com/hanzoai/commerce/models/paymentorg"
|
|
"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/nscontext"
|
|
|
|
// Blank-import the provider barrel so every provider's init() registers
|
|
// with processor.Global() before HandleProviderWebhook runs. Without this
|
|
// the global registry is empty and tryValidateWebhook can never reach any
|
|
// provider's ValidateWebhook (the per-org payment.ProcessorsForOrg path is
|
|
// separate and unaffected). This is the single owning import for the
|
|
// generic webhook dispatcher; do not scatter barrel imports elsewhere.
|
|
_ "github.com/hanzoai/commerce/payment/providers"
|
|
jsonhttp "github.com/hanzoai/commerce/util/json/http"
|
|
|
|
. "github.com/hanzoai/commerce/types"
|
|
)
|
|
|
|
// HandleProviderWebhook is the single ingress for payment-provider webhooks.
|
|
//
|
|
// POST /v1/commerce/webhooks/:provider
|
|
//
|
|
// The path names the processor, and only that processor verifies the delivery: a
|
|
// name no processor is registered under is refused, never tried against the rest.
|
|
// The verified event is recorded once — globally, by (processor, event id) — then
|
|
// written to the billing events of the org the processor account belongs to, and
|
|
// its settlement, subscription and clawback effects are applied.
|
|
//
|
|
// Nothing about WHERE an event lands comes from the request. The org is the one
|
|
// the verifying processor names (processor.WebhookEvent.Org), else the
|
|
// deployment's own brand: a provider sends no identity, and a header or session
|
|
// on a replayed delivery is the replayer's.
|
|
func HandleProviderWebhook(c *zip.Ctx) error {
|
|
providerHint := strings.ToLower(strings.TrimSpace(c.Param("provider")))
|
|
payload := c.Body()
|
|
|
|
// Every provider puts its signature in a different header.
|
|
reqHeaders := http.Header{}
|
|
for k, vals := range c.Fiber().GetReqHeaders() {
|
|
for _, v := range vals {
|
|
reqHeaders.Add(k, v)
|
|
}
|
|
}
|
|
signature := pickSignatureHeader(reqHeaders, providerHint)
|
|
if signature == "" {
|
|
return jsonhttp.Fail(c, nethttp.StatusBadRequest, "missing webhook signature header", nil)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(c.Context(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
event, err := tryValidateWebhook(ctx, providerHint, payload, signature)
|
|
if err != nil || event == nil {
|
|
log.Warn("webhook signature validation failed (provider hint=%s): %v", providerHint, err)
|
|
return jsonhttp.Fail(c, nethttp.StatusUnauthorized, "invalid webhook signature", err)
|
|
}
|
|
|
|
// ONCE, AND FOR THE WHOLE DEPLOYMENT. A provider retries a delivery until it
|
|
// sees a 2xx, and resends the same body each time, so the event id is what
|
|
// makes the second one a duplicate. The guard is keyed on the processor that
|
|
// verified it and the event id, in the system namespace, and taken with an
|
|
// insert-if-absent swap — not on the path segment and not inside one org's
|
|
// books, either of which a replayer chooses.
|
|
guard, seen, err := idempotencykey.Begin(webhookGuardDB(ctx), "webhook", string(event.Processor)+":"+event.ID)
|
|
switch {
|
|
case err != nil:
|
|
log.Error("webhook %s %s: the delivery guard could not be read: %v", event.Processor, event.ID, err)
|
|
return jsonhttp.Fail(c, nethttp.StatusServiceUnavailable, "webhook guard unavailable", err)
|
|
case seen && guard.Status == idempotencykey.StatusCompleted:
|
|
return c.JSON(nethttp.StatusOK, map[string]any{
|
|
"received": true,
|
|
"type": event.Type,
|
|
"id": event.ID,
|
|
"duplicate": true,
|
|
})
|
|
case seen:
|
|
// Another delivery of this event is being applied right now. Refuse this one
|
|
// so the provider retries it, rather than acknowledging work that may not land.
|
|
return jsonhttp.Fail(c, nethttp.StatusConflict, "webhook delivery already in progress", nil)
|
|
}
|
|
|
|
org := bookedOrg(c.Context(), event)
|
|
if org == nil {
|
|
return jsonhttp.Fail(c, nethttp.StatusServiceUnavailable, "organization context unavailable", nil)
|
|
}
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
// The effects. Each one is safe to apply again — a credit is keyed on the
|
|
// payment id, a clawback on the refund or dispute id, a dispute report on its
|
|
// version — so one that could not land (a store or ledger that did not answer)
|
|
// is reported here and the delivery is left OPEN: no Complete, a 503, and the
|
|
// provider redelivers. The guard is re-claimed once StartedTTL has passed.
|
|
var effects []error
|
|
if strings.HasPrefix(event.Type, "subscription.") || strings.HasPrefix(event.Type, "invoice.") {
|
|
effects = append(effects, applySubscriptionEvent(db, event))
|
|
}
|
|
// Settle funds for completed payments. This is the path Square's
|
|
// payment.updated(status=COMPLETED) callback exercises once a card
|
|
// authorization captures — the moment funds are guaranteed.
|
|
if isSettlementEvent(event.Type) {
|
|
effects = append(effects, applySettlementEvent(ctx, db, org, event))
|
|
}
|
|
if isClawbackEvent(event.Type) {
|
|
effects = append(effects, applyClawbackEvent(ctx, db, org, event))
|
|
}
|
|
if err := errors.Join(effects...); err != nil {
|
|
log.Error("webhook %s %s: an effect did not land; the delivery stays open for the provider to redeliver: %v", event.Processor, event.ID, err)
|
|
return jsonhttp.Fail(c, nethttp.StatusServiceUnavailable, "webhook effect did not land; redeliver", err)
|
|
}
|
|
|
|
// The org's own record of the event, an audit trail independent of the
|
|
// processor's retention. Written once, by the delivery that applied it.
|
|
evt := billingevent.New(db)
|
|
evt.Type = event.Type
|
|
evt.ObjectType = string(event.Processor)
|
|
evt.ObjectId = event.ID
|
|
evt.Livemode = !org.TestMode() // one authority for the books and the record of them: the org
|
|
if event.Data != nil {
|
|
evt.Data = event.Data
|
|
}
|
|
if err := evt.Create(); err != nil {
|
|
log.Warn("failed to persist billing event %s: %v", event.ID, err)
|
|
}
|
|
|
|
if err := idempotencykey.Complete(guard, ""); err != nil {
|
|
log.Warn("webhook %s %s: applied, and the delivery guard was not marked complete: %v", event.Processor, event.ID, err)
|
|
}
|
|
return c.JSON(nethttp.StatusOK, map[string]any{
|
|
"received": true,
|
|
"type": event.Type,
|
|
"id": event.ID,
|
|
})
|
|
}
|
|
|
|
// webhookGuardDB is the deployment-wide namespace the delivery guard lives in, so
|
|
// one event is one record whichever org it is booked to.
|
|
func webhookGuardDB(ctx context.Context) *datastore.Datastore {
|
|
return datastore.New(nscontext.WithNamespace(ctx, "system"))
|
|
}
|
|
|
|
// bookedOrg is the org a verified delivery is booked to: the org the verifying
|
|
// processor's account belongs to, or the deployment's own brand when the
|
|
// processor names none (the deployment's Square account, seeded from its
|
|
// environment). The request contributes nothing — no header, no session.
|
|
//
|
|
// THE ORG RECORD SAYS WHICH BOOKS. It is loaded and believed, never forced live:
|
|
// an org that does not say Live transacts in sandbox, so a sandbox callback can
|
|
// never credit the spendable books.
|
|
func bookedOrg(ctx context.Context, event *processor.WebhookEvent) *organization.Organization {
|
|
name := strings.TrimSpace(event.Org)
|
|
if name == "" {
|
|
name = checkout.DefaultBrandSlug()
|
|
}
|
|
org := organization.New(datastore.New(ctx))
|
|
org.Name = name
|
|
if err := org.GetOrCreate("Name=", name); err != nil {
|
|
log.Warn("webhook org resolve for '%s' failed: %v", name, err)
|
|
return nil
|
|
}
|
|
return org
|
|
}
|
|
|
|
// isSettlementEvent reports whether an event represents settled (captured)
|
|
// funds that should credit a balance.
|
|
func isSettlementEvent(eventType string) bool {
|
|
switch eventType {
|
|
case "payment.completed", "payment.updated", "invoice.paid":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// applySettlementEvent credits the balance a settled payment funds — in the ONE
|
|
// ledger the spend gate reads, at the address that gate will debit.
|
|
//
|
|
// # The money that arrived and never landed
|
|
//
|
|
// This callback used to write a row into commerce's OWN transaction store and stop
|
|
// there. Nothing in the embedded binary spends from that store: the prepaid balance
|
|
// every gate consults is the host's finance ledger, reached through [creditledger].
|
|
// So an asynchronously-settled payment moved real money, wrote a row, and the balance
|
|
// that decides whether a request is served never changed. The in-session endpoints close
|
|
// that half themselves (the host's settle.go, at the point their handler returns);
|
|
// this is the same half for the settlements that have no endpoint.
|
|
//
|
|
// # WHO PAID IS READ FROM WHAT WE WROTE, NEVER FROM THE CALLBACK
|
|
//
|
|
// Nothing on a provider's payment object names a wallet in our books, and the code
|
|
// that believed otherwise is what this replaces. `reference_id` is OUR field, but the
|
|
// only thing that ever sets it is checkout, which sets it to an ORDER id
|
|
// (api/checkout/square/authorize.go); `customer_id` is the PROVIDER's own customer,
|
|
// which is not an identity in this system at all. Both were being lowercased into
|
|
// DestinationId under DestinationKind "iam-user" — the gateway-spendable wallet — so
|
|
// a settled payment minted balance at an address that was an order id, a Square
|
|
// customer id, or nobody, and did it behind a 200.
|
|
//
|
|
// So the payer is resolved by looking the PAYMENT ID up in the records commerce
|
|
// itself wrote when it took the money ([settlementDestination]). Everything that cannot be
|
|
// resolved that way is REFUSED and reconciled by hand — see [reconcileSettlement].
|
|
// There is no fallback address, because every candidate for one is somebody else's
|
|
// wallet.
|
|
//
|
|
// # Idempotent, and idempotent about the SAME thing
|
|
//
|
|
// The key is the provider's payment id on both halves: the stamped transaction this
|
|
// writes is found by the next delivery, and the seam credit carries it as the
|
|
// idempotency key. The host dedups a credit on that key WITHIN one ledger, so the
|
|
// key is only exactly-once if every path that can credit one payment resolves the
|
|
// same (org, subject, test) — which is why the address comes from the one persisted
|
|
// record rather than from whichever endpoint happened to be looking.
|
|
//
|
|
// A "payment.updated" event only settles when the payment object reports a
|
|
// terminal completed/captured status; pending or failed updates are ignored.
|
|
func applySettlementEvent(ctx context.Context, db *datastore.Datastore, org *organization.Organization, event *processor.WebhookEvent) error {
|
|
if event.Data == nil {
|
|
return nil
|
|
}
|
|
|
|
// Square nests the changed resource under data.object.<kind> (e.g.
|
|
// data.object.payment for payment.* events). The processor passes
|
|
// data.object through as event.Data, so unwrap the payment object here.
|
|
// Fall back to event.Data itself for processors that put fields at the
|
|
// top level.
|
|
pay := unwrapObject(event.Data, "payment")
|
|
|
|
paymentID := stringField(pay, "id")
|
|
if paymentID == "" {
|
|
return nil
|
|
}
|
|
|
|
// Only act on CAPTURED funds. Whenever the payment object reports a status it
|
|
// MUST mean settled — processor.Settled, the SAME definition the charge path
|
|
// uses, so a callback can never credit something a charge would have refused.
|
|
// One rule for every payment.* event, not a per-event-type special case.
|
|
//
|
|
// This is where an APPROVED authorization used to be credited: funds reserved
|
|
// but never taken, so a void or an expiry left the balance standing against
|
|
// money that never arrived (a card-verification pre-auth is authorized and
|
|
// then deliberately voided). An authorization that is genuinely captured
|
|
// emits a later status change to COMPLETED, which credits then.
|
|
//
|
|
// An event whose object carries no status at all (invoice.paid) is settled by
|
|
// its event type, which isSettlementEvent already gated.
|
|
if st := stringField(pay, "status"); st != "" && !processor.Settled(st) {
|
|
return nil
|
|
}
|
|
|
|
kind := chargeSourceKind(event.Processor)
|
|
if kind == "" {
|
|
reconcileSettlement(org, event, paymentID, "the delivery names no processor, so this payment has no reference this ledger can key on")
|
|
return nil
|
|
}
|
|
|
|
amount, cur := settlementAmount(pay)
|
|
if amount <= 0 {
|
|
log.Warn("settlement %s: non-positive amount, recorded event only", paymentID)
|
|
return nil
|
|
}
|
|
|
|
// WHOSE MONEY THIS IS, or nobody's. Everything below this line addresses the
|
|
// wallet this answers with; nothing below it can invent one.
|
|
who, why, err := settlementDestination(db, org, event, pay, kind, paymentID)
|
|
if err != nil {
|
|
return fmt.Errorf("settlement %s: %w", paymentID, err)
|
|
}
|
|
if who == nil {
|
|
if why == "" {
|
|
return nil // already accounted for by an endpoint that owns the credit
|
|
}
|
|
reconcileSettlement(org, event, paymentID, why)
|
|
return nil
|
|
}
|
|
|
|
// THE SPENDABLE LEDGER FIRST, the local receipt second, and that order is the
|
|
// recovery. This write is what makes the money spendable; the row below is a
|
|
// record of it AND the marker that stops the next delivery. Writing the marker
|
|
// first would mean a failed credit is never retried — the redelivery would find
|
|
// the marker and skip a payment that funded nothing. This way a lost credit is
|
|
// simply re-attempted on the next delivery, and a repeated one is the host's
|
|
// idempotent replay of the same key at the same address.
|
|
if led := creditledger.Get(); led != nil {
|
|
if _, _, err := led.Credit(ctx, creditledger.CreditInput{
|
|
Org: who.org,
|
|
Subject: who.subject,
|
|
Currency: string(cur),
|
|
Reason: "settlement " + paymentID,
|
|
Tag: "topup",
|
|
IdempotencyKey: paymentID,
|
|
AmountCents: int64(amount),
|
|
Test: who.test,
|
|
Cash: true, // a provider-verified settlement of a captured payment
|
|
}); err != nil {
|
|
// The provider retries for up to 72h, so the honest answer is to leave no
|
|
// marker and let the next delivery try again. Loud, because until one of
|
|
// them succeeds this is a payment that settled and a balance that did not.
|
|
log.Error("RECONCILE: settlement %s (%s %d %s) settled and the spendable balance was NOT credited: org=%s subject=%s test=%v: %v — retryable: the provider redelivers and the credit is keyed on the payment id",
|
|
paymentID, event.Type, amount, cur, who.org, who.subject, who.test, err)
|
|
return fmt.Errorf("settlement %s: credit: %w", paymentID, err)
|
|
}
|
|
}
|
|
|
|
// The local receipt: commerce's own record of the same payment, at the SAME
|
|
// address the seam credit went to, stamped with the payment id so the next
|
|
// delivery recognises it ([settlementDestination]). Standalone — no host ledger — this
|
|
// row IS the balance, which is why it is written either way.
|
|
trans := transaction.New(db)
|
|
trans.Type = transaction.Deposit
|
|
trans.DestinationId = who.subject
|
|
trans.DestinationKind = transaction.IAMUserKind
|
|
trans.Currency = cur
|
|
trans.Amount = amount
|
|
trans.SourceId = paymentID
|
|
trans.SourceKind = kind
|
|
trans.Event = event.ID
|
|
trans.Tags = "topup"
|
|
trans.Notes = string(event.Processor) + " settlement " + paymentID
|
|
trans.Metadata = Map{"provider": string(event.Processor), "eventType": event.Type}
|
|
// The SAME bit the seam credit above carried, off the same value — one resolution
|
|
// of which books, spent twice, so the two records of one payment can never
|
|
// disagree about whether the money is spendable.
|
|
trans.Test = who.test
|
|
// A provider-signature-verified settlement for a real captured payment IS the
|
|
// mint authority (the trust anchor is the per-provider signature checked in
|
|
// HandleProviderWebhook), so authorize this write at the ledger sink.
|
|
trans.SetContext(mintauth.WithAuthorized(trans.Context()))
|
|
if err := trans.Create(); err != nil {
|
|
log.Warn("settlement %s: failed to write the local receipt: %v", paymentID, err)
|
|
}
|
|
if err := paymentorg.Put(paymentID, org.Name, who.subject, "", who.test); err != nil {
|
|
log.Warn("settlement %s: the org index was not written: %v", paymentID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// destination is WHERE a settled payment's money belongs: the ledger, the account
|
|
// inside it, and which books. It is the same three-part address
|
|
// [creditledger.CreditInput] names — and the same one the transaction model calls a
|
|
// Destination — because it is resolved once here and spent on both writes below.
|
|
type destination struct {
|
|
org string
|
|
subject string
|
|
test bool
|
|
}
|
|
|
|
// settlementDestination answers who a settled payment funds, from what commerce
|
|
// ALREADY WROTE about that payment id — never from the callback's own fields.
|
|
//
|
|
// It returns one of three things, and the difference between the last two is the
|
|
// whole point:
|
|
//
|
|
// an address — this payment funds prepaid balance and nothing has credited it yet.
|
|
// (nil, "") — ACCOUNTED FOR. Some other path owns this payment's credit, so
|
|
// crediting again would be the double-credit. Silent, because it is not a defect.
|
|
// (nil, why) — UNATTRIBUTABLE. The money moved and this process cannot say whose
|
|
// it is. Refused and reconciled by hand; there is no safe address to guess.
|
|
//
|
|
// An error means a record could not be READ, so none of the three is known yet:
|
|
// the delivery stays open and the provider redelivers it.
|
|
//
|
|
// The lookups are all keyed on the provider's payment id, and each one is a
|
|
// record commerce wrote itself:
|
|
//
|
|
// THE PAYMENT INDEX names the org and wallet of every receipt a charge endpoint
|
|
// wrote, wherever it wrote it. Indexed means credited, in whichever books.
|
|
//
|
|
// A STAMPED LEDGER ROW is an in-session charge. Both card endpoints stamp the deposit
|
|
// they write with (SourceKind, SourceId) at charge time, so this row means either
|
|
// that an endpoint took this payment — in which case the host credited the spendable
|
|
// ledger at its own resolved payer as the handler returned — or that an earlier
|
|
// delivery of this very callback already credited it. Both are "already done".
|
|
//
|
|
// A PAID INVOICE is a subscription charge. The money bought a PLAN: the collection
|
|
// waterfall charges the card for the invoice REMAINDER and marks the invoice paid
|
|
// with the processor's reference. Crediting a prepaid wallet for it would hand the
|
|
// customer their subscription payment back as spendable balance.
|
|
//
|
|
// A SUBSCRIPTION KEYED BY THE PROVIDER'S ID is a subscription billed OUTSIDE
|
|
// commerce, reached only after the two above missed — so there is no local invoice
|
|
// and no local deposit, and this callback is the only record of the money there
|
|
// will be. Its UserId is the billing subject, the same <org> or <org>/<user> key
|
|
// the endpoints resolve and the gate debits.
|
|
//
|
|
// EVERYTHING ELSE IS REFUSED: a payment link, a charge raised in the provider's
|
|
// dashboard, a checkout order's capture. Those really are unattributable here —
|
|
// commerce wrote nothing keyed on the payment id — and the previous code's habit of
|
|
// reaching for reference_id or customer_id is what put money in wallets that were an
|
|
// order id or a stranger's.
|
|
// pay is the SETTLED OBJECT, already unwrapped by the caller — the same map paymentID
|
|
// was read from. It is passed rather than re-derived because reading the subscription
|
|
// link off the ENVELOPE while the payment id came from the object inside it is how the
|
|
// two stopped describing one payment: a delivery nesting its object one level down
|
|
// answered a real id for the payment and nothing at all for the subscription, which is
|
|
// precisely the blank key the guard below now refuses.
|
|
func settlementDestination(db *datastore.Datastore, org *organization.Organization, event *processor.WebhookEvent, pay map[string]interface{}, kind, paymentID string) (*destination, string, error) {
|
|
// THE PAYMENT INDEX FIRST, because it is the one record that is not in this
|
|
// org's books. A charge taken in session writes its receipt in the PAYER's org
|
|
// and then indexes the payment id to it (models/paymentorg); this delivery
|
|
// resolved the org the processor account belongs to, which is a different
|
|
// namespace for every payer but one. Without this read, every in-session charge
|
|
// by a payer outside that org came back "no charge this process wrote", a
|
|
// RECONCILE alarm for money that was already credited.
|
|
if _, found, err := paymentorg.Get(paymentID); err != nil {
|
|
return nil, "", fmt.Errorf("the payment index could not be read: %w", err)
|
|
} else if found {
|
|
return nil, "", nil
|
|
}
|
|
|
|
stamped := transaction.New(db)
|
|
if found, err := stamped.Query().
|
|
Filter("SourceKind=", kind).
|
|
Filter("SourceId=", paymentID).
|
|
Get(); err != nil {
|
|
return nil, "", fmt.Errorf("the ledger could not be read: %w", err)
|
|
} else if found {
|
|
return nil, "", nil
|
|
}
|
|
|
|
inv := billinginvoice.New(db)
|
|
if found, err := inv.Query().Filter("PaymentRef=", paymentID).Get(); err != nil {
|
|
return nil, "", fmt.Errorf("the invoices could not be read: %w", err)
|
|
} else if found {
|
|
return nil, "", nil
|
|
}
|
|
|
|
// Only a subscription lifecycle delivery carries a subscription to resolve. A
|
|
// payment.* event that reached here named no subscription and matched no record
|
|
// commerce wrote, which is exactly the unattributable case.
|
|
if !strings.HasPrefix(event.Type, "subscription.") && !strings.HasPrefix(event.Type, "invoice.") {
|
|
return nil, "no charge this process wrote carries this payment reference, and the payload names no subscription — the payer is unknown", nil
|
|
}
|
|
// A BLANK KEY IS NOT A LOOKUP, IT IS A WILDCARD, and this is the guard that says so.
|
|
//
|
|
// No production call site sets ProviderId — every subscription commerce creates
|
|
// itself leaves it "" — so `Filter("ProviderId=", "")` does not miss. It MATCHES,
|
|
// and .Get() answers with the first internal subscription in the org. A delivery
|
|
// naming no subscription therefore credited a stranger's wallet for whatever
|
|
// amount the payload asked for, which is the wildcard a signature forger would
|
|
// aim at. The sibling reconciler has always refused a blank id up front
|
|
// ([applySubscriptionEvent]); this asks the same question before the same query.
|
|
//
|
|
// It is stated here rather than left to the not-found branch because a blank key
|
|
// never REACHES not-found: the emptier the payload, the more it matches.
|
|
ref := subscriptionRef(event.Type, pay)
|
|
if ref == "" {
|
|
return nil, "the settlement names no subscription, so there is nothing to resolve a payer from", nil
|
|
}
|
|
sub := subscription.New(db)
|
|
found, err := sub.Query().Filter("ProviderId=", ref).Get()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("the subscriptions could not be read: %w", err)
|
|
}
|
|
if !found {
|
|
return nil, "the subscription this settlement names is not in these books, so the payer is unknown", nil
|
|
}
|
|
subject := strings.ToLower(strings.TrimSpace(sub.UserId))
|
|
if subject == "" {
|
|
return nil, "the subscription this settlement names states no owner, so the payer is unknown", nil
|
|
}
|
|
return &destination{
|
|
org: strings.ToLower(strings.TrimSpace(org.Name)),
|
|
subject: subject,
|
|
// Which books, from the one authority the charge itself used: a sandbox
|
|
// deployment's settlement funds the sandbox ledger and can never buy live
|
|
// inference.
|
|
test: org.TestMode(),
|
|
}, "", nil
|
|
}
|
|
|
|
// subscriptionRef is the provider's SUBSCRIPTION id in a lifecycle delivery, or "".
|
|
//
|
|
// An explicit link is the only thing read for an INVOICE delivery. The object's own
|
|
// "id" used to be a general fallback, and it is a different thing on every event type
|
|
// this is asked about: on an invoice.paid it is the INVOICE's id, so the fallback
|
|
// quietly offered an invoice id as a subscription key. That is not merely a lookup
|
|
// that misses — paired with the blank-key wildcard it was the shape of a payload that
|
|
// resolves to somebody. A subscription is named or it is not.
|
|
//
|
|
// The one place the object's own id IS the subscription is a subscription.* delivery,
|
|
// where the object simply IS the subscription. That is a fact about the event type,
|
|
// so the event type is what decides it rather than a guess ordered by luck.
|
|
func subscriptionRef(eventType string, data map[string]interface{}) string {
|
|
if s := firstNonEmpty(
|
|
stringField(data, "subscription_id"),
|
|
stringField(data, "subscriptionId"),
|
|
stringField(data, "subscription"),
|
|
); s != "" {
|
|
return s
|
|
}
|
|
// THIS BRANCH IS SAFE ONLY BECAUSE [isSettlementEvent] EXCLUDES subscription.*.
|
|
//
|
|
// Read together, the bare id here and [settlementAmount]'s read of the same
|
|
// attacker-supplied object are a mint: name any subscription's ProviderId, state
|
|
// any amount, and its owner is credited. Nothing in THIS function prevents that.
|
|
// What prevents it is that a subscription.* delivery never reaches
|
|
// [applySettlementEvent] at all — only payment.completed, payment.updated and
|
|
// invoice.paid settle, and this branch cannot fire for any of them.
|
|
//
|
|
// So the two guards are not equals: the event-type gate is the load-bearing one.
|
|
// Adding ANY subscription.* type to isSettlementEvent turns this line into a live
|
|
// mint, and it would look like a one-word change to whoever makes it. If a
|
|
// subscription.* type ever has to settle, resolve its payer from a record commerce
|
|
// wrote — the way the other branches do — before that type is added.
|
|
if strings.HasPrefix(eventType, "subscription.") {
|
|
return stringField(data, "id")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// chargeSourceKind is the ledger SourceKind under which a processor's own charge
|
|
// reference is filed. It is ONE function because two sides must spell it identically:
|
|
// the card endpoints stamp a settled charge with it, and this callback looks that charge
|
|
// up by it. Two literals drift, the lookup silently finds nothing, and "nothing" here
|
|
// reads exactly like "no endpoint ever took this payment" — which credits it twice.
|
|
//
|
|
// It carries the processor's name because the reference inside it is that processor's,
|
|
// and two processors' id spaces are not one space. For Square it is the same
|
|
// "square-payment" every row already written by this callback carries.
|
|
func chargeSourceKind(p processor.ProcessorType) string {
|
|
name := strings.ToLower(strings.TrimSpace(string(p)))
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
return name + "-payment"
|
|
}
|
|
|
|
// reconcileSettlement is the ONE answer to "money arrived and this process cannot say
|
|
// whose it is".
|
|
//
|
|
// It is loud and it credits nothing, and those are the same decision. Every address
|
|
// available to guess with belongs to somebody else — the service org whose token
|
|
// resolved the namespace, the provider's own customer id, an order id sitting in
|
|
// reference_id — so a guess is not a slightly-wrong credit, it is a customer's money
|
|
// in a stranger's wallet, reported as success. The refusal names the payment, the
|
|
// books it was refused in, and the endpoint that settles it, because a settled payment
|
|
// nobody is told about is the defect this whole path exists to end.
|
|
func reconcileSettlement(org *organization.Organization, event *processor.WebhookEvent, paymentID, why string) {
|
|
log.Error("RECONCILE: a settled payment could not be attributed and NO balance was credited — "+
|
|
"payment=%s provider=%s eventType=%s books=%s test=%v why=%s recovery=identify the payer and issue the balance with POST /v1/admin/grants, or refund the charge with the processor",
|
|
paymentID, event.Processor, event.Type, org.Name, org.TestMode(), why)
|
|
}
|
|
|
|
// settlementAmount extracts the captured amount (cents) and currency from a
|
|
// provider payment object. Square nests it under amount_money{amount,currency}.
|
|
func settlementAmount(data Map) (currency.Cents, currency.Type) {
|
|
cur := currency.USD
|
|
if m, ok := data["amount_money"].(map[string]interface{}); ok {
|
|
amt, ok := numberField(m, "amount")
|
|
if !ok {
|
|
return 0, cur
|
|
}
|
|
if c := stringField(m, "currency"); c != "" {
|
|
cur = currency.Type(strings.ToLower(c))
|
|
}
|
|
return currency.Cents(amt), cur
|
|
}
|
|
// Flat fallbacks used by other processors.
|
|
if c := stringField(data, "currency"); c != "" {
|
|
cur = currency.Type(strings.ToLower(c))
|
|
}
|
|
amt, ok := numberField(data, "amount")
|
|
if !ok {
|
|
return 0, cur
|
|
}
|
|
return currency.Cents(amt), cur
|
|
}
|
|
|
|
// unwrapObject returns data[key] as a map when present (Square nests the
|
|
// changed resource one level deep, e.g. data.object.payment), otherwise the
|
|
// data map itself (processors that expose fields at the top level).
|
|
func unwrapObject(data Map, key string) map[string]interface{} {
|
|
if inner, ok := data[key].(map[string]interface{}); ok {
|
|
return inner
|
|
}
|
|
return data
|
|
}
|
|
|
|
func stringField(m map[string]interface{}, key string) string {
|
|
v, _ := m[key].(string)
|
|
return strings.TrimSpace(v)
|
|
}
|
|
|
|
// numberField reads a MINOR-UNIT amount out of a decoded webhook body, and
|
|
// reports whether it could be read exactly.
|
|
//
|
|
// Every processor that reaches here states the amount in minor units as a whole
|
|
// number — Square's amount_money.amount and Stripe's amount are both integer
|
|
// cents — but the body arrives as map[string]interface{}, so encoding/json has
|
|
// already turned each one into a float64. A whole number survives that exactly.
|
|
//
|
|
// A FRACTIONAL value therefore does not mean "cents with a fraction", it means
|
|
// the payload is not the shape this code assumes — most likely major units, so
|
|
// 19.99 is a $19.99 payment and not 19 cents. int64() truncated it, and because
|
|
// this feeds a Deposit credited to a customer, it under-credited them by two
|
|
// orders of magnitude and did it silently.
|
|
//
|
|
// There is no safe guess between the two readings, so this refuses. The caller
|
|
// already treats a non-positive amount as "record the event, write no ledger
|
|
// row" and warns, which is exactly the right outcome for an amount we cannot
|
|
// read.
|
|
func numberField(m map[string]interface{}, key string) (int64, bool) {
|
|
switch n := m[key].(type) {
|
|
case float64:
|
|
if n != math.Trunc(n) {
|
|
return 0, false
|
|
}
|
|
return int64(n), true
|
|
case int64:
|
|
return n, true
|
|
case int:
|
|
return int64(n), true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// applySubscriptionEvent reconciles the local subscription row with a
|
|
// lifecycle event from the payment provider.
|
|
func applySubscriptionEvent(db *datastore.Datastore, event *processor.WebhookEvent) error {
|
|
// The provider's SUBSCRIPTION id, which for an invoice delivery is not the
|
|
// object's own id — see [subscriptionRef]. Reading "id" alone meant every
|
|
// invoice.* event looked a subscription up by an INVOICE id, found none, and
|
|
// silently left the local row stale.
|
|
id := subscriptionRef(event.Type, event.Data)
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
|
|
sub := subscription.New(db)
|
|
found, err := sub.Query().Filter("ProviderId=", id).Get()
|
|
if err != nil {
|
|
return fmt.Errorf("subscription %s: %w", id, err)
|
|
}
|
|
if !found {
|
|
// Unknown subscription — likely created outside commerce.
|
|
return nil
|
|
}
|
|
|
|
if status, ok := event.Data["status"].(string); ok && status != "" {
|
|
sub.Status = subscription.Status(status)
|
|
}
|
|
if event.Type == "subscription.canceled" {
|
|
sub.Canceled = true
|
|
sub.CanceledAt = time.Now().UTC()
|
|
}
|
|
if err := sub.Update(); err != nil {
|
|
return fmt.Errorf("subscription %s: %w", sub.Id(), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// tryValidateWebhook asks the processor the path names, and only it, to verify a
|
|
// delivery. A name no processor is registered under, and a processor that holds
|
|
// no webhook secret, are refused.
|
|
//
|
|
// Inbound validation ignores the disabled-processor deny policy (which governs
|
|
// OUTBOUND charge selection only): a processor disabled for new charges — e.g.
|
|
// Stripe — must still validate its existing customers' webhook deliveries. Hence
|
|
// Registered, not Get.
|
|
func tryValidateWebhook(ctx context.Context, providerHint string, payload []byte, signature string) (*processor.WebhookEvent, error) {
|
|
p, err := processor.Global().Registered(processor.ProcessorType(providerHint))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: no processor named %q", processor.ErrWebhookValidationFailed, providerHint)
|
|
}
|
|
// Only a processor that holds the secret its deliveries are signed with may
|
|
// verify one. Every provider package is registered, configured or not, and
|
|
// some verifiers need no secret at all (a public-chain lookup, a certificate
|
|
// fetched from a URL the payload names); asking one of those would let a
|
|
// caller author an event.
|
|
if rx, ok := p.(processor.WebhookReceiver); !ok || !rx.ReceivesWebhooks() {
|
|
return nil, fmt.Errorf("%w: %q holds no webhook secret", processor.ErrWebhookValidationFailed, providerHint)
|
|
}
|
|
evt, err := p.ValidateWebhook(ctx, payload, signature)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if evt != nil && evt.ID == "" {
|
|
// The event id is the delivery guard's key; an event without one cannot
|
|
// be recorded once, so it is not applied at all.
|
|
return nil, fmt.Errorf("%w: %s delivery carries no event id", processor.ErrWebhookValidationFailed, providerHint)
|
|
}
|
|
return evt, nil
|
|
}
|
|
|
|
// pickSignatureHeader returns the signature header for the given provider.
|
|
// We check the most common header names regardless of hint so a misconfigured
|
|
// Stripe endpoint (e.g. /webhooks/paypal) still validates correctly.
|
|
func pickSignatureHeader(h http.Header, providerHint string) string {
|
|
candidates := []string{
|
|
"Stripe-Signature",
|
|
"X-Square-Hmacsha256-Signature", // Square (HMAC-SHA256 over notificationURL+body)
|
|
"Paypal-Transmission-Sig",
|
|
"X-Adyen-Signature",
|
|
"X-Paypal-Auth-Algo",
|
|
"X-CC-Webhook-Signature", // Coinbase Commerce
|
|
"X-Webhook-Signature", // Hanzo MPC (hex HMAC-SHA256 over the raw body)
|
|
"X-Signature",
|
|
}
|
|
if providerHint != "" {
|
|
// Try a provider-specific guess first.
|
|
switch providerHint {
|
|
case "stripe":
|
|
if v := h.Get("Stripe-Signature"); v != "" {
|
|
return v
|
|
}
|
|
case "square":
|
|
if v := h.Get("X-Square-Hmacsha256-Signature"); v != "" {
|
|
return v
|
|
}
|
|
case "paypal":
|
|
if v := h.Get("Paypal-Transmission-Sig"); v != "" {
|
|
return v
|
|
}
|
|
case "coinbase":
|
|
if v := h.Get("X-CC-Webhook-Signature"); v != "" {
|
|
return v
|
|
}
|
|
case "mpc":
|
|
if v := h.Get("X-Webhook-Signature"); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
}
|
|
for _, name := range candidates {
|
|
if v := h.Get(name); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isClawbackEvent reports whether an event returns a settled payment's money to the
|
|
// payer, or reports a dispute of it: a refund, or a dispute's creation or change of
|
|
// state (Square's dispute.state.updated, once named dispute.state.changed).
|
|
func isClawbackEvent(eventType string) bool {
|
|
switch eventType {
|
|
case "refund.created", "refund.updated", "dispute.created", "dispute.state.updated", "dispute.state.changed", "dispute.updated":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// applyClawbackEvent takes a refunded or disputed payment's money back out of the
|
|
// wallet it credited, and gives a dispute's back when the merchant wins it. A payment
|
|
// that paid an invoice credited no wallet: the invoice records the refund, or every
|
|
// state the dispute reports, instead, and the period it paid for reads as its
|
|
// payment now stands.
|
|
//
|
|
// The org and the wallet are the ones the payment's own receipt names — found through
|
|
// the payment index (models/paymentorg), since the callback names only the payment —
|
|
// never the request's org. A refund acts once it has COMPLETED. A dispute moves money
|
|
// only while the processor withholds the funds (dispute.Withholds — a chargeback, or
|
|
// one lost or accepted; an inquiry holds none) and gives it back when it is WON. Every
|
|
// dispute report is ordered by the dispute's version, else its updated_at, and one
|
|
// older than the report already recorded changes nothing. The ledger posts each once
|
|
// per refund or dispute id, and never takes back more than the payment brought in.
|
|
func applyClawbackEvent(ctx context.Context, _ *datastore.Datastore, org *organization.Organization, event *processor.WebhookEvent) error {
|
|
if event.Data == nil {
|
|
return nil
|
|
}
|
|
obj := unwrapObject(event.Data, "refund")
|
|
paymentID := stringField(obj, "payment_id")
|
|
isDispute := strings.HasPrefix(event.Type, "dispute.")
|
|
state, version, at := "", int64(0), time.Time{}
|
|
if isDispute {
|
|
obj = unwrapObject(event.Data, "dispute")
|
|
paymentID = stringField(obj, "payment_id")
|
|
if paymentID == "" {
|
|
if dp, ok := obj["disputed_payment"].(map[string]interface{}); ok {
|
|
paymentID = stringField(dp, "payment_id")
|
|
}
|
|
}
|
|
state = strings.ToUpper(stringField(obj, "state"))
|
|
version, _ = numberField(obj, "version")
|
|
at, _ = time.Parse(time.RFC3339, stringField(obj, "updated_at"))
|
|
} else if st := stringField(obj, "status"); !strings.EqualFold(st, "COMPLETED") {
|
|
return nil
|
|
}
|
|
id := stringField(obj, "id")
|
|
amount, _ := settlementAmount(obj)
|
|
// What moves wallet money: a refund, funds a dispute withholds, a dispute won.
|
|
won := state == dispute.StateWon
|
|
moves := won || (amount > 0 && (!isDispute || dispute.Withholds(state)))
|
|
// What is recorded: a refund, and any state a dispute reports.
|
|
records := (!isDispute && amount > 0) || state != ""
|
|
if paymentID == "" || id == "" || !records {
|
|
return nil
|
|
}
|
|
where, found, err := paymentorg.Get(paymentID)
|
|
if err != nil {
|
|
return fmt.Errorf("%s %s: the payment index could not be read: %w", event.Type, id, err)
|
|
}
|
|
if !found {
|
|
if moves {
|
|
log.Error("RECONCILE: %s %s concerns payment %s, which no receipt recorded — settle it by hand", event.Type, id, paymentID)
|
|
}
|
|
return nil
|
|
}
|
|
if event.Org != "" && event.Org != where.Org {
|
|
// An account that belongs to one org reports on its own payments only.
|
|
log.Error("RECONCILE: %s %s from %s's account names payment %s, whose receipt is %s's — refused", event.Type, id, event.Org, paymentID, where.Org)
|
|
return nil
|
|
}
|
|
if where.Invoice != "" {
|
|
db := datastore.New(nscontext.WithNamespace(ctx, where.Org))
|
|
if isDispute {
|
|
err = engine.RecordDispute(db, where.Invoice, state, version, at)
|
|
} else {
|
|
err = engine.RecordRefund(db, where.Invoice, id, int64(amount))
|
|
}
|
|
switch {
|
|
case errors.Is(err, datastore.ErrNoSuchEntity):
|
|
// The receipt names an invoice these books do not hold; redelivering
|
|
// cannot make it appear.
|
|
log.Error("RECONCILE: %s %s on payment %s names invoice %s in %s, which does not exist — record it by hand", event.Type, id, paymentID, where.Invoice, where.Org)
|
|
case err != nil:
|
|
return fmt.Errorf("%s %s on payment %s did not reach invoice %s in %s: %w", event.Type, id, paymentID, where.Invoice, where.Org, err)
|
|
}
|
|
return nil
|
|
}
|
|
if isDispute {
|
|
latest, err := engine.RecordPaymentDispute(paymentID, state, version, at)
|
|
if err != nil {
|
|
return fmt.Errorf("%s %s on payment %s: the dispute's state was not recorded: %w", event.Type, id, paymentID, err)
|
|
}
|
|
if !latest {
|
|
return nil // an earlier report than the one recorded moves nothing
|
|
}
|
|
}
|
|
led := creditledger.Get()
|
|
if led == nil || !moves {
|
|
return nil
|
|
}
|
|
kind := "refund"
|
|
if isDispute {
|
|
kind = "dispute"
|
|
}
|
|
in := creditledger.ClawbackInput{
|
|
Org: where.Org, Subject: where.Subject, Payment: paymentID,
|
|
Ref: kind + ":" + id, AmountCents: int64(amount), Reason: event.Type + " " + id, Test: where.Test,
|
|
}
|
|
if won {
|
|
_, err = led.Restore(ctx, in)
|
|
} else {
|
|
_, err = led.Clawback(ctx, in)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("%s %s on payment %s for %s/%s did not post: %w", event.Type, id, paymentID, where.Org, where.Subject, err)
|
|
}
|
|
_ = org
|
|
return nil
|
|
}
|