Skip to content

Commit db1e734

Browse files
feat:add payment return
1 parent 4f69608 commit db1e734

File tree

9 files changed

+212
-67
lines changed

9 files changed

+212
-67
lines changed

cmd/api/payment.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import (
1717
"time"
1818
)
1919

20+
// Flow:
21+
// 1. User completes payment on Esewa/Khalti
22+
// 2. Gateway redirects to your Go endpoint
23+
// 3. Go endpoint tries Khel://payment/return which open mobile app
24+
// if app is not installed then fallback to webpage /payments/return
25+
2026
// redirectToAppReturn serves an HTML page that:
2127
// 1) tries to open your app via deep link: khel://payments/return?...params...
2228
// 2) falls back to a web URL if the app is not installed
@@ -295,7 +301,7 @@ func (app *application) verifyEsewaResponseSignature(totalAmount, transactionUUI
295301
// /v1/store/payments/esewa/return?result=success|failure&data=<base64_json>
296302
//
297303
// This is the *canonical* completion endpoint for eSewa.
298-
// eSewa redirects the user's browser/webview here after payment.
304+
// eSewa hit this endpoint after payment.
299305
// We do NOT trust the redirect payload alone; we:
300306
// 1) decode base64 payload
301307
// 2) verify signature (integrity check)
@@ -308,9 +314,34 @@ func (app *application) esewaReturnHandler(w http.ResponseWriter, r *http.Reques
308314
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
309315
defer cancel()
310316

317+
// --------------------------------------------------------------------
318+
// Defensive query normalization:
319+
//
320+
// eSewa (or misconfigured URLs) can sometimes produce a malformed query string like:
321+
// ?payment_id=9&result=success?data=BASE64
322+
// ^ second "?"
323+
//
324+
// In that case, Go's URL parser will treat everything after the second "?" as part of
325+
// the previous param value, and r.URL.Query().Get("data") will be empty.
326+
//
327+
// We recover by rewriting "?data=" into "&data=" inside RawQuery.
328+
// This is safe because "data=" is supposed to be another query parameter.
329+
// --------------------------------------------------------------------
330+
331+
raw := r.URL.RawQuery
332+
if !strings.Contains(raw, "data=") && strings.Contains(raw, "?data=") {
333+
parts := strings.SplitN(raw, "?data=", 2)
334+
if len(parts) == 2 {
335+
r.URL.RawQuery = parts[0] + "&data=" + parts[1]
336+
}
337+
}
338+
339+
// Now it's safe to read query params.
311340
result := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("result")))
312341
dataB64 := strings.TrimSpace(r.URL.Query().Get("data"))
342+
313343
if dataB64 == "" {
344+
// No base64 payload => can't verify. Return to app as failed.
314345
app.redirectToAppReturn(w, "failed", 0, 0, "esewa", "", "", "missing_data")
315346
return
316347
}

cmd/api/sales.go

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,47 @@ import (
1717
"github.com/go-chi/chi/v5"
1818
)
1919

20-
// ============ CART ============
21-
22-
// GET /v1/store/cart
20+
// GetCart godoc
21+
//
22+
// @Summary Get user's cart
23+
// @Description Retrieves the current user's active or checkout_pending shopping cart
24+
// @Tags User-Cart
25+
// @Accept json
26+
// @Produce json
27+
// @Success 200 {object} CartView "Cart retrieved successfully"
28+
// @Failure 401 {object} error "Unauthorized"
29+
// @Failure 500 {object} error "Internal Server Error"
30+
// @Security ApiKeyAuth
31+
// @Router /store/cart [get]
2332
func (app *application) getCartHandler(w http.ResponseWriter, r *http.Request) {
2433
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
2534
defer cancel()
2635

2736
user := getUserFromContext(r)
2837
userID := user.ID
2938

30-
// optional: ensure cart exists
31-
if _, err := app.store.Sales.Carts.EnsureActive(ctx, userID); err != nil {
32-
app.internalServerError(w, r, err)
33-
return
34-
}
35-
3639
view, err := app.store.Sales.Carts.GetView(ctx, userID)
3740
if err != nil {
3841
app.internalServerError(w, r, err)
3942
return
4043
}
4144

45+
if view == nil {
46+
// Create a new cart
47+
cartID, err := app.store.Sales.Carts.GetOrCreateCart(ctx, userID)
48+
if err != nil {
49+
app.internalServerError(w, r, err)
50+
return
51+
}
52+
53+
// Get the newly created cart view
54+
view, err = app.store.Sales.Carts.GetViewByCartID(ctx, cartID)
55+
if err != nil {
56+
app.internalServerError(w, r, err)
57+
return
58+
}
59+
}
60+
4261
app.jsonResponse(w, http.StatusOK, view)
4362
}
4463

@@ -722,8 +741,10 @@ func (app *application) verifyPaymentHandler(w http.ResponseWriter, r *http.Requ
722741
if in.Data["total_amount"] == "" {
723742
in.Data["total_amount"] = fmt.Sprintf("%.2f", float64(pay.AmountCents)/100.0)
724743
}
725-
// product_code ideally injected from config; if missing you can set it here too:
726-
// in.Data["product_code"] = app.config.Payments.EsewaMerchantCode
744+
// ✅ REQUIRED for eSewa verify
745+
if in.Data["product_code"] == "" {
746+
in.Data["product_code"] = app.config.payment.Esewa.MerchantID
747+
}
727748
}
728749

729750
txid := ""

cmd/api/users.go

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ func (app *application) uploadProfilePictureHandler(w http.ResponseWriter, r *ht
7575
PublicID: fmt.Sprintf("/%d", userID), // Save with userID as filename
7676
Overwrite: overwrite,
7777
// Replace old profile pic
78-
Folder: "profile_pictures", // Organized storage
79-
Transformation: "w_300,h_300,c_fill,q_auto", // Resize to 300x300, auto quality
78+
Folder: "profile_pictures", // Organized storage
79+
Transformation: "w_128,h_128,c_fill,q_auto,f_auto",
8080
}
8181
uploadResult, err := app.cld.Upload.Upload(ctx, file, uploadParams)
8282
if err != nil {
@@ -130,9 +130,9 @@ func (app *application) updateProfilePictureHandler(w http.ResponseWriter, r *ht
130130
// Upload the new file to Cloudinary with specific PublicID to ensure replacement
131131
uploadParams := uploader.UploadParams{
132132
Folder: "profile_pictures",
133-
Overwrite: boolPtr(true), // Ensure overwrite of the existing file
134-
Transformation: "w_300,h_300,c_fill,q_auto", // Optional transformations (e.g., resizing)
135-
PublicID: fmt.Sprintf("/%d", userID), // Use userID as the PublicID to replace the old image
133+
Overwrite: boolPtr(true), // Ensure overwrite of the existing file
134+
Transformation: "w_128,h_128,c_fill,q_auto,f_auto", // Optional transformations (e.g., resizing)
135+
PublicID: fmt.Sprintf("/%d", userID), // Use userID as the PublicID to replace the old image
136136
}
137137

138138
uploadResult, err := app.cld.Upload.Upload(r.Context(), file, uploadParams)
@@ -359,7 +359,7 @@ func (app *application) editProfileHandler(w http.ResponseWriter, r *http.Reques
359359
}
360360

361361
// Handle optional image upload
362-
var newURL string
362+
var newURL *string
363363
file, header, err := r.FormFile("profile_picture")
364364
if err == nil {
365365
defer file.Close()
@@ -372,24 +372,15 @@ func (app *application) editProfileHandler(w http.ResponseWriter, r *http.Reques
372372
PublicID: fmt.Sprintf("/%d", userID),
373373
Overwrite: boolPtr(true),
374374
Folder: "profile_pictures",
375-
Transformation: "w_300,h_300,c_fill,q_auto",
375+
Transformation: "w_128,h_128,c_fill,q_auto,f_auto",
376376
}
377377
res, err := app.cld.Upload.Upload(r.Context(), file, uploadParams)
378378
if err != nil {
379379
http.Error(w, "upload failed", http.StatusInternalServerError)
380380
return
381381
}
382-
newURL = res.SecureURL
383-
}
384-
385-
// If no image was provided, keep existing URL:
386-
if newURL == "" {
387-
old, err := app.store.Users.GetProfileUrl(r.Context(), userID)
388-
if err != nil {
389-
app.internalServerError(w, r, err)
390-
return
391-
}
392-
newURL = old
382+
u := res.SecureURL
383+
newURL = &u
393384
}
394385

395386
// 4) Call our new UpdateAndUpload

internal/domain/carts/carts.go

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,56 @@ WHERE user_id = $1
5050
return err
5151
}
5252

53+
// GetOrCreateCart returns the user's current cart (active or checkout_pending)
54+
// or creates a new active cart if none exists
55+
func (r *Repository) GetOrCreateCart(ctx context.Context, userID int64) (int64, error) {
56+
var id int64
57+
var status string
58+
59+
// First, try to get ANY current cart (active or checkout_pending)
60+
err := r.db.QueryRow(ctx, `
61+
SELECT id, status
62+
FROM carts
63+
WHERE user_id = $1
64+
AND (status = 'active' OR status = 'checkout_pending')
65+
AND (expires_at IS NULL OR expires_at > now())
66+
ORDER BY
67+
CASE status
68+
WHEN 'checkout_pending' THEN 1
69+
WHEN 'active' THEN 2
70+
END,
71+
updated_at DESC
72+
LIMIT 1
73+
`, userID).Scan(&id, &status)
74+
75+
if err == nil {
76+
// Found an existing cart
77+
return id, nil
78+
}
79+
80+
if !errors.Is(err, pgx.ErrNoRows) {
81+
// Real DB error
82+
return 0, fmt.Errorf("get cart: %w", err)
83+
}
84+
85+
// No cart exists → create new active cart
86+
exp := time.Now().Add(r.ttl)
87+
if err := r.db.QueryRow(ctx, `
88+
INSERT INTO carts (user_id, guest_token, status, expires_at)
89+
VALUES ($1, NULL, 'active', $2)
90+
RETURNING id
91+
`, userID, exp).Scan(&id); err != nil {
92+
return 0, fmt.Errorf("create cart: %w", err)
93+
}
94+
95+
return id, nil
96+
}
97+
5398
// --- User flows ---
5499

55100
// EnsureActive returns an existing active cart id or creates a new one with TTL.
56101
// It only sets expires_at when creating a cart; it does NOT bump TTL for existing carts.
102+
// if you want checkout_pending state too use GetOrCreateCart method
57103
func (r *Repository) EnsureActive(ctx context.Context, userID int64) (int64, error) {
58104
var id int64
59105

@@ -220,26 +266,36 @@ func (r *Repository) UnlockCheckoutCart(ctx context.Context, orderID int64) erro
220266
//
221267
// We only convert carts that are explicitly linked to the order via checkout_order_id
222268
// AND currently in 'checkout_pending'. This prevents converting a wrong cart due to bugs
223-
// or race conditions.
269+
// or race conditions. remember db constraint if converted then checkout_order_id=null
224270
func (r *Repository) ConvertCheckoutCart(ctx context.Context, orderID int64) error {
225271
_, err := r.db.Exec(ctx, `
226272
UPDATE carts
227-
SET status='converted', updated_at=now()
228-
WHERE checkout_order_id=$1 AND status='checkout_pending'
273+
SET status='converted',
274+
checkout_order_id=NULL,
275+
updated_at=now()
276+
WHERE checkout_order_id=$1
277+
AND status='checkout_pending'
229278
`, orderID)
230279
return err
231280
}
232281

233-
// Get active cart view by user
282+
// Get active cart or checkout_pending view by user
234283
func (r *Repository) GetView(ctx context.Context, userID int64) (*CartView, error) {
235284
var v CartView
236285

237286
err := r.db.QueryRow(ctx, `
238-
SELECT id, user_id, guest_token, status, expires_at, created_at, updated_at
287+
SELECT id, user_id, guest_token, status, expires_at, created_at, updated_at, checkout_order_id
239288
FROM carts
240289
WHERE user_id = $1
241-
AND status = 'active'
290+
AND (status = 'active' OR status = 'checkout_pending')
242291
AND (expires_at IS NULL OR expires_at > now())
292+
ORDER BY
293+
CASE status
294+
WHEN 'checkout_pending' THEN 1
295+
WHEN 'active' THEN 2
296+
ELSE 3
297+
END,
298+
updated_at DESC
243299
LIMIT 1
244300
`, userID).Scan(
245301
&v.Cart.ID,
@@ -249,6 +305,7 @@ LIMIT 1
249305
&v.Cart.ExpiresAt,
250306
&v.Cart.CreatedAt,
251307
&v.Cart.UpdatedAt,
308+
&v.Cart.CheckoutOrderID,
252309
)
253310

254311
if err != nil {
@@ -266,7 +323,7 @@ func (r *Repository) GetViewByCartID(ctx context.Context, cartID int64) (*CartVi
266323
var v CartView
267324

268325
if err := r.db.QueryRow(ctx, `
269-
SELECT id, user_id, guest_token, status, expires_at, created_at, updated_at
326+
SELECT id, user_id, guest_token, status, expires_at, created_at, updated_at, checkout_order_id
270327
FROM carts
271328
WHERE id = $1
272329
`, cartID).Scan(
@@ -277,6 +334,7 @@ WHERE id = $1
277334
&v.Cart.ExpiresAt,
278335
&v.Cart.CreatedAt,
279336
&v.Cart.UpdatedAt,
337+
&v.Cart.CheckoutOrderID,
280338
); err != nil {
281339
if errors.Is(err, pgx.ErrNoRows) {
282340
return nil, fmt.Errorf("cart not found")
@@ -287,6 +345,7 @@ WHERE id = $1
287345
return r.fillLines(ctx, &v, cartID)
288346
}
289347

348+
// fillLines fetches cart items for any cartID, calculates totals directly and return the CartView structure
290349
func (r *Repository) fillLines(ctx context.Context, v *CartView, cartID int64) (*CartView, error) {
291350
rows, err := r.db.Query(ctx, `
292351
SELECT

internal/domain/carts/types.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import (
66
)
77

88
type Cart struct {
9-
ID int64 `json:"id"`
10-
UserID *int64 `json:"user_id,omitempty"`
11-
GuestToken *string `json:"guest_token,omitempty"`
12-
Status string `json:"status"` // active, converted, abandoned
13-
ExpiresAt *time.Time `json:"expires_at,omitempty"`
14-
CreatedAt time.Time `json:"created_at"`
15-
UpdatedAt time.Time `json:"updated_at"`
9+
ID int64 `json:"id"`
10+
UserID *int64 `json:"user_id,omitempty"`
11+
GuestToken *string `json:"guest_token,omitempty"`
12+
Status string `json:"status"` // active, converted, abandoned, checkout_pending
13+
ExpiresAt *time.Time `json:"expires_at,omitempty"`
14+
CreatedAt time.Time `json:"created_at"`
15+
UpdatedAt time.Time `json:"updated_at"`
16+
CheckoutOrderID *int64 `json:"checkout_order_id,omitempty"`
1617
}
1718

1819
type CartItem struct {
@@ -45,6 +46,7 @@ type CartLine struct {
4546

4647
type Store interface {
4748
// --- User-level operations ---
49+
GetOrCreateCart(ctx context.Context, userID int64) (int64, error)
4850
EnsureActive(ctx context.Context, userID int64) (int64, error)
4951
AddItem(ctx context.Context, userID, variantID int64, qty int) error
5052
UpdateItemQty(ctx context.Context, userID, itemID int64, qty int) error

internal/domain/orders/orders.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ func (r *Repository) CreateFromCart(
164164
cmd, err := r.q.Exec(ctx, `
165165
UPDATE carts
166166
SET status='converted',
167+
checkout_order_id=NULL,
167168
updated_at=now()
168169
WHERE id=$1
169170
AND status='active'::cart_status

internal/domain/paymentsrepo/paymentsrepo.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,19 +90,35 @@ func (r *Repository) SetPrimaryToOrder(ctx context.Context, orderID, paymentID i
9090
}
9191

9292
func (r *Repository) MarkPaid(ctx context.Context, paymentID int64) error {
93+
// 1️⃣ Mark payment as paid
9394
_, err := r.q.Exec(ctx, `
9495
UPDATE payments
95-
SET status='paid'::payment_status, updated_at=now()
96-
WHERE id=$1;
96+
SET status='paid'::payment_status,
97+
updated_at=now()
98+
WHERE id=$1
99+
`, paymentID)
100+
if err != nil {
101+
return fmt.Errorf("mark payment paid: %w", err)
102+
}
97103

104+
// 2️⃣ Mark order as paid + processing
105+
_, err = r.q.Exec(ctx, `
98106
UPDATE orders
99107
SET payment_status='paid'::payment_status,
100108
status='processing'::order_status,
101109
paid_at=now(),
102110
updated_at=now()
103-
WHERE id=(SELECT order_id FROM payments WHERE id=$1);
111+
WHERE id = (
112+
SELECT order_id
113+
FROM payments
114+
WHERE id=$1
115+
)
104116
`, paymentID)
105-
return err
117+
if err != nil {
118+
return fmt.Errorf("mark order paid: %w", err)
119+
}
120+
121+
return nil
106122
}
107123

108124
func (r *Repository) SetStatus(ctx context.Context, paymentID int64, status string) error {

0 commit comments

Comments
 (0)