TransferService.CreateTransfer signature changes from (...) error to
(...) (string, error) — the caller now captures the Stripe transfer
identifier and persists it on the SellerTransfer row. Pre-v1.0.7 the
stripe_transfer_id column was declared on the model and table but
never written to, which blocked the reversal worker (v1.0.7 item B)
from identifying which transfer to reverse on refund.
Changes:
* `TransferService` interface and `StripeConnectService.CreateTransfer`
both return the Stripe transfer id alongside the error.
* `processSellerTransfers` (marketplace service) persists the id on
success before `tx.Create(&st)` so a crash between Stripe ACK and
DB commit leaves no inconsistency.
* `TransferRetryWorker.retryOne` persists on retry success — a row
that failed on first attempt and succeeded via the worker is
reversal-ready all the same.
* `admin_transfer_handler.RetryTransfer` (manual retry) persists too.
* `SellerPayout.ExternalPayoutID` is populated by the Connect payout
flow (`payout.go`) — the field existed but was never written.
* Four test mocks updated; two tests assert the id is persisted on
the happy path, one on the failure path confirms we don't write a
fake id when the provider errors.
Migration `981_seller_transfers_stripe_reversal_id.sql`:
* Adds nullable `stripe_reversal_id` column for item B.
* Partial UNIQUE indexes on both stripe_transfer_id and
stripe_reversal_id (WHERE IS NOT NULL AND <> ''), mirroring the
v1.0.6.1 pattern for refunds.hyperswitch_refund_id.
* Logs a count of historical completed transfers that lack an id —
these are candidates for the backfill CLI follow-up task.
Backfill for historical rows is a separate follow-up (cmd/tools/
backfill_stripe_transfer_ids, calling Stripe's transfers.List with
Destination + Metadata[order_id]). Pre-v1.0.7 transfers without a
backfilled id cannot be auto-reversed on refund — document in P2.9
admin-recovery when it lands. Acceptable scope per v107-plan.
Migration number bumped 980 → 981 because v1.0.6.2 used 980 for the
unpaid-subscription cleanup; v107-plan updated with the note.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
6.5 KiB
Go
212 lines
6.5 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"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"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
|
|
"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)
|
|
}
|
|
return tr.ID, nil
|
|
}
|