224 lines
6.1 KiB
Go
224 lines
6.1 KiB
Go
package models
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
|
)
|
|
|
|
type UserModel struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewUserModel(pool *pgxpool.Pool) *UserModel {
|
|
return &UserModel{pool: pool}
|
|
}
|
|
|
|
const userColumns = `id, firebase_uid, email, display_name,
|
|
stripe_customer_id, stripe_subscription_id, subscription_status,
|
|
subscription_tier, subscription_created_at, created_at, updated_at`
|
|
|
|
func scanUser(row pgx.Row) (*domain.User, error) {
|
|
var u domain.User
|
|
err := row.Scan(
|
|
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
|
|
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
|
|
&u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil //nolint:nilnil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &u, nil
|
|
}
|
|
|
|
func (m *UserModel) getByFirebaseUID(ctx context.Context, firebaseUID string) (*domain.User, error) {
|
|
row := m.pool.QueryRow(ctx,
|
|
`SELECT `+userColumns+` FROM users WHERE firebase_uid = $1`,
|
|
firebaseUID,
|
|
)
|
|
return scanUser(row)
|
|
}
|
|
|
|
func (m *UserModel) getByEmail(ctx context.Context, email string) (*domain.User, error) {
|
|
row := m.pool.QueryRow(ctx,
|
|
`SELECT `+userColumns+` FROM users WHERE email = $1`,
|
|
email,
|
|
)
|
|
return scanUser(row)
|
|
}
|
|
|
|
func isUniqueViolation(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) {
|
|
return pgErr.Code == "23505"
|
|
}
|
|
return false
|
|
}
|
|
|
|
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
|
|
// creating one if it doesn't exist yet.
|
|
//
|
|
// For onboarding races or legacy duplicate-identity states, this method
|
|
// gracefully falls back to an existing row by email instead of failing
|
|
// with a unique-email violation.
|
|
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
|
|
existingByUID, err := m.getByFirebaseUID(ctx, firebaseUID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if existingByUID != nil {
|
|
return existingByUID, nil
|
|
}
|
|
|
|
if email != "" {
|
|
existingByEmail, err := m.getByEmail(ctx, email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if existingByEmail != nil {
|
|
return existingByEmail, nil
|
|
}
|
|
}
|
|
|
|
row := m.pool.QueryRow(ctx,
|
|
`INSERT INTO users (firebase_uid, email)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
|
|
RETURNING `+userColumns,
|
|
firebaseUID, email,
|
|
)
|
|
user, err := scanUser(row)
|
|
if err == nil {
|
|
return user, nil
|
|
}
|
|
|
|
// If a concurrent request inserted by email or firebase_uid first,
|
|
// read the existing record and continue without surfacing a 500.
|
|
if isUniqueViolation(err) {
|
|
existingByUID, readErr := m.getByFirebaseUID(ctx, firebaseUID)
|
|
if readErr != nil {
|
|
return nil, readErr
|
|
}
|
|
if existingByUID != nil {
|
|
return existingByUID, nil
|
|
}
|
|
if email != "" {
|
|
existingByEmail, readErr := m.getByEmail(ctx, email)
|
|
if readErr != nil {
|
|
return nil, readErr
|
|
}
|
|
if existingByEmail != nil {
|
|
return existingByEmail, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
|
row := m.pool.QueryRow(ctx,
|
|
`SELECT `+userColumns+` FROM users WHERE id = $1`, id,
|
|
)
|
|
return scanUser(row)
|
|
}
|
|
|
|
// GetByStripeCustomerID loads a user by their Stripe Customer ID if set.
|
|
func (m *UserModel) GetByStripeCustomerID(ctx context.Context, stripeCustomerID string) (*domain.User, error) {
|
|
row := m.pool.QueryRow(ctx,
|
|
`SELECT `+userColumns+` FROM users WHERE stripe_customer_id = $1`,
|
|
stripeCustomerID,
|
|
)
|
|
return scanUser(row)
|
|
}
|
|
|
|
// ListPaidUsersWithoutActiveSubscription finds paid-tier rows whose Stripe
|
|
// subscription is not active or trialing. Used by the periodic billing sweep.
|
|
func (m *UserModel) ListPaidUsersWithoutActiveSubscription(ctx context.Context) ([]domain.User, error) {
|
|
rows, err := m.pool.Query(ctx,
|
|
`SELECT `+userColumns+` FROM users
|
|
WHERE subscription_tier IN ('premium', 'pro')
|
|
AND subscription_status NOT IN ('active', 'trialing')
|
|
AND COALESCE(trim(firebase_uid), '') <> ''`,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []domain.User{}
|
|
for rows.Next() {
|
|
var u domain.User
|
|
rowErr := rows.Scan(
|
|
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
|
|
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
|
|
&u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
|
|
)
|
|
if rowErr != nil {
|
|
return nil, rowErr
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
|
|
_, err := m.pool.Exec(ctx,
|
|
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,
|
|
userID, stripeCustomerID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string, tier domain.SubscriptionTier) error {
|
|
_, err := m.pool.Exec(ctx,
|
|
`UPDATE users
|
|
SET stripe_subscription_id = $2,
|
|
subscription_status = $3,
|
|
subscription_tier = $4,
|
|
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
|
updated_at = NOW()
|
|
WHERE stripe_customer_id = $1`,
|
|
stripeCustomerID, subscriptionID, status, string(tier),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (m *UserModel) UpdateSubscriptionTier(ctx context.Context, userID string, tier domain.SubscriptionTier) error {
|
|
_, err := m.pool.Exec(ctx,
|
|
`UPDATE users SET subscription_tier = $2, updated_at = NOW() WHERE id = $1`,
|
|
userID, string(tier),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (m *UserModel) ActivateFreeTier(ctx context.Context, userID string) error {
|
|
_, err := m.pool.Exec(ctx,
|
|
`UPDATE users
|
|
SET subscription_status = 'active',
|
|
subscription_tier = 'free',
|
|
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
|
updated_at = NOW()
|
|
WHERE id = $1`,
|
|
userID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (m *UserModel) UpdateSubscriptionStatus(ctx context.Context, stripeCustomerID, status string) error {
|
|
_, err := m.pool.Exec(ctx,
|
|
`UPDATE users SET subscription_status = $2, updated_at = NOW()
|
|
WHERE stripe_customer_id = $1`,
|
|
stripeCustomerID, status,
|
|
)
|
|
return err
|
|
}
|