126 lines
3.1 KiB
Go
126 lines
3.1 KiB
Go
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
|
|
}
|