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 }