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>
312 lines
11 KiB
Go
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"))
|
|
}
|