veza/veza-backend-api/internal/services/stripe_connect_service.go
senke 1a133af9ac feat(marketplace): stripe reversal error disambiguation + CHECK constraint + E2E — v1.0.7 item B day 3
Day-3 closure of item B. The three things day 2 deferred are now done:

1. Stripe error disambiguation.
   ReverseTransfer in StripeConnectService now parses
   stripe.Error.Code + HTTPStatusCode + Msg to emit the sentinels
   the worker routes on. Pre-day-3 the sentinels were declared but
   the service wrapped every error opaquely, making this the exact
   "temporary compromise frozen into permanent" pattern the audit
   was meant to prevent — flagged during review and fixed same day.

   Mapping:
     * 404 + code=resource_missing  → ErrTransferNotFound
     * 400 + msg matches "already" + "reverse" → ErrTransferAlreadyReversed
     * any other                    → transient (wrapped raw, retry)

   The "already reversed" case has no machine-readable code in
   stripe-go (unlike ChargeAlreadyRefunded for charges — the SDK
   doesn't enumerate the equivalent for transfers), so it's
   message-parsed. Fragility documented at the call site: if Stripe
   changes the wording, the worker treats the response as transient
   and eventually surfaces the row to permanently_failed after max
   retries. Worst-case regression is "benign case gets noisier",
   not data loss.

2. Migration 983: CHECK constraint chk_reversal_pending_has_next_
   retry_at CHECK (status != 'reversal_pending' OR next_retry_at
   IS NOT NULL). Added NOT VALID so the constraint is enforced on
   new writes without scanning existing rows; a follow-up VALIDATE
   can run once the table is known to be clean. Prevents the
   "invisible orphan" failure mode where a reversal_pending row
   with NULL next_retry_at would be skipped by any future stricter
   worker query.

3. End-to-end reversal flow test (reversal_e2e_test.go) chains
   three sub-scenarios: (a) happy path — refund.succeeded →
   reversal_pending → worker → reversed with stripe_reversal_id
   persisted; (b) invalid stripe_transfer_id → worker terminates
   rapidly to permanently_failed with single Stripe call, no
   retries (the highest-value coverage per day-3 review); (c)
   already-reversed out-of-band → worker flips to reversed with
   informative message.

Architecture note — the sentinels were moved to a new leaf
package `internal/core/connecterrors` because both marketplace
(needs them for the worker's errors.Is checks) and services (needs
them to emit) import them, and an import cycle
(marketplace → monitoring → services) would form if either owned
them directly. marketplace re-exports them as type aliases so the
worker code reads naturally against the marketplace namespace.

New tests:
  * services/stripe_connect_service_test.go — 7 cases on
    isAlreadyReversedMessage (pins Stripe's wording), 1 case on
    the error-classification shape. Doesn't invoke stripe.SetBackend
    — the translation logic is tested via a crafted *stripe.Error,
    the emission is trusted on the read of `errors.As` + the known
    shape of stripe.Error.
  * marketplace/reversal_e2e_test.go — 3 end-to-end sub-tests
    chaining refund → worker against a dual-role mock. The
    invalid-id case asserts single-call-no-retries termination.
  * Migration 983 applied cleanly to the local Postgres; constraint
    visible in \d seller_transfers as NOT VALID (behavior correct
    for future writes, existing rows grandfathered).

Self-assessment on day-2's struct-literal refactor of
processSellerTransfers (deferred from day 2):
The refactor is borderline — neither clearer nor confusing than the
original mutation-after-construct pattern. Logged in the v1.0.7-rc1
CHANGELOG as a post-v1.0.7 consideration: if GORM BeforeUpdate
hooks prove cleaner on other state machines (axis 2), revisit the
anti-mutation test approach.

CHANGELOG v1.0.7-rc1 entry added documenting items A + B end-to-end.
Tag not yet applied — items C, D, E, F remain on the v1.0.7 plan.
The rc1 tag lands when those four items close + the smoke probe
validates the full cadence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:12:03 +02:00

312 lines
11 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/stripe/stripe-go/v82"
"github.com/stripe/stripe-go/v82/account"
"github.com/stripe/stripe-go/v82/accountlink"
"github.com/stripe/stripe-go/v82/balance"
"github.com/stripe/stripe-go/v82/transfer"
"github.com/stripe/stripe-go/v82/transferreversal"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/core/connecterrors"
"veza-backend-api/internal/models"
)
var (
ErrStripeConnectDisabled = errors.New("Stripe Connect is not enabled")
ErrNoStripeAccount = errors.New("seller has no Stripe Connect account")
)
// BalanceResponse is the balance for a connected account
type BalanceResponse struct {
Connected bool `json:"connected"` // true if seller has completed Stripe Connect onboarding
Available int64 `json:"available"` // in minor units (cents)
Pending int64 `json:"pending"` // in minor units (cents)
}
// StripeConnectService handles Stripe Connect operations for seller payouts
type StripeConnectService struct {
db *gorm.DB
secretKey string
logger *zap.Logger
}
// NewStripeConnectService creates a new Stripe Connect service
func NewStripeConnectService(db *gorm.DB, secretKey string, logger *zap.Logger) *StripeConnectService {
return &StripeConnectService{
db: db,
secretKey: secretKey,
logger: logger,
}
}
// CreateOnboardingLink creates or retrieves a Stripe Express account and returns an onboarding URL
func (s *StripeConnectService) CreateOnboardingLink(ctx context.Context, userID uuid.UUID, returnURL, refreshURL string) (string, error) {
if s.secretKey == "" {
return "", ErrStripeConnectDisabled
}
stripe.Key = s.secretKey
// Get user email
var user models.User
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", fmt.Errorf("user not found: %w", err)
}
return "", err
}
// Check if account already exists
var existing models.SellerStripeAccount
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&existing).Error; err == nil {
// Account exists, create new account link for onboarding/update
params := &stripe.AccountLinkParams{
Account: stripe.String(existing.StripeAccountID),
ReturnURL: stripe.String(returnURL),
RefreshURL: stripe.String(refreshURL),
Type: stripe.String(string(stripe.AccountLinkTypeAccountOnboarding)),
}
link, err := accountlink.New(params)
if err != nil {
return "", fmt.Errorf("create account link: %w", err)
}
return link.URL, nil
}
// Create new Express account
accountParams := &stripe.AccountParams{
Type: stripe.String(string(stripe.AccountTypeExpress)),
Email: stripe.String(user.Email),
}
acct, err := account.New(accountParams)
if err != nil {
return "", fmt.Errorf("create stripe account: %w", err)
}
// Save to DB
sa := models.SellerStripeAccount{
UserID: userID,
StripeAccountID: acct.ID,
OnboardingCompleted: false,
}
if err := s.db.WithContext(ctx).Create(&sa).Error; err != nil {
return "", fmt.Errorf("save seller stripe account: %w", err)
}
// Create account link
linkParams := &stripe.AccountLinkParams{
Account: stripe.String(acct.ID),
ReturnURL: stripe.String(returnURL),
RefreshURL: stripe.String(refreshURL),
Type: stripe.String(string(stripe.AccountLinkTypeAccountOnboarding)),
}
link, err := accountlink.New(linkParams)
if err != nil {
return "", fmt.Errorf("create account link: %w", err)
}
return link.URL, nil
}
// HandleOnboardingCallback updates the account status after Stripe redirect
func (s *StripeConnectService) HandleOnboardingCallback(ctx context.Context, userID uuid.UUID) error {
var sa models.SellerStripeAccount
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&sa).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNoStripeAccount
}
return err
}
if s.secretKey == "" {
return ErrStripeConnectDisabled
}
stripe.Key = s.secretKey
// Retrieve account from Stripe to get charges_enabled, payouts_enabled
params := &stripe.AccountParams{}
acct, err := account.GetByID(sa.StripeAccountID, params)
if err != nil {
return fmt.Errorf("get stripe account: %w", err)
}
sa.ChargesEnabled = acct.ChargesEnabled
sa.PayoutsEnabled = acct.PayoutsEnabled
sa.OnboardingCompleted = acct.DetailsSubmitted
if err := s.db.WithContext(ctx).Save(&sa).Error; err != nil {
return fmt.Errorf("update seller stripe account: %w", err)
}
return nil
}
// GetBalance returns the balance for a seller's connected account
func (s *StripeConnectService) GetBalance(ctx context.Context, userID uuid.UUID) (*BalanceResponse, error) {
var sa models.SellerStripeAccount
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&sa).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &BalanceResponse{Connected: false, Available: 0, Pending: 0}, nil
}
return nil, err
}
if s.secretKey == "" {
return nil, ErrStripeConnectDisabled
}
stripe.Key = s.secretKey
params := &stripe.BalanceParams{}
params.SetStripeAccount(sa.StripeAccountID)
bal, err := balance.Get(params)
if err != nil {
return nil, fmt.Errorf("get stripe balance: %w", err)
}
var available, pending int64
for _, a := range bal.Available {
available += a.Amount
}
for _, p := range bal.Pending {
pending += p.Amount
}
return &BalanceResponse{Connected: true, Available: available, Pending: pending}, nil
}
// CreateTransfer transfers funds to a connected account (for payout on sale).
// Returns the Stripe transfer identifier (tr_*) on success so the caller can
// persist it on the SellerTransfer row — required by item B (reversal worker)
// and by reconciliation against the Stripe dashboard.
func (s *StripeConnectService) CreateTransfer(ctx context.Context, sellerUserID uuid.UUID, amount int64, currency, orderID string) (string, error) {
var sa models.SellerStripeAccount
if err := s.db.WithContext(ctx).Where("user_id = ?", sellerUserID).First(&sa).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", ErrNoStripeAccount
}
return "", err
}
if !sa.PayoutsEnabled {
return "", fmt.Errorf("seller account does not have payouts enabled")
}
if s.secretKey == "" {
return "", ErrStripeConnectDisabled
}
stripe.Key = s.secretKey
params := &stripe.TransferParams{
Amount: stripe.Int64(amount),
Currency: stripe.String(currency),
Destination: stripe.String(sa.StripeAccountID),
}
if orderID != "" {
params.AddMetadata("order_id", orderID)
}
tr, err := transfer.New(params)
if err != nil {
return "", fmt.Errorf("create stripe transfer: %w", err)
}
// Defensive: Stripe's Go SDK should never return (tr, nil) with an empty
// tr.ID, but the invariant is load-bearing for item B (reversal needs a
// real id to target). If it's ever violated we'd rather fail the transfer
// than persist an empty string and leave the row permanently un-reversible.
if tr.ID == "" {
return "", fmt.Errorf("create stripe transfer: provider returned empty transfer id")
}
return tr.ID, nil
}
// ReverseTransfer issues a reversal against a previously-created Stripe
// transfer (v1.0.7 item B). amount=nil reverses the full transfer;
// amount>0 reverses that portion (partial reversal, used for partial
// refunds in a future item — v1.0.7 always passes nil). Returns the
// Stripe reversal id on success.
//
// Error handling in this function is intentionally opaque in v1.0.7:
// every non-nil return is a raw wrapped Stripe error. Day 3 of item B
// will parse stripe.Error.Code to return the marketplace-package
// sentinels ErrTransferAlreadyReversed ("transfer was already reversed
// out-of-band") and ErrTransferNotFound ("stripe_transfer_id doesn't
// exist at Stripe — data-integrity incident"). The worker routes by
// sentinel, so this function is the single place disambiguation needs
// to happen; until day 3 lands the worker treats every error as a
// transient retry candidate.
func (s *StripeConnectService) ReverseTransfer(ctx context.Context, stripeTransferID string, amount *int64, reason string) (string, error) {
if s.secretKey == "" {
return "", ErrStripeConnectDisabled
}
if stripeTransferID == "" {
return "", fmt.Errorf("reverse stripe transfer: empty stripe_transfer_id")
}
stripe.Key = s.secretKey
params := &stripe.TransferReversalParams{
ID: stripe.String(stripeTransferID),
Amount: amount,
}
if reason != "" {
params.Description = stripe.String(reason)
params.AddMetadata("reason", reason)
}
rev, err := transferreversal.New(params)
if err != nil {
// Disambiguate the two alarming-looking cases from the generic
// transient error. Stripe's SDK surfaces these as *stripe.Error
// with a Code + HTTPStatusCode; we map to marketplace sentinels
// so the worker can route correctly (see reversal_worker.go).
//
// 404 + code=resource_missing → the transfer id doesn't exist
// at Stripe. This is a data-integrity incident on our side
// (our DB has an id Stripe can't find): never retry, surface.
//
// 400 + message containing "already" + "revers" → Stripe
// reports the transfer is already fully reversed. Benign,
// idempotent — someone else (admin, Dashboard, another
// instance) did the reversal out-of-band. Treat as success.
//
// Any other error (including 5xx, network timeout, rate-limit)
// is a transient retry candidate and bubbles unchanged.
//
// "already reversed" isn't codified in stripe-go as an enum —
// the SDK only lists codes like ChargeAlreadyRefunded for
// charges, not transfers. Message-parse is fragile but Stripe's
// wording on this has been stable for years. Document here
// rather than hiding in a helper so a future reader sees the
// reasoning and the fragility together.
var stripeErr *stripe.Error
if errors.As(err, &stripeErr) {
switch {
case stripeErr.HTTPStatusCode == http.StatusNotFound && stripeErr.Code == stripe.ErrorCodeResourceMissing:
return "", fmt.Errorf("%w: %s", connecterrors.ErrTransferNotFound, stripeErr.Msg)
case stripeErr.HTTPStatusCode == http.StatusBadRequest && isAlreadyReversedMessage(stripeErr.Msg):
return "", fmt.Errorf("%w: %s", connecterrors.ErrTransferAlreadyReversed, stripeErr.Msg)
}
}
return "", fmt.Errorf("create stripe reversal: %w", err)
}
if rev.ID == "" {
return "", fmt.Errorf("create stripe reversal: provider returned empty reversal id")
}
return rev.ID, nil
}
// isAlreadyReversedMessage detects Stripe's "transfer already fully
// reversed" wording. Parsing a free-text message is fragile, but
// Stripe doesn't publish a machine-readable code for this case on the
// transferreversal endpoint. The strings we look for have been stable
// across Stripe API versions. If Stripe changes the wording, the
// worker will treat the response as a transient error and retry — the
// row stays in reversal_pending and eventually hits max_retries, at
// which point permanently_failed surfaces it for ops to inspect.
// Worst-case regression is "benign case gets noisier", not data loss.
func isAlreadyReversedMessage(msg string) bool {
lower := strings.ToLower(msg)
return strings.Contains(lower, "already") &&
(strings.Contains(lower, "reversed") || strings.Contains(lower, "reversal"))
}