This commit is contained in:
Josh 2026-01-09 00:16:46 +02:00
commit d69342b2e9
160 changed files with 28681 additions and 0 deletions

126
internal/billing/payout.go Normal file
View file

@ -0,0 +1,126 @@
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
}