init
This commit is contained in:
commit
d69342b2e9
160 changed files with 28681 additions and 0 deletions
126
internal/billing/payout.go
Normal file
126
internal/billing/payout.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue