package payments import ( "context" "database/sql" "errors" "fmt" "time" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" "scrabble/backend/internal/postgres/jet/payments/model" "scrabble/backend/internal/postgres/jet/payments/table" ) // ErrProductTransacted is returned when a hard delete is attempted on a product an order or ledger // row references — it must be archived instead, to keep the append-only ledger resolvable. var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete") // adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag. func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) { var prods []model.Product if err := postgres.SELECT(table.Product.AllColumns). FROM(table.Product). ORDER_BY(table.Product.CreatedAt.ASC()). QueryContext(ctx, s.db, &prods); err != nil { return nil, fmt.Errorf("payments: admin list products: %w", err) } out := make([]AdminProduct, 0, len(prods)) for _, p := range prods { atoms, prices, err := s.productComposition(ctx, p.ProductID) if err != nil { return nil, err } transacted, err := s.productTransacted(ctx, p.ProductID) if err != nil { return nil, err } out = append(out, AdminProduct{ ID: p.ProductID, Title: p.Title, Active: p.Active, Atoms: atoms, Prices: prices, Transacted: transacted, }) } return out, nil } // productComposition reads one product's atom lines and price rows. func (s *Store) productComposition(ctx context.Context, id uuid.UUID) ([]AtomLine, []PriceLine, error) { var items []model.ProductItem if err := postgres.SELECT(table.ProductItem.AllColumns). FROM(table.ProductItem). WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(id))). ORDER_BY(table.ProductItem.AtomType.ASC()). QueryContext(ctx, s.db, &items); err != nil { return nil, nil, fmt.Errorf("payments: load items %s: %w", id, err) } atoms := make([]AtomLine, len(items)) for i, it := range items { atoms[i] = AtomLine{Atom: it.AtomType, Quantity: int(it.Quantity)} } var prs []model.ProductPrice if err := postgres.SELECT(table.ProductPrice.AllColumns). FROM(table.ProductPrice). WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(id))). QueryContext(ctx, s.db, &prs); err != nil { return nil, nil, fmt.Errorf("payments: load prices %s: %w", id, err) } prices := make([]PriceLine, len(prs)) for i, pr := range prs { method := "" if pr.Method != nil { method = *pr.Method } prices[i] = PriceLine{Method: method, Currency: Currency(pr.Currency), Amount: pr.Amount} } return atoms, prices, nil } // productTransacted reports whether any order or ledger row references the product. func (s *Store) productTransacted(ctx context.Context, id uuid.UUID) (bool, error) { var yes bool if err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM payments.orders WHERE product_id=$1) OR EXISTS(SELECT 1 FROM payments.ledger WHERE product_id=$1)`, id).Scan(&yes); err != nil { return false, fmt.Errorf("payments: product transacted %s: %w", id, err) } return yes, nil } // productActive reads a product's archived flag, ErrProductNotFound when it is missing. func (s *Store) productActive(ctx context.Context, id uuid.UUID) (bool, error) { var p model.Product err := postgres.SELECT(table.Product.Active).FROM(table.Product). WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1). QueryContext(ctx, s.db, &p) if errors.Is(err, qrm.ErrNoRows) { return false, ErrProductNotFound } if err != nil { return false, fmt.Errorf("payments: product active %s: %w", id, err) } return p.Active, nil } // productInput reads a product's current editable content (title, atoms, prices). func (s *Store) productInput(ctx context.Context, id uuid.UUID) (ProductInput, error) { var p model.Product err := postgres.SELECT(table.Product.AllColumns).FROM(table.Product). WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1). QueryContext(ctx, s.db, &p) if errors.Is(err, qrm.ErrNoRows) { return ProductInput{}, ErrProductNotFound } if err != nil { return ProductInput{}, fmt.Errorf("payments: product input %s: %w", id, err) } atoms, prices, err := s.productComposition(ctx, id) if err != nil { return ProductInput{}, err } return ProductInput{Title: p.Title, Atoms: atoms, Prices: prices}, nil } // createProduct inserts a product with its atoms and prices in one transaction, returning its id. func (s *Store) createProduct(ctx context.Context, in ProductInput, active bool, now time.Time) (uuid.UUID, error) { id := uuid.New() if err := withTx(ctx, s.db, func(tx *sql.Tx) error { if _, err := tx.ExecContext(ctx, `INSERT INTO payments.product (product_id, title, active, created_at, updated_at) VALUES ($1,$2,$3,$4,$4)`, id, in.Title, active, now); err != nil { return fmt.Errorf("insert product: %w", err) } return insertComposition(ctx, tx, id, in) }); err != nil { return uuid.Nil, fmt.Errorf("payments: create product: %w", err) } return id, nil } // updateProduct replaces a product's title, atoms and prices in one transaction (active unchanged). func (s *Store) updateProduct(ctx context.Context, id uuid.UUID, in ProductInput, now time.Time) error { return withTx(ctx, s.db, func(tx *sql.Tx) error { res, err := tx.ExecContext(ctx, `UPDATE payments.product SET title=$2, updated_at=$3 WHERE product_id=$1`, id, in.Title, now) if err != nil { return fmt.Errorf("payments: update product %s: %w", id, err) } if n, _ := res.RowsAffected(); n == 0 { return ErrProductNotFound } if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_item WHERE product_id=$1`, id); err != nil { return fmt.Errorf("payments: clear items %s: %w", id, err) } if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_price WHERE product_id=$1`, id); err != nil { return fmt.Errorf("payments: clear prices %s: %w", id, err) } return insertComposition(ctx, tx, id, in) }) } // insertComposition inserts a product's atom items and price rows inside tx (a value's CHIP price // carries a NULL method). func insertComposition(ctx context.Context, tx *sql.Tx, id uuid.UUID, in ProductInput) error { for _, a := range in.Atoms { if _, err := tx.ExecContext(ctx, `INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,$2,$3)`, id, a.Atom, a.Quantity); err != nil { return fmt.Errorf("insert item %s: %w", a.Atom, err) } } for _, p := range in.Prices { var method any if p.Method != "" { method = p.Method } if _, err := tx.ExecContext(ctx, `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1,$2,$3,$4)`, id, method, string(p.Currency), p.Amount); err != nil { return fmt.Errorf("insert price %s: %w", p.Currency, err) } } return nil } // setProductActive flips the archived flag. func (s *Store) setProductActive(ctx context.Context, id uuid.UUID, active bool, now time.Time) error { res, err := s.db.ExecContext(ctx, `UPDATE payments.product SET active=$2, updated_at=$3 WHERE product_id=$1`, id, active, now) if err != nil { return fmt.Errorf("payments: set product active %s: %w", id, err) } if n, _ := res.RowsAffected(); n == 0 { return ErrProductNotFound } return nil } // deleteProduct hard-deletes a never-transacted product (its items/prices cascade); a transacted // product is refused with ErrProductTransacted. func (s *Store) deleteProduct(ctx context.Context, id uuid.UUID) error { transacted, err := s.productTransacted(ctx, id) if err != nil { return err } if transacted { return ErrProductTransacted } res, err := s.db.ExecContext(ctx, `DELETE FROM payments.product WHERE product_id=$1`, id) if err != nil { return fmt.Errorf("payments: delete product %s: %w", id, err) } if n, _ := res.RowsAffected(); n == 0 { return ErrProductNotFound } return nil }