//go:build test && dev package payments import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "crussell/db" "crussell/internal/square" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ============================================================================= // BuyGiftCard / CreateTillSale verification_token wiring // ============================================================================= // recordingCardOnFileClient records the customerID passed to // CreateCardOnFile (the new P14 4th parameter) so tests can assert save-card // flows provision the Square customer before tokenizing, while one-off flows // pass "". type recordingCardOnFileClient struct { square.SquareClient mu sync.Mutex customerID string cofCalls int } func (c *recordingCardOnFileClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) { c.mu.Lock() c.customerID = customerID c.cofCalls++ c.mu.Unlock() return c.SquareClient.CreateCardOnFile(ctx, userID, cardToken, customerID) } func (c *recordingCardOnFileClient) lastCustomerID() string { c.mu.Lock() defer c.mu.Unlock() return c.customerID } func (c *recordingCardOnFileClient) callCount() int { c.mu.Lock() defer c.mu.Unlock() return c.cofCalls } func TestBuyGiftCard_VerificationTokenPassthrough(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateUserToken(userID) origClient := SquareClient rec := &recordingPaymentClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() vrf := "vrf_gc_token_789" newToken := "cnon:test-card" req := BuyGiftCardRequest{ Amount: 1000, RecipientType: "self", NewCardToken: &newToken, SaveCard: false, IdempotencyKey: "buy-gc-vrf-key", VerificationToken: &vrf, } w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx) require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) rec.mu.Lock() got := rec.lastReq.VerificationToken rec.mu.Unlock() require.Equal(t, vrf, got, "the SCA verification token completed by the customer must be forwarded to Square") } func TestBuyGiftCard_VerificationTokenTooLongRejected(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateUserToken(userID) big := strings.Repeat("a", 600) newToken := "cnon:test-card" req := BuyGiftCardRequest{ Amount: 1000, RecipientType: "self", NewCardToken: &newToken, IdempotencyKey: "buy-gc-vrf-long-key", VerificationToken: &big, } w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx) require.Equal(t, http.StatusBadRequest, w.Code) } func TestBuyGiftCard_SaveCard_ProvisionsCustomerForCreateCardOnFile(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateUserToken(userID) origClient := SquareClient rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() newToken := "cnon:test-card" req := BuyGiftCardRequest{ Amount: 1000, RecipientType: "self", NewCardToken: &newToken, SaveCard: true, IdempotencyKey: "buy-gc-save-cust-key", } w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx) require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) require.Equal(t, 1, rec.callCount()) require.NotEmpty(t, rec.lastCustomerID(), "a save-card flow must pass the provisioned Square customer id to CreateCardOnFile") var cid string require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cid)) require.Equal(t, rec.lastCustomerID(), cid, "the stored square_customer_id must match the id passed to CreateCardOnFile") } func TestBuyGiftCard_NoSaveCard_CreateCardOnFileEmptyCustomer(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateUserToken(userID) origClient := SquareClient rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() newToken := "cnon:test-card" req := BuyGiftCardRequest{ Amount: 1000, RecipientType: "self", NewCardToken: &newToken, SaveCard: false, IdempotencyKey: "buy-gc-nosave-cust-key", } w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx) require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) require.Equal(t, 1, rec.callCount()) require.Equal(t, "", rec.lastCustomerID(), "a one-off non-save charge needs no Square customer") } func TestCreateTillSale_VerificationTokenPassthrough(t *testing.T) { _, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") origClient := SquareClient rec := &recordingPaymentClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() vrf := "vrf_till_token_012" reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "online_square", CardToken: "cnon:visa", IdempotencyKey: "till-vrf-key", VerificationToken: &vrf, } bodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w, req) require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) rec.mu.Lock() got := rec.lastReq.VerificationToken rec.mu.Unlock() require.Equal(t, vrf, got, "the SCA verification token completed by the customer must be forwarded to Square") } func TestCreateTillSale_VerificationTokenTooLongRejected(t *testing.T) { _, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") big := strings.Repeat("a", 600) reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "online_square", CardToken: "cnon:visa", IdempotencyKey: "till-vrf-long-key", VerificationToken: &big, } bodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w, req) require.Equal(t, http.StatusBadRequest, w.Code) } func TestCreateTillSale_OnlineSquare_NoCustomerProvisioned(t *testing.T) { _, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") origClient := SquareClient rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "online_square", CardToken: "cnon:visa", IdempotencyKey: "till-nosave-cust-key", } bodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w, req) require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) require.Equal(t, 1, rec.callCount()) require.Equal(t, "", rec.lastCustomerID(), "the ephemeral till card is a one-off cnon: charge — no Square customer") } // ============================================================================= // INSUFFICIENT_FUNDS and other definitive decline codes // ============================================================================= func TestIsDefinitiveChargeFailure_CoversInsufficientFunds(t *testing.T) { errs := []error{ fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/INSUFFICIENT_FUNDS] insufficient funds"), fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/ADDRESS_VERIFICATION_FAILURE] avs mismatch"), fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/TRANSACTION_LIMIT] limit reached"), } for _, err := range errs { if !isDefinitiveChargeFailure(err) { t.Errorf("expected %v to be classified as a definitive charge failure", err) } } if isDefinitiveChargeFailure(fmt.Errorf("network error: connection reset by peer")) { t.Error("expected an ambiguous transport error to NOT be definitive") } if isDefinitiveChargeFailure(nil) { t.Error("expected nil to not be a definitive charge failure") } } // ============================================================================= // GetCheckoutStatus — cancellation recheck before recording a completed payment // ============================================================================= func TestGetCheckoutStatus_CancelledBooking_RejectsRecord(t *testing.T) { origClient := SquareClient SquareClient = &testCheckoutClient{ SquareClient: square.NewDevClient(), hexIDs: make(map[string]string), } defer func() { SquareClient = origClient }() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() checkoutID := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000) // Cancel the booking after the checkout was created but before it is polled — // a terminal payment landing on a cancelled booking must NOT be recorded. if _, err := tx.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, bookingID); err != nil { t.Fatalf("failed to cancel booking: %v", err) } var w *httptest.ResponseRecorder assert.Eventually(t, func() bool { statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil) statusRCtx := chi.NewRouteContext() statusRCtx.URLParams.Add("checkout_id", checkoutID) statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx) if info := extractUserFromTestJWT(adminToken); info != nil { statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID) statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role) } statusReq = statusReq.WithContext(statusCtx) w = httptest.NewRecorder() GetCheckoutStatus(w, statusReq) return w.Code == http.StatusConflict }, 10*time.Second, 100*time.Millisecond, "expected the cancelled-booking checkout to be rejected with 409") var completedCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&completedCount)) require.Zero(t, completedCount, "no completed payment may be recorded on a cancelled booking") var rowStatus string require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID).Scan(&rowStatus)) require.Equal(t, "failed", rowStatus, "the terminal_checkouts row must be marked failed so a fresh charge is possible") } // ============================================================================= // activeTerminalCheckoutID — definitively cancelled checkout must not wedge // ============================================================================= // canceledCheckoutClient makes one checkout report CANCELED at Square (the // error the real HTTP client produces for a non-COMPLETED, non-PENDING status) // while delegating everything else to the real mock. type canceledCheckoutClient struct { square.SquareClient checkoutID string } func (c *canceledCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) { if checkoutID == c.checkoutID { return nil, fmt.Errorf("square: checkout %s is CANCELED (not COMPLETED)", checkoutID) } return c.SquareClient.GetCheckout(ctx, checkoutID) } func TestActiveTerminalCheckoutID_ResolvesCanceledCheckout(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) checkoutID := "chk_canceled_12345" if _, err := tx.Exec(ctx, ` INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount) VALUES ($1, $2, 'full', 'PENDING', 50.00) `, checkoutID, bookingID); err != nil { t.Fatalf("failed to seed terminal checkout: %v", err) } origClient := SquareClient SquareClient = &canceledCheckoutClient{SquareClient: square.NewDevClient(), checkoutID: checkoutID} defer func() { SquareClient = origClient }() got := activeTerminalCheckoutID(ctx, bookingID) require.Equal(t, "", got, "a definitively CANCELED checkout must resolve to \"\" so a new checkout can be created") var status string require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID).Scan(&status)) require.Equal(t, "failed", status, "the canceled checkout row must be marked failed") } // ============================================================================= // SweepStaleTerminalCheckouts — terminal_checkouts (booking) coverage // ============================================================================= func TestSweepStaleTerminalCheckouts_CoversTerminalCheckoutsTable(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, serviceID := setupTestData(t, ctx, tx) origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) mock.HoldCheckouts = true checkout, err := mock.CreateCheckout(context.Background(), square.CreateCheckoutReq{ Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-stale-terminal-booking", }) require.NoError(t, err) SquareClient = mock defer func() { SquareClient = origClient }() if _, err := tx.Exec(ctx, ` INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at) VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours') `, checkout.ID, bookingID); err != nil { t.Fatalf("failed to seed stale terminal checkout row: %v", err) } pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx) require.NoError(t, pgxTx.Commit(ctx)) t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, checkout.ID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) freshCtx := context.Background() // Drop stale rows left by other sweep tests so the count is deterministic. if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, checkout.ID); err != nil { t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err) } if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil { t.Fatalf("failed to clean leftover stale till sales: %v", err) } n, err := SweepStaleTerminalCheckouts(freshCtx) require.NoError(t, err) require.Equal(t, 1, n, "the stale booking terminal checkout must be resolved by the sweep") var status string require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, checkout.ID).Scan(&status)) require.Equal(t, "failed", status) // The checkout must no longer be PENDING at Square (it was cancelled). if _, gErr := mock.GetCheckout(freshCtx, checkout.ID); gErr == nil || errors.Is(gErr, square.ErrCheckoutPending) { t.Errorf("expected checkout %s to be cancelled at Square (no longer pending), GetCheckout err=%v", checkout.ID, gErr) } } // ============================================================================= // Till-sale clawback on pending-retry definitive failure // ============================================================================= func TestCreateTillSale_PendingRetry_DefinitiveFailure_ClawsBack(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234") require.NoError(t, err) // Seed a PENDING till_sale whose gift card was already funded by a prior // attempt of this same sale (the prior charge failed ambiguously). The // retry's definitive failure must claw the funding back. key := "till-pending-definitive-clawback-key" var giftCardID string require.NoError(t, tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase) VALUES (50.00, 50.00, $1, FALSE, 'SPV') RETURNING id `, adminID).Scan(&giftCardID)) _, err = tx.Exec(ctx, ` INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at) VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $2, $3, $4, $5, NOW(), NOW()) `, giftCardID, userID, cardID, key, adminID) require.NoError(t, err) origClient := SquareClient SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()} defer func() { SquareClient = origClient }() reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "saved_card", UserSavedCardID: &cardID, UserID: &userID, IdempotencyKey: key, } bodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w, req) require.Equal(t, http.StatusPaymentRequired, w.Code) // The reused sale row must be 'failed' and the previously funded gift card // clawed back — a definitive failure on retry means the charge can never // complete, so the funded card must not be left behind (free gift card). var status string require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&status)) require.Equal(t, "failed", status) var gcCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&gcCount)) require.Zero(t, gcCount, "the funded gift card must be clawed back after a definitive failure on retry") } // ============================================================================= // customer_id on saved-card (ccof:) charges // ============================================================================= func TestCreateBookingPayment_SavedCard_ForwardsCustomerID(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_saved", "VISA", "4242") require.NoError(t, err) if _, err := tx.Exec(ctx, `UPDATE user_saved_cards SET square_customer_id = 'cus_test_123' WHERE id = $1`, cardID); err != nil { t.Fatalf("failed to set square_customer_id: %v", err) } origClient := SquareClient rec := &recordingPaymentClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", CardID: &cardID, IdempotencyKey: "saved-cust-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) rec.mu.Lock() got := rec.lastReq.CustomerID rec.mu.Unlock() require.Equal(t, "cus_test_123", got, "a saved-card (ccof:) charge must carry the saved-card row's Square customer id") } func TestCreateBookingPayment_SaveCard_ProvisionsCustomerForCreateCardOnFile(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") origClient := SquareClient rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "save-cust-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) require.Equal(t, 1, rec.callCount()) require.NotEmpty(t, rec.lastCustomerID(), "a save-card flow must pass the provisioned Square customer id to CreateCardOnFile") var cid string require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cid)) require.Equal(t, rec.lastCustomerID(), cid) } func TestCreateBookingPayment_NoSaveCard_CreateCardOnFileEmptyCustomer(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") origClient := SquareClient rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "nosave-cust-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) require.Equal(t, 1, rec.callCount()) require.Equal(t, "", rec.lastCustomerID(), "a one-off non-save charge needs no Square customer") }