334 lines
9.8 KiB
Go
334 lines
9.8 KiB
Go
|
|
package billing
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"database/sql"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
)
|
||
|
|
|
||
|
|
type Store struct {
|
||
|
|
db *pgxpool.Pool
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewStore(db *pgxpool.Pool) *Store {
|
||
|
|
return &Store{db: db}
|
||
|
|
}
|
||
|
|
|
||
|
|
type Tier struct {
|
||
|
|
ID string
|
||
|
|
TenantID string
|
||
|
|
Name string
|
||
|
|
PriceCents int
|
||
|
|
Description string
|
||
|
|
LemonVariantID string
|
||
|
|
Active bool
|
||
|
|
CreatedAt time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
type Subscription struct {
|
||
|
|
ID string
|
||
|
|
TenantID string
|
||
|
|
UserID string
|
||
|
|
TierID string
|
||
|
|
TierName string
|
||
|
|
Status string
|
||
|
|
LemonSubscriptionID string
|
||
|
|
LemonCustomerID string
|
||
|
|
AmountCents int
|
||
|
|
CurrentPeriodStart time.Time
|
||
|
|
CurrentPeriodEnd time.Time
|
||
|
|
CancelledAt *time.Time
|
||
|
|
CreatedAt time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
type Donation struct {
|
||
|
|
ID string
|
||
|
|
TenantID string
|
||
|
|
UserID string
|
||
|
|
DonorEmail string
|
||
|
|
DonorName string
|
||
|
|
AmountCents int
|
||
|
|
LemonOrderID string
|
||
|
|
Message string
|
||
|
|
CreatedAt time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
type Balance struct {
|
||
|
|
TenantID string
|
||
|
|
AvailableCents int
|
||
|
|
LifetimeEarningsCents int
|
||
|
|
LifetimePaidCents int
|
||
|
|
UpdatedAt time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
type PayoutSettings struct {
|
||
|
|
TenantID string
|
||
|
|
WiseRecipientID string
|
||
|
|
AccountHolderName string
|
||
|
|
Currency string
|
||
|
|
PayoutEmail string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) CreateTier(ctx context.Context, tier *Tier) error {
|
||
|
|
_, err := s.db.Exec(ctx, `
|
||
|
|
INSERT INTO membership_tiers (tenant_id, name, price_cents, description, lemon_variant_id, active)
|
||
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||
|
|
`, tier.TenantID, tier.Name, tier.PriceCents, tier.Description, tier.LemonVariantID, tier.Active)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) GetTiersByTenant(ctx context.Context, tenantID string) ([]Tier, error) {
|
||
|
|
rows, err := s.db.Query(ctx, `
|
||
|
|
SELECT id, tenant_id, name, price_cents, description, lemon_variant_id, active, created_at
|
||
|
|
FROM membership_tiers
|
||
|
|
WHERE tenant_id = $1 AND active = TRUE
|
||
|
|
ORDER BY price_cents ASC
|
||
|
|
`, tenantID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
var tiers []Tier
|
||
|
|
for rows.Next() {
|
||
|
|
var t Tier
|
||
|
|
if err := rows.Scan(&t.ID, &t.TenantID, &t.Name, &t.PriceCents, &t.Description, &t.LemonVariantID, &t.Active, &t.CreatedAt); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
tiers = append(tiers, t)
|
||
|
|
}
|
||
|
|
return tiers, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) CreateSubscription(ctx context.Context, sub *Subscription) error {
|
||
|
|
_, err := s.db.Exec(ctx, `
|
||
|
|
INSERT INTO subscriptions (tenant_id, user_id, tier_id, tier_name, status, lemon_subscription_id, lemon_customer_id, amount_cents, current_period_start, current_period_end)
|
||
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||
|
|
`, sub.TenantID, sub.UserID, sub.TierID, sub.TierName, sub.Status, sub.LemonSubscriptionID, sub.LemonCustomerID, sub.AmountCents, sub.CurrentPeriodStart, sub.CurrentPeriodEnd)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) GetSubscriptionByLemonID(ctx context.Context, lemonSubID string) (*Subscription, error) {
|
||
|
|
var sub Subscription
|
||
|
|
var userID, tierID sql.NullString
|
||
|
|
var cancelledAt sql.NullTime
|
||
|
|
var periodStart, periodEnd sql.NullTime
|
||
|
|
|
||
|
|
err := s.db.QueryRow(ctx, `
|
||
|
|
SELECT id, tenant_id, user_id, tier_id, tier_name, status, lemon_subscription_id, lemon_customer_id,
|
||
|
|
amount_cents, current_period_start, current_period_end, cancelled_at, created_at
|
||
|
|
FROM subscriptions WHERE lemon_subscription_id = $1
|
||
|
|
`, lemonSubID).Scan(&sub.ID, &sub.TenantID, &userID, &tierID, &sub.TierName, &sub.Status,
|
||
|
|
&sub.LemonSubscriptionID, &sub.LemonCustomerID, &sub.AmountCents, &periodStart, &periodEnd,
|
||
|
|
&cancelledAt, &sub.CreatedAt)
|
||
|
|
|
||
|
|
if err == sql.ErrNoRows {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
sub.UserID = userID.String
|
||
|
|
sub.TierID = tierID.String
|
||
|
|
if periodStart.Valid {
|
||
|
|
sub.CurrentPeriodStart = periodStart.Time
|
||
|
|
}
|
||
|
|
if periodEnd.Valid {
|
||
|
|
sub.CurrentPeriodEnd = periodEnd.Time
|
||
|
|
}
|
||
|
|
if cancelledAt.Valid {
|
||
|
|
sub.CancelledAt = &cancelledAt.Time
|
||
|
|
}
|
||
|
|
return &sub, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) UpdateSubscriptionStatus(ctx context.Context, lemonSubID, status string, renewsAt *time.Time) error {
|
||
|
|
_, err := s.db.Exec(ctx, `
|
||
|
|
UPDATE subscriptions
|
||
|
|
SET status = $1, current_period_end = $2, updated_at = NOW()
|
||
|
|
WHERE lemon_subscription_id = $3
|
||
|
|
`, status, renewsAt, lemonSubID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) CancelSubscription(ctx context.Context, lemonSubID string) error {
|
||
|
|
_, err := s.db.Exec(ctx, `
|
||
|
|
UPDATE subscriptions
|
||
|
|
SET status = 'cancelled', cancelled_at = NOW(), updated_at = NOW()
|
||
|
|
WHERE lemon_subscription_id = $1
|
||
|
|
`, lemonSubID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) CreateDonation(ctx context.Context, donation *Donation) error {
|
||
|
|
err := s.db.QueryRow(ctx, `
|
||
|
|
INSERT INTO donations (tenant_id, user_id, donor_email, donor_name, amount_cents, lemon_order_id, message)
|
||
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
|
|
RETURNING id
|
||
|
|
`, donation.TenantID, donation.UserID, donation.DonorEmail, donation.DonorName, donation.AmountCents, donation.LemonOrderID, donation.Message).Scan(&donation.ID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) AddEarnings(ctx context.Context, tenantID, sourceType, sourceID, description string, grossCents, platformFeeCents, processorFeeCents, netCents int) error {
|
||
|
|
tx, err := s.db.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer tx.Rollback(ctx)
|
||
|
|
|
||
|
|
_, err = tx.Exec(ctx, `
|
||
|
|
INSERT INTO earnings (tenant_id, source_type, source_id, description, gross_cents, platform_fee_cents, processor_fee_cents, net_cents)
|
||
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||
|
|
`, tenantID, sourceType, sourceID, description, grossCents, platformFeeCents, processorFeeCents, netCents)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err = tx.Exec(ctx, `
|
||
|
|
INSERT INTO balances (tenant_id, available_cents, lifetime_earnings_cents, updated_at)
|
||
|
|
VALUES ($1, $2, $2, NOW())
|
||
|
|
ON CONFLICT (tenant_id) DO UPDATE
|
||
|
|
SET available_cents = balances.available_cents + $2,
|
||
|
|
lifetime_earnings_cents = balances.lifetime_earnings_cents + $2,
|
||
|
|
updated_at = NOW()
|
||
|
|
`, tenantID, netCents)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
return tx.Commit(ctx)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) GetBalance(ctx context.Context, tenantID string) (*Balance, error) {
|
||
|
|
var b Balance
|
||
|
|
err := s.db.QueryRow(ctx, `
|
||
|
|
SELECT tenant_id, available_cents, lifetime_earnings_cents, lifetime_paid_cents, updated_at
|
||
|
|
FROM balances WHERE tenant_id = $1
|
||
|
|
`, tenantID).Scan(&b.TenantID, &b.AvailableCents, &b.LifetimeEarningsCents, &b.LifetimePaidCents, &b.UpdatedAt)
|
||
|
|
|
||
|
|
if err == sql.ErrNoRows {
|
||
|
|
return &Balance{TenantID: tenantID}, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return &b, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) GetTenantsReadyForPayout(ctx context.Context, thresholdCents int) ([]Balance, error) {
|
||
|
|
rows, err := s.db.Query(ctx, `
|
||
|
|
SELECT b.tenant_id, b.available_cents, b.lifetime_earnings_cents, b.lifetime_paid_cents, b.updated_at
|
||
|
|
FROM balances b
|
||
|
|
JOIN payout_settings ps ON b.tenant_id = ps.tenant_id
|
||
|
|
WHERE b.available_cents >= $1 AND ps.wise_recipient_id IS NOT NULL
|
||
|
|
`, thresholdCents)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
var balances []Balance
|
||
|
|
for rows.Next() {
|
||
|
|
var b Balance
|
||
|
|
if err := rows.Scan(&b.TenantID, &b.AvailableCents, &b.LifetimeEarningsCents, &b.LifetimePaidCents, &b.UpdatedAt); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
balances = append(balances, b)
|
||
|
|
}
|
||
|
|
return balances, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) DeductBalance(ctx context.Context, tenantID string, amountCents int) error {
|
||
|
|
_, err := s.db.Exec(ctx, `
|
||
|
|
UPDATE balances
|
||
|
|
SET available_cents = available_cents - $1,
|
||
|
|
lifetime_paid_cents = lifetime_paid_cents + $1,
|
||
|
|
updated_at = NOW()
|
||
|
|
WHERE tenant_id = $2 AND available_cents >= $1
|
||
|
|
`, amountCents, tenantID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) CreatePayout(ctx context.Context, tenantID string, amountCents int, currency, wiseTransferID, wiseQuoteID, status string) (string, error) {
|
||
|
|
var id string
|
||
|
|
err := s.db.QueryRow(ctx, `
|
||
|
|
INSERT INTO payouts (tenant_id, amount_cents, currency, wise_transfer_id, wise_quote_id, status)
|
||
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||
|
|
RETURNING id
|
||
|
|
`, tenantID, amountCents, currency, wiseTransferID, wiseQuoteID, status).Scan(&id)
|
||
|
|
return id, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) UpdatePayoutStatus(ctx context.Context, payoutID, status string, completedAt *time.Time, failureReason string) error {
|
||
|
|
_, err := s.db.Exec(ctx, `
|
||
|
|
UPDATE payouts
|
||
|
|
SET status = $1, completed_at = $2, failure_reason = $3
|
||
|
|
WHERE id = $4
|
||
|
|
`, status, completedAt, failureReason, payoutID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) GetPayoutSettings(ctx context.Context, tenantID string) (*PayoutSettings, error) {
|
||
|
|
var ps PayoutSettings
|
||
|
|
var recipientID, holderName, currency, email sql.NullString
|
||
|
|
|
||
|
|
err := s.db.QueryRow(ctx, `
|
||
|
|
SELECT tenant_id, wise_recipient_id, account_holder_name, currency, payout_email
|
||
|
|
FROM payout_settings WHERE tenant_id = $1
|
||
|
|
`, tenantID).Scan(&ps.TenantID, &recipientID, &holderName, ¤cy, &email)
|
||
|
|
|
||
|
|
if err == sql.ErrNoRows {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
ps.WiseRecipientID = recipientID.String
|
||
|
|
ps.AccountHolderName = holderName.String
|
||
|
|
ps.Currency = currency.String
|
||
|
|
if ps.Currency == "" {
|
||
|
|
ps.Currency = "USD"
|
||
|
|
}
|
||
|
|
ps.PayoutEmail = email.String
|
||
|
|
|
||
|
|
return &ps, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) SavePayoutSettings(ctx context.Context, ps *PayoutSettings) error {
|
||
|
|
_, err := s.db.Exec(ctx, `
|
||
|
|
INSERT INTO payout_settings (tenant_id, wise_recipient_id, account_holder_name, currency, payout_email)
|
||
|
|
VALUES ($1, $2, $3, $4, $5)
|
||
|
|
ON CONFLICT (tenant_id) DO UPDATE
|
||
|
|
SET wise_recipient_id = $2,
|
||
|
|
account_holder_name = $3,
|
||
|
|
currency = $4,
|
||
|
|
payout_email = $5,
|
||
|
|
updated_at = NOW()
|
||
|
|
`, ps.TenantID, ps.WiseRecipientID, ps.AccountHolderName, ps.Currency, ps.PayoutEmail)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
type User struct {
|
||
|
|
ID string
|
||
|
|
Email string
|
||
|
|
Name string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
|
||
|
|
var u User
|
||
|
|
err := s.db.QueryRow(ctx,
|
||
|
|
`SELECT id, email, COALESCE(name, '') FROM users WHERE id = $1`,
|
||
|
|
id).Scan(&u.ID, &u.Email, &u.Name)
|
||
|
|
if err == sql.ErrNoRows {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return &u, nil
|
||
|
|
}
|