From 98a0bb01e2b6d6d9d09bf14f3ce7328a8cd44f4c Mon Sep 17 00:00:00 2001 From: shop Date: Sat, 27 Jun 2026 16:29:33 +0300 Subject: [PATCH] Add product features, Heleket crypto payments and order status icons. --- .env.example | 8 + cmd/shop/main.go | 27 +- docker-compose.yml | 2 + internal/compare/compare.go | 66 +++++ internal/config/config.go | 29 +- internal/database/migrate.go | 30 ++ internal/handlers/admin.go | 11 +- internal/handlers/admin_products.go | 29 +- internal/handlers/customer.go | 181 ++++++++++-- internal/handlers/customer_product.go | 162 +++++++++++ internal/handlers/home.go | 11 + internal/handlers/payment.go | 110 ++++++++ internal/handlers/static/css/admin.css | 7 + internal/handlers/static/css/shop.css | 262 +++++++++++++++++- internal/handlers/templates/admin_orders.html | 7 +- .../templates/admin_product_form.html | 28 ++ internal/handlers/templates/catalog.html | 202 ++++++++++++-- internal/handlers/templates/shop_header.html | 2 +- internal/handlers/templates/shop_pages.html | 50 +++- internal/heleket/client.go | 138 +++++++++ internal/heleket/sign.go | 55 ++++ internal/models/product.go | 5 + internal/models/product_extra.go | 47 ++++ internal/models/user.go | 29 +- internal/orderstatus/status.go | 26 +- internal/repository/order.go | 86 ++++-- internal/repository/price_history.go | 49 ++++ internal/repository/product.go | 27 +- internal/repository/reservation.go | 79 ++++++ migrations/006_product_attrs_history.sql | 29 ++ migrations/007_heleket.sql | 5 + 31 files changed, 1673 insertions(+), 126 deletions(-) create mode 100644 internal/compare/compare.go create mode 100644 internal/handlers/customer_product.go create mode 100644 internal/handlers/payment.go create mode 100644 internal/heleket/client.go create mode 100644 internal/heleket/sign.go create mode 100644 internal/models/product_extra.go create mode 100644 internal/repository/price_history.go create mode 100644 internal/repository/reservation.go create mode 100644 migrations/006_product_attrs_history.sql create mode 100644 migrations/007_heleket.sql diff --git a/.env.example b/.env.example index 3a39781..a33c3e2 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,14 @@ ADMIN_PASSWORD=change_me # Секрет для сессий (обязательно смените в продакшене) SESSION_SECRET=your_random_secret_key_here +# Публичный URL магазина (для callback Heleket) +# PUBLIC_BASE_URL=https://shop.example.com + +# Heleket — крипто-платежи (https://doc.heleket.com/ru) +# HELEKET_MERCHANT_ID=your-merchant-uuid +# HELEKET_PAYMENT_API_KEY=your-payment-api-key +# HELEKET_CURRENCY=RUB + # Часовой пояс сервера (Ubuntu) TZ=Europe/Moscow diff --git a/cmd/shop/main.go b/cmd/shop/main.go index de9b2d9..6f334c5 100644 --- a/cmd/shop/main.go +++ b/cmd/shop/main.go @@ -13,6 +13,7 @@ import ( "shop/internal/config" "shop/internal/database" "shop/internal/handlers" + "shop/internal/heleket" "shop/internal/middleware" "shop/internal/repository" "shop/internal/upload" @@ -41,6 +42,9 @@ func main() { cartRepo := repository.NewCartRepository(pool) orderRepo := repository.NewOrderRepository(pool) + priceHistoryRepo := repository.NewPriceHistoryRepository(pool) + reservationRepo := repository.NewReservationRepository(pool) + storage, err := upload.NewStorage(cfg.UploadDir) if err != nil { log.Fatalf("upload storage: %v", err) @@ -61,12 +65,21 @@ func main() { adminSession := auth.NewSession(cfg.SessionSecret, "shop_admin_session", "/admin") userSession := auth.NewSession(cfg.SessionSecret, "shop_user_session", "/") cartSession := auth.NewSession(cfg.SessionSecret, "shop_cart_key", "/") + compareSession := auth.NewSession(cfg.SessionSecret, "shop_compare", "/") + + heleketClient := heleket.NewClient(cfg.HeleketMerchantID, cfg.HeleketPaymentAPIKey) + if heleketClient.Enabled() { + log.Printf("heleket payments enabled (currency: %s)", cfg.HeleketCurrency) + } customer := handlers.NewCustomerHandler( - productRepo, categoryRepo, userRepo, cartRepo, orderRepo, statsRepo, - userSession, cartSession, tmpl, + productRepo, categoryRepo, userRepo, cartRepo, orderRepo, + priceHistoryRepo, reservationRepo, statsRepo, + userSession, cartSession, compareSession, + heleketClient, cfg.HeleketPaymentAPIKey, cfg.PublicBaseURL, cfg.HeleketCurrency, + tmpl, ) - admin := handlers.NewAdminHandler(adminRepo, productRepo, categoryRepo, orderRepo, statsRepo, storage, adminSession, tmpl) + admin := handlers.NewAdminHandler(adminRepo, productRepo, categoryRepo, orderRepo, priceHistoryRepo, statsRepo, storage, adminSession, tmpl) mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler())) @@ -75,6 +88,11 @@ func main() { mux.HandleFunc("GET /{$}", customer.Home) mux.HandleFunc("GET /product/{id}", customer.ProductPage) + mux.HandleFunc("POST /product/{id}/reserve", customer.ProductReserve) + mux.HandleFunc("POST /buy", customer.BuyNow) + mux.HandleFunc("GET /compare", customer.ComparePage) + mux.HandleFunc("POST /compare/add", customer.CompareAdd) + mux.HandleFunc("POST /compare/remove", customer.CompareRemove) mux.HandleFunc("GET /category/{slug}", customer.CategoryPage) mux.HandleFunc("GET /register", customer.RegisterPage) mux.HandleFunc("POST /register", customer.Register) @@ -87,6 +105,9 @@ func main() { mux.HandleFunc("POST /cart/remove", customer.CartRemove) mux.HandleFunc("GET /checkout", customer.CheckoutPage) mux.HandleFunc("POST /checkout", customer.Checkout) + mux.HandleFunc("GET /order/{id}/success", customer.OrderSuccess) + mux.HandleFunc("GET /order/{id}/pay", customer.OrderPay) + mux.HandleFunc("POST /webhooks/heleket", customer.HeleketWebhook) mux.HandleFunc("GET /account", customer.Account) mux.HandleFunc("POST /account", customer.Account) diff --git a/docker-compose.yml b/docker-compose.yml index a32923a..01b4aab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: - ./migrations/003_admin_features.sql:/docker-entrypoint-initdb.d/003_admin_features.sql:ro - ./migrations/004_users_cart_orders.sql:/docker-entrypoint-initdb.d/004_users_cart_orders.sql:ro - ./migrations/005_categories_sale.sql:/docker-entrypoint-initdb.d/005_categories_sale.sql:ro + - ./migrations/006_product_attrs_history.sql:/docker-entrypoint-initdb.d/006_product_attrs_history.sql:ro + - ./migrations/007_heleket.sql:/docker-entrypoint-initdb.d/007_heleket.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"] interval: 5s diff --git a/internal/compare/compare.go b/internal/compare/compare.go new file mode 100644 index 0000000..d501bc1 --- /dev/null +++ b/internal/compare/compare.go @@ -0,0 +1,66 @@ +package compare + +import ( + "strconv" + "strings" +) + +const maxItems = 2 + +func Parse(raw string) []int { + if raw == "" { + return nil + } + var ids []int + for _, part := range strings.Split(raw, ",") { + id, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil || id <= 0 { + continue + } + ids = appendUnique(ids, id) + } + return ids +} + +func Format(ids []int) string { + if len(ids) == 0 { + return "" + } + parts := make([]string, len(ids)) + for i, id := range ids { + parts[i] = strconv.Itoa(id) + } + return strings.Join(parts, ",") +} + +func Add(raw string, id int) string { + if id <= 0 { + return raw + } + ids := Parse(raw) + ids = appendUnique(ids, id) + if len(ids) > maxItems { + ids = ids[len(ids)-maxItems:] + } + return Format(ids) +} + +func Remove(raw string, id int) string { + ids := Parse(raw) + var next []int + for _, v := range ids { + if v != id { + next = append(next, v) + } + } + return Format(next) +} + +func appendUnique(ids []int, id int) []int { + for _, v := range ids { + if v == id { + return ids + } + } + return append(ids, id) +} diff --git a/internal/config/config.go b/internal/config/config.go index b21533a..a4477a1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,21 +5,34 @@ import ( "encoding/hex" "log" "os" + "strings" ) type Config struct { - AdminEmail string - AdminPassword string - SessionSecret string - UploadDir string + AdminEmail string + AdminPassword string + SessionSecret string + UploadDir string + PublicBaseURL string + HeleketMerchantID string + HeleketPaymentAPIKey string + HeleketCurrency string } func Load() Config { cfg := Config{ - AdminEmail: os.Getenv("ADMIN_EMAIL"), - AdminPassword: os.Getenv("ADMIN_PASSWORD"), - SessionSecret: os.Getenv("SESSION_SECRET"), - UploadDir: os.Getenv("UPLOAD_DIR"), + AdminEmail: os.Getenv("ADMIN_EMAIL"), + AdminPassword: os.Getenv("ADMIN_PASSWORD"), + SessionSecret: os.Getenv("SESSION_SECRET"), + UploadDir: os.Getenv("UPLOAD_DIR"), + PublicBaseURL: strings.TrimRight(os.Getenv("PUBLIC_BASE_URL"), "/"), + HeleketMerchantID: os.Getenv("HELEKET_MERCHANT_ID"), + HeleketPaymentAPIKey: os.Getenv("HELEKET_PAYMENT_API_KEY"), + HeleketCurrency: os.Getenv("HELEKET_CURRENCY"), + } + + if cfg.HeleketCurrency == "" { + cfg.HeleketCurrency = "RUB" } if cfg.UploadDir == "" { diff --git a/internal/database/migrate.go b/internal/database/migrate.go index 80ae330..ee9585c 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -96,6 +96,36 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error { ON CONFLICT (slug) DO NOTHING`, `UPDATE products p SET category_id = c.id FROM categories c WHERE p.category = c.name AND p.category_id IS NULL`, + `ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_size VARCHAR(50) NOT NULL DEFAULT ''`, + `ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_color VARCHAR(50) NOT NULL DEFAULT ''`, + `ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_material VARCHAR(100) NOT NULL DEFAULT ''`, + `ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_weight VARCHAR(50) NOT NULL DEFAULT ''`, + `ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_brand VARCHAR(100) NOT NULL DEFAULT ''`, + `CREATE TABLE IF NOT EXISTS product_price_history ( + id SERIAL PRIMARY KEY, + product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE, + price NUMERIC(10,2) NOT NULL, + sale_price NUMERIC(10,2), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_price_history_product ON product_price_history (product_id, recorded_at DESC)`, + `CREATE TABLE IF NOT EXISTS product_reservations ( + id SERIAL PRIMARY KEY, + product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE, + session_key VARCHAR(64) NOT NULL DEFAULT '', + user_id INT REFERENCES users(id) ON DELETE SET NULL, + customer_name VARCHAR(255) NOT NULL DEFAULT '', + customer_phone VARCHAR(50) NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_reservations_product ON product_reservations (product_id, expires_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_reservations_session ON product_reservations (session_key)`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) NOT NULL DEFAULT 'cod'`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_uuid VARCHAR(64) NOT NULL DEFAULT ''`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_order_id VARCHAR(128) NOT NULL DEFAULT ''`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_payment_url TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_payment_status VARCHAR(50) NOT NULL DEFAULT ''`, } for _, q := range queries { diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go index e709a44..7d1d8e2 100644 --- a/internal/handlers/admin.go +++ b/internal/handlers/admin.go @@ -16,8 +16,9 @@ type AdminHandler struct { repo *repository.AdminRepository products *repository.ProductRepository categories *repository.CategoryRepository - orders *repository.OrderRepository - stats *repository.StatsRepository + orders *repository.OrderRepository + priceHistory *repository.PriceHistoryRepository + stats *repository.StatsRepository storage *upload.Storage session *auth.Session templates *template.Template @@ -73,6 +74,7 @@ func NewAdminHandler( productRepo *repository.ProductRepository, categoryRepo *repository.CategoryRepository, orderRepo *repository.OrderRepository, + priceHistory *repository.PriceHistoryRepository, statsRepo *repository.StatsRepository, storage *upload.Storage, session *auth.Session, @@ -82,8 +84,9 @@ func NewAdminHandler( repo: adminRepo, products: productRepo, categories: categoryRepo, - orders: orderRepo, - stats: statsRepo, + orders: orderRepo, + priceHistory: priceHistory, + stats: statsRepo, storage: storage, session: session, templates: templates, diff --git a/internal/handlers/admin_products.go b/internal/handlers/admin_products.go index 5d746bb..d294c92 100644 --- a/internal/handlers/admin_products.go +++ b/internal/handlers/admin_products.go @@ -97,16 +97,21 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) { } product := models.Product{ - ID: productID, - Name: r.FormValue("name"), - Description: r.FormValue("description"), - Details: r.FormValue("details"), - Price: price, - SalePrice: salePrice, - Category: categoryName, - CategoryID: catID, - IsActive: r.FormValue("is_active") == "on", - ImageURL: r.FormValue("image_url"), + ID: productID, + Name: r.FormValue("name"), + Description: r.FormValue("description"), + Details: r.FormValue("details"), + Price: price, + SalePrice: salePrice, + Category: categoryName, + CategoryID: catID, + IsActive: r.FormValue("is_active") == "on", + ImageURL: r.FormValue("image_url"), + AttrSize: r.FormValue("attr_size"), + AttrColor: r.FormValue("attr_color"), + AttrMaterial: r.FormValue("attr_material"), + AttrWeight: r.FormValue("attr_weight"), + AttrBrand: r.FormValue("attr_brand"), } if product.Name == "" || product.Price < 0 { @@ -128,6 +133,7 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } + _ = h.priceHistory.Record(ctx, product.ID, product.Price, product.SalePrice) } else { existing, err := h.products.GetByID(ctx, product.ID) if err != nil { @@ -142,6 +148,9 @@ func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } + if existing.Price != product.Price || existing.SalePrice != product.SalePrice { + _ = h.priceHistory.Record(ctx, product.ID, product.Price, product.SalePrice) + } } if err := h.saveUploadedImages(r, product.ID, true); err != nil { diff --git a/internal/handlers/customer.go b/internal/handlers/customer.go index 6b2de78..b896971 100644 --- a/internal/handlers/customer.go +++ b/internal/handlers/customer.go @@ -8,6 +8,8 @@ import ( "strconv" "shop/internal/auth" + "shop/internal/compare" + "shop/internal/heleket" "shop/internal/models" "shop/internal/repository" "shop/internal/version" @@ -18,18 +20,26 @@ type CustomerHandler struct { categories *repository.CategoryRepository users *repository.UserRepository cart *repository.CartRepository - orders *repository.OrderRepository - stats *repository.StatsRepository - userSession *auth.Session - cartSession *auth.Session - templates *template.Template + orders *repository.OrderRepository + priceHistory *repository.PriceHistoryRepository + reservations *repository.ReservationRepository + stats *repository.StatsRepository + userSession *auth.Session + cartSession *auth.Session + compareSession *auth.Session + heleket *heleket.Client + heleketAPIKey string + publicBaseURL string + heleketCurrency string + templates *template.Template } type shopPage struct { Title string User *models.User - CartCount int - Version string + CartCount int + CompareCount int + Version string Products interface{} Categories interface{} Error string @@ -44,12 +54,13 @@ type cartPage struct { type checkoutPage struct { shopPage - Items []models.CartItem - Total float64 - Name string - Email string - Phone string - Address string + Items []models.CartItem + Total float64 + Name string + Email string + Phone string + Address string + HeleketEnabled bool } type accountPage struct { @@ -62,7 +73,20 @@ type accountPage struct { type productPage struct { shopPage - Product models.Product + Product models.Product + PriceHistory []models.PriceHistoryEntry + MaxHistoryPrice float64 + Reservation *models.Reservation + ReservedByOther bool + CompareProducts []models.Product + CompareCount int + Success string + Error string +} + +type comparePage struct { + shopPage + Products []models.Product } type categoryPage struct { @@ -77,13 +101,21 @@ func NewCustomerHandler( users *repository.UserRepository, cart *repository.CartRepository, orders *repository.OrderRepository, + priceHistory *repository.PriceHistoryRepository, + reservations *repository.ReservationRepository, stats *repository.StatsRepository, - userSession, cartSession *auth.Session, + userSession, cartSession, compareSession *auth.Session, + heleketClient *heleket.Client, + heleketAPIKey, publicBaseURL, heleketCurrency string, templates *template.Template, ) *CustomerHandler { return &CustomerHandler{ - products: products, categories: categories, users: users, cart: cart, orders: orders, stats: stats, - userSession: userSession, cartSession: cartSession, templates: templates, + products: products, categories: categories, users: users, cart: cart, orders: orders, + priceHistory: priceHistory, reservations: reservations, stats: stats, + userSession: userSession, cartSession: cartSession, compareSession: compareSession, + heleket: heleketClient, heleketAPIKey: heleketAPIKey, + publicBaseURL: publicBaseURL, heleketCurrency: heleketCurrency, + templates: templates, } } @@ -108,6 +140,7 @@ func (h *CustomerHandler) basePage(r *http.Request, w http.ResponseWriter, title p.CartCount = n } } + p.CompareCount = h.compareCount(r) return p } @@ -140,15 +173,64 @@ func (h *CustomerHandler) ProductPage(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - product, err := h.products.GetActiveByID(r.Context(), id) + ctx := r.Context() + product, err := h.products.GetActiveByID(ctx, id) if err != nil { http.NotFound(w, r) return } + p := productPage{ shopPage: h.basePage(r, w, product.Name), Product: product, } + p.PriceHistory, _ = h.priceHistory.ByProduct(ctx, id, 12) + for _, e := range p.PriceHistory { + if e.Price > p.MaxHistoryPrice { + p.MaxHistoryPrice = e.Price + } + } + + sessionKey := h.cartSession.EnsureKey(w, r) + if res, err := h.reservations.ActiveByProduct(ctx, id); err == nil { + p.Reservation = res + if res.SessionKey != sessionKey { + var uid int + if u, ok := auth.UserIDFromSession(h.userSession, r); ok { + uid = u + } + if res.UserID == nil || uid == 0 || *res.UserID != uid { + p.ReservedByOther = true + } + } + } + + compareRaw, _ := h.compareSession.FromRequest(r) + compareIDs := compare.Parse(compareRaw) + p.CompareCount = len(compareIDs) + + var others []models.Product + if product.CategoryID != nil { + others, _ = h.products.ByCategoryID(ctx, *product.CategoryID) + } else { + others, _ = h.products.Featured(ctx, 50) + } + for _, o := range others { + if o.ID != id { + p.CompareProducts = append(p.CompareProducts, o) + } + } + + switch r.URL.Query().Get("ok") { + case "reserve": + p.Success = "Товар забронирован на 24 часа" + case "compare": + p.Success = "Товар добавлен к сравнению" + } + if r.URL.Query().Get("err") == "reserved" { + p.Error = "Товар уже забронирован другим покупателем" + } + h.render(w, "product.html", p) } @@ -321,9 +403,10 @@ func (h *CustomerHandler) CheckoutPage(w http.ResponseWriter, r *http.Request) { } total, _ := h.cart.Total(ctx, cartID) p := checkoutPage{ - shopPage: h.basePage(r, w, "Оформление заказа"), - Items: items, - Total: total, + shopPage: h.basePage(r, w, "Оформление заказа"), + Items: items, + Total: total, + HeleketEnabled: h.heleket.Enabled(), } if p.User != nil { p.Name = p.User.Name @@ -354,9 +437,14 @@ func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) { address := r.FormValue("address") p := checkoutPage{ - shopPage: h.basePage(r, w, "Оформление заказа"), - Items: items, Total: total, - Name: name, Email: email, Phone: phone, Address: address, + shopPage: h.basePage(r, w, "Оформление заказа"), + Items: items, + Total: total, + Name: name, + Email: email, + Phone: phone, + Address: address, + HeleketEnabled: h.heleket.Enabled(), } if name == "" || email == "" || phone == "" || address == "" { p.Error = "Заполните все поля доставки" @@ -364,9 +452,22 @@ func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) { return } + paymentMethod := r.FormValue("payment_method") + if paymentMethod != "heleket" { + paymentMethod = "cod" + } + if paymentMethod == "heleket" && !h.heleket.Enabled() { + p.Error = "Онлайн-оплата временно недоступна" + h.render(w, "checkout.html", p) + return + } + order := models.Order{ GuestName: name, GuestEmail: email, GuestPhone: phone, - Address: address, Total: total, + Address: address, Total: total, PaymentMethod: paymentMethod, + } + if paymentMethod == "heleket" { + order.Status = "unpaid" } if uid, ok := auth.UserIDFromSession(h.userSession, r); ok { order.UserID = &uid @@ -379,6 +480,36 @@ func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) { h.render(w, "checkout.html", p) return } + + if paymentMethod == "heleket" { + externalID := heleket.OrderExternalID(order.ID) + base := h.publicBaseURL + if base == "" { + base = "http://" + r.Host + } + invoice, err := h.heleket.CreateInvoice(ctx, heleket.InvoiceRequest{ + Amount: heleket.FormatAmount(total), + Currency: h.heleketCurrency, + OrderID: externalID, + URLReturn: base + "/cart", + URLSuccess: base + "/order/" + strconv.Itoa(order.ID) + "/success", + URLCallback: base + "/webhooks/heleket", + PayerEmail: email, + Lifetime: 3600, + AdditionalData: "order:" + strconv.Itoa(order.ID), + }) + if err != nil { + log.Printf("heleket invoice: %v", err) + p.Error = "Заказ #" + strconv.Itoa(order.ID) + " создан, но счёт не выдан. Попробуйте оплатить из личного кабинета." + h.render(w, "checkout.html", p) + return + } + _ = h.orders.UpdateHeleketPayment(ctx, order.ID, invoice.UUID, externalID, invoice.URL, invoice.PaymentStatus) + _ = h.cart.Clear(ctx, cartID) + http.Redirect(w, r, invoice.URL, http.StatusSeeOther) + return + } + _ = h.cart.Clear(ctx, cartID) success := h.basePage(r, w, "Заказ оформлен") diff --git a/internal/handlers/customer_product.go b/internal/handlers/customer_product.go new file mode 100644 index 0000000..673e28c --- /dev/null +++ b/internal/handlers/customer_product.go @@ -0,0 +1,162 @@ +package handlers + +import ( + "log" + "net/http" + "strconv" + "time" + + "shop/internal/auth" + "shop/internal/compare" + "shop/internal/models" +) + +func (h *CustomerHandler) BuyNow(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + productID, _ := strconv.Atoi(r.FormValue("product_id")) + qty, _ := strconv.Atoi(r.FormValue("quantity")) + if qty < 1 { + qty = 1 + } + if productID <= 0 { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + cartID, err := h.resolveCart(r, w) + if err == nil { + _ = h.cart.AddItem(r.Context(), cartID, productID, qty) + } + http.Redirect(w, r, "/checkout", http.StatusSeeOther) +} + +func (h *CustomerHandler) ProductReserve(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + ctx := r.Context() + if _, err := h.products.GetActiveByID(ctx, id); err != nil { + http.NotFound(w, r) + return + } + + sessionKey := h.cartSession.EnsureKey(w, r) + if active, err := h.reservations.ActiveByProduct(ctx, id); err == nil { + if active.SessionKey != sessionKey { + var uid int + if u, ok := auth.UserIDFromSession(h.userSession, r); ok { + uid = u + } + if active.UserID == nil || uid == 0 || *active.UserID != uid { + http.Redirect(w, r, "/product/"+strconv.Itoa(id)+"?err=reserved", http.StatusSeeOther) + return + } + } + } + + name := r.FormValue("name") + phone := r.FormValue("phone") + if uid, ok := auth.UserIDFromSession(h.userSession, r); ok { + if u, err := h.users.GetByID(ctx, uid); err == nil { + if name == "" { + name = u.Name + } + if phone == "" { + phone = u.Phone + } + } + } + if name == "" { + name = "Гость" + } + + res := models.Reservation{ + ProductID: id, + SessionKey: sessionKey, + CustomerName: name, + CustomerPhone: phone, + ExpiresAt: time.Now().Add(h.reservations.ExpiresIn()), + } + if uid, ok := auth.UserIDFromSession(h.userSession, r); ok { + res.UserID = &uid + } + if err := h.reservations.ReplaceForProduct(ctx, &res); err != nil { + log.Printf("reserve: %v", err) + http.Redirect(w, r, "/product/"+strconv.Itoa(id), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/product/"+strconv.Itoa(id)+"?ok=reserve", http.StatusSeeOther) +} + +func (h *CustomerHandler) CompareAdd(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + productID, _ := strconv.Atoi(r.FormValue("product_id")) + otherID, _ := strconv.Atoi(r.FormValue("other_id")) + redirect := r.FormValue("redirect") + if redirect == "" { + redirect = "/compare" + } + raw, _ := h.compareSession.FromRequest(r) + next := raw + if productID > 0 { + next = compare.Add(next, productID) + } + if otherID > 0 { + next = compare.Add(next, otherID) + } + if next == raw && productID <= 0 { + http.Redirect(w, r, redirect, http.StatusSeeOther) + return + } + token, _ := h.compareSession.Create(next) + h.compareSession.SetCookie(w, r, token) + http.Redirect(w, r, redirect, http.StatusSeeOther) +} + +func (h *CustomerHandler) CompareRemove(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + productID, _ := strconv.Atoi(r.FormValue("product_id")) + raw, _ := h.compareSession.FromRequest(r) + next := compare.Remove(raw, productID) + token, _ := h.compareSession.Create(next) + h.compareSession.SetCookie(w, r, token) + http.Redirect(w, r, "/compare", http.StatusSeeOther) +} + +func (h *CustomerHandler) ComparePage(w http.ResponseWriter, r *http.Request) { + raw, _ := h.compareSession.FromRequest(r) + ids := compare.Parse(raw) + ctx := r.Context() + + var products []models.Product + for _, id := range ids { + if p, err := h.products.GetActiveByID(ctx, id); err == nil { + products = append(products, p) + } + } + + p := comparePage{ + shopPage: h.basePage(r, w, "Сравнение товаров"), + Products: products, + } + h.render(w, "compare.html", p) +} + +func (h *CustomerHandler) compareCount(r *http.Request) int { + raw, _ := h.compareSession.FromRequest(r) + return len(compare.Parse(raw)) +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go index 1b2c755..6068e44 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -32,6 +32,7 @@ func ParseTemplates() (*template.Template, error) { "effective": effectivePrice, "safeHTML": func(s string) template.HTML { return template.HTML(s) }, "orderLabel": orderstatus.Label, + "orderIcon": orderstatus.Icon, "orderClass": orderstatus.Class, "orderStep": orderstatus.Step, "orderTimeline": func() []string { return orderstatus.Timeline }, @@ -44,6 +45,16 @@ func ParseTemplates() (*template.Template, error) { }, "orderTerminal": orderstatus.IsTerminal, "orderNorm": orderstatus.Normalize, + "historyBarPct": func(price, max float64) int { + if max <= 0 { + return 20 + } + pct := int(price / max * 100) + if pct < 8 { + return 8 + } + return pct + }, "catSelected": func(catID int, productCatID *int) bool { return productCatID != nil && *productCatID == catID }, diff --git a/internal/handlers/payment.go b/internal/handlers/payment.go new file mode 100644 index 0000000..d0f88e7 --- /dev/null +++ b/internal/handlers/payment.go @@ -0,0 +1,110 @@ +package handlers + +import ( + "io" + "log" + "net" + "net/http" + "strconv" + + "shop/internal/heleket" + "shop/internal/models" +) + +const heleketWebhookIP = "31.133.220.8" + +func (h *CustomerHandler) HeleketWebhook(w http.ResponseWriter, r *http.Request) { + if !h.heleket.Enabled() { + http.NotFound(w, r) + return + } + + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + host = fwd + } + if host != heleketWebhookIP && host != "" { + log.Printf("heleket webhook: note ip %s (expected %s)", host, heleketWebhookIP) + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + payload, ok := heleket.VerifyWebhook(body, h.heleketAPIKey) + if !ok { + log.Printf("heleket webhook: invalid signature") + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + orderIDStr, _ := payload["order_id"].(string) + orderID, ok := heleket.ParseOrderExternalID(orderIDStr) + if !ok { + log.Printf("heleket webhook: unknown order_id %q", orderIDStr) + w.WriteHeader(http.StatusOK) + return + } + + paymentStatus, _ := payload["payment_status"].(string) + if paymentStatus == "" { + if s, ok := payload["status"].(string); ok { + paymentStatus = s + } + } + + ctx := r.Context() + orderStatus := heleket.MapPaymentStatus(paymentStatus) + if err := h.orders.UpdateHeleketStatus(ctx, orderID, paymentStatus, orderStatus); err != nil { + log.Printf("heleket webhook update: %v", err) + } + + w.WriteHeader(http.StatusOK) +} + +type orderPayPage struct { + shopPage + Order models.Order +} + +func (h *CustomerHandler) OrderSuccess(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + order, err := h.orders.GetByID(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + + p := orderPayPage{ + shopPage: h.basePage(r, w, "Заказ #"+strconv.Itoa(order.ID)), + Order: order, + } + h.render(w, "order_success.html", p) +} + +func (h *CustomerHandler) OrderPay(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + order, err := h.orders.GetByID(r.Context(), id) + if err != nil || !order.IsHeleket() { + http.NotFound(w, r) + return + } + if order.HeleketPaymentURL != "" && !heleket.IsPaid(order.HeleketPaymentStatus) { + http.Redirect(w, r, order.HeleketPaymentURL, http.StatusSeeOther) + return + } + http.Redirect(w, r, "/order/"+strconv.Itoa(id)+"/success", http.StatusSeeOther) +} diff --git a/internal/handlers/static/css/admin.css b/internal/handlers/static/css/admin.css index 5215085..ae24302 100644 --- a/internal/handlers/static/css/admin.css +++ b/internal/handlers/static/css/admin.css @@ -385,6 +385,13 @@ margin-bottom: 8px; } +.admin-section__subtitle { + font-size: 0.95rem; + font-weight: 600; + margin: 20px 0 8px; + color: #374151; +} + .admin-editor { min-height: 160px; background: #fff; diff --git a/internal/handlers/static/css/shop.css b/internal/handlers/static/css/shop.css index c31cfa4..5b930b8 100644 --- a/internal/handlers/static/css/shop.css +++ b/internal/handlers/static/css/shop.css @@ -385,6 +385,9 @@ } .order-status { + display: inline-flex; + align-items: center; + gap: 6px; padding: 6px 14px; border-radius: 100px; font-size: 0.8rem; @@ -453,11 +456,268 @@ align-items: flex-end; } +/* Иконки статусов */ +.order-status__icon { + font-size: 0.95em; + line-height: 1; +} + +/* Характеристики товара */ +.product-specs { + display: grid; + gap: 8px; + margin: 16px 0; + padding: 16px; + background: #f9fafb; + border-radius: 12px; + border: 1px solid #e5e7eb; +} + +.product-specs__row { + display: grid; + grid-template-columns: 120px 1fr; + gap: 12px; + font-size: 0.9rem; +} + +.product-specs__row dt { + color: #6b7280; + margin: 0; +} + +.product-specs__row dd { + margin: 0; + font-weight: 600; + color: #111827; +} + +/* Кнопки товара */ +.product-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 20px 0; +} + +.product-actions__form { + display: flex; + gap: 8px; + align-items: center; +} + +.product-reserve { + margin: 20px 0; + padding: 16px; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 12px; +} + +.product-reserve__row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.shop-input { + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.9rem; + min-width: 140px; +} + +.shop-input--sm { + min-width: 200px; + padding: 8px 10px; +} + +.btn--secondary { + background: #fef3c7; + color: #92400e; + border: 1px solid #fcd34d; +} + +.btn--secondary:hover { + background: #fde68a; +} + +.shop-alert--warn { + background: #fffbeb; + color: #92400e; + border: 1px solid #fcd34d; +} + +.product-compare { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #e5e7eb; +} + +.product-compare__form { + display: flex; + gap: 8px; + align-items: center; +} + +/* История цены */ +.product-price-history { + margin-top: 32px; +} + +.price-history-chart { + display: flex; + align-items: flex-end; + gap: 12px; + height: 160px; + margin: 20px 0; + padding: 0 8px; +} + +.price-history-chart__bar { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + justify-content: flex-end; + min-width: 48px; +} + +.price-history-chart__fill { + width: 100%; + max-width: 48px; + background: linear-gradient(180deg, #818cf8, #4f46e5); + border-radius: 6px 6px 0 0; + min-height: 8px; +} + +.price-history-chart__value { + font-size: 0.7rem; + color: #4f46e5; + font-weight: 600; + margin-bottom: 4px; +} + +.price-history-chart__label { + font-size: 0.7rem; + color: #9ca3af; + margin-top: 6px; +} + +.price-history-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.price-history-table th, +.price-history-table td { + padding: 10px 12px; + border-bottom: 1px solid #e5e7eb; + text-align: left; +} + +.price-history-table th { + color: #6b7280; + font-weight: 600; +} + +/* Сравнение */ +.compare-table-wrap { + overflow-x: auto; + margin-top: 24px; +} + +.compare-table { + width: 100%; + border-collapse: collapse; + min-width: 600px; +} + +.compare-table th, +.compare-table td { + padding: 14px 16px; + border: 1px solid #e5e7eb; + vertical-align: top; +} + +.compare-table th { + background: #f9fafb; +} + +.compare-table__img { + width: 80px; + height: 80px; + object-fit: cover; + border-radius: 8px; +} + +.compare-table__remove { + margin-top: 8px; +} + +.compare-table__spec td:first-child { + font-weight: 600; + color: #6b7280; + background: #f9fafb; +} + +.checkout-payment-title { + margin: 24px 0 12px; + font-size: 1rem; +} + +.checkout-payment { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; +} + +.checkout-payment__option { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border: 2px solid #e5e7eb; + border-radius: 12px; + cursor: pointer; + transition: border-color 0.15s; +} + +.checkout-payment__option:has(input:checked) { + border-color: #4f46e5; + background: #eef2ff; +} + +.checkout-payment__label { + display: flex; + flex-direction: column; + gap: 2px; +} + +.checkout-payment__label small { + color: #6b7280; + font-size: 0.85rem; +} + +.order-card__payment { + font-size: 0.85rem; + color: #6b7280; + margin-top: 8px; +} + @media (max-width: 768px) { .product-detail { grid-template-columns: 1fr; } -} + .cart-layout, .checkout-layout, .account-layout { diff --git a/internal/handlers/templates/admin_orders.html b/internal/handlers/templates/admin_orders.html index a8fb5a8..99ba2d0 100644 --- a/internal/handlers/templates/admin_orders.html +++ b/internal/handlers/templates/admin_orders.html @@ -41,10 +41,13 @@
{{.GuestName}}
{{.GuestEmail}}
{{.GuestPhone}}
+ {{if eq .PaymentMethod "heleket"}} +
Heleket {{if .HeleketPaymentStatus}}({{.HeleketPaymentStatus}}){{end}}
+ {{end}} {{printf "%.0f" .Total}} ₽ - {{orderLabel .Status}} + {{orderIcon .Status}} {{orderLabel .Status}}
@@ -80,7 +83,7 @@

