EnakPoint can only be redeemed for vouchers now: it can no longer pay for orders and is never cashed out (docs/enakgame-prd.md §3.2, EG-001, EG-002). No order was ever paid with EnakPoint, so there is no data to move. Removed: - POST /customer/wallet/payment-code, POST /customer/orders/:id/pay-with-points and GET /orders/:id/point-payment/preview, with their processors, repositories, services, handlers and tests. - The point payment method type: paying, splitting and refunding with it, the outlet filter on the method list, and the system-method guard. - points and payment_code on CreatePayment; points_used and point_value on payments; accepts_point_payment on the customer outlets. - The outlet point_payment settings. A PUT that still sends them is rejected as an unknown field. - The EnakPoint split in the payment method analytics. - PAYMENT and PAYMENT_REFUND from the wallet type rules. Tests that used them as a generic EnakPoint debit use REWARD_REDEEM. - The EnakPoint-paid part from the earning basis, which is subtotal − discount again. Migration 000102 drops the trigger, the point methods and their index, the payments columns, and the outlet settings, and restores the method type CHECK without point. payments.payment_method_id is ON DELETE RESTRICT, so it fails rather than lose a payment made with EnakPoint. The integration docs list the removed endpoints and fields, and the EnakPoint & EnakCoin PRD and tasks note what is superseded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
7.9 KiB
Go
172 lines
7.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"apskel-pos-be/internal/appcontext"
|
|
applogger "apskel-pos-be/internal/logger"
|
|
"apskel-pos-be/internal/middleware"
|
|
"apskel-pos-be/internal/processor"
|
|
"apskel-pos-be/internal/repository"
|
|
"apskel-pos-be/internal/service"
|
|
)
|
|
|
|
// Drives GET/PUT /outlets/:id/loyalty-settings over HTTP down to Postgres. Needs
|
|
// TEST_DATABASE_URL pointing at a migrated database; see
|
|
// internal/repository/wallet_repository_test.go.
|
|
func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL not set")
|
|
}
|
|
applogger.Setup("fatal", "json")
|
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
require.NoError(t, err)
|
|
|
|
org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
|
exec := func(q string, args ...any) {
|
|
t.Helper()
|
|
require.NoError(t, db.Exec(q, args...).Error)
|
|
}
|
|
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic'), (?, 'B', 'basic')`, org, otherOrg)
|
|
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
|
|
t.Cleanup(func() {
|
|
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
|
|
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
|
|
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
|
|
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
|
|
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
|
|
})
|
|
|
|
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))
|
|
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db)))
|
|
auth := middleware.NewAuthMiddleware(nil)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
as := func(orgID uuid.UUID, role string) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, orgID.String())
|
|
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
|
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
|
|
c.Request = c.Request.WithContext(ctx)
|
|
}
|
|
}
|
|
for prefix, who := range map[string]struct {
|
|
org uuid.UUID
|
|
role string
|
|
}{"/manager": {org, "manager"}, "/purchasing": {org, "purchasing"}, "/other": {otherOrg, "admin"}} {
|
|
g := router.Group(prefix, as(who.org, who.role))
|
|
g.GET("/outlets/:outlet_id/loyalty-settings", h.GetOutletSettings)
|
|
g.PUT("/outlets/:outlet_id/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOutletSettings)
|
|
}
|
|
call := func(method, path string, body string) (int, map[string]any) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
var out map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
|
return rec.Code, out
|
|
}
|
|
path := "/outlets/" + outlet.String() + "/loyalty-settings"
|
|
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
|
|
|
// An outlet without settings shows every default and 1% cashback.
|
|
status, body := call(http.MethodGet, "/manager"+path, "")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
got := data(body)
|
|
assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(100), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
|
|
assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(25000), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
|
|
assert.NotContains(t, got, "point_payment", "EnakPoint cannot pay (docs/enakgame-prd.md §3.2)")
|
|
assert.EqualValues(t, 1, got["point_value"])
|
|
assert.EqualValues(t, 1, got["point_cashback_percent"])
|
|
|
|
// A partial update keeps everything it does not mention.
|
|
status, body = call(http.MethodPut, "/manager"+path, `{"point": {"enabled": true, "earn_per_amount": 1000}, "coin": {"max_per_order": 3}}`)
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
got = data(body)
|
|
assert.Equal(t, true, got["point"].(map[string]any)["enabled"])
|
|
assert.EqualValues(t, 1000, got["point"].(map[string]any)["earn_per_amount"])
|
|
assert.EqualValues(t, 1, got["point"].(map[string]any)["earn_value"], "untouched")
|
|
assert.EqualValues(t, 25000, got["coin"].(map[string]any)["earn_per_amount"], "untouched")
|
|
assert.EqualValues(t, 3, got["coin"].(map[string]any)["max_per_order"])
|
|
assert.EqualValues(t, 0.1, got["point_cashback_percent"], "1 point per Rp 1.000 at Rp 1 a point")
|
|
assert.Len(t, got["changes"], 3)
|
|
|
|
// The cashback follows the organization's point value.
|
|
orgSettings, err := settings.Organization(context.Background(), org)
|
|
require.NoError(t, err)
|
|
orgSettings.PointValue = 5
|
|
_, _, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings)
|
|
require.NoError(t, err)
|
|
status, body = call(http.MethodGet, "/manager"+path, "")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.EqualValues(t, 0.5, data(body)["point_cashback_percent"])
|
|
|
|
// Sending back what GET returned changes nothing.
|
|
echo, err := json.Marshal(data(body))
|
|
require.NoError(t, err)
|
|
status, body = call(http.MethodPut, "/manager"+path, string(echo))
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.Empty(t, data(body)["changes"])
|
|
|
|
// null clears a limit.
|
|
status, body = call(http.MethodPut, "/manager"+path, `{"coin": {"max_per_order": null}}`)
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.Nil(t, data(body)["coin"].(map[string]any)["max_per_order"])
|
|
|
|
// Values out of bounds, unknown fields and bad JSON are refused and change nothing.
|
|
for name, bad := range map[string]string{
|
|
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
|
|
"negative earn_value": `{"coin": {"earn_value": -1}}`,
|
|
"earn mode unknown": `{"point": {"earn_mode": "PERCENT"}}`,
|
|
"earn_percent over 100": `{"coin": {"earn_percent": 101}}`,
|
|
"negative min_order": `{"point": {"min_order_amount": -5}}`,
|
|
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
|
|
"point_payment removed": `{"point_payment": {"accept_payment": true}}`,
|
|
"unknown field": `{"point": {"earn_per_amout": 50}}`,
|
|
"wrong type": `{"point": {"enabled": "yes"}}`,
|
|
"not json": `enabled=true`,
|
|
} {
|
|
status, _ = call(http.MethodPut, "/manager"+path, bad)
|
|
assert.Equal(t, http.StatusBadRequest, status, name)
|
|
}
|
|
status, body = call(http.MethodGet, "/manager"+path, "")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.EqualValues(t, 1000, data(body)["point"].(map[string]any)["earn_per_amount"])
|
|
|
|
// Purchasing staff can read but not change; another organization sees nothing.
|
|
status, _ = call(http.MethodPut, "/purchasing"+path, `{"point": {"enabled": false}}`)
|
|
assert.Equal(t, http.StatusForbidden, status)
|
|
status, _ = call(http.MethodGet, "/other"+path, "")
|
|
assert.Equal(t, http.StatusNotFound, status)
|
|
status, _ = call(http.MethodPut, "/other"+path, `{"point": {"enabled": false}}`)
|
|
assert.Equal(t, http.StatusNotFound, status)
|
|
status, _ = call(http.MethodGet, "/manager/outlets/not-a-uuid/loyalty-settings", "")
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
|
|
|
// Every change is in the history with who made it.
|
|
history, err := settings.ListChanges(context.Background(), org, &outlet, 1, 100)
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 4, history.Pagination.Total, "three keys, then the limit cleared")
|
|
for _, c := range history.Data {
|
|
assert.Equal(t, admin, c.ChangedBy)
|
|
}
|
|
}
|