mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
294 lines
13 KiB
Go
294 lines
13 KiB
Go
// Copyright 2014-present Hanzo AI Inc. Licensed under MIT OR Apache-2.0.
|
|
|
|
package billing
|
|
|
|
// refusal.go — what a card money move does when the card is refused, in one place:
|
|
// which answers are refusals, the gateway key a retry reaches the gateway under, and
|
|
// how many refusals a wallet may collect before its cards are no longer tried.
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/models/idempotencykey"
|
|
"github.com/hanzoai/commerce/payment/processor"
|
|
)
|
|
|
|
// refusalOf is the processor's refusal of a charge, or nil when the processor did
|
|
// not refuse the card but failed to answer at all.
|
|
//
|
|
// A refusal is a processor.Decline, found in the error or in the result that carried
|
|
// it. A result that did not settle and states no Decline is a refusal with no code,
|
|
// which reads to the buyer as a plain decline. Nothing else is read: a processor's
|
|
// message is never searched for a reason, because the text beside a refusal can say
|
|
// anything (Square's carries the card's cvv_status on every decline), and an error
|
|
// that is not a refusal is the processor failing, which is ours to report and not the
|
|
// card's.
|
|
func refusalOf(res *processor.PaymentResult, err error) *processor.Decline {
|
|
if d, ok := declineOf(res, err); ok {
|
|
return d
|
|
}
|
|
if err == nil && res != nil && !res.Success {
|
|
return &processor.Decline{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// declinedCard is a refusal as an error: the buyer's sentence, with the processor's
|
|
// Decline beneath it for a caller that answers with the processor's code.
|
|
type declinedCard struct{ decline *processor.Decline }
|
|
|
|
func (e declinedCard) Error() string { return e.decline.Sentence() }
|
|
func (e declinedCard) Unwrap() error { return e.decline }
|
|
|
|
// errProcessorFailed marks a card money move the processor failed to answer: no
|
|
// refusal, and an outcome it did not state. It is the processor's fault and never the
|
|
// card's, so it is reported as an outage rather than as a decline, and it is
|
|
// processor.ErrUnknownOutcome, so a later attempt keeps the gateway key.
|
|
var errProcessorFailed = fmt.Errorf("the payment processor could not take the card: %w", processor.ErrUnknownOutcome)
|
|
|
|
// errRequestRejected marks a card money move whose request the processor refused
|
|
// outside a card decline ([processor.Rejected]): a spent or malformed token, a saved
|
|
// card that no longer exists. No payment was taken, so its outcome is known. A buyer
|
|
// is answered as for any processor failure.
|
|
var errRequestRejected = errors.New("the payment processor rejected the card request")
|
|
|
|
// IsProcessorFailed reports whether err is a card money move the processor failed
|
|
// to answer or rejected, as distinct from a card it refused.
|
|
func IsProcessorFailed(err error) bool {
|
|
return errors.Is(err, errProcessorFailed) || errors.Is(err, errRequestRejected)
|
|
}
|
|
|
|
// processorSentence is what a buyer reads when the processor failed to answer.
|
|
const processorSentence = "The payment processor could not take the card. Try again in a moment."
|
|
|
|
// processorFailure is a card money move the processor failed to answer or rejected.
|
|
// It is logged here with the processor's own error, which [thirdparty/square] has
|
|
// already reduced to a status and a code, and answers only [processorSentence]:
|
|
// errRequestRejected for a rejected request, errProcessorFailed for anything else.
|
|
func processorFailure(what, subject string, err error) error {
|
|
log.Error("%s: the payment processor failed (subject=%s): %v", what, subject, err)
|
|
if processor.Rejected(err) {
|
|
return errRequestRejected
|
|
}
|
|
return errProcessorFailed
|
|
}
|
|
|
|
// failedAttempt is [processorFailure] for a buyer's attempt. A rejected request is
|
|
// the buyer's (a spent or malformed token) and is counted against the wallet's window
|
|
// ([tally]); a processor that failed to answer, answered 5xx or 429, or refused the
|
|
// merchant's own credentials is not, and an outage never spends a buyer's ceiling.
|
|
func failedAttempt(db *datastore.Datastore, what, subject string, err error) error {
|
|
failure := processorFailure(what, subject, err)
|
|
if failure == errRequestRejected {
|
|
tally(db, subject)
|
|
}
|
|
return failure
|
|
}
|
|
|
|
// ── the gateway key a retry reaches the gateway under ─────────────────────────
|
|
|
|
// A gateway stores its answer under the idempotency key it was sent, a refusal
|
|
// included, and answers the same key the same way for as long as it keeps it. The
|
|
// cores derive that key from facts that are stable across a retry, never from the
|
|
// card, so that a lost response retried with a re-tokenized card is not charged
|
|
// twice. After a REFUSAL that stability is wrong: nothing was charged, and the
|
|
// buyer's next attempt, with a corrected card or another one, must reach the gateway
|
|
// under a key of its own or the gateway answers it with the first refusal.
|
|
//
|
|
// So each refusal is counted under the key it was answered for, and the count is
|
|
// part of the next attempt's key. A retry after a failure that was not a refusal
|
|
// keeps its key, which is the de-duplication the key exists for.
|
|
|
|
// declinesScope names the count of refusals under one gateway key.
|
|
const declinesScope = "declined:"
|
|
|
|
// attemptKey is the gateway key for the next attempt under base: base itself before
|
|
// any refusal, and base with the refusal count after.
|
|
func attemptKey(db *datastore.Datastore, base string) string {
|
|
if n := count(db, declinesScope, base); n > 0 {
|
|
return base + ":r" + strconv.Itoa(n)
|
|
}
|
|
return base
|
|
}
|
|
|
|
// ── the failed attempts a wallet may collect ──────────────────────────────────
|
|
|
|
// A buyer's card attempts are held per wallet: one attempt at a time, and a wallet
|
|
// that has collected [declineCeiling] failed attempts in [declineWindow] has no more
|
|
// cards tried until the window turns, refused before the processor is asked. Testing
|
|
// stolen cards is a run of refusals against one account, so this bounds what one
|
|
// account can learn and what it costs the merchant at the processor, and a buyer
|
|
// whose own card is refused a few times is not affected. It is keyed on the wallet
|
|
// the charge would credit, which is the org for a tenant and the person in the shared
|
|
// signup org, so one stranger's refusals never lock out the rest of that org.
|
|
//
|
|
// A failed attempt is a card the processor refused, or a request it rejected
|
|
// ([failedAttempt]): a spent or malformed token holds the reservation as long as a
|
|
// refused card, so counting only refusals would let one member hold the wallet from
|
|
// every other member indefinitely. A processor that failed to answer, answered 5xx or
|
|
// 429, or refused the merchant's credentials is not counted, so an outage never locks
|
|
// a buyer out; a settled payment and one still processing are not counted either.
|
|
//
|
|
// ONE ATTEMPT AT A TIME is what makes the ceiling a ceiling. The count is read before
|
|
// a charge and written after it fails, so attempts that overlap all read the same
|
|
// count and all reach the processor. commerce is the single writer for each tenant,
|
|
// so this process holds every attempt a wallet makes, and a reservation in memory is
|
|
// the whole of what serialising them takes.
|
|
//
|
|
// ONLY A BUYER'S ATTEMPT IS HELD. The off-session charges (auto-recharge and
|
|
// renewals) charge a card the org already chose, are not a buyer testing anything,
|
|
// and must neither spend a buyer's ceiling nor be stopped by one.
|
|
//
|
|
// It is not per card: a card is known to this process only after the processor has
|
|
// been asked (a single-use token names no card), and counting it afterwards would
|
|
// mean keeping card identifiers the processor's answer is otherwise scrubbed of.
|
|
|
|
// declineCeiling is how many failed attempts one wallet may collect in [declineWindow].
|
|
const declineCeiling = 5
|
|
|
|
// declineWindow is the span declineCeiling counts over.
|
|
const declineWindow = time.Hour
|
|
|
|
// walletScope names a wallet's failed-attempt count for one window.
|
|
const walletScope = "declined-wallet:"
|
|
|
|
// errDeclineCeiling marks a charge refused before the processor was asked, because
|
|
// the wallet has collected declineCeiling failed attempts in this window.
|
|
var errDeclineCeiling = errors.New("too many declined card attempts; try again later")
|
|
|
|
// IsDeclineCeiling reports whether err is that refusal.
|
|
func IsDeclineCeiling(err error) bool { return errors.Is(err, errDeclineCeiling) }
|
|
|
|
// windowOf is the current window's key.
|
|
func windowOf() string {
|
|
return strconv.FormatInt(time.Now().Unix()/int64(declineWindow/time.Second), 10)
|
|
}
|
|
|
|
// errAttemptInFlight marks a buyer's card attempt refused because another attempt
|
|
// for the same wallet is in progress.
|
|
var errAttemptInFlight = errors.New("another card attempt for this account is in progress")
|
|
|
|
// IsAttemptInFlight reports whether err is that refusal.
|
|
func IsAttemptInFlight(err error) bool { return errors.Is(err, errAttemptInFlight) }
|
|
|
|
// inFlight is the wallets with a buyer's card attempt in progress in this process.
|
|
var inFlight = struct {
|
|
sync.Mutex
|
|
wallets map[string]bool
|
|
}{wallets: map[string]bool{}}
|
|
|
|
// attempt reserves wallet subject of org for one buyer's card attempt, refusing when
|
|
// another is in progress or the wallet is at its ceiling. The caller releases the
|
|
// reservation once the attempt's refusal, if any, is recorded.
|
|
func attempt(db *datastore.Datastore, org, subject string) (release func(), err error) {
|
|
key := org + "\x00" + subject
|
|
inFlight.Lock()
|
|
if inFlight.wallets[key] {
|
|
inFlight.Unlock()
|
|
return nil, errAttemptInFlight
|
|
}
|
|
inFlight.wallets[key] = true
|
|
inFlight.Unlock()
|
|
release = func() {
|
|
inFlight.Lock()
|
|
delete(inFlight.wallets, key)
|
|
inFlight.Unlock()
|
|
}
|
|
if count(db, walletScope+subject, windowOf()) >= declineCeiling {
|
|
release()
|
|
return nil, errDeclineCeiling
|
|
}
|
|
return release, nil
|
|
}
|
|
|
|
// answered records the processor's answer to a charge that did not settle, made
|
|
// under gateway key base, and reports whether it was a refusal. A refusal moves the
|
|
// next attempt onto a key of its own ([rotate]); a payment still processing keeps
|
|
// its key, so a retry is answered with that payment rather than taking another. It
|
|
// logs the processor's category and code and nothing else of the answer. An empty
|
|
// base is an answer given with no gateway key (a card vaulted).
|
|
func answered(db *datastore.Datastore, what, subject, base string, d *processor.Decline) bool {
|
|
if d.Processing() {
|
|
log.Warn("%s: payment still processing (subject=%s): %s %s", what, subject, d.Category, d.Code)
|
|
return false
|
|
}
|
|
log.Warn("%s: card declined (subject=%s): %s %s", what, subject, d.Category, d.Code)
|
|
rotate(db, base)
|
|
return true
|
|
}
|
|
|
|
// refused is [answered] for a buyer's attempt, and a refusal is also counted against
|
|
// the wallet's window ([tally]).
|
|
func refused(db *datastore.Datastore, what, subject, base string, d *processor.Decline) {
|
|
if answered(db, what, subject, base, d) {
|
|
tally(db, subject)
|
|
}
|
|
}
|
|
|
|
// declineStatus is the HTTP status a buyer is answered for d: 402 for a refusal, and
|
|
// 409 for a payment still processing, which is neither the buyer's fault nor
|
|
// something another attempt should repeat.
|
|
func declineStatus(d *processor.Decline) int {
|
|
if d.Processing() {
|
|
return 409
|
|
}
|
|
return 402
|
|
}
|
|
|
|
// The sentences a buyer reads when an attempt is held.
|
|
const (
|
|
ceilingSentence = "Too many declined card attempts. Try again later."
|
|
attemptSentence = "Another card payment for this account is in progress. Try again in a moment."
|
|
)
|
|
|
|
// rotate moves the next attempt under gateway key base onto a key of its own
|
|
// ([attemptKey]), after a refusal answered under it.
|
|
func rotate(db *datastore.Datastore, base string) {
|
|
if base != "" {
|
|
add(db, declinesScope, base)
|
|
}
|
|
}
|
|
|
|
// tally counts a failed attempt against wallet subject's window.
|
|
func tally(db *datastore.Datastore, subject string) {
|
|
add(db, walletScope+subject, windowOf())
|
|
}
|
|
|
|
// count reads a count, zero when none is recorded or the store cannot say.
|
|
// The store failing open here is deliberate: a count that cannot be read costs a
|
|
// retry its fresh key or lifts the ceiling for one attempt, and refusing every
|
|
// charge whenever the store blinks would cost every buyer their purchase.
|
|
func count(db *datastore.Datastore, scope, key string) int {
|
|
rec := idempotencykey.New(db)
|
|
if rec.Get(db.NewKey(rec.Kind(), idempotencykey.DeterministicID(scope, key), 0, nil)) != nil {
|
|
return 0
|
|
}
|
|
n, _ := strconv.Atoi(rec.Response)
|
|
return n
|
|
}
|
|
|
|
// counts serialises every count's read-and-write in this process, the single writer
|
|
// for each tenant, so two attempts recorded at once are two.
|
|
var counts sync.Mutex
|
|
|
|
// add increments a count.
|
|
func add(db *datastore.Datastore, scope, key string) {
|
|
counts.Lock()
|
|
defer counts.Unlock()
|
|
rec := idempotencykey.New(db)
|
|
rec.SetId(idempotencykey.DeterministicID(scope, key))
|
|
rec.Scope = scope
|
|
rec.IdemKey = key
|
|
rec.Status = "declined"
|
|
rec.Response = strconv.Itoa(count(db, scope, key) + 1)
|
|
if err := rec.Put(); err != nil {
|
|
log.Error("recording a failed card attempt (%s%s): %v", scope, key, err)
|
|
}
|
|
}
|