Статусы

{{range .StatusList}} - {{.Label}} + {{orderIcon .Code}} {{.Label}} {{end}}
diff --git a/internal/handlers/templates/admin_product_form.html b/internal/handlers/templates/admin_product_form.html index 06f1765..84453cc 100644 --- a/internal/handlers/templates/admin_product_form.html +++ b/internal/handlers/templates/admin_product_form.html @@ -63,6 +63,34 @@ Показывать в каталоге + +

Характеристики

+

Пустые поля не отображаются на сайте

+ +
+ + +
+
+ + +
+
diff --git a/internal/handlers/templates/catalog.html b/internal/handlers/templates/catalog.html index 21c1dee..827aaa2 100644 --- a/internal/handlers/templates/catalog.html +++ b/internal/handlers/templates/catalog.html @@ -21,6 +21,19 @@ {{.Product.Name}} + {{if .Success}}
{{.Success}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} + + {{if .ReservedByOther}} +
+ Товар забронирован до {{.Reservation.ExpiresAt.Format "02.01.2006 15:04"}} +
+ {{else if .Reservation}} +
+ Вы забронировали этот товар до {{.Reservation.ExpiresAt.Format "02.01.2006 15:04"}} +
+ {{end}} +
+ + {{$specs := .Product.Specs}} + {{if $specs}} +
+ {{range $specs}} +
+
{{.Label}}
+
{{.Value}}
+
+ {{end}} +
+ {{end}} +

{{plain .Product.Description}}

- - - - + +
+ + + + + +
+ + + +
+
+ + {{if not .ReservedByOther}} +
+

Забронировать на 24 часа — товар будет отложен для вас

+
+ + + +
+ {{end}} + +
+
+ + + +
+ {{if .CompareProducts}} +
+ + + + +
+ {{end}} +
+ {{if .PriceHistory}} +
+

История цены

+
+ {{range .PriceHistory}} +
+
+ {{printf "%.0f" .Price}} ₽ + {{.RecordedAt.Format "02.01"}} +
+ {{end}} +
+ + + + + + {{range .PriceHistory}} + + + + + + {{end}} + +
ДатаЦенаСо скидкой
{{.RecordedAt.Format "02.01.2006"}}{{printf "%.0f" .Price}} ₽{{if gt .SalePrice 0}}{{printf "%.0f" .SalePrice}} ₽{{else}}—{{end}}
+
+ {{end}} + {{if .Product.Details}}

Описание

@@ -66,34 +161,103 @@ {{end}} -{{define "category.html"}} +{{define "compare.html"}} - {{.Category.Name}} — Shop + Сравнение товаров — Shop {{template "shop_header" .}}
- -

{{.Category.Name}}

- {{if .Category.Description}}

{{.Category.Description}}

{{end}} -
- {{range .Products}} - {{template "product_card" .}} - {{else}} -

В этой категории пока нет товаров.

- {{end}} +

Сравнение товаров

+ {{if .Products}} +
+ + + + + {{range .Products}} + + {{end}} + + + + + + {{range .Products}} + + {{end}} + + + + {{range .Products}} + + {{end}} + + + + {{range .Products}}{{end}} + + + + {{range .Products}}{{end}} + + + + {{range .Products}}{{end}} + + + + {{range .Products}}{{end}} + + + + {{range .Products}}{{end}} + + + + {{range .Products}}{{end}} + + + + {{range .Products}} + + {{end}} + + +
+ {{.Name}} +
+ + +
+
Фото
Цена + {{if .HasSale}} + {{printf "%.0f" .Price}} ₽ + {{printf "%.0f" .SalePrice}} ₽ + {{else}} + {{printf "%.0f" .Price}} ₽ + {{end}} +
Категория{{.Category}}
Бренд{{if .AttrBrand}}{{.AttrBrand}}{{else}}—{{end}}
Размер{{if .AttrSize}}{{.AttrSize}}{{else}}—{{end}}
Цвет{{if .AttrColor}}{{.AttrColor}}{{else}}—{{end}}
Материал{{if .AttrMaterial}}{{.AttrMaterial}}{{else}}—{{end}}
Вес{{if .AttrWeight}}{{.AttrWeight}}{{else}}—{{end}}
+
+ + + +
+
+ {{else}} +

Добавьте до 2 товаров для сравнения со страницы товара.

+ В каталог + {{end}}
{{end}} + +{{define "category.html"}} \ No newline at end of file diff --git a/internal/handlers/templates/shop_header.html b/internal/handlers/templates/shop_header.html index b8e7d89..5d5aa89 100644 --- a/internal/handlers/templates/shop_header.html +++ b/internal/handlers/templates/shop_header.html @@ -6,9 +6,9 @@ Shop
{{if .User}} diff --git a/internal/handlers/templates/shop_pages.html b/internal/handlers/templates/shop_pages.html index c7b73cc..a57dfcf 100644 --- a/internal/handlers/templates/shop_pages.html +++ b/internal/handlers/templates/shop_pages.html @@ -86,6 +86,27 @@ + +

Способ оплаты

+
+ + {{if .HeleketEnabled}} + + {{end}} +
+
@@ -195,6 +232,15 @@ {{end}}

Итого: {{printf "%.0f" .Total}} ₽

+ {{if .IsHeleket}} +

+ Оплата: Heleket + {{if .HeleketPaymentStatus}}({{.HeleketPaymentStatus}}){{end}} + {{if and .HeleketPaymentURL (ne .Status "paid")}} + — оплатить + {{end}} +

+ {{end}} {{end}} {{else}} diff --git a/internal/heleket/client.go b/internal/heleket/client.go new file mode 100644 index 0000000..1777b32 --- /dev/null +++ b/internal/heleket/client.go @@ -0,0 +1,138 @@ +package heleket + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +const apiBase = "https://api.heleket.com" + +type Client struct { + merchantID string + apiKey string + http *http.Client +} + +type InvoiceRequest struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + OrderID string `json:"order_id"` + URLReturn string `json:"url_return,omitempty"` + URLSuccess string `json:"url_success,omitempty"` + URLCallback string `json:"url_callback,omitempty"` + PayerEmail string `json:"payer_email,omitempty"` + Lifetime int `json:"lifetime,omitempty"` + AdditionalData string `json:"additional_data,omitempty"` +} + +type Invoice struct { + UUID string `json:"uuid"` + OrderID string `json:"order_id"` + Amount string `json:"amount"` + PaymentStatus string `json:"payment_status"` + URL string `json:"url"` + ExpiredAt int64 `json:"expired_at"` + IsFinal bool `json:"is_final"` +} + +type apiResponse struct { + State int `json:"state"` + Result json.RawMessage `json:"result"` + Errors json.RawMessage `json:"errors"` +} + +func NewClient(merchantID, apiKey string) *Client { + return &Client{ + merchantID: merchantID, + apiKey: apiKey, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *Client) Enabled() bool { + return c.merchantID != "" && c.apiKey != "" +} + +func OrderExternalID(orderID int) string { + return fmt.Sprintf("shop-order-%d", orderID) +} + +func ParseOrderExternalID(orderID string) (int, bool) { + const prefix = "shop-order-" + if len(orderID) <= len(prefix) || orderID[:len(prefix)] != prefix { + return 0, false + } + id, err := strconv.Atoi(orderID[len(prefix):]) + return id, err == nil && id > 0 +} + +func FormatAmount(amount float64) string { + return strconv.FormatFloat(amount, 'f', 2, 64) +} + +func (c *Client) CreateInvoice(ctx context.Context, req InvoiceRequest) (Invoice, error) { + body, err := encodeBody(req) + if err != nil { + return Invoice{}, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+"/v1/payment", bytes.NewReader(body)) + if err != nil { + return Invoice{}, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("merchant", c.merchantID) + httpReq.Header.Set("sign", SignBody(body, c.apiKey)) + + resp, err := c.http.Do(httpReq) + if err != nil { + return Invoice{}, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return Invoice{}, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return Invoice{}, fmt.Errorf("heleket http %d: %s", resp.StatusCode, string(respBody)) + } + + var wrapped apiResponse + if err := json.Unmarshal(respBody, &wrapped); err != nil { + return Invoice{}, err + } + if wrapped.State != 0 { + return Invoice{}, fmt.Errorf("heleket api state %d: %s", wrapped.State, string(wrapped.Errors)) + } + + var invoice Invoice + if err := json.Unmarshal(wrapped.Result, &invoice); err != nil { + return Invoice{}, err + } + return invoice, nil +} + +// MapPaymentStatus maps Heleket payment_status to shop order status. +func MapPaymentStatus(paymentStatus string) string { + switch paymentStatus { + case "paid", "paid_over": + return "paid" + case "cancel", "fail", "system_fail": + return "cancelled" + case "refund_paid": + return "refunded" + default: + return "unpaid" + } +} + +func IsPaid(paymentStatus string) bool { + return paymentStatus == "paid" || paymentStatus == "paid_over" +} diff --git a/internal/heleket/sign.go b/internal/heleket/sign.go new file mode 100644 index 0000000..550d83e --- /dev/null +++ b/internal/heleket/sign.go @@ -0,0 +1,55 @@ +package heleket + +import ( + "crypto/md5" + "encoding/base64" + "encoding/hex" + "encoding/json" + "strings" +) + +// encodeBody matches PHP json_encode default (slashes escaped, unicode unescaped). +func encodeBody(v any) ([]byte, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + return []byte(strings.ReplaceAll(string(b), "/", `\/`)), nil +} + +func SignBody(body []byte, apiKey string) string { + escaped := strings.ReplaceAll(string(body), "/", `\/`) + sum := md5.Sum([]byte(base64.StdEncoding.EncodeToString([]byte(escaped)) + apiKey)) + return hex.EncodeToString(sum[:]) +} + +func VerifyWebhook(body []byte, apiKey string) (map[string]any, bool) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return nil, false + } + signRaw, ok := raw["sign"] + if !ok { + return nil, false + } + var sign string + if err := json.Unmarshal(signRaw, &sign); err != nil { + return nil, false + } + delete(raw, "sign") + + payload, err := encodeBody(raw) + if err != nil { + return nil, false + } + if SignBody(payload, apiKey) != sign { + return nil, false + } + + var data map[string]any + if err := json.Unmarshal(body, &data); err != nil { + return nil, false + } + delete(data, "sign") + return data, true +} diff --git a/internal/models/product.go b/internal/models/product.go index 637d1d5..0c8944b 100644 --- a/internal/models/product.go +++ b/internal/models/product.go @@ -14,6 +14,11 @@ type Product struct { CategoryID *int CategorySlug string IsActive bool + AttrSize string + AttrColor string + AttrMaterial string + AttrWeight string + AttrBrand string CreatedAt time.Time UpdatedAt time.Time Images []ProductImage diff --git a/internal/models/product_extra.go b/internal/models/product_extra.go new file mode 100644 index 0000000..56958b3 --- /dev/null +++ b/internal/models/product_extra.go @@ -0,0 +1,47 @@ +package models + +import "time" + +type ProductSpec struct { + Label string + Value string +} + +func (p Product) Specs() []ProductSpec { + var specs []ProductSpec + if p.AttrBrand != "" { + specs = append(specs, ProductSpec{"Бренд", p.AttrBrand}) + } + if p.AttrSize != "" { + specs = append(specs, ProductSpec{"Размер", p.AttrSize}) + } + if p.AttrColor != "" { + specs = append(specs, ProductSpec{"Цвет", p.AttrColor}) + } + if p.AttrMaterial != "" { + specs = append(specs, ProductSpec{"Материал", p.AttrMaterial}) + } + if p.AttrWeight != "" { + specs = append(specs, ProductSpec{"Вес", p.AttrWeight}) + } + return specs +} + +type PriceHistoryEntry struct { + ID int + ProductID int + Price float64 + SalePrice float64 + RecordedAt time.Time +} + +type Reservation struct { + ID int + ProductID int + SessionKey string + UserID *int + CustomerName string + CustomerPhone string + ExpiresAt time.Time + CreatedAt time.Time +} diff --git a/internal/models/user.go b/internal/models/user.go index e9b6d89..af71c6e 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -28,16 +28,25 @@ type CartItem struct { } type Order struct { - ID int - UserID *int - GuestName string - GuestEmail string - GuestPhone string - Address string - Total float64 - Status string - CreatedAt time.Time - Items []OrderItem + ID int + UserID *int + GuestName string + GuestEmail string + GuestPhone string + Address string + Total float64 + Status string + PaymentMethod string + HeleketUUID string + HeleketOrderID string + HeleketPaymentURL string + HeleketPaymentStatus string + CreatedAt time.Time + Items []OrderItem +} + +func (o Order) IsHeleket() bool { + return o.PaymentMethod == "heleket" } type OrderItem struct { diff --git a/internal/orderstatus/status.go b/internal/orderstatus/status.go index 03bfc38..63bdef7 100644 --- a/internal/orderstatus/status.go +++ b/internal/orderstatus/status.go @@ -1,6 +1,7 @@ package orderstatus type Info struct { + Icon string Label string Class string Step int @@ -8,17 +9,17 @@ type Info struct { } var statuses = map[string]Info{ - "pending": {Label: "В ожидании", Class: "status--pending", Step: 1}, - "unpaid": {Label: "Не оплачено", Class: "status--unpaid", Step: 1}, - "paid": {Label: "Оплачено", Class: "status--paid", Step: 2}, - "processing": {Label: "В обработке", Class: "status--processing", Step: 3}, - "shipped": {Label: "Отправлен", Class: "status--shipped", Step: 4}, - "delivered": {Label: "Доставлен", Class: "status--delivered", Step: 5}, - "cancelled": {Label: "Отменён", Class: "status--cancelled", Step: 0, Terminal: true}, - "refunded": {Label: "Возврат", Class: "status--refunded", Step: 0, Terminal: true}, + "pending": {Icon: "⏳", Label: "В ожидании", Class: "status--pending", Step: 1}, + "unpaid": {Icon: "💳", Label: "Не оплачено", Class: "status--unpaid", Step: 1}, + "paid": {Icon: "✓", Label: "Оплачено", Class: "status--paid", Step: 2}, + "processing": {Icon: "⚙", Label: "В обработке", Class: "status--processing", Step: 3}, + "shipped": {Icon: "📦", Label: "Отправлен", Class: "status--shipped", Step: 4}, + "delivered": {Icon: "✓", Label: "Доставлен", Class: "status--delivered", Step: 5}, + "cancelled": {Icon: "✕", Label: "Отменён", Class: "status--cancelled", Step: 0, Terminal: true}, + "refunded": {Icon: "↩", Label: "Возврат", Class: "status--refunded", Step: 0, Terminal: true}, // совместимость со старыми заказами - "new": {Label: "В ожидании", Class: "status--pending", Step: 1}, - "confirmed": {Label: "Оплачено", Class: "status--paid", Step: 2}, + "new": {Icon: "⏳", Label: "В ожидании", Class: "status--pending", Step: 1}, + "confirmed": {Icon: "✓", Label: "Оплачено", Class: "status--paid", Step: 2}, } // Timeline — основной путь доставки (для таймлайна в кабинете) @@ -47,8 +48,9 @@ func Get(code string) Info { return Info{Label: code, Class: "status--pending", Step: 1} } -func Label(code string) string { return Get(code).Label } -func Class(code string) string { return Get(code).Class } +func Label(code string) string { return Get(code).Label } +func Icon(code string) string { return Get(code).Icon } +func Class(code string) string { return Get(code).Class } func Step(code string) int { return Get(code).Step } func IsTerminal(code string) bool { return Get(code).Terminal } diff --git a/internal/repository/order.go b/internal/repository/order.go index 76a51f0..ba50385 100644 --- a/internal/repository/order.go +++ b/internal/repository/order.go @@ -9,6 +9,13 @@ import ( "shop/internal/models" ) +const orderSelect = ` + SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, + COALESCE(payment_method, 'cod'), COALESCE(heleket_uuid, ''), COALESCE(heleket_order_id, ''), + COALESCE(heleket_payment_url, ''), COALESCE(heleket_payment_status, ''), created_at + FROM orders +` + type OrderRepository struct { pool *pgxpool.Pool } @@ -17,6 +24,15 @@ func NewOrderRepository(pool *pgxpool.Pool) *OrderRepository { return &OrderRepository{pool: pool} } +func scanOrder(row pgx.Row) (models.Order, error) { + var o models.Order + err := row.Scan( + &o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, + &o.PaymentMethod, &o.HeleketUUID, &o.HeleketOrderID, &o.HeleketPaymentURL, &o.HeleketPaymentStatus, &o.CreatedAt, + ) + return o, err +} + func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items []models.CartItem) error { tx, err := r.pool.Begin(ctx) if err != nil { @@ -24,11 +40,20 @@ func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items } defer tx.Rollback(ctx) + status := order.Status + if status == "" { + status = "pending" + } + paymentMethod := order.PaymentMethod + if paymentMethod == "" { + paymentMethod = "cod" + } + err = tx.QueryRow(ctx, ` - INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status) - VALUES ($1, $2, $3, $4, $5, $6, 'pending') + INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status, payment_method) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at - `, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total, + `, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total, status, paymentMethod, ).Scan(&order.ID, &order.CreatedAt) if err != nil { return err @@ -47,11 +72,37 @@ func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items return tx.Commit(ctx) } +func (r *OrderRepository) UpdateHeleketPayment(ctx context.Context, orderID int, uuid, externalID, paymentURL, paymentStatus string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE orders SET + heleket_uuid = $1, + heleket_order_id = $2, + heleket_payment_url = $3, + heleket_payment_status = $4 + WHERE id = $5 + `, uuid, externalID, paymentURL, paymentStatus, orderID) + return err +} + +func (r *OrderRepository) UpdateHeleketStatus(ctx context.Context, orderID int, paymentStatus, orderStatus string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE orders SET heleket_payment_status = $1, status = $2 WHERE id = $3 + `, paymentStatus, orderStatus, orderID) + return err +} + +func (r *OrderRepository) GetByHeleketOrderID(ctx context.Context, externalID string) (models.Order, error) { + row := r.pool.QueryRow(ctx, orderSelect+` WHERE heleket_order_id = $1`, externalID) + o, err := scanOrder(row) + if err != nil { + return models.Order{}, err + } + o.Items, err = r.items(ctx, o.ID) + return o, err +} + func (r *OrderRepository) ByUser(ctx context.Context, userID int) ([]models.Order, error) { - rows, err := r.pool.Query(ctx, ` - SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at - FROM orders WHERE user_id = $1 ORDER BY created_at DESC - `, userID) + rows, err := r.pool.Query(ctx, orderSelect+` WHERE user_id = $1 ORDER BY created_at DESC`, userID) if err != nil { return nil, err } @@ -59,8 +110,8 @@ func (r *OrderRepository) ByUser(ctx context.Context, userID int) ([]models.Orde var orders []models.Order for rows.Next() { - var o models.Order - if err := rows.Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt); err != nil { + o, err := scanOrder(rows) + if err != nil { return nil, err } o.Items, err = r.items(ctx, o.ID) @@ -94,10 +145,7 @@ func (r *OrderRepository) items(ctx context.Context, orderID int) ([]models.Orde } func (r *OrderRepository) All(ctx context.Context, limit int) ([]models.Order, error) { - rows, err := r.pool.Query(ctx, ` - SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at - FROM orders ORDER BY created_at DESC LIMIT $1 - `, limit) + rows, err := r.pool.Query(ctx, orderSelect+` ORDER BY created_at DESC LIMIT $1`, limit) if err != nil { return nil, err } @@ -106,11 +154,8 @@ func (r *OrderRepository) All(ctx context.Context, limit int) ([]models.Order, e } func (r *OrderRepository) GetByID(ctx context.Context, id int) (models.Order, error) { - var o models.Order - err := r.pool.QueryRow(ctx, ` - SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at - FROM orders WHERE id = $1 - `, id).Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt) + row := r.pool.QueryRow(ctx, orderSelect+` WHERE id = $1`, id) + o, err := scanOrder(row) if err != nil { return models.Order{}, err } @@ -138,11 +183,10 @@ func (r *OrderRepository) Count(ctx context.Context) (int, error) { func (r *OrderRepository) collectOrders(ctx context.Context, rows pgx.Rows) ([]models.Order, error) { var orders []models.Order for rows.Next() { - var o models.Order - if err := rows.Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt); err != nil { + o, err := scanOrder(rows) + if err != nil { return nil, err } - var err error o.Items, err = r.items(ctx, o.ID) if err != nil { return nil, err diff --git a/internal/repository/price_history.go b/internal/repository/price_history.go new file mode 100644 index 0000000..32dc36f --- /dev/null +++ b/internal/repository/price_history.go @@ -0,0 +1,49 @@ +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + + "shop/internal/models" +) + +type PriceHistoryRepository struct { + pool *pgxpool.Pool +} + +func NewPriceHistoryRepository(pool *pgxpool.Pool) *PriceHistoryRepository { + return &PriceHistoryRepository{pool: pool} +} + +func (r *PriceHistoryRepository) Record(ctx context.Context, productID int, price, salePrice float64) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO product_price_history (product_id, price, sale_price) + VALUES ($1, $2, NULLIF($3, 0)) + `, productID, price, salePrice) + return err +} + +func (r *PriceHistoryRepository) ByProduct(ctx context.Context, productID int, limit int) ([]models.PriceHistoryEntry, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, product_id, price, COALESCE(sale_price, 0), recorded_at + FROM product_price_history + WHERE product_id = $1 + ORDER BY recorded_at DESC + LIMIT $2 + `, productID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []models.PriceHistoryEntry + for rows.Next() { + var e models.PriceHistoryEntry + if err := rows.Scan(&e.ID, &e.ProductID, &e.Price, &e.SalePrice, &e.RecordedAt); err != nil { + return nil, err + } + entries = append(entries, e) + } + return entries, rows.Err() +} diff --git a/internal/repository/product.go b/internal/repository/product.go index 3eacea3..911f016 100644 --- a/internal/repository/product.go +++ b/internal/repository/product.go @@ -22,7 +22,10 @@ func NewProductRepository(pool *pgxpool.Pool) *ProductRepository { const productSelect = ` SELECT p.id, p.name, p.description, p.details, p.price, COALESCE(p.sale_price, 0), p.image_url, COALESCE(c.name, p.category, 'Разное'), p.category_id, - COALESCE(c.slug, ''), p.is_active, p.created_at, p.updated_at + COALESCE(c.slug, ''), p.is_active, + COALESCE(p.attr_size, ''), COALESCE(p.attr_color, ''), COALESCE(p.attr_material, ''), + COALESCE(p.attr_weight, ''), COALESCE(p.attr_brand, ''), + p.created_at, p.updated_at FROM products p LEFT JOIN categories c ON c.id = p.category_id ` @@ -32,7 +35,9 @@ func (r *ProductRepository) scanProduct(row pgx.Row) (models.Product, error) { var catID sql.NullInt64 err := row.Scan( &p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice, - &p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive, &p.CreatedAt, &p.UpdatedAt, + &p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive, + &p.AttrSize, &p.AttrColor, &p.AttrMaterial, &p.AttrWeight, &p.AttrBrand, + &p.CreatedAt, &p.UpdatedAt, ) if catID.Valid { id := int(catID.Int64) @@ -94,10 +99,12 @@ func (r *ProductRepository) GetActiveByID(ctx context.Context, id int) (models.P func (r *ProductRepository) Create(ctx context.Context, p *models.Product) error { return r.pool.QueryRow(ctx, ` - INSERT INTO products (name, description, details, price, sale_price, image_url, category, category_id, is_active) - VALUES ($1, $2, $3, $4, NULLIF($5, 0), $6, $7, $8, $9) + INSERT INTO products (name, description, details, price, sale_price, image_url, category, category_id, is_active, + attr_size, attr_color, attr_material, attr_weight, attr_brand) + VALUES ($1, $2, $3, $4, NULLIF($5, 0), $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at `, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive, + p.AttrSize, p.AttrColor, p.AttrMaterial, p.AttrWeight, p.AttrBrand, ).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) } @@ -105,9 +112,11 @@ func (r *ProductRepository) Update(ctx context.Context, p *models.Product) error tag, err := r.pool.Exec(ctx, ` UPDATE products SET name = $1, description = $2, details = $3, price = $4, sale_price = NULLIF($5, 0), image_url = $6, category = $7, category_id = $8, - is_active = $9, updated_at = NOW() - WHERE id = $10 - `, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive, p.ID) + is_active = $9, attr_size = $10, attr_color = $11, attr_material = $12, + attr_weight = $13, attr_brand = $14, updated_at = NOW() + WHERE id = $15 + `, p.Name, p.Description, p.Details, p.Price, p.SalePrice, p.ImageURL, p.Category, p.CategoryID, p.IsActive, + p.AttrSize, p.AttrColor, p.AttrMaterial, p.AttrWeight, p.AttrBrand, p.ID) if err != nil { return err } @@ -200,7 +209,9 @@ func (r *ProductRepository) collectProducts(rows pgx.Rows) ([]models.Product, er var catID sql.NullInt64 if err := rows.Scan( &p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.SalePrice, - &p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive, &p.CreatedAt, &p.UpdatedAt, + &p.ImageURL, &p.Category, &catID, &p.CategorySlug, &p.IsActive, + &p.AttrSize, &p.AttrColor, &p.AttrMaterial, &p.AttrWeight, &p.AttrBrand, + &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, err } diff --git a/internal/repository/reservation.go b/internal/repository/reservation.go new file mode 100644 index 0000000..494eadd --- /dev/null +++ b/internal/repository/reservation.go @@ -0,0 +1,79 @@ +package repository + +import ( + "context" + "database/sql" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "shop/internal/models" +) + +type ReservationRepository struct { + pool *pgxpool.Pool +} + +func NewReservationRepository(pool *pgxpool.Pool) *ReservationRepository { + return &ReservationRepository{pool: pool} +} + +func (r *ReservationRepository) Create(ctx context.Context, res *models.Reservation) error { + return r.pool.QueryRow(ctx, ` + INSERT INTO product_reservations (product_id, session_key, user_id, customer_name, customer_phone, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at + `, res.ProductID, res.SessionKey, res.UserID, res.CustomerName, res.CustomerPhone, res.ExpiresAt, + ).Scan(&res.ID, &res.CreatedAt) +} + +func (r *ReservationRepository) ActiveByProduct(ctx context.Context, productID int) (*models.Reservation, error) { + var res models.Reservation + var userID sql.NullInt64 + err := r.pool.QueryRow(ctx, ` + SELECT id, product_id, session_key, user_id, customer_name, customer_phone, expires_at, created_at + FROM product_reservations + WHERE product_id = $1 AND expires_at > NOW() + ORDER BY created_at DESC + LIMIT 1 + `, productID).Scan( + &res.ID, &res.ProductID, &res.SessionKey, &userID, + &res.CustomerName, &res.CustomerPhone, &res.ExpiresAt, &res.CreatedAt, + ) + if err != nil { + return nil, err + } + if userID.Valid { + id := int(userID.Int64) + res.UserID = &id + } + return &res, nil +} + +func (r *ReservationRepository) ReplaceForProduct(ctx context.Context, res *models.Reservation) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + _, err = tx.Exec(ctx, `DELETE FROM product_reservations WHERE product_id = $1`, res.ProductID) + if err != nil { + return err + } + + err = tx.QueryRow(ctx, ` + INSERT INTO product_reservations (product_id, session_key, user_id, customer_name, customer_phone, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at + `, res.ProductID, res.SessionKey, res.UserID, res.CustomerName, res.CustomerPhone, res.ExpiresAt, + ).Scan(&res.ID, &res.CreatedAt) + if err != nil { + return err + } + return tx.Commit(ctx) +} + +func (r *ReservationRepository) ExpiresIn() time.Duration { + return 24 * time.Hour +} diff --git a/migrations/006_product_attrs_history.sql b/migrations/006_product_attrs_history.sql new file mode 100644 index 0000000..489a55a --- /dev/null +++ b/migrations/006_product_attrs_history.sql @@ -0,0 +1,29 @@ +ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_size VARCHAR(50) NOT NULL DEFAULT ''; +ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_color VARCHAR(50) NOT NULL DEFAULT ''; +ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_material VARCHAR(100) NOT NULL DEFAULT ''; +ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_weight VARCHAR(50) NOT NULL DEFAULT ''; +ALTER TABLE products ADD COLUMN IF NOT EXISTS attr_brand VARCHAR(100) NOT NULL DEFAULT ''; + +CREATE TABLE IF NOT EXISTS product_price_history ( + id SERIAL PRIMARY KEY, + product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE, + price NUMERIC(10,2) NOT NULL, + sale_price NUMERIC(10,2), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_price_history_product ON product_price_history (product_id, recorded_at DESC); + +CREATE TABLE IF NOT EXISTS product_reservations ( + id SERIAL PRIMARY KEY, + product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE, + session_key VARCHAR(64) NOT NULL DEFAULT '', + user_id INT REFERENCES users(id) ON DELETE SET NULL, + customer_name VARCHAR(255) NOT NULL DEFAULT '', + customer_phone VARCHAR(50) NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_reservations_product ON product_reservations (product_id, expires_at DESC); +CREATE INDEX IF NOT EXISTS idx_reservations_session ON product_reservations (session_key); diff --git a/migrations/007_heleket.sql b/migrations/007_heleket.sql new file mode 100644 index 0000000..1efd042 --- /dev/null +++ b/migrations/007_heleket.sql @@ -0,0 +1,5 @@ +ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) NOT NULL DEFAULT 'cod'; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_uuid VARCHAR(64) NOT NULL DEFAULT ''; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_order_id VARCHAR(128) NOT NULL DEFAULT ''; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_payment_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS heleket_payment_status VARCHAR(50) NOT NULL DEFAULT '';