package billing import ( "context" "fmt" "log/slog" "os" "strconv" "time" ) type PayoutWorker struct { store *Store wise *WiseClient thresholdCents int interval time.Duration } func NewPayoutWorker(store *Store, wiseClient *WiseClient) *PayoutWorker { threshold := 1000 if v := os.Getenv("PAYOUT_THRESHOLD_CENTS"); v != "" { if t, err := strconv.Atoi(v); err == nil { threshold = t } } return &PayoutWorker{ store: store, wise: wiseClient, thresholdCents: threshold, interval: 5 * time.Minute, } } func (w *PayoutWorker) Start(ctx context.Context) { if !w.wise.IsConfigured() { slog.Warn("wise not configured, payouts disabled") return } ticker := time.NewTicker(w.interval) defer ticker.Stop() slog.Info("payout worker started", "threshold_cents", w.thresholdCents, "interval", w.interval) for { select { case <-ctx.Done(): slog.Info("payout worker stopped") return case <-ticker.C: w.processPayouts(ctx) } } } func (w *PayoutWorker) processPayouts(ctx context.Context) { balances, err := w.store.GetTenantsReadyForPayout(ctx, w.thresholdCents) if err != nil { slog.Error("failed to get tenants ready for payout", "error", err) return } for _, balance := range balances { if err := w.processPayout(ctx, balance.TenantID, balance.AvailableCents); err != nil { slog.Error("failed to process payout", "tenant_id", balance.TenantID, "amount_cents", balance.AvailableCents, "error", err, ) continue } } } func (w *PayoutWorker) processPayout(ctx context.Context, tenantID string, amountCents int) error { settings, err := w.store.GetPayoutSettings(ctx, tenantID) if err != nil { return fmt.Errorf("get payout settings: %w", err) } if settings == nil || settings.WiseRecipientID == "" { return fmt.Errorf("payout not configured for tenant") } recipientID, err := strconv.ParseInt(settings.WiseRecipientID, 10, 64) if err != nil { return fmt.Errorf("invalid recipient ID: %w", err) } quote, err := w.wise.CreateQuote(ctx, "USD", settings.Currency, amountCents) if err != nil { return fmt.Errorf("create quote: %w", err) } reference := fmt.Sprintf("WK-%s-%d", tenantID[:8], time.Now().Unix()) transfer, err := w.wise.CreateTransfer(ctx, quote.ID, recipientID, reference) if err != nil { return fmt.Errorf("create transfer: %w", err) } payoutID, err := w.store.CreatePayout(ctx, tenantID, amountCents, settings.Currency, fmt.Sprintf("%d", transfer.ID), quote.ID, "pending") if err != nil { return fmt.Errorf("record payout: %w", err) } if err := w.wise.FundTransfer(ctx, transfer.ID); err != nil { w.store.UpdatePayoutStatus(ctx, payoutID, "failed", nil, err.Error()) return fmt.Errorf("fund transfer: %w", err) } if err := w.store.DeductBalance(ctx, tenantID, amountCents); err != nil { return fmt.Errorf("deduct balance: %w", err) } w.store.UpdatePayoutStatus(ctx, payoutID, "processing", nil, "") slog.Info("payout initiated", "tenant_id", tenantID, "amount_cents", amountCents, "transfer_id", transfer.ID, "payout_id", payoutID, ) return nil